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/airbnb.py : Airbnb (airbnb.ca) — source vedette, toute la province.4#5# Méthode : les pages de recherche Airbnb embarquent un JSON complet dans6# <script id="data-deferred-state-0"> (niobeClientData → presentation.7# staysSearch.results) avec, pour chaque carte : id (base648# "DemandStayListing:<room id>"), nom, coordonnées, note/avis, chambres/lits/9# sdb, photos et prix détaillé ("N nights x $X CAD"). HTML via Bright Data10# Web Unlocker (pas de rendu JS nécessaire), Scrapfly ASP en secours.11#12# Couverture provinciale ROBUSTE par quadrillage carte (search_by_map=true +13# bbox ne_lat/ne_lng/sw_lat/sw_lng) : Airbnb plafonne chaque recherche à14# ~270 résultats (15 pages × 18 cartes) ; quand une cellule sature (1515# cursors), on la subdivise en 4 et on recommence (quadtree). En recherche16# par carte, 100 % des cartes ont des coordonnées → la région touristique17# est déduite du point (centroïde le plus proche). Dédoublonnage par room id.18#19# Le bbox englobe l'est de l'Ontario, le nord des États-Unis et le NB : un20# polygone frontière grossier (QC_POLY) élague les cellules hors province et21# filtre les cartes (sinon Ottawa & cie se font étiqueter « Outaouais »).22# L'ordre des cellules est mélangé à chaque run pour que les cellules restantes23# après épuisement du budget tournent d'un run à l'autre.24#25# Enrichissement détail : la fiche /rooms/<id> embarque le même genre de JSON26# (<script data-deferred-state-0> → data.node.pdpPresentation) avec description27# longue (descriptions.longDescriptionHtml), capacité (personCapacity +28# overview.items "6 guests · 2 bedrooms…"), commodités groupées29# (amenities.seeAllAmenitiesGroups, drapeau available), règles (rules.groupItems30# → animaux) et municipalité (localizedLocation). Visite via le cache détail31# self.detail() (louka_ct.db) avec budget par run : le parc (~15 000) se32# remplit au fil des syncs. Les échecs réseau/anti-bot ne sont PAS mis en33# cache (retentés au prochain run) ; 8 échecs consécutifs coupent34# l'enrichissement du run (tempête anti-bot).35#36# Réglages env : LOUKA_AIRBNB_PAGES (pages par cellule feuille, défaut 15),37# LOUKA_AIRBNB_BUDGET (budget de requêtes HTML, défaut 1200),38# LOUKA_AIRBNB_DEPTH (profondeur max du quadtree, défaut 7),39# LOUKA_AIRBNB_DETAIL_LIMIT (fiches détail par run, défaut 800).40# -----------------------------------------------------------------------------41from __future__ import annotations4243import base6444import json45import math46import os47import random48import re49import sys50import time51import urllib.parse5253import requests5455from ..schema import StListing56from .base import StConnector5758BRIGHTDATA_API = "https://api.brightdata.com/request"5960# Zone habitée du Québec (sud de la baie James) : (sud, ouest, nord, est).61# Découpée d'emblée en cellules de départ pour éviter un tronc trop profond.62QC_BBOX = (44.95, -79.80, 52.20, -56.90)63GRID0 = (6, 8) # lignes × colonnes de départ (cellules ~1,2° × ~2,9°)6465# Centroïdes approximatifs des régions touristiques (lat, lng) — la région66# d'une annonce = centroïde le plus proche de ses coordonnées.67REGION_CENTROIDS: list[tuple[str, float, float]] = [68 ("Montréal", 45.55, -73.65),69 ("Laval", 45.60, -73.72),70 ("Montérégie", 45.35, -73.20),71 ("Québec", 46.85, -71.30),72 ("Chaudière-Appalaches", 46.45, -70.80),73 ("Laurentides", 46.05, -74.35),74 ("Lanaudière", 46.15, -73.60),75 ("Cantons-de-l'Est", 45.35, -72.10),76 ("Charlevoix", 47.55, -70.35),77 ("Mauricie", 46.85, -72.85),78 ("Centre-du-Québec", 46.05, -72.15),79 ("Outaouais", 45.80, -75.85),80 ("Abitibi-Témiscamingue", 48.20, -78.35),81 ("Saguenay–Lac-Saint-Jean", 48.50, -71.65),82 ("Bas-Saint-Laurent", 48.10, -68.75),83 ("Gaspésie", 48.80, -65.40),84 ("Îles-de-la-Madeleine", 47.38, -61.90),85 ("Côte-Nord", 49.60, -67.20),86 ("Nord-du-Québec", 51.50, -77.00),87]888990# Polygone frontière grossier du Québec (lat, lng) : suit approximativement91# la rivière des Outaouais / lac Témiscamingue (Ontario), la frontière92# américaine, le NB, puis englobe golfe + Îles-de-la-Madeleine + Anticosti +93# Côte-Nord. Les petites erreurs près des frontières sont acceptables.94QC_POLY: list[tuple[float, float]] = [95 (52.20, -79.50), (47.55, -79.58), (46.75, -79.15), (46.15, -77.90),96 (45.85, -77.20), (45.45, -75.90), (45.50, -74.90), (45.35, -74.50),97 (45.00, -74.35), (45.00, -71.50), (45.30, -71.10), (46.00, -70.50),98 (47.30, -68.50), (47.50, -68.30), (47.50, -66.50), (47.90, -66.40),99 (48.00, -64.50), (46.90, -62.50), (47.00, -61.30), (48.50, -61.00),100 (49.30, -61.60), (50.00, -58.50), (51.20, -56.90), (52.20, -57.00),101]102103104def _in_quebec(lat: float | None, lng: float | None) -> bool:105 if lat is None or lng is None:106 return False107 inside = False108 n = len(QC_POLY)109 for i in range(n):110 la1, lo1 = QC_POLY[i]111 la2, lo2 = QC_POLY[(i + 1) % n]112 if (la1 > lat) != (la2 > lat):113 x = lo1 + (lat - la1) / (la2 - la1) * (lo2 - lo1)114 if lng < x:115 inside = not inside116 return inside117118119def _cell_touches_qc(s: float, w: float, n: float, e: float) -> bool:120 """Vrai si au moins un point d'un échantillon 4×4 de la cellule est au QC."""121 return any(_in_quebec(s + (n - s) * i / 3, w + (e - w) * j / 3)122 for i in range(4) for j in range(4))123124125def _region_from_latlng(lat: float | None, lng: float | None) -> str:126 if lat is None or lng is None:127 return ""128 best, best_d = "", 1e9129 for name, clat, clng in REGION_CENTROIDS:130 d = (lat - clat) ** 2 + ((lng - clng) * 0.7) ** 2131 if d < best_d:132 best, best_d = name, d133 return best134135# Type de carte Airbnb ("Home in X", "Chalet in X"…) → type canonique Lou-Ka.136# Clés en minuscules, les plus longues testées d'abord (startswith).137TYPE_MAP = {138 "tiny home": "Mini-maison", "mini-maison": "Mini-maison",139 "bed and breakfast": "Gîte", "bed & breakfast": "Gîte",140 "boutique hotel": "Auberge", "hostel": "Auberge", "aparthotel": "Appartement",141 "hotel room": "Chambre", "private room": "Chambre", "shared room": "Chambre",142 "guest suite": "Appartement", "serviced apartment": "Appartement",143 "rental unit": "Appartement", "apartment": "Appartement",144 "appartement": "Appartement", "logement": "Appartement",145 "vacation home": "Maison", "guesthouse": "Maison", "farm stay": "Maison",146 "townhouse": "Maison", "bungalow": "Maison", "villa": "Maison",147 "home": "Maison", "house": "Maison", "maison": "Maison",148 "chalet": "Chalet", "cabin": "Chalet", "cottage": "Chalet", "cabane": "Chalet",149 "condo": "Condo", "loft": "Loft", "studio": "Studio",150 "dome": "Dôme", "dôme": "Dôme", "yurt": "Yourte", "yourte": "Yourte",151 "camper/rv": "Prêt-à-camper", "campsite": "Camping", "tent": "Camping",152 "hut": "Refuge", "room": "Chambre", "chambre": "Chambre",153 "hotel": "Auberge", "auberge": "Auberge",154}155_TYPE_KEYS = sorted(TYPE_MAP, key=len, reverse=True)156157_RATING_RE = re.compile(r"^\s*(\d+(?:[.,]\d+)?)\s*\((\d[\d\s,]*)\)")158_NIGHTLY_RE = re.compile(r"(\d+)\s*nights?\s*x\s*\$\s*([\d,]+(?:\.\d+)?)", re.I)159_MONEY_RE = re.compile(r"\$\s*([\d,]+(?:\.\d+)?)")160_NUM_RE = re.compile(r"(\d+(?:\.\d+)?)")161162# Clé de version du parseur de fiche détail (bump → re-visite du parc)163_PDP_KEY = "pdp-v1"164165166class _DetailSkip(Exception):167 """Fiche détail indisponible ce run (budget épuisé, blocage anti-bot) —168 on ne met RIEN en cache pour retenter au prochain sync."""169170171def _html_to_text(fragment: str) -> str:172 """HTML de description Airbnb (<br />, <b>…) → texte propre."""173 import html as _h174 txt = re.sub(r"<br\s*/?>", "\n", fragment)175 txt = re.sub(r"<[^>]+>", " ", txt)176 txt = _h.unescape(txt)177 txt = re.sub(r"[ \t]+", " ", txt)178 txt = re.sub(r" ?\n ?", "\n", txt)179 return re.sub(r"\n{3,}", "\n\n", txt).strip()180181182def _b64_room_id(demand_id: str) -> str:183 """"RGVtYW5kU3RheUxpc3Rpbmc6MTIz" → "123" (DemandStayListing:<id>)."""184 try:185 raw = base64.b64decode(demand_id + "=" * (-len(demand_id) % 4)).decode()186 return raw.split(":")[-1].strip()187 except Exception: # noqa: BLE001188 return ""189190191def _card_type_and_city(card_title: str) -> tuple[str, str]:192 """"Home in Les Laurentides" → ("Maison", "Les Laurentides")."""193 if not card_title:194 return "", ""195 kind, city = card_title, ""196 for sep in (" in ", " à ", " a ", " : "):197 if sep in card_title:198 kind, city = card_title.split(sep, 1)199 break200 kl = kind.strip().lower()201 for key in _TYPE_KEYS:202 if kl.startswith(key):203 return TYPE_MAP[key], city.strip()204 return ("Autre" if kind != card_title or kl else ""), city.strip()205206207class Airbnb(StConnector):208 source_id = "airbnb"209 request_delay = 0.4210211 # -- fetch HTML -----------------------------------------------------------212 def _brightdata(self, url: str) -> str:213 key = os.environ.get("BRIGHTDATA_API_KEY")214 if not key:215 return ""216 wait = self.request_delay - (time.time() - self._last_request)217 if wait > 0:218 time.sleep(wait)219 try:220 resp = requests.post(221 BRIGHTDATA_API,222 headers={"Authorization": f"Bearer {key}",223 "Content-Type": "application/json"},224 json={"zone": os.environ.get("BRIGHTDATA_ZONE", "web_unlocker1"),225 "url": url, "format": "raw"},226 timeout=150)227 except requests.RequestException:228 return ""229 finally:230 self._last_request = time.time()231 return resp.text if resp.status_code == 200 else ""232233 def _search_html(self, url: str) -> str:234 """Bright Data d'abord (pas de rendu JS requis), Scrapfly ASP sinon."""235 html = self._brightdata(url)236 if "data-deferred-state" in html:237 return html238 html = self.get_scrapfly(url, render_js=False, asp=True)239 if "data-deferred-state" in html:240 return html241 return self.get_scrapfly(url, render_js=True, asp=True,242 rendering_wait=3000)243244 # -- fiche détail (/rooms/<id>) --------------------------------------------245 def _pdp_html(self, room_id: str) -> str:246 """HTML d'une fiche : Bright Data d'abord, Scrapfly ASP en secours247 (pas de rendu JS : le JSON est embarqué côté serveur)."""248 url = f"https://www.airbnb.ca/rooms/{room_id}?locale=en¤cy=CAD"249 html = self._brightdata(url)250 if "data-deferred-state" in html:251 return html252 try:253 return self.get_scrapfly(url, render_js=False, asp=True)254 except Exception: # noqa: BLE001 — 429/403/timeout : simple échec255 return ""256257 @staticmethod258 def _parse_pdp(html: str) -> dict:259 """Champs riches depuis data.node.pdpPresentation du JSON embarqué."""260 pp = None261 for blob in re.findall(262 r'<script[^>]+id="data-deferred-state[^"]*"[^>]*>(.*?)</script>',263 html, re.S):264 try:265 data = json.loads(blob)266 except ValueError:267 continue268 for entry in data.get("niobeClientData") or []:269 if not (isinstance(entry, list) and len(entry) > 1270 and isinstance(entry[1], dict)):271 continue272 node = ((entry[1].get("data") or {}).get("node") or {})273 if isinstance(node.get("pdpPresentation"), dict):274 pp = node["pdpPresentation"]275 break276 if pp:277 break278 if not pp:279 return {}280281 out: dict = {}282 # description longue : texte ORIGINAL de l'hôte (souvent français au283 # Québec), repli sur la version traduite284 desc = (pp.get("descriptions") or {}).get("longDescriptionHtml") or {}285 txt = (desc.get("localizedString")286 or desc.get("localizedStringWithTranslationPreference") or "")287 if txt:288 out["description"] = _html_to_text(txt)[:6000]289290 cap = pp.get("personCapacity")291 if isinstance(cap, (int, float)) and 0 < cap <= 200:292 out["capacity"] = float(cap)293294 # overview.items : "6 guests", "2 bedrooms", "3 beds", "2 baths"295 ov = pp.get("overview") or {}296 for item in ov.get("items") or []:297 low = (item or "").lower()298 m = _NUM_RE.search(low)299 if not m:300 continue301 val = float(m.group(1))302 if "guest" in low:303 out.setdefault("capacity", val)304 elif "bedroom" in low:305 out["bedrooms"] = val306 elif "bed" in low:307 out["beds"] = val308 elif "bath" in low:309 out["bathrooms"] = val310 if ov.get("title"):311 out["overview_title"] = ov["title"]312313 # commodités disponibles (les groupes "Not included" ont available=False)314 amen: list[str] = []315 for grp in (pp.get("amenities") or {}).get("seeAllAmenitiesGroups") or []:316 for a in grp.get("amenities") or []:317 t = (a.get("title") or "").strip()318 if a.get("available") and t and t not in amen:319 amen.append(t)320 if amen:321 out["amenities"] = amen[:120]322323 # règles de la maison → animaux ("No pets", "Pets allowed", "2 pets…")324 for grp in (pp.get("rules") or {}).get("groupItems") or []:325 for it in grp.get("items") or []:326 if it.get("type") != "HOUSE_RULES_PETS":327 continue328 t = (it.get("title") or "").lower()329 if "no pets" in t or "pas d" in t or "aucun animal" in t:330 out["pets"] = "non"331 elif "pets allowed" in t or "animaux accept" in t:332 out["pets"] = "oui"333 elif t:334 out["pets"] = "conditions"335336 loc = (pp.get("localizedLocation") or "").split(",")[0].strip()337 if loc:338 out["city"] = loc339 return out340341 @staticmethod342 def _apply_pdp(lst: StListing, p: dict) -> None:343 """Applique un payload détail sans écraser ce que la carte a fourni."""344 if p.get("description") and not lst.description:345 lst.description = p["description"]346 if p.get("amenities") and not lst.amenities:347 lst.amenities = list(p["amenities"])348 for attr in ("capacity", "bedrooms", "beds", "bathrooms"):349 if getattr(lst, attr) is None and p.get(attr) is not None:350 setattr(lst, attr, p[attr])351 if p.get("pets") and lst.pets is None:352 lst.pets = p["pets"]353 if p.get("city") and not lst.city:354 lst.city = p["city"]355 if not lst.property_type and p.get("overview_title"):356 ptype, _ = _card_type_and_city(p["overview_title"])357 lst.property_type = ptype358 lst.finalize() # drapeaux/citq/pets dérivés du nouveau texte359360 def _enrich_details(self, listings: list[StListing]) -> None:361 """Visite les fiches détail via le cache self.detail() sous budget :362 les hits de cache sont gratuits, seuls les fetchs réseau comptent."""363 limit = max(0, int(os.environ.get("LOUKA_AIRBNB_DETAIL_LIMIT", "800")364 or 800))365 used = enriched = 0366 streak = 0 # échecs réseau consécutifs367368 for lst in listings:369 def fetch_fn(rid=lst.external_id):370 nonlocal used, streak371 if used >= limit or streak >= 8:372 raise _DetailSkip373 used += 1374 html = self._pdp_html(rid)375 if "data-deferred-state" not in html:376 streak += 1377 raise _DetailSkip # blocage/vide : pas de mise en cache378 streak = 0379 return self._parse_pdp(html)380381 try:382 payload = self.detail(lst.external_id, _PDP_KEY, fetch_fn)383 except _DetailSkip:384 continue385 except Exception: # noqa: BLE001 — une fiche ne bloque pas le run386 continue387 if payload:388 self._apply_pdp(lst, payload)389 enriched += 1390 print(f"[airbnb] détail : {enriched} annonces enrichies"391 f" ({used}/{limit} fetchs réseau, série d'échecs {streak})",392 file=sys.stderr)393394 # -- parse JSON embarqué ----------------------------------------------------395 @staticmethod396 def _deferred_results(html: str) -> tuple[list[dict], list[str]]:397 """(searchResults, pageCursors) depuis les <script data-deferred-state-*>."""398 for blob in re.findall(399 r'<script[^>]+id="data-deferred-state[^"]*"[^>]*>(.*?)</script>',400 html, re.S):401 try:402 data = json.loads(blob)403 except ValueError:404 continue405 found: list = []406407 def walk(o):408 if found:409 return410 if isinstance(o, dict):411 res = ((o.get("staysSearch") or {}).get("results")412 if isinstance(o.get("staysSearch"), dict) else None)413 if isinstance(res, dict) and isinstance(414 res.get("searchResults"), list):415 found.append(res)416 return417 for v in o.values():418 walk(v)419 elif isinstance(o, list):420 for v in o:421 walk(v)422423 walk(data)424 if found:425 res = found[0]426 cursors = (res.get("paginationInfo") or {}).get("pageCursors") or []427 return res["searchResults"], list(cursors)428 return [], []429430 # -- une carte → StListing --------------------------------------------------431 def _to_listing(self, r: dict, region: str) -> StListing | None:432 demand = r.get("demandStayListing") or {}433 room_id = _b64_room_id(demand.get("id") or "")434 if not room_id or not room_id.isdigit():435 return None436 name = (((demand.get("description") or {}).get("name") or {})437 .get("localizedStringWithTranslationPreference")438 or ((r.get("nameLocalized") or {})439 .get("localizedStringWithTranslationPreference"))440 or r.get("subtitle") or r.get("title") or "").strip()441 if not name:442 return None443444 card_title = (r.get("title") or "").strip()445 ptype, city = _card_type_and_city(card_title)446447 # note / avis : "4.92 (128)"448 rating = reviews = None449 m = _RATING_RE.match(r.get("avgRatingLocalized") or "")450 if m:451 try:452 rating = float(m.group(1).replace(",", "."))453 reviews = int(re.sub(r"[\s,]", "", m.group(2)))454 except ValueError:455 rating = reviews = None456 if rating is not None and not (0 < rating <= 5):457 rating = reviews = None458459 # chambres / lits / sdb : structuredContent.primaryLine460 bedrooms = beds = baths = None461 sc = r.get("structuredContent") or {}462 for line in (sc.get("primaryLine") or []):463 body = (line.get("body") or "").lower()464 mm = _NUM_RE.search(body)465 if not mm:466 continue467 val = float(mm.group(1))468 if "bedroom" in body or "chambre" in body:469 bedrooms = val470 elif re.search(r"\bbeds?\b|\blits?\b", body):471 beds = val472 elif "bath" in body or "salle" in body and "bain" in body:473 baths = val474475 # prix : "5 nights x $277.60 CAD" (détail), sinon ligne « per night »476 price_night, price_label = None, ""477 sdp = r.get("structuredDisplayPrice") or {}478 primary = sdp.get("primaryLine") or {}479 blob = json.dumps(sdp, ensure_ascii=False)480 mm = _NIGHTLY_RE.search(blob)481 if mm:482 try:483 price_night = float(mm.group(2).replace(",", ""))484 price_label = f"{mm.group(1)} nights x ${mm.group(2)} CAD"485 except ValueError:486 price_night = None487 if price_night is None:488 label = (primary.get("accessibilityLabel")489 or primary.get("price") or "")490 qual = (primary.get("qualifier") or "").lower()491 mv = _MONEY_RE.search(label)492 if mv and ("night" in qual or "night" in label.lower()493 or "nuit" in label.lower()):494 try:495 price_night = float(mv.group(1).replace(",", ""))496 price_label = label.strip()497 except ValueError:498 price_night = None499 if not price_label:500 price_label = (primary.get("accessibilityLabel") or "").strip()501502 coord = ((demand.get("location") or {}).get("coordinate") or {})503 lat = coord.get("latitude")504 lng = coord.get("longitude")505506 images = [p.get("picture") for p in (r.get("contextualPictures") or [])507 if isinstance(p.get("picture"), str)508 and p["picture"].startswith("http")]509510 details: dict = {}511 badges = [b.get("text") for b in (r.get("badges") or []) if b.get("text")]512 if badges:513 details["badges"] = badges514 if card_title:515 details["card_title"] = card_title516517 return StListing(518 source=self.source_id,519 external_id=room_id,520 url=f"https://www.airbnb.ca/rooms/{room_id}",521 title=name,522 property_type=ptype,523 city=city,524 region=region,525 price_night=price_night,526 price_label=price_label,527 bedrooms=bedrooms,528 beds=beds,529 bathrooms=baths,530 rating=rating,531 reviews=reviews,532 details=details,533 images=images,534 lat=lat if isinstance(lat, (int, float)) else None,535 lng=lng if isinstance(lng, (int, float)) else None,536 ).finalize()537538 # -- quadtree ------------------------------------------------------------539 @staticmethod540 def _map_url(s: float, w: float, n: float, e: float) -> str:541 zoom = min(18, max(4, round(math.log2(360.0 / max(e - w, 1e-6))) + 1))542 return ("https://www.airbnb.ca/s/Qu%C3%A9bec--Canada/homes"543 "?locale=en¤cy=CAD&search_by_map=true"544 f"&ne_lat={n:.5f}&ne_lng={e:.5f}"545 f"&sw_lat={s:.5f}&sw_lng={w:.5f}&zoom_level={zoom}")546547 def _ingest(self, cards: list[dict], out: list[StListing],548 seen: set[str]) -> int:549 added = 0550 for card in cards:551 coord = (((card.get("demandStayListing") or {})552 .get("location") or {}).get("coordinate") or {})553 lat, lng = coord.get("latitude"), coord.get("longitude")554 if lat is not None and lng is not None and not _in_quebec(lat, lng):555 continue # Ontario / États-Unis / NB dans le bbox556 lst = self._to_listing(card, _region_from_latlng(lat, lng))557 if lst is None or lst.external_id in seen:558 continue559 seen.add(lst.external_id)560 out.append(lst)561 added += 1562 return added563564 # -- contrat ------------------------------------------------------------565 def fetch(self) -> list[StListing]:566 pages = max(1, int(os.environ.get("LOUKA_AIRBNB_PAGES", "15") or 15))567 budget = max(1, int(os.environ.get("LOUKA_AIRBNB_BUDGET", "1200")568 or 1200))569 max_depth = max(0, int(os.environ.get("LOUKA_AIRBNB_DEPTH", "7") or 7))570571 s0, w0, n0, e0 = QC_BBOX572 rows, cols = GRID0573 dlat, dlng = (n0 - s0) / rows, (e0 - w0) / cols574 stack: list[tuple[float, float, float, float, int]] = [575 (s0 + i * dlat, w0 + j * dlng,576 s0 + (i + 1) * dlat, w0 + (j + 1) * dlng, 0)577 for i in range(rows) for j in range(cols)578 ]579 random.shuffle(stack)580581 out: list[StListing] = []582 seen: set[str] = set()583 used = 0584585 while stack and used < budget:586 cs, cw, cn, ce, depth = stack.pop()587 if not _cell_touches_qc(cs, cw, cn, ce):588 continue # cellule entièrement hors Québec589 base = self._map_url(cs, cw, cn, ce)590 used += 1591 try:592 html = self._search_html(base)593 results, cursors = self._deferred_results(html)594 except Exception as exc: # noqa: BLE001 — une cellule ne bloque pas les autres595 print(f"[airbnb] cellule ({cs:.2f},{cw:.2f}) en échec : {exc}",596 file=sys.stderr)597 continue598 if not results:599 continue600601 if len(cursors) >= 15 and depth < max_depth:602 # Cellule saturée (~270 résultats) → on garde la page 1 (déjà603 # payée, dédoublonnée) et on subdivise en 4.604 self._ingest(results, out, seen)605 mlat, mlng = (cs + cn) / 2, (cw + ce) / 2606 stack.extend([607 (cs, cw, mlat, mlng, depth + 1),608 (cs, mlng, mlat, ce, depth + 1),609 (mlat, cw, cn, mlng, depth + 1),610 (mlat, mlng, cn, ce, depth + 1),611 ])612 print(f"[airbnb] cellule ({cs:.2f},{cw:.2f})→({cn:.2f},{ce:.2f})"613 f" saturée → subdivision (prof. {depth + 1},"614 f" req {used}/{budget})", file=sys.stderr)615 continue616617 n_before = len(out)618 self._ingest(results, out, seen)619 for cur in cursors[1:pages]:620 if used >= budget:621 break622 used += 1623 try:624 more, _ = self._deferred_results(self._search_html(625 base + "&cursor=" + urllib.parse.quote(cur, safe="")))626 except Exception: # noqa: BLE001627 break628 if not more:629 break630 self._ingest(more, out, seen)631 print(f"[airbnb] cellule ({cs:.2f},{cw:.2f})→({cn:.2f},{ce:.2f}) :"632 f" {len(out) - n_before} nouvelles (total {len(out)},"633 f" req {used}/{budget})", file=sys.stderr)634635 if stack:636 print(f"[airbnb] budget épuisé ({budget} req),"637 f" {len(stack)} cellules non visitées", file=sys.stderr)638 self._enrich_details(out)639 return out640