SPB Git forge

spb/rent-ka

Public
8commits 1branches 0releases
7.4 MBsize
maindefault branch
19 days agolast push
Python 68.8% TypeScript 18.6% CSS 8.7% JavaScript 3.3% HTML 0.6%
16.7 KB · 377 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Rent-Ka — Rental listings aggregator (Canada, outside Québec)3# Author: Simon-Pierre Boucher — contact@spboucher.ai4# connectors/zumper.py : Zumper (zumper.com) — North-American rental portal,5#   filtered to CANADA OUTSIDE QUÉBEC, queried DIRECTLY (no paid proxy).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#   Search by metro bboxes (city slugs are fragile) + `state` filter on24#   Canadian province codes outside QC (US border bleed excluded).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 of covered metros: name -> (minLat, maxLat, minLng, maxLng)43# overridable: RENTKA_ZUMPER_REGIONS="name:minLat,maxLat,minLng,maxLng;…"44_DEFAULT_REGIONS = {45    # Ontario46    "toronto-gta":    (43.35, 44.10, -80.00, -78.60),   # Toronto + GTA47    "hamilton":       (42.85, 43.35, -80.00, -79.00),   # Hamilton/Niagara48    "kitchener":      (43.30, 43.65, -80.65, -80.15),   # KW + Cambridge49    "london":         (42.85, 43.10, -81.45, -81.05),50    "windsor":        (42.20, 42.45, -83.15, -82.80),51    "ottawa":         (45.20, 45.55, -76.00, -75.40),   # state filter drops Gatineau52    "kingston":       (44.15, 44.35, -76.70, -76.35),53    "barrie":         (44.30, 44.45, -79.80, -79.55),54    # British Columbia55    "vancouver":      (49.00, 49.40, -123.30, -122.50), # Metro Vancouver56    "victoria":       (48.40, 48.70, -123.60, -123.25),57    "kelowna":        (49.80, 50.00, -119.60, -119.30),58    # Prairies59    "calgary":        (50.85, 51.25, -114.35, -113.85),60    "edmonton":       (53.35, 53.75, -113.75, -113.25),61    "winnipeg":       (49.75, 50.00, -97.40, -96.95),62    "saskatoon":      (52.05, 52.25, -106.85, -106.45),63    "regina":         (50.35, 50.55, -104.75, -104.45),64    # Atlantic65    "halifax":        (44.55, 44.80, -63.75, -63.45),66    "moncton":        (46.05, 46.20, -64.90, -64.65),67    "st-johns":       (47.45, 47.65, -52.90, -52.60),68}6970# Canadian province codes accepted from the API's `state` field (Zumper is71# North-American: border bboxes could bleed US rows — MI, WA, NY…)72_PROVINCES = {"ON", "BC", "AB", "SK", "MB", "NB", "NS", "PE", "NL",73              "YT", "NT", "NU"}747576def _regions() -> dict[str, tuple[float, float, float, float]]:77    raw = os.environ.get("RENTKA_ZUMPER_REGIONS", "")78    if not raw.strip():79        return _DEFAULT_REGIONS80    out = {}81    for part in raw.split(";"):82        if ":" not in part:83            continue84        name, coords = part.split(":", 1)85        try:86            a, b, c, d = (float(x) for x in coords.split(","))87            out[name.strip()] = (a, b, c, d)88        except ValueError:89            continue90    return out or _DEFAULT_REGIONS919293PAGE_SIZE = int(os.environ.get("RENTKA_ZUMPER_PAGE_SIZE", "100"))94MAX_PER_REGION = int(os.environ.get("RENTKA_ZUMPER_MAX_PER_REGION", "1500"))95# budget de fiches immeuble (vraies requêtes) par synchronisation — le cache BD96# (clé modified_on) absorbe l'essentiel après le premier passage97DETAIL_LIMIT = int(os.environ.get("RENTKA_ZUMPER_DETAIL_LIMIT", "150"))9899100def _unit_type_from_bedrooms(bedrooms) -> str:101    if bedrooms is None:102        return ""103    try:104        n = int(float(bedrooms))105    except (TypeError, ValueError):106        return ""107    if n <= 0:108        return "Studio"109    if n >= 5:110        return "5+ bedrooms"111    return f"{n} bedroom" + ("s" if n > 1 else "")112113114class ZumperConnector(BaseConnector):115    source_id = "zumper"116    request_delay = 1.2117118    def __init__(self) -> None:119        super().__init__()120        self._csrf = ""121        self.session.headers.update({122            "Accept": "application/json",123            "Origin": BASE,124            "Referer": f"{BASE}/apartments-for-rent/montreal-qc",125        })126127    # -- rituel CSRF -----------------------------------------------------------128    def _bootstrap(self) -> None:129        data = self.get(BUNDLE_URL).json()130        self._csrf = data.get("csrf") or ""131        if not self._csrf:132            raise RuntimeError("zumper: jeton csrf absent du bundle")133134    def _api(self, method: str, url: str, payload: dict | None = None) -> dict:135        """Appel API avec en-tête CSRF + une reprise sur 429/403 (rebootstrap)."""136        if not self._csrf:137            self._bootstrap()138        headers = {"x-csrftoken": self._csrf,139                   "content-type": "application/json"}140        for attempt in (1, 2):141            try:142                if method == "POST":143                    resp = self.post(url, data=json.dumps(payload or {}),144                                     headers=headers)145                else:146                    resp = self.get(url, headers=headers)147                return resp.json()148            except Exception as exc:149                code = getattr(getattr(exc, "response", None),150                               "status_code", None)151                if attempt == 1 and code in (403, 429):152                    time.sleep(30 if code == 429 else 3)153                    self._bootstrap()154                    headers["x-csrftoken"] = self._csrf155                    continue156                raise157        return {}158159    # -- collecte --------------------------------------------------------------160    def fetch(self) -> list[Listing]:161        rows: dict[int, dict] = {}162        for name, (min_lat, max_lat, min_lng, max_lng) in _regions().items():163            offset = 0164            while offset < MAX_PER_REGION:165                data = self._api("POST", LISTABLES_URL, {166                    "external": True, "longTerm": True, "shortTerm": False,167                    "minPrice": 0, "transits": {},168                    "minLat": min_lat, "maxLat": max_lat,169                    "minLng": min_lng, "maxLng": max_lng,170                    "limit": PAGE_SIZE, "offset": offset,171                    "matching": True, "excludeGroupIds": [],172                    "ignorePopular": True, "descriptionLength": 0,173                })174                batch = data.get("listables") or []175                for row in batch:176                    lid = row.get("listing_id")177                    if lid and row.get("state") in _PROVINCES:178                        rows.setdefault(int(lid), row)179                total = data.get("matching") or 0180                offset += PAGE_SIZE181                if not batch or offset >= total:182                    break183184        listings: dict[str, Listing] = {}185        budget = DETAIL_LIMIT186        for row in rows.values():187            multi = (row.get("floorplan_count") or 1) > 1 or (188                row.get("min_price") != row.get("max_price"))189            building_id = row.get("building_id")190            expanded = False191            payload = None192            # fiche immeuble même pour les rangées à unité unique : c'est la193            # SEULE source de description (listables ne renvoie jamais194            # short_description, peu importe descriptionLength — testé195            # 2026-08-18) ; cache BD (clé modified_on) + budget partagés196            if building_id:197                payload, budget = self._building_units(row, budget)198            if multi and payload:199                for unit in payload.get("floorplans", []):200                    lst = self._unit_listing(row, unit, payload)201                    if lst and lst.uid not in listings:202                        listings[lst.uid] = lst203                        expanded = True204            if not expanded:205                desc = (payload or {}).get("description") or ""206                if not desc and not row.get("short_description"):207                    # pad sans immeuble (building_id null) : la description208                    # vit sur /api/t/1/listings/{id} (fiche du pad)209                    pad, budget = self._pad_detail(row, budget)210                    desc = (pad or {}).get("description") or ""211                lst = self._row_listing(row, desc)212                if lst and lst.uid not in listings:213                    listings[lst.uid] = lst214        return list(listings.values())215216    # -- fiche pad (cache BD, clé modified_on) ----------------------------------217    def _pad_detail(self, row: dict, budget: int) -> tuple[dict | None, int]:218        """Fiche /api/t/1/listings/{id} d'un pad individuel : description219        longue (1 000-2 500 caractères). Même cache/budget que les immeubles."""220        lid = row.get("listing_id")221        if not lid:222            return None, budget223        key = f"m{row.get('modified_on') or 0}"224        from .. import db225        if self._detail_con is None:226            self._detail_con = db.connect()227        cached = db.get_cached_detail(self._detail_con, self.source_id,228                                      f"p{lid}", key)229        if cached is not None:230            return cached, budget231        if budget <= 0:232            return None, budget233        budget -= 1234        try:235            data = self._api("GET", LISTING_URL.format(lid))236        except Exception:237            return None, budget238        payload = {"description": (data.get("description") or "")[:2500]}239        db.put_cached_detail(self._detail_con, self.source_id,240                             f"p{lid}", key, payload)241        return payload, budget242243    # -- fiche immeuble (cache BD, clé modified_on) -----------------------------244    def _building_units(self, row: dict, budget: int) -> tuple[dict | None, int]:245        building_id = row["building_id"]246        key = f"m{row.get('modified_on') or 0}"247        from .. import db248        if self._detail_con is None:249            self._detail_con = db.connect()250        cached = db.get_cached_detail(self._detail_con, self.source_id,251                                      f"b{building_id}", key)252        if cached is not None:253            return cached, budget254        if budget <= 0:255            return None, budget256        budget -= 1257        try:258            data = self._api("GET", BUILDING_URL.format(building_id))259        except Exception:260            return None, budget261        fps = []262        for fp in data.get("floorplan_listings") or []:263            if fp.get("listing_status") not in (None, 1):264                continue265            fps.append({266                "listing_id": fp.get("listing_id"),267                "title": fp.get("title") or "",268                "price": fp.get("min_price") or fp.get("price"),269                "max_price": fp.get("max_price"),270                "bedrooms": fp.get("bedrooms"),271                "bathrooms": fp.get("bathrooms"),272                "half_bathrooms": fp.get("half_bathrooms"),273                "square_feet": fp.get("square_feet"),274                "date_available": fp.get("date_available"),275                "image_ids": [m.get("media_id") for m in fp.get("media") or []276                              if m.get("media_type") == 1][:8],277            })278        payload = {"floorplans": fps,279                   "description": (data.get("description") or "")[:2000],280                   "year_built": data.get("year_built"),281                   "homepage": data.get("homepage")}282        db.put_cached_detail(self._detail_con, self.source_id,283                             f"b{building_id}", key, payload)284        return payload, budget285286    # -- constructions ----------------------------------------------------------287    def _common(self, row: dict) -> dict:288        amen = list(dict.fromkeys((row.get("amenity_tags") or []) +289                                  (row.get("building_amenity_tags") or [])))290        details = {k: row[k] for k in ("neighborhood_name", "brokerage_name",291                                       "building_name", "zipcode")292                   if row.get(k)}293        return {"amenities": amen[:40], "details": details}294295    def _url(self, row: dict) -> str:296        path = row.get("url") or ""297        if path.startswith("/"):298            return f"{BASE}{path}"299        if row.get("pb_url"):300            return f"{BASE}/apartment-buildings/p{row.get('pb_id')}/{row['pb_url']}"301        return f"{BASE}/apartments-for-rent/montreal-qc"302303    def _unit_listing(self, row: dict, unit: dict, payload: dict) -> Listing | None:304        price = unit.get("price")305        if not price or price < 200:306            return None307        ext_id = str(unit.get("listing_id") or308                     f"{row['building_id']}-{unit.get('title', '')}")309        beds = unit.get("bedrooms")310        baths = unit.get("bathrooms")311        if baths is not None and unit.get("half_bathrooms"):312            baths = float(baths) + 0.5 * float(unit["half_bathrooms"])313        common = self._common(row)314        imgs = [IMG_URL.format(m) for m in unit.get("image_ids") or []] or \315               [IMG_URL.format(m) for m in (row.get("image_ids") or [])[:8]]316        avail = unit.get("date_available") or ""317        return Listing(318            source=self.source_id,319            external_id=ext_id,320            url=self._url(row),321            title=(f"{row.get('building_name') or row.get('address', '')} — "322                   f"{unit.get('title') or ''}").strip(" —"),323            address=row.get("address", ""),324            sector=row.get("neighborhood_name") or "",325            city=row.get("city", ""),326            province=row.get("state") or "ON",327            unit_type=_unit_type_from_bedrooms(beds),328            bedrooms=float(beds) if beds is not None else None,329            bathrooms=float(baths) if baths is not None else None,330            price=float(price),331            price_label=f"${price:,.0f}/month",332            availability=str(avail),333            area_sqft=(float(unit["square_feet"])334                       if unit.get("square_feet") else None),335            description=payload.get("description") or "",336            amenities=common["amenities"],337            details=common["details"],338            images=imgs,339            lat=row.get("lat"),340            lng=row.get("lng"),341        )342343    def _row_listing(self, row: dict, description: str = "") -> Listing | None:344        price = row.get("min_price")345        if not price or price < 200:346            return None347        mn, mx = row.get("min_price"), row.get("max_price")348        from_price = bool(mx and mx != mn)349        beds = row.get("min_bedrooms")350        baths = row.get("min_bathrooms")351        common = self._common(row)352        label = (f"From ${price:,.0f}/month" if from_price353                 else f"${price:,.0f}/month")354        return Listing(355            source=self.source_id,356            external_id=str(row["listing_id"]),357            url=self._url(row),358            title=(row.get("building_name") or row.get("title")359                   or row.get("address", "")),360            address=row.get("address", ""),361            sector=row.get("neighborhood_name") or "",362            city=row.get("city", ""),363            province=row.get("state") or "ON",364            unit_type=_unit_type_from_bedrooms(beds),365            bedrooms=float(beds) if beds is not None else None,366            bathrooms=float(baths) if baths is not None else None,367            price=float(price),368            price_label=label,369            availability=str(row.get("date_available") or ""),370            description=row.get("short_description") or description,371            amenities=common["amenities"],372            details=common["details"],373            images=[IMG_URL.format(m) for m in (row.get("image_ids") or [])[:8]],374            lat=row.get("lat"),375            lng=row.get("lng"),376        )377