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/booking.py : Booking.com — locations de vacances au Québec4# (chalets, maisons de vacances, villas, appartements — PAS les hôtels).5#6# Méthode : les pages /searchresults.fr.html embarquent côté serveur le store7# Apollo (<script data-capla-store-data="apollo">…</script>) avec les 258# premiers résultats structurés (prix, coordonnées, chambres, occupancy…).9# Le site est derrière AWS WAF → Scrapfly ASP (sans rendu JS, le JSON est SSR).10# Le paramètre `offset` n'est PAS respecté côté SSR (vérifié 2026-08-22 :11# offset=25 recoupe 24/25 résultats de la page 1) → on couvre le territoire12# en multipliant destinations × jeux de filtres ht_id (25 max par requête).13#14# ht_id (types d'hébergement Booking) : 220 = maisons de vacances,15# 228 = chalets, 213 = villas, 201 = appartements, 204 = hôtels (exclu).16#17# Recherche AVEC dates génériques (~30 jours, 2 nuits) : sans dates, Booking18# ne renvoie ni prix ni configuration des unités. Le prix est donc indicatif19# → price_label « à partir de … » + price_night (le plus bas trouvé).20#21# Enrichissement : la page détail /hotel/ca/<pageName>.fr.html (Scrapfly ASP22# SANS rendu JS) embarque son propre store Apollo SSR : description complète23# (data-testid="property-description"), commodités localisées (entités24# Instance/SimpleFacility) et galerie (AccommodationPhoto). Vérifié live25# 2026-08-25 sur /hotel/ca/renarde.fr.html.26# Réglage env : LOUKA_BOOKING_DETAIL_LIMIT (fetchs détail par sync, défaut27# 150 ; cache permanent dans louka_ct.db, le parc se complète au fil des syncs).28# -----------------------------------------------------------------------------29from __future__ import annotations3031import datetime32import html as _html33import json34import os35import re36import sys37from urllib.parse import quote3839from ..schema import StListing40from .base import StConnector414243class _DetailSkip(Exception):44 """Fiche détail sautée (budget épuisé / page bloquée) — pas de cache."""4546# (texte de recherche Booking, région touristique QC)47DESTINATIONS = [48 ("Mont-Tremblant", "Laurentides"),49 ("Saint-Sauveur", "Laurentides"),50 ("Magog", "Cantons-de-l'Est"),51 ("Bromont", "Cantons-de-l'Est"),52 ("Baie-Saint-Paul", "Charlevoix"),53 ("La Malbaie", "Charlevoix"),54 ("Québec", "Québec"),55 ("Montréal", "Montréal"),56 ("Percé", "Gaspésie"),57 ("Rimouski", "Bas-Saint-Laurent"),58 ("Saguenay", "Saguenay–Lac-Saint-Jean"),59 ("Shawinigan", "Mauricie"),60 ("Gatineau", "Outaouais"),61]6263# Jeux de filtres par destination (25 résultats max chacun, cf. en-tête)64FILTER_SETS = [65 "ht_id=220", # maisons de vacances66 "ht_id=228;ht_id=213", # chalets + villas67 "ht_id=201", # appartements entiers68]6970# accommodationTypeId Booking → type canonique Lou-Ka71TYPE_MAP = {72 201: "Appartement",73 203: "Auberge",74 208: "Gîte",75 213: "Maison", # villa76 216: "Gîte", # maison d'hôtes77 220: "Maison", # maison de vacances78 222: "Chambre", # séjour chez l'habitant79 228: "Chalet",80}8182_CAPLA_RE = re.compile(83 r'<script[^>]*data-capla-store-data="apollo"[^>]*>(.*?)</script>', re.S)84_IMG_BASE = "https://cf.bstatic.com"85_DESC_RE = re.compile(86 r'data-testid="property-description"[^>]*>(.*?)</(?:p|div)>', re.S)878889class Booking(StConnector):90 source_id = "booking"91 request_delay = 1.09293 # -- extraction -----------------------------------------------------------94 def _search_results(self, html: str) -> list[dict]:95 """Résultats de recherche depuis le store Apollo SSR de la page."""96 m = _CAPLA_RE.search(html or "")97 if not m:98 return []99 try:100 store = json.loads(m.group(1))101 except ValueError:102 try:103 store = json.loads(_html.unescape(m.group(1)))104 except ValueError:105 return []106 queries = (store.get("ROOT_QUERY") or {}).get("searchQueries") or {}107 for key, val in queries.items():108 if key.startswith("search(") and isinstance(val, dict):109 return val.get("results") or []110 return []111112 def _to_listing(self, card: dict, region: str) -> StListing | None:113 basic = card.get("basicPropertyData") or {}114 hotel_id = basic.get("id")115 page_name = basic.get("pageName") or ""116 title = ((card.get("displayName") or {}).get("text") or "").strip()117 if not hotel_id or not page_name or not title:118 return None119 loc = basic.get("location") or {}120 if (loc.get("countryCode") or "ca").lower() != "ca":121 return None # jamais hors Canada122123 # prix : total du séjour (2 nuits, dates indicatives) → prix/nuit plancher124 price_night, price_label = None, ""125 pdi = card.get("priceDisplayInfoIrene") or {}126 amount = (((pdi.get("displayPrice") or {}).get("amountPerStay") or {})127 .get("amountUnformatted"))128 if isinstance(amount, (int, float)) and amount > 0:129 price_night = round(float(amount) / self._nights, 2)130 price_label = (f"à partir de {price_night:.0f} $ / nuit "131 f"(séjour de {self._nights} nuits)")132133 # configuration des unités (chambres, lits, sdb, superficie)134 bedrooms = beds = bathrooms = None135 area = ""136 muc = card.get("matchingUnitConfigurations") or {}137 common = muc.get("commonConfiguration") or {}138 if common:139 bedrooms = common.get("nbBedrooms")140 beds = common.get("nbAllBeds")141 bathrooms = common.get("nbBathrooms")142 la = common.get("localizedArea") or {}143 if la.get("localizedArea"):144 area = f"{la['localizedArea']} {la.get('unit') or 'm²'}"145146 # capacité : occupancy max des blocs tarifaires retournés147 capacity = None148 for blk in card.get("blocks") or []:149 occ = (blk.get("blockId") or {}).get("occupancy")150 if isinstance(occ, (int, float)):151 capacity = max(capacity or 0, occ)152153 rating = reviews = None154 rev = basic.get("reviews") or {}155 if rev.get("showScore") and rev.get("totalScore"):156 rating = round(float(rev["totalScore"]) / 2, 2) # /10 → /5157 reviews = rev.get("reviewsCount")158159 images = []160 rel = ((((basic.get("photos") or {}).get("main") or {})161 .get("highResUrl") or {}).get("relativeUrl"))162 if rel:163 images.append(_IMG_BASE + rel)164165 type_id = basic.get("accommodationTypeId")166 details: dict = {"booking_type_id": type_id}167 if area:168 details["superficie"] = area169 main_dist = (card.get("location") or {}).get("mainDistance")170 if main_dist:171 details["distance_centre"] = main_dist172173 return StListing(174 source=self.source_id,175 external_id=str(hotel_id),176 url=f"https://www.booking.com/hotel/ca/{page_name}.fr.html",177 title=title,178 property_type=TYPE_MAP.get(type_id, "Autre"),179 address=(loc.get("address") or "").strip(),180 city=(loc.get("city") or "").strip(),181 region=region,182 price_night=price_night,183 price_label=price_label,184 capacity=float(capacity) if capacity else None,185 bedrooms=float(bedrooms) if bedrooms else None,186 beds=float(beds) if beds else None,187 bathrooms=float(bathrooms) if bathrooms else None,188 rating=rating,189 reviews=int(reviews) if reviews else None,190 description=((card.get("description") or {}).get("text") or "").strip(),191 details=details,192 images=images,193 lat=loc.get("latitude"),194 lng=loc.get("longitude"),195 )196197 # -- page détail ------------------------------------------------------------198 @staticmethod199 def _parse_detail(html: str) -> dict:200 """Payload {description, amenities, images} d'une page détail Booking201 ({} si page bloquée/invalide)."""202 out: dict = {}203204 # description complète (SSR) — HTML → texte205 m = _DESC_RE.search(html or "")206 if m:207 txt = re.sub(r"<br\s*/?>|</p>", "\n", m.group(1))208 txt = _html.unescape(re.sub(r"<[^>]+>", " ", txt))209 lines = [re.sub(r"\s+", " ", ln).strip() for ln in txt.split("\n")]210 desc = "\n".join(ln for ln in lines if ln).strip()211 if desc:212 out["description"] = desc[:6000]213214 # store Apollo de la page détail : commodités localisées + galerie215 mm = _CAPLA_RE.search(html or "")216 if mm:217 try:218 store = json.loads(mm.group(1))219 except ValueError:220 try:221 store = json.loads(_html.unescape(mm.group(1)))222 except ValueError:223 store = {}224 amenities: list[str] = []225 for key, val in store.items():226 if not isinstance(val, dict):227 continue228 name = ""229 if key.startswith("Instance:"): # équipements du lieu230 name = (val.get("title") or "").strip()231 elif key.startswith("SimpleFacility:"): # équipements des unités232 name = (val.get("name") or "").strip()233 if name and name not in amenities:234 amenities.append(name)235 if amenities:236 out["amenities"] = amenities[:80]237238 images: list[str] = []239 for key, val in store.items():240 if not (key.startswith("AccommodationPhoto:")241 and isinstance(val, dict)):242 continue243 for k2, v2 in val.items():244 if k2.startswith("resource(") and isinstance(v2, dict) \245 and v2.get("relativeUrl"):246 rel = re.sub(r"/(?:square|max)\w+/", "/max1024x768/",247 v2["relativeUrl"], count=1)248 u = _IMG_BASE + rel249 if u not in images:250 images.append(u)251 break252 if len(images) >= 15:253 break254 if images:255 out["images"] = images256 return out257258 def _enrich_details(self, listings: list[StListing]) -> None:259 """Visite les fiches détail via le cache self.detail() sous budget :260 les hits de cache sont gratuits, seuls les fetchs réseau comptent."""261 limit = max(0, int(os.environ.get("LOUKA_BOOKING_DETAIL_LIMIT", "150")262 or 150))263 used = enriched = streak = 0264 for lst in listings:265 def fetch_fn(url=lst.url):266 nonlocal used, streak267 if used >= limit or streak >= 5: # tempête anti-bot : on coupe268 raise _DetailSkip269 used += 1270 res = self.scrapfly(url, render_js=False, asp=True)271 if res.get("status_code") in (404, 410):272 return {} # fiche retirée : cacher vide273 payload = self._parse_detail(res.get("content") or "")274 if not payload:275 streak += 1276 raise _DetailSkip # blocage/vide : pas de cache277 streak = 0278 return payload279280 try:281 d = self.detail(lst.external_id, "v1", fetch_fn)282 except _DetailSkip:283 continue284 except Exception: # noqa: BLE001 — une fiche ne bloque pas le run285 continue286 if not d:287 continue288 if d.get("description") and len(d["description"]) > \289 len(lst.description or ""):290 lst.description = d["description"]291 if d.get("amenities"):292 seen = {a.lower() for a in lst.amenities}293 for a in d["amenities"]:294 if a.lower() not in seen:295 lst.amenities.append(a)296 seen.add(a.lower())297 if d.get("images") and len(d["images"]) > len(lst.images):298 lst.images = list(d["images"])299 enriched += 1300 print(f"[booking] détail : {enriched} annonces enrichies"301 f" ({used}/{limit} fetchs réseau)", file=sys.stderr)302303 # -- contrat ---------------------------------------------------------------304 def fetch(self) -> list[StListing]:305 today = datetime.date.today()306 checkin = (today + datetime.timedelta(days=30)).isoformat()307 checkout = (today + datetime.timedelta(days=32)).isoformat()308 self._nights = 2309310 listings: dict[str, StListing] = {}311 for dest, region in DESTINATIONS:312 for nflt in FILTER_SETS:313 url = ("https://www.booking.com/searchresults.fr.html"314 f"?ss={quote(dest)}&nflt={quote(nflt)}"315 f"&checkin={checkin}&checkout={checkout}"316 "&group_adults=2&no_rooms=1"317 "&selected_currency=CAD&lang=fr")318 try:319 html = self.get_scrapfly(url, render_js=False, asp=True)320 cards = self._search_results(html)321 except Exception as exc: # noqa: BLE001 — une requête ratée ≠ sync ratée322 print(f"[booking] {dest} ({nflt}) : {exc}", file=sys.stderr)323 continue324 for card in cards:325 try:326 lst = self._to_listing(card, region)327 except Exception: # noqa: BLE001328 continue329 if lst and lst.external_id not in listings:330 listings[lst.external_id] = lst331 out = list(listings.values())332 self._enrich_details(out)333 return out334