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%
15.7 KB · 354 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/zumper.py : Zumper (zumper.com) — portail locatif nord-américain,5#   filtré PROVINCE DE QUÉBEC, en DIRECT (aucun proxy payant).6#   API JSON interne (celle du site) :7#     1. GET  /api/t/1/bundle                → cookie `csrftoken` + jeton `csrf`8#     2. POST /api/t/1/pages/listables       → annonces par bbox (en-tête9#        `x-csrftoken`) ; paginé offset/limit, réponse = groupes « immeuble »10#        (fourchettes prix/chambres) et pads individuels, champ `state` filtrable11#     3. GET  /api/t/1/buildings/{id}        → fiche immeuble : description,12#        `floorplan_listings` = TOUTES les unités (prix, chambres, sdb, pi²,13#        disponibilité) — 1 requête par immeuble, mise en cache BD et invalidée14#        par `modified_on` (le champ bouge quand l'inventaire change).15#   Photos : https://img.zumpercdn.com/{media_id}/1280x960 (vérifié 2026-08-18).16#   ⚠️ Le HTML du site est derrière un « Client Challenge » JS, mais l'API17#   répond en direct avec des en-têtes navigateur + le rituel CSRF ci-dessus.18#   Un 429 ponctuel = throttling : une seule reprise après pause, sinon on19#   laisse le disjoncteur d'ingestion faire son travail.20#   ⚠️ PadMapper (padmapper.com) = MÊME compagnie et MÊME API (`/api/t/1/…`,21#   mêmes listing_id — vérifié 100/100 identiques sur Montréal 2026-08-18) :22#   un seul connecteur suffit, `padmapper` reste dans PORTALS (dédup) au cas où.23#   Recherche par bbox métropolitaines (le slug de ville est fragile, ex.24#   « quebec-city-qc » ne matche rien) + filtre state == QC (Ottawa exclu).25# -----------------------------------------------------------------------------26from __future__ import annotations2728import json29import os30import time3132from ..schema import Listing33from .base import BaseConnector3435BASE = "https://www.zumper.com"36BUNDLE_URL = f"{BASE}/api/t/1/bundle"37LISTABLES_URL = f"{BASE}/api/t/1/pages/listables"38BUILDING_URL = f"{BASE}/api/t/1/buildings/{{}}"39LISTING_URL = f"{BASE}/api/t/1/listings/{{}}"40IMG_URL = "https://img.zumpercdn.com/{}/1280x960"4142# bbox des régions QC couvertes : nom -> (minLat, maxLat, minLng, maxLng)43# surchargeable : LOUKA_ZUMPER_REGIONS="nom:minLat,maxLat,minLng,maxLng;…"44_DEFAULT_REGIONS = {45    "montreal":       (45.30, 45.80, -74.10, -73.20),   # inclut Laval/Longueuil46    "quebec-levis":   (46.65, 47.00, -71.55, -71.00),47    "gatineau":       (45.35, 45.75, -76.10, -75.40),   # state==QC exclut Ottawa48    "sherbrooke":     (45.25, 45.55, -72.10, -71.70),49    "trois-rivieres": (46.25, 46.50, -72.75, -72.40),50    "saguenay":       (48.30, 48.55, -71.35, -70.90),51    "drummondville":  (45.80, 45.95, -72.60, -72.40),52    "granby-stjean":  (45.20, 45.45, -73.35, -72.60),53}545556def _regions() -> dict[str, tuple[float, float, float, float]]:57    raw = os.environ.get("LOUKA_ZUMPER_REGIONS", "")58    if not raw.strip():59        return _DEFAULT_REGIONS60    out = {}61    for part in raw.split(";"):62        if ":" not in part:63            continue64        name, coords = part.split(":", 1)65        try:66            a, b, c, d = (float(x) for x in coords.split(","))67            out[name.strip()] = (a, b, c, d)68        except ValueError:69            continue70    return out or _DEFAULT_REGIONS717273PAGE_SIZE = int(os.environ.get("LOUKA_ZUMPER_PAGE_SIZE", "100"))74MAX_PER_REGION = int(os.environ.get("LOUKA_ZUMPER_MAX_PER_REGION", "1500"))75# budget de fiches immeuble (vraies requêtes) par synchronisation — le cache BD76# (clé modified_on) absorbe l'essentiel après le premier passage77DETAIL_LIMIT = int(os.environ.get("LOUKA_ZUMPER_DETAIL_LIMIT", "150"))787980def _unit_type_from_bedrooms(bedrooms) -> str:81    if bedrooms is None:82        return ""83    try:84        n = int(float(bedrooms))85    except (TypeError, ValueError):86        return ""87    if n <= 0:88        return "Studio"89    pieces = n + 2                      # convention QC : n ch. -> (n+2)½90    return "6½+" if pieces >= 6 else f"{pieces}½"919293class ZumperConnector(BaseConnector):94    source_id = "zumper"95    request_delay = 1.29697    def __init__(self) -> None:98        super().__init__()99        self._csrf = ""100        self.session.headers.update({101            "Accept": "application/json",102            "Origin": BASE,103            "Referer": f"{BASE}/apartments-for-rent/montreal-qc",104        })105106    # -- rituel CSRF -----------------------------------------------------------107    def _bootstrap(self) -> None:108        data = self.get(BUNDLE_URL).json()109        self._csrf = data.get("csrf") or ""110        if not self._csrf:111            raise RuntimeError("zumper: jeton csrf absent du bundle")112113    def _api(self, method: str, url: str, payload: dict | None = None) -> dict:114        """Appel API avec en-tête CSRF + une reprise sur 429/403 (rebootstrap)."""115        if not self._csrf:116            self._bootstrap()117        headers = {"x-csrftoken": self._csrf,118                   "content-type": "application/json"}119        for attempt in (1, 2):120            try:121                if method == "POST":122                    resp = self.post(url, data=json.dumps(payload or {}),123                                     headers=headers)124                else:125                    resp = self.get(url, headers=headers)126                return resp.json()127            except Exception as exc:128                code = getattr(getattr(exc, "response", None),129                               "status_code", None)130                if attempt == 1 and code in (403, 429):131                    time.sleep(30 if code == 429 else 3)132                    self._bootstrap()133                    headers["x-csrftoken"] = self._csrf134                    continue135                raise136        return {}137138    # -- collecte --------------------------------------------------------------139    def fetch(self) -> list[Listing]:140        rows: dict[int, dict] = {}141        for name, (min_lat, max_lat, min_lng, max_lng) in _regions().items():142            offset = 0143            while offset < MAX_PER_REGION:144                data = self._api("POST", LISTABLES_URL, {145                    "external": True, "longTerm": True, "shortTerm": False,146                    "minPrice": 0, "transits": {},147                    "minLat": min_lat, "maxLat": max_lat,148                    "minLng": min_lng, "maxLng": max_lng,149                    "limit": PAGE_SIZE, "offset": offset,150                    "matching": True, "excludeGroupIds": [],151                    "ignorePopular": True, "descriptionLength": 0,152                })153                batch = data.get("listables") or []154                for row in batch:155                    lid = row.get("listing_id")156                    if lid and row.get("state") == "QC":157                        rows.setdefault(int(lid), row)158                total = data.get("matching") or 0159                offset += PAGE_SIZE160                if not batch or offset >= total:161                    break162163        listings: dict[str, Listing] = {}164        budget = DETAIL_LIMIT165        for row in rows.values():166            multi = (row.get("floorplan_count") or 1) > 1 or (167                row.get("min_price") != row.get("max_price"))168            building_id = row.get("building_id")169            expanded = False170            payload = None171            # fiche immeuble même pour les rangées à unité unique : c'est la172            # SEULE source de description (listables ne renvoie jamais173            # short_description, peu importe descriptionLength — testé174            # 2026-08-18) ; cache BD (clé modified_on) + budget partagés175            if building_id:176                payload, budget = self._building_units(row, budget)177            if multi and payload:178                for unit in payload.get("floorplans", []):179                    lst = self._unit_listing(row, unit, payload)180                    if lst and lst.uid not in listings:181                        listings[lst.uid] = lst182                        expanded = True183            if not expanded:184                desc = (payload or {}).get("description") or ""185                if not desc and not row.get("short_description"):186                    # pad sans immeuble (building_id null) : la description187                    # vit sur /api/t/1/listings/{id} (fiche du pad)188                    pad, budget = self._pad_detail(row, budget)189                    desc = (pad or {}).get("description") or ""190                lst = self._row_listing(row, desc)191                if lst and lst.uid not in listings:192                    listings[lst.uid] = lst193        return list(listings.values())194195    # -- fiche pad (cache BD, clé modified_on) ----------------------------------196    def _pad_detail(self, row: dict, budget: int) -> tuple[dict | None, int]:197        """Fiche /api/t/1/listings/{id} d'un pad individuel : description198        longue (1 000-2 500 caractères). Même cache/budget que les immeubles."""199        lid = row.get("listing_id")200        if not lid:201            return None, budget202        key = f"m{row.get('modified_on') or 0}"203        from .. import db204        if self._detail_con is None:205            self._detail_con = db.connect()206        cached = db.get_cached_detail(self._detail_con, self.source_id,207                                      f"p{lid}", key)208        if cached is not None:209            return cached, budget210        if budget <= 0:211            return None, budget212        budget -= 1213        try:214            data = self._api("GET", LISTING_URL.format(lid))215        except Exception:216            return None, budget217        payload = {"description": (data.get("description") or "")[:2500]}218        db.put_cached_detail(self._detail_con, self.source_id,219                             f"p{lid}", key, payload)220        return payload, budget221222    # -- fiche immeuble (cache BD, clé modified_on) -----------------------------223    def _building_units(self, row: dict, budget: int) -> tuple[dict | None, int]:224        building_id = row["building_id"]225        key = f"m{row.get('modified_on') or 0}"226        from .. import db227        if self._detail_con is None:228            self._detail_con = db.connect()229        cached = db.get_cached_detail(self._detail_con, self.source_id,230                                      f"b{building_id}", key)231        if cached is not None:232            return cached, budget233        if budget <= 0:234            return None, budget235        budget -= 1236        try:237            data = self._api("GET", BUILDING_URL.format(building_id))238        except Exception:239            return None, budget240        fps = []241        for fp in data.get("floorplan_listings") or []:242            if fp.get("listing_status") not in (None, 1):243                continue244            fps.append({245                "listing_id": fp.get("listing_id"),246                "title": fp.get("title") or "",247                "price": fp.get("min_price") or fp.get("price"),248                "max_price": fp.get("max_price"),249                "bedrooms": fp.get("bedrooms"),250                "bathrooms": fp.get("bathrooms"),251                "half_bathrooms": fp.get("half_bathrooms"),252                "square_feet": fp.get("square_feet"),253                "date_available": fp.get("date_available"),254                "image_ids": [m.get("media_id") for m in fp.get("media") or []255                              if m.get("media_type") == 1][:8],256            })257        payload = {"floorplans": fps,258                   "description": (data.get("description") or "")[:2000],259                   "year_built": data.get("year_built"),260                   "homepage": data.get("homepage")}261        db.put_cached_detail(self._detail_con, self.source_id,262                             f"b{building_id}", key, payload)263        return payload, budget264265    # -- constructions ----------------------------------------------------------266    def _common(self, row: dict) -> dict:267        amen = list(dict.fromkeys((row.get("amenity_tags") or []) +268                                  (row.get("building_amenity_tags") or [])))269        details = {k: row[k] for k in ("neighborhood_name", "brokerage_name",270                                       "building_name", "zipcode")271                   if row.get(k)}272        return {"amenities": amen[:40], "details": details}273274    def _url(self, row: dict) -> str:275        path = row.get("url") or ""276        if path.startswith("/"):277            return f"{BASE}{path}"278        if row.get("pb_url"):279            return f"{BASE}/apartment-buildings/p{row.get('pb_id')}/{row['pb_url']}"280        return f"{BASE}/apartments-for-rent/montreal-qc"281282    def _unit_listing(self, row: dict, unit: dict, payload: dict) -> Listing | None:283        price = unit.get("price")284        if not price or price < 200:285            return None286        ext_id = str(unit.get("listing_id") or287                     f"{row['building_id']}-{unit.get('title', '')}")288        beds = unit.get("bedrooms")289        baths = unit.get("bathrooms")290        if baths is not None and unit.get("half_bathrooms"):291            baths = float(baths) + 0.5 * float(unit["half_bathrooms"])292        common = self._common(row)293        imgs = [IMG_URL.format(m) for m in unit.get("image_ids") or []] or \294               [IMG_URL.format(m) for m in (row.get("image_ids") or [])[:8]]295        avail = unit.get("date_available") or ""296        return Listing(297            source=self.source_id,298            external_id=ext_id,299            url=self._url(row),300            title=(f"{row.get('building_name') or row.get('address', '')} — "301                   f"{unit.get('title') or ''}").strip(" —"),302            address=row.get("address", ""),303            sector=row.get("neighborhood_name") or "",304            city=row.get("city", ""),305            unit_type=_unit_type_from_bedrooms(beds),306            bedrooms=float(beds) if beds is not None else None,307            bathrooms=float(baths) if baths is not None else None,308            price=float(price),309            price_label=f"{price:,.0f} $/mois".replace(",", " "),310            availability=str(avail),311            area_sqft=(float(unit["square_feet"])312                       if unit.get("square_feet") else None),313            description=payload.get("description") or "",314            amenities=common["amenities"],315            details=common["details"],316            images=imgs,317            lat=row.get("lat"),318            lng=row.get("lng"),319        )320321    def _row_listing(self, row: dict, description: str = "") -> Listing | None:322        price = row.get("min_price")323        if not price or price < 200:324            return None325        mn, mx = row.get("min_price"), row.get("max_price")326        from_price = bool(mx and mx != mn)327        beds = row.get("min_bedrooms")328        baths = row.get("min_bathrooms")329        common = self._common(row)330        label = (f"à partir de {price:,.0f} $/mois" if from_price331                 else f"{price:,.0f} $/mois").replace(",", " ")332        return Listing(333            source=self.source_id,334            external_id=str(row["listing_id"]),335            url=self._url(row),336            title=(row.get("building_name") or row.get("title")337                   or row.get("address", "")),338            address=row.get("address", ""),339            sector=row.get("neighborhood_name") or "",340            city=row.get("city", ""),341            unit_type=_unit_type_from_bedrooms(beds),342            bedrooms=float(beds) if beds is not None else None,343            bathrooms=float(baths) if baths is not None else None,344            price=float(price),345            price_label=label,346            availability=str(row.get("date_available") or ""),347            description=row.get("short_description") or description,348            amenities=common["amenities"],349            details=common["details"],350            images=[IMG_URL.format(m) for m in (row.get("image_ids") or [])[:8]],351            lat=row.get("lat"),352            lng=row.get("lng"),353        )354