# ----------------------------------------------------------------------------- # Lou-Ka — Location court terme # connectors/expedia.py : Expedia (expedia.ca) — locations de vacances au Québec # (onglet « Homes » : maisons, chalets, condos… — PAS les hôtels). # # Méthode : même pile que Vrbo (groupe Expedia) — anti-bot Akamai + page # client-side. Le SSR (__PLUGIN_STATE__ → controllers.stores.searchResults) # ne porte que les 3 premières cartes (vérifié 2026-08-23), le reste arrive # par GraphQL après hydratation → Scrapfly ASP avec rendu JS + js_scenario de # scrolls, puis parsing DOM des cartes `[data-stid="lodging-card-responsive"]`. # Le filtre « Homes » de la recherche = paramètre d'URL # `categorySearch=vacation_rentals_option` (exclut les hôtels classiques). # # Limites assumées : ~19 cartes rendues par requête (liste virtualisée, comme # Vrbo) → on multiplie destinations × tris (RECOMMENDED + PRICE_LOW_TO_HIGH) # pour élargir la couverture ; pas de lat/lng ni d'adresse sur les cartes. # Recherche SANS dates : Expedia auto-assigne des dates (~2 semaines) et # affiche un prix par nuit avant taxes → price_label « à partir de … ». # (AVEC dates explicites la disponibilité ralentit le rendu : 3 cartes et # aucun prix dans le snapshot — vérifié 2026-08-23, ne pas en remettre.) # L'inventaire recoupe en partie Vrbo (même groupe) mais avec ses propres ids # et des exclusivités hôtelières-résidentielles (apparts-hôtels, glamping). # # Enrichissement : la page détail hXXXXXX.Hotel-Information (Scrapfly ASP # SANS rendu JS — le SSR suffit) porte description, commodités, lat/lng et # ~6 photos (parseur partagé avec Vrbo : _expediadetail.py). # # Réglages env : LOUKA_EXPEDIA_LIMIT (nb max d'annonces, pour tester petit), # LOUKA_EXPEDIA_DETAIL_LIMIT (fetchs détail par sync, défaut # 100 ; cache permanent, 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 Expedia, ville affichée par défaut, région touristique QC) DESTINATIONS = [ ("Mont-Tremblant, Quebec, Canada", "Mont-Tremblant", "Laurentides"), ("Saint-Sauveur, Quebec, Canada", "Saint-Sauveur", "Laurentides"), ("Magog, Quebec, Canada", "Magog", "Cantons-de-l'Est"), ("Bromont, Quebec, Canada", "Bromont", "Cantons-de-l'Est"), ("Baie-Saint-Paul, Quebec, Canada", "Baie-Saint-Paul", "Charlevoix"), ("La Malbaie, Quebec, Canada", "La Malbaie", "Charlevoix"), ("Quebec City, Quebec, Canada", "Québec", "Québec"), ("Montreal, Quebec, Canada", "Montréal", "Montréal"), ("Perce, Quebec, Canada", "Percé", "Gaspésie"), ("Rimouski, Quebec, Canada", "Rimouski", "Bas-Saint-Laurent"), ("Saguenay, Quebec, Canada", "Saguenay", "Saguenay–Lac-Saint-Jean"), ("Shawinigan, Quebec, Canada", "Shawinigan", "Mauricie"), ("Gatineau, Quebec, Canada", "Gatineau", "Outaouais"), ] # Deux tris par destination pour dépasser la vingtaine de cartes rendues SORTS = ["RECOMMENDED", "PRICE_LOW_TO_HIGH"] # Mot-clé du libellé type Expedia (en) → type canonique Lou-Ka TYPE_MAP = { "apartment": "Appartement", "apart-hotel": "Appartement", "aparthotel": "Appartement", "condo": "Condo", "chalet": "Chalet", "cabin": "Chalet", "cottage": "Chalet", "house": "Maison", "home": "Maison", "villa": "Maison", "townhouse": "Maison", "bungalow": "Maison", "studio": "Studio", "loft": "Loft", "room": "Chambre", "guesthouse": "Gîte", "bed & breakfast": "Gîte", "lodge": "Auberge", "hostel": "Auberge", "yurt": "Yourte", "tiny house": "Mini-maison", "houseboat": "Autre", } _ID_RE = re.compile(r"\.h(\d+)\.Hotel-Information") _TYPELINE_RE = re.compile(r"^(?:Entire|Private|Shared)\s+(.+?)(?:\s+by\s+Vrbo)?$", re.I) _SLEEPS_RE = re.compile(r"Sleeps\s+(\d+)") _BEDROOMS_RE = re.compile(r"(\d+)\s*bedrooms?") _BATHROOMS_RE = re.compile(r"([\d.]+)\s*bathrooms?") _RATING_RE = re.compile(r"([\d.]+)\s*out of 10") _REVIEWS_RE = re.compile(r"\((\d[\d,\s]*)\s*reviews?\)") _PRICE_RE = re.compile(r"The current price is CA\s*\$([\d,]+)") # \s :   # Segments de carte qui ne sont PAS une localité (drapeaux, commodités…) _NOT_A_PLACE = re.compile( r"refundable|reserve now|sign in|member price|pool|hot tub|washer|dryer|" r"kitchen|parking|wifi|out of 10|review|current price|previous price|" r"total|includes|off\b|ad\b|sleeps|photo gallery|show (previous|next)|" r"more information|opens", re.I) # Hors Québec possible dans les résultats frontaliers (Gatineau → Ottawa…) _OUT_OF_QC = re.compile(r"ottawa|ontario|vermont|new hampshire|new york|maine", re.I) class Expedia(StConnector): source_id = "expedia" 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*=".Hotel-Information"]') href = (link.get("href") if link else "") or "" m = _ID_RE.search(href) if not m: return None # carte commanditée / lien de connexion external_id = m.group(1) url = "https://www.expedia.ca" + href.split("?")[0].lstrip() if not url.startswith("https://www.expedia.ca/"): url = f"https://www.expedia.ca/h{external_id}.Hotel-Information" title = "" for h in card.find_all("h3"): if "is-visually-hidden" not in " ".join(h.get("class") or []): title = h.get_text(strip=True) break if not title: return None segs = list(card.stripped_strings) blob = " | ".join(segs) if _OUT_OF_QC.search(blob): return None # ligne type (« Entire home by Vrbo ») + ligne config (« Sleeps 4 · … ») property_type = "" capacity = bedrooms = bathrooms = None try: i_title = segs.index(title) except ValueError: i_title = 0 place = "" for seg in segs[i_title + 1:]: tm = _TYPELINE_RE.match(seg) if tm and len(seg) < 60: kind = tm.group(1).strip().lower() property_type = next( (v for k, v in TYPE_MAP.items() if k in kind), "Autre") continue if _SLEEPS_RE.search(seg): sm = _SLEEPS_RE.search(seg) capacity = float(sm.group(1)) bm = _BEDROOMS_RE.search(seg) if bm: bedrooms = float(bm.group(1)) elif "studio" in seg.lower(): bedrooms = 0.0 am = _BATHROOMS_RE.search(seg) if am: bathrooms = float(am.group(1)) continue # première ligne « libre » après titre/type/config = localité if (not place and seg != title and len(seg) < 60 and not _NOT_A_PLACE.search(seg) and not re.match(r"^[\d($]", seg)): place = seg if place: city = place rating = reviews = None rm = _RATING_RE.search(blob) if rm: try: rating = round(float(rm.group(1)) / 2, 2) # /10 → /5 except ValueError: pass vm = _REVIEWS_RE.search(blob) if vm: reviews = int(re.sub(r"[\s,]", "", vm.group(1))) # prix par nuit avant taxes (dates indicatives passées dans l'URL) price_night, price_label = None, "" pm = _PRICE_RE.search(blob) if pm: try: price_night = float(pm.group(1).replace(",", "")) except ValueError: price_night = None if price_night: price_label = (f"à partir de {price_night:.0f} $ / nuit " "(prochaines dates, avant taxes)") images = [] for img in card.select("img[src]"): src = img.get("src") or "" if src.startswith("https://images.trvl-media.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, bathrooms=bathrooms, 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_EXPEDIA_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"[expedia] détail : {enriched} annonces enrichies" f" ({used}/{limit} fetchs réseau)", file=sys.stderr) # -- contrat ----------------------------------------------------------------- def fetch(self) -> list[StListing]: limit = int(os.environ.get("LOUKA_EXPEDIA_LIMIT", "0") or 0) # scrolls progressifs : déclenche le fetch client + rend les cartes scenario = [{"wait": 1500}] for y in (4000, 9000, 15000): scenario += [{"scroll": {"y": y}}, {"wait": 1500}] listings: dict[str, StListing] = {} for dest, city, region in DESTINATIONS: for sort in SORTS: if limit and len(listings) >= limit: out = list(listings.values()) self._enrich_details(out) return out url = ("https://www.expedia.ca/Hotel-Search?destination=" + quote(dest) + "&adults=2&categorySearch=vacation_rentals_option") if sort != "RECOMMENDED": url += f"&sort={sort}" cards = [] for attempt in (1, 2): # le rendu revient parfois vide try: html = self.get_scrapfly( url, render_js=True, asp=True, rendering_wait=2000, wait_for_selector='[data-stid="lodging-card-responsive"]', js_scenario=scenario) except Exception as exc: # noqa: BLE001 — une requête ratée ≠ sync ratée print(f"[expedia] {city} ({sort}) : {exc}", file=sys.stderr) continue soup = BeautifulSoup(html or "", "html.parser") cards = soup.select('[data-stid="lodging-card-responsive"]') if cards: break print(f"[expedia] {city} ({sort}) : 0 carte rendue" f" (essai {attempt})", file=sys.stderr) n_before = len(listings) for card in cards: 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 print(f"[expedia] {city} ({sort}) :" f" {len(listings) - n_before} nouvelles" f" (total {len(listings)})", file=sys.stderr) out = list(listings.values()) self._enrich_details(out) return out