# ----------------------------------------------------------------------------- # Lou-Ka — Location court terme # connectors/booking.py : Booking.com — locations de vacances au Québec # (chalets, maisons de vacances, villas, appartements — PAS les hôtels). # # Méthode : les pages /searchresults.fr.html embarquent côté serveur le store # Apollo () avec les 25 # premiers résultats structurés (prix, coordonnées, chambres, occupancy…). # Le site est derrière AWS WAF → Scrapfly ASP (sans rendu JS, le JSON est SSR). # Le paramètre `offset` n'est PAS respecté côté SSR (vérifié 2026-08-22 : # offset=25 recoupe 24/25 résultats de la page 1) → on couvre le territoire # en multipliant destinations × jeux de filtres ht_id (25 max par requête). # # ht_id (types d'hébergement Booking) : 220 = maisons de vacances, # 228 = chalets, 213 = villas, 201 = appartements, 204 = hôtels (exclu). # # Recherche AVEC dates génériques (~30 jours, 2 nuits) : sans dates, Booking # ne renvoie ni prix ni configuration des unités. Le prix est donc indicatif # → price_label « à partir de … » + price_night (le plus bas trouvé). # # Enrichissement : la page détail /hotel/ca/.fr.html (Scrapfly ASP # SANS rendu JS) embarque son propre store Apollo SSR : description complète # (data-testid="property-description"), commodités localisées (entités # Instance/SimpleFacility) et galerie (AccommodationPhoto). Vérifié live # 2026-08-25 sur /hotel/ca/renarde.fr.html. # Réglage env : LOUKA_BOOKING_DETAIL_LIMIT (fetchs détail par sync, défaut # 150 ; cache permanent dans louka_ct.db, le parc se complète au fil des syncs). # ----------------------------------------------------------------------------- from __future__ import annotations import datetime import html as _html import json import os import re import sys from urllib.parse import quote from ..schema import StListing from .base import StConnector class _DetailSkip(Exception): """Fiche détail sautée (budget épuisé / page bloquée) — pas de cache.""" # (texte de recherche Booking, région touristique QC) DESTINATIONS = [ ("Mont-Tremblant", "Laurentides"), ("Saint-Sauveur", "Laurentides"), ("Magog", "Cantons-de-l'Est"), ("Bromont", "Cantons-de-l'Est"), ("Baie-Saint-Paul", "Charlevoix"), ("La Malbaie", "Charlevoix"), ("Québec", "Québec"), ("Montréal", "Montréal"), ("Percé", "Gaspésie"), ("Rimouski", "Bas-Saint-Laurent"), ("Saguenay", "Saguenay–Lac-Saint-Jean"), ("Shawinigan", "Mauricie"), ("Gatineau", "Outaouais"), ] # Jeux de filtres par destination (25 résultats max chacun, cf. en-tête) FILTER_SETS = [ "ht_id=220", # maisons de vacances "ht_id=228;ht_id=213", # chalets + villas "ht_id=201", # appartements entiers ] # accommodationTypeId Booking → type canonique Lou-Ka TYPE_MAP = { 201: "Appartement", 203: "Auberge", 208: "Gîte", 213: "Maison", # villa 216: "Gîte", # maison d'hôtes 220: "Maison", # maison de vacances 222: "Chambre", # séjour chez l'habitant 228: "Chalet", } _CAPLA_RE = re.compile( r']*data-capla-store-data="apollo"[^>]*>(.*?)', re.S) _IMG_BASE = "https://cf.bstatic.com" _DESC_RE = re.compile( r'data-testid="property-description"[^>]*>(.*?)', re.S) class Booking(StConnector): source_id = "booking" request_delay = 1.0 # -- extraction ----------------------------------------------------------- def _search_results(self, html: str) -> list[dict]: """Résultats de recherche depuis le store Apollo SSR de la page.""" m = _CAPLA_RE.search(html or "") if not m: return [] try: store = json.loads(m.group(1)) except ValueError: try: store = json.loads(_html.unescape(m.group(1))) except ValueError: return [] queries = (store.get("ROOT_QUERY") or {}).get("searchQueries") or {} for key, val in queries.items(): if key.startswith("search(") and isinstance(val, dict): return val.get("results") or [] return [] def _to_listing(self, card: dict, region: str) -> StListing | None: basic = card.get("basicPropertyData") or {} hotel_id = basic.get("id") page_name = basic.get("pageName") or "" title = ((card.get("displayName") or {}).get("text") or "").strip() if not hotel_id or not page_name or not title: return None loc = basic.get("location") or {} if (loc.get("countryCode") or "ca").lower() != "ca": return None # jamais hors Canada # prix : total du séjour (2 nuits, dates indicatives) → prix/nuit plancher price_night, price_label = None, "" pdi = card.get("priceDisplayInfoIrene") or {} amount = (((pdi.get("displayPrice") or {}).get("amountPerStay") or {}) .get("amountUnformatted")) if isinstance(amount, (int, float)) and amount > 0: price_night = round(float(amount) / self._nights, 2) price_label = (f"à partir de {price_night:.0f} $ / nuit " f"(séjour de {self._nights} nuits)") # configuration des unités (chambres, lits, sdb, superficie) bedrooms = beds = bathrooms = None area = "" muc = card.get("matchingUnitConfigurations") or {} common = muc.get("commonConfiguration") or {} if common: bedrooms = common.get("nbBedrooms") beds = common.get("nbAllBeds") bathrooms = common.get("nbBathrooms") la = common.get("localizedArea") or {} if la.get("localizedArea"): area = f"{la['localizedArea']} {la.get('unit') or 'm²'}" # capacité : occupancy max des blocs tarifaires retournés capacity = None for blk in card.get("blocks") or []: occ = (blk.get("blockId") or {}).get("occupancy") if isinstance(occ, (int, float)): capacity = max(capacity or 0, occ) rating = reviews = None rev = basic.get("reviews") or {} if rev.get("showScore") and rev.get("totalScore"): rating = round(float(rev["totalScore"]) / 2, 2) # /10 → /5 reviews = rev.get("reviewsCount") images = [] rel = ((((basic.get("photos") or {}).get("main") or {}) .get("highResUrl") or {}).get("relativeUrl")) if rel: images.append(_IMG_BASE + rel) type_id = basic.get("accommodationTypeId") details: dict = {"booking_type_id": type_id} if area: details["superficie"] = area main_dist = (card.get("location") or {}).get("mainDistance") if main_dist: details["distance_centre"] = main_dist return StListing( source=self.source_id, external_id=str(hotel_id), url=f"https://www.booking.com/hotel/ca/{page_name}.fr.html", title=title, property_type=TYPE_MAP.get(type_id, "Autre"), address=(loc.get("address") or "").strip(), city=(loc.get("city") or "").strip(), region=region, price_night=price_night, price_label=price_label, capacity=float(capacity) if capacity else None, bedrooms=float(bedrooms) if bedrooms else None, beds=float(beds) if beds else None, bathrooms=float(bathrooms) if bathrooms else None, rating=rating, reviews=int(reviews) if reviews else None, description=((card.get("description") or {}).get("text") or "").strip(), details=details, images=images, lat=loc.get("latitude"), lng=loc.get("longitude"), ) # -- page détail ------------------------------------------------------------ @staticmethod def _parse_detail(html: str) -> dict: """Payload {description, amenities, images} d'une page détail Booking ({} si page bloquée/invalide).""" out: dict = {} # description complète (SSR) — HTML → texte m = _DESC_RE.search(html or "") if m: txt = re.sub(r"|

", "\n", m.group(1)) txt = _html.unescape(re.sub(r"<[^>]+>", " ", txt)) lines = [re.sub(r"\s+", " ", ln).strip() for ln in txt.split("\n")] desc = "\n".join(ln for ln in lines if ln).strip() if desc: out["description"] = desc[:6000] # store Apollo de la page détail : commodités localisées + galerie mm = _CAPLA_RE.search(html or "") if mm: try: store = json.loads(mm.group(1)) except ValueError: try: store = json.loads(_html.unescape(mm.group(1))) except ValueError: store = {} amenities: list[str] = [] for key, val in store.items(): if not isinstance(val, dict): continue name = "" if key.startswith("Instance:"): # équipements du lieu name = (val.get("title") or "").strip() elif key.startswith("SimpleFacility:"): # équipements des unités name = (val.get("name") or "").strip() if name and name not in amenities: amenities.append(name) if amenities: out["amenities"] = amenities[:80] images: list[str] = [] for key, val in store.items(): if not (key.startswith("AccommodationPhoto:") and isinstance(val, dict)): continue for k2, v2 in val.items(): if k2.startswith("resource(") and isinstance(v2, dict) \ and v2.get("relativeUrl"): rel = re.sub(r"/(?:square|max)\w+/", "/max1024x768/", v2["relativeUrl"], count=1) u = _IMG_BASE + rel if u not in images: images.append(u) break if len(images) >= 15: break if images: out["images"] = images return out 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_BOOKING_DETAIL_LIMIT", "150") or 150)) 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 res = self.scrapfly(url, render_js=False, asp=True) if res.get("status_code") in (404, 410): return {} # fiche retirée : cacher vide payload = self._parse_detail(res.get("content") or "") 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 not d: continue if d.get("description") and len(d["description"]) > \ len(lst.description or ""): lst.description = d["description"] if d.get("amenities"): seen = {a.lower() for a in lst.amenities} for a in d["amenities"]: if a.lower() not in seen: lst.amenities.append(a) seen.add(a.lower()) if d.get("images") and len(d["images"]) > len(lst.images): lst.images = list(d["images"]) enriched += 1 print(f"[booking] détail : {enriched} annonces enrichies" f" ({used}/{limit} fetchs réseau)", file=sys.stderr) # -- contrat --------------------------------------------------------------- def fetch(self) -> list[StListing]: today = datetime.date.today() checkin = (today + datetime.timedelta(days=30)).isoformat() checkout = (today + datetime.timedelta(days=32)).isoformat() self._nights = 2 listings: dict[str, StListing] = {} for dest, region in DESTINATIONS: for nflt in FILTER_SETS: url = ("https://www.booking.com/searchresults.fr.html" f"?ss={quote(dest)}&nflt={quote(nflt)}" f"&checkin={checkin}&checkout={checkout}" "&group_adults=2&no_rooms=1" "&selected_currency=CAD&lang=fr") try: html = self.get_scrapfly(url, render_js=False, asp=True) cards = self._search_results(html) except Exception as exc: # noqa: BLE001 — une requête ratée ≠ sync ratée print(f"[booking] {dest} ({nflt}) : {exc}", file=sys.stderr) continue for card in cards: try: lst = self._to_listing(card, 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