Python 68.8%
TypeScript 18.6%
CSS 8.7%
JavaScript 3.3%
HTML 0.6%
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