SPB Git forge

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
26.7 KB · 640 lines python
Raw Blame History
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&currency=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&currency=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