Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1# -----------------------------------------------------------------------------2# Lou-Ka — Location court terme3# connectors/vrbo.py : Vrbo (groupe Expedia) — locations de vacances au Québec.4#5# Méthode : anti-bot Expedia costaud + page 100 % client-side (le SSR ne6# contient qu'un squelette ; __PLUGIN_STATE__/__APOLLO_STATE__ ne portent PAS7# les résultats — vérifié 2026-08-22). On passe donc par Scrapfly ASP avec8# rendu JS + wait_for_selector sur les cartes, puis on parse le DOM des9# cartes `[data-stid="lodging-card-responsive"]`.10#11# Limites assumées : ~18 cartes rendues par destination (liste virtualisée,12# le scroll ne persiste pas plus de cartes dans le snapshot DOM), pas de13# lat/lng ni d'adresse sur les cartes, images présentes seulement sur les14# cartes proches du viewport initial.15# Recherche SANS dates : Vrbo affiche alors un prix « à partir de » par nuit16# sur les prochaines dates disponibles → price_label + price_night plancher.17#18# Enrichissement : la page détail (Scrapfly ASP SANS rendu JS — le SSR suffit)19# porte description, commodités, capacité, lat/lng et ~6 photos (voir20# _expediadetail.py). ⚠️ certains slugs ont un suffixe (p1234567vb) : l'URL21# sans suffixe redirige vers une page région — on conserve le slug complet.22# Réglage env : LOUKA_VRBO_DETAIL_LIMIT (fetchs détail par sync, défaut 100 ;23# cache permanent dans louka_ct.db, le parc se complète au fil des syncs).24# -----------------------------------------------------------------------------25from __future__ import annotations2627import os28import re29import sys30from urllib.parse import quote3132from bs4 import BeautifulSoup3334from ..schema import StListing35from . import _expediadetail as _ed36from .base import StConnector373839class _DetailSkip(Exception):40 """Fiche détail sautée (budget épuisé / page invalide) — pas de cache."""4142# (destination Vrbo, ville affichée, région touristique QC)43DESTINATIONS = [44 ("Mont-Tremblant, Québec, Canada", "Mont-Tremblant", "Laurentides"),45 ("Saint-Sauveur, Québec, Canada", "Saint-Sauveur", "Laurentides"),46 ("Magog, Québec, Canada", "Magog", "Cantons-de-l'Est"),47 ("Bromont, Québec, Canada", "Bromont", "Cantons-de-l'Est"),48 ("Baie-Saint-Paul, Québec, Canada", "Baie-Saint-Paul", "Charlevoix"),49 ("La Malbaie, Québec, Canada", "La Malbaie", "Charlevoix"),50 ("Québec, Québec, Canada", "Québec", "Québec"),51 ("Montréal, Québec, Canada", "Montréal", "Montréal"),52 ("Percé, Québec, Canada", "Percé", "Gaspésie"),53 ("Rimouski, Québec, Canada", "Rimouski", "Bas-Saint-Laurent"),54 ("Saguenay, Québec, Canada", "Saguenay", "Saguenay–Lac-Saint-Jean"),55 ("Shawinigan, Québec, Canada", "Shawinigan", "Mauricie"),56 ("Gatineau, Québec, Canada", "Gatineau", "Outaouais"),57]5859# libellé Vrbo (fr) → type canonique Lou-Ka60TYPE_MAP = {61 "appartement": "Appartement",62 "condo": "Condo",63 "chalet": "Chalet",64 "maison": "Maison",65 "villa": "Maison",66 "studio": "Studio",67 "loft": "Loft",68 "bungalow": "Maison",69 "cottage": "Chalet",70 "cabane": "Chalet",71 "chambre": "Chambre",72 "gîte": "Gîte",73 "auberge": "Auberge",74 "hébergement": "Autre",75}7677# slug complet (p123vb) ET id numérique — le suffixe est requis dans l'URL78_ID_RE = re.compile(r"/location/(p(\d+)[a-z]{0,2})")79_TYPELINE_RE = re.compile(80 r"^([A-ZÀ-Ý][\w’' -]{2,30})\s*·", re.UNICODE)81_BEDROOMS_RE = re.compile(r"(\d+)\s*chambres?")82_BEDS_RE = re.compile(r"(\d+)\s*(?:grands?\s+|très\s+grands?\s+|petits?\s+)?lits?\b")83_RATING_RE = re.compile(r"([\d,.]+)\s*sur\s*10")84_REVIEWS_RE = re.compile(r"\((\d[\d\s]*)\s*avis\)")85_PRICE_RE = re.compile(r"Le prix actuel est de\s*([\d\s,.]+)\s*\$")86_CAPACITY_RE = re.compile(r"(\d+)\s*(?:voyageurs?|personnes?)")878889class Vrbo(StConnector):90 source_id = "vrbo"91 request_delay = 1.09293 # -- parsing d'une carte ----------------------------------------------------94 def _parse_card(self, card, city: str, region: str) -> StListing | None:95 link = card.select_one('a[data-stid="open-product-information"]') \96 or card.select_one('a[href*="/location/"]')97 href = (link.get("href") if link else "") or ""98 m = _ID_RE.search(href)99 if not m:100 return None101 external_id = m.group(2)102 url = f"https://www.vrbo.com/fr-ca/location/{m.group(1)}"103104 title = ""105 for h in card.find_all("h3"):106 cls = " ".join(h.get("class") or [])107 if "is-visually-hidden" not in cls:108 title = h.get_text(strip=True)109 break110 if not title:111 return None112113 segs = list(card.stripped_strings)114 blob = " | ".join(segs)115116 property_type, bedrooms, beds, capacity = "", None, None, None117 for seg in segs:118 tm = _TYPELINE_RE.match(seg)119 if tm and ("lit" in seg or "chambre" in seg or "voyageur" in seg):120 property_type = TYPE_MAP.get(tm.group(1).strip().lower(), "Autre")121 bm = _BEDROOMS_RE.search(seg)122 if bm:123 bedrooms = float(bm.group(1))124 lm = _BEDS_RE.search(seg)125 if lm:126 beds = float(lm.group(1))127 cm = _CAPACITY_RE.search(seg)128 if cm:129 capacity = float(cm.group(1))130 break131132 rating = reviews = None133 rm = _RATING_RE.search(blob)134 if rm:135 try:136 rating = round(float(rm.group(1).replace(",", ".")) / 2, 2)137 except ValueError:138 pass139 vm = _REVIEWS_RE.search(blob)140 if vm:141 reviews = int(vm.group(1).replace(" ", ""))142143 price_night, price_label = None, ""144 pm = _PRICE_RE.search(blob)145 if pm:146 try:147 price_night = float(pm.group(1).replace(" ", "")148 .replace(",", "."))149 except ValueError:150 price_night = None151 if price_night:152 price_label = (f"à partir de {price_night:.0f} $ / nuit "153 "(prochaines dates disponibles)")154155 images = []156 for img in card.select("img[src]"):157 src = img.get("src") or ""158 if src.startswith("https://media.vrbo.com/") and src not in images:159 images.append(src)160 if len(images) >= 5:161 break162163 return StListing(164 source=self.source_id,165 external_id=external_id,166 url=url,167 title=title,168 property_type=property_type,169 city=city,170 region=region,171 price_night=price_night,172 price_label=price_label,173 capacity=capacity,174 bedrooms=bedrooms,175 beds=beds,176 rating=rating,177 reviews=reviews,178 images=images,179 )180181 # -- enrichissement par la page détail ---------------------------------------182 def _enrich_details(self, listings: list[StListing]) -> None:183 """Visite les fiches détail via le cache self.detail() sous budget :184 les hits de cache sont gratuits, seuls les fetchs réseau comptent."""185 limit = max(0, int(os.environ.get("LOUKA_VRBO_DETAIL_LIMIT", "100")186 or 100))187 used = enriched = streak = 0188 for lst in listings:189 def fetch_fn(url=lst.url):190 nonlocal used, streak191 if used >= limit or streak >= 5: # tempête anti-bot : on coupe192 raise _DetailSkip193 used += 1194 html = self.get_scrapfly(url, render_js=False, asp=True)195 payload = _ed.parse_detail(html)196 if not payload:197 streak += 1198 raise _DetailSkip # blocage/vide : pas de cache199 streak = 0200 return payload201202 try:203 d = self.detail(lst.external_id, "v1", fetch_fn)204 except _DetailSkip:205 continue206 except Exception: # noqa: BLE001 — une fiche ne bloque pas le run207 continue208 if d:209 _ed.apply_detail(lst, d)210 enriched += 1211 print(f"[vrbo] détail : {enriched} annonces enrichies"212 f" ({used}/{limit} fetchs réseau)", file=sys.stderr)213214 # -- contrat -----------------------------------------------------------------215 def fetch(self) -> list[StListing]:216 listings: dict[str, StListing] = {}217 for dest, city, region in DESTINATIONS:218 url = ("https://www.vrbo.com/fr-ca/search?destination="219 + quote(dest) + "&adults=2")220 try:221 html = self.get_scrapfly(222 url, render_js=True, asp=True, rendering_wait=3000,223 wait_for_selector='[data-stid="lodging-card-responsive"]')224 except Exception as exc: # noqa: BLE001225 print(f"[vrbo] {city} : {exc}", file=sys.stderr)226 continue227 if not html:228 print(f"[vrbo] {city} : page vide (rendu raté)", file=sys.stderr)229 continue230 soup = BeautifulSoup(html, "html.parser")231 for card in soup.select('[data-stid="lodging-card-responsive"]'):232 try:233 lst = self._parse_card(card, city, region)234 except Exception: # noqa: BLE001235 continue236 if lst and lst.external_id not in listings:237 listings[lst.external_id] = lst238 out = list(listings.values())239 self._enrich_details(out)240 return out241