# ----------------------------------------------------------------------------- # Lou-Ka — Location court terme # connectors/vrbo.py : Vrbo (groupe Expedia) — locations de vacances au Québec. # # Méthode : anti-bot Expedia costaud + page 100 % client-side (le SSR ne # contient qu'un squelette ; __PLUGIN_STATE__/__APOLLO_STATE__ ne portent PAS # les résultats — vérifié 2026-08-22). On passe donc par Scrapfly ASP avec # rendu JS + wait_for_selector sur les cartes, puis on parse le DOM des # cartes `[data-stid="lodging-card-responsive"]`. # # Limites assumées : ~18 cartes rendues par destination (liste virtualisée, # le scroll ne persiste pas plus de cartes dans le snapshot DOM), pas de # lat/lng ni d'adresse sur les cartes, images présentes seulement sur les # cartes proches du viewport initial. # Recherche SANS dates : Vrbo affiche alors un prix « à partir de » par nuit # sur les prochaines dates disponibles → price_label + price_night plancher. # # Enrichissement : la page détail (Scrapfly ASP SANS rendu JS — le SSR suffit) # porte description, commodités, capacité, lat/lng et ~6 photos (voir # _expediadetail.py). ⚠️ certains slugs ont un suffixe (p1234567vb) : l'URL # sans suffixe redirige vers une page région — on conserve le slug complet. # Réglage env : LOUKA_VRBO_DETAIL_LIMIT (fetchs détail par sync, défaut 100 ; # cache permanent dans louka_ct.db, le parc se complète au fil des syncs). # ----------------------------------------------------------------------------- from __future__ import annotations import os import re import sys from urllib.parse import quote from bs4 import BeautifulSoup from ..schema import StListing from . import _expediadetail as _ed from .base import StConnector class _DetailSkip(Exception): """Fiche détail sautée (budget épuisé / page invalide) — pas de cache.""" # (destination Vrbo, ville affichée, région touristique QC) DESTINATIONS = [ ("Mont-Tremblant, Québec, Canada", "Mont-Tremblant", "Laurentides"), ("Saint-Sauveur, Québec, Canada", "Saint-Sauveur", "Laurentides"), ("Magog, Québec, Canada", "Magog", "Cantons-de-l'Est"), ("Bromont, Québec, Canada", "Bromont", "Cantons-de-l'Est"), ("Baie-Saint-Paul, Québec, Canada", "Baie-Saint-Paul", "Charlevoix"), ("La Malbaie, Québec, Canada", "La Malbaie", "Charlevoix"), ("Québec, Québec, Canada", "Québec", "Québec"), ("Montréal, Québec, Canada", "Montréal", "Montréal"), ("Percé, Québec, Canada", "Percé", "Gaspésie"), ("Rimouski, Québec, Canada", "Rimouski", "Bas-Saint-Laurent"), ("Saguenay, Québec, Canada", "Saguenay", "Saguenay–Lac-Saint-Jean"), ("Shawinigan, Québec, Canada", "Shawinigan", "Mauricie"), ("Gatineau, Québec, Canada", "Gatineau", "Outaouais"), ] # libellé Vrbo (fr) → type canonique Lou-Ka TYPE_MAP = { "appartement": "Appartement", "condo": "Condo", "chalet": "Chalet", "maison": "Maison", "villa": "Maison", "studio": "Studio", "loft": "Loft", "bungalow": "Maison", "cottage": "Chalet", "cabane": "Chalet", "chambre": "Chambre", "gîte": "Gîte", "auberge": "Auberge", "hébergement": "Autre", } # slug complet (p123vb) ET id numérique — le suffixe est requis dans l'URL _ID_RE = re.compile(r"/location/(p(\d+)[a-z]{0,2})") _TYPELINE_RE = re.compile( r"^([A-ZÀ-Ý][\w’' -]{2,30})\s*·", re.UNICODE) _BEDROOMS_RE = re.compile(r"(\d+)\s*chambres?") _BEDS_RE = re.compile(r"(\d+)\s*(?:grands?\s+|très\s+grands?\s+|petits?\s+)?lits?\b") _RATING_RE = re.compile(r"([\d,.]+)\s*sur\s*10") _REVIEWS_RE = re.compile(r"\((\d[\d\s]*)\s*avis\)") _PRICE_RE = re.compile(r"Le prix actuel est de\s*([\d\s,.]+)\s*\$") _CAPACITY_RE = re.compile(r"(\d+)\s*(?:voyageurs?|personnes?)") class Vrbo(StConnector): source_id = "vrbo" request_delay = 1.0 # -- parsing d'une carte ---------------------------------------------------- def _parse_card(self, card, city: str, region: str) -> StListing | None: link = card.select_one('a[data-stid="open-product-information"]') \ or card.select_one('a[href*="/location/"]') href = (link.get("href") if link else "") or "" m = _ID_RE.search(href) if not m: return None external_id = m.group(2) url = f"https://www.vrbo.com/fr-ca/location/{m.group(1)}" title = "" for h in card.find_all("h3"): cls = " ".join(h.get("class") or []) if "is-visually-hidden" not in cls: title = h.get_text(strip=True) break if not title: return None segs = list(card.stripped_strings) blob = " | ".join(segs) property_type, bedrooms, beds, capacity = "", None, None, None for seg in segs: tm = _TYPELINE_RE.match(seg) if tm and ("lit" in seg or "chambre" in seg or "voyageur" in seg): property_type = TYPE_MAP.get(tm.group(1).strip().lower(), "Autre") bm = _BEDROOMS_RE.search(seg) if bm: bedrooms = float(bm.group(1)) lm = _BEDS_RE.search(seg) if lm: beds = float(lm.group(1)) cm = _CAPACITY_RE.search(seg) if cm: capacity = float(cm.group(1)) break rating = reviews = None rm = _RATING_RE.search(blob) if rm: try: rating = round(float(rm.group(1).replace(",", ".")) / 2, 2) except ValueError: pass vm = _REVIEWS_RE.search(blob) if vm: reviews = int(vm.group(1).replace(" ", "")) price_night, price_label = None, "" pm = _PRICE_RE.search(blob) if pm: try: price_night = float(pm.group(1).replace(" ", "") .replace(",", ".")) except ValueError: price_night = None if price_night: price_label = (f"à partir de {price_night:.0f} $ / nuit " "(prochaines dates disponibles)") images = [] for img in card.select("img[src]"): src = img.get("src") or "" if src.startswith("https://media.vrbo.com/") and src not in images: images.append(src) if len(images) >= 5: break return StListing( source=self.source_id, external_id=external_id, url=url, title=title, property_type=property_type, city=city, region=region, price_night=price_night, price_label=price_label, capacity=capacity, bedrooms=bedrooms, beds=beds, rating=rating, reviews=reviews, images=images, ) # -- enrichissement par la page détail --------------------------------------- def _enrich_details(self, listings: list[StListing]) -> None: """Visite les fiches détail via le cache self.detail() sous budget : les hits de cache sont gratuits, seuls les fetchs réseau comptent.""" limit = max(0, int(os.environ.get("LOUKA_VRBO_DETAIL_LIMIT", "100") or 100)) used = enriched = streak = 0 for lst in listings: def fetch_fn(url=lst.url): nonlocal used, streak if used >= limit or streak >= 5: # tempête anti-bot : on coupe raise _DetailSkip used += 1 html = self.get_scrapfly(url, render_js=False, asp=True) payload = _ed.parse_detail(html) if not payload: streak += 1 raise _DetailSkip # blocage/vide : pas de cache streak = 0 return payload try: d = self.detail(lst.external_id, "v1", fetch_fn) except _DetailSkip: continue except Exception: # noqa: BLE001 — une fiche ne bloque pas le run continue if d: _ed.apply_detail(lst, d) enriched += 1 print(f"[vrbo] détail : {enriched} annonces enrichies" f" ({used}/{limit} fetchs réseau)", file=sys.stderr) # -- contrat ----------------------------------------------------------------- def fetch(self) -> list[StListing]: listings: dict[str, StListing] = {} for dest, city, region in DESTINATIONS: url = ("https://www.vrbo.com/fr-ca/search?destination=" + quote(dest) + "&adults=2") try: html = self.get_scrapfly( url, render_js=True, asp=True, rendering_wait=3000, wait_for_selector='[data-stid="lodging-card-responsive"]') except Exception as exc: # noqa: BLE001 print(f"[vrbo] {city} : {exc}", file=sys.stderr) continue if not html: print(f"[vrbo] {city} : page vide (rendu raté)", file=sys.stderr) continue soup = BeautifulSoup(html, "html.parser") for card in soup.select('[data-stid="lodging-card-responsive"]'): try: lst = self._parse_card(card, city, region) except Exception: # noqa: BLE001 continue if lst and lst.external_id not in listings: listings[lst.external_id] = lst out = list(listings.values()) self._enrich_details(out) return out