Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
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