Python 67%
TypeScript 18.2%
CSS 14.4%
1# -----------------------------------------------------------------------------2# House-Ka — Agrégateur de maisons à vendre (Canada hors Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/c21_canada.py : Century 21 Canada — API MoxiWorks corporative.5# c21.ca (tous les bureaux C21 du pays) sert sa recherche via l'API JSONP6# svc.moxiworks.com `/service/v1/listing/search_v2`. La réponse LISTE contient7# déjà tout (prix, chambres, GPS, photo, description, MLS®) : aucune passe8# détail nécessaire. Un connecteur par PROVINCE (source c21_bc, c21_ab, …).9#10# Rétro-ingénierie 2026-08-27 (sniff Playwright sur c21.ca/search) :11# - les paramètres d'ATTRIBUTION (`send_from_agent`, `from_aws`, `from_app`,12# `site_owner_uuid`) sont OBLIGATOIRES — sans eux `result_list` reste vide ;13# - `location_search_field=<Province>` filtre par province ;14# - `startidx` est IGNORÉ par l'API (toutes les « pages » renvoient les mêmes15# fiches) et `pgsize` plafonne à 500 → découpage ADAPTATIF par tranches de16# prix (`pricemin`/`pricemax`) jusqu'à number_found ≤ SLICE_MAX, puis UNE17# requête pgsize=500 récupère la tranche entière ;18# - `pstatus=1,11` (active + coming soon), `ptype=1..9` (tous types résidentiels).19# Fiche publique : https://www.c21.ca/listing<url_slug>.20# -----------------------------------------------------------------------------21from __future__ import annotations2223import json24import re25import time2627import requests2829from .base import BaseConnector30from ..schema import PropertyListing3132API = "https://svc.moxiworks.com/service/v1/listing/search_v2"33COMPANY_UUID = "3341197" # CENTURY 21 Canada34SITE_OWNER = "825ef2f8-0716-4a50-862c-ddad45234e9c" # c21.ca (config publique)3536SLICE_MAX = 480 # pgsize plafonne à 500 : la tranche doit tenir en 1 requête37PGSIZE = 50038PRICE_CEIL = 100_000_0003940_CB_RE = re.compile(r"^/\*\*/cb\(|\)\s*$")4142BASE_PARAMS = {43 "status": "active",44 "pstatus": "1,11",45 "sort_by": "10",46 "company_uuid": COMPANY_UUID,47 "ptype": "1,2,3,4,5,7,9,8",48 "searchType": "criteria",49 "omit_hidden": "true",50 "ex_pend": "true",51 "currency": "CAD",52 "callback": "cb",53 # attribution : indispensable pour obtenir result_list54 "send_from_agent": "true",55 "from_aws": "true",56 "from_app": "aws:https://www.c21.ca",57 "source": "agent website",58 "site_type": "Brokerage Website",59 "site_owner_uuid": SITE_OWNER,60}616263def _num(v) -> float | None:64 try:65 f = float(v)66 return f if f == f else None67 except (TypeError, ValueError):68 return None697071class _C21Province(BaseConnector):72 """Century 21 Canada — une province (voir les sous-classes en bas)."""7374 province = "" # libellé exact pour location_search_field75 state_code = "" # code à 2 lettres attendu dans location.state76 locations: tuple = () # localités interrogées (défaut : la province) —77 # l'API n'a PAS de polygone pour certaines78 # provinces (Saskatchewan : « Unable to locate79 # GeoLayer ») → liste de villes à la place80 request_delay = 0.358182 # ------------------------------------------------------------------ API --83 def _query(self, pricemin: int | None, pricemax: int | None,84 startidx: int = 0, pgsize: int = PGSIZE,85 location: str | None = None) -> dict:86 params = dict(BASE_PARAMS)87 params["location_search_field"] = location or self.province88 params["pgsize"] = str(pgsize)89 params["startidx"] = str(startidx)90 if pricemin is not None and pricemin > 0:91 params["pricemin"] = str(pricemin)92 if pricemax is not None:93 params["pricemax"] = str(pricemax)94 # Un fetch provincial = des centaines de requêtes sur ~45 min (Ontario) :95 # Moxi throttle parfois, ou l'escalade anti-bot renvoie un 200 au corps96 # non-JSONP. Sans retry, UNE réponse pourrie avortait tout le sync97 # (cause du stale c21_on 2026-08-29/30) → on retente avec backoff.98 last_exc: Exception | None = None99 for attempt in range(4):100 if attempt:101 time.sleep(min(5.0 * 2 ** (attempt - 1), 20.0))102 try:103 resp = self.get(API, params=params)104 body = _CB_RE.sub("", resp.text.strip())105 d = json.loads(body)106 if d.get("status") != "success":107 raise RuntimeError(108 f"c21 API: {d.get('message', d.get('status'))}")109 return d["data"]110 except (json.JSONDecodeError, RuntimeError,111 requests.RequestException) as exc:112 last_exc = exc113 raise last_exc # type: ignore[misc]114115 def _count(self, lo: int | None, hi: int | None,116 location: str | None = None) -> int:117 d = self._query(lo, hi, startidx=0, pgsize=1, location=location)118 return int(d.get("number_found") or 0)119120 # ------------------------------------------------------ tranches de prix --121 def _slices(self, location: str | None = None122 ) -> list[tuple[int | None, int | None]]:123 """Tranches [lo, hi] dont number_found ≤ SLICE_MAX (scission dichotomique)."""124 out: list[tuple[int | None, int | None]] = []125 stack: list[tuple[int, int]] = [(0, PRICE_CEIL)]126 while stack:127 lo, hi = stack.pop()128 n = self._count(lo or None, hi, location=location)129 time.sleep(self.request_delay)130 if n == 0:131 continue132 if n <= SLICE_MAX or hi - lo <= 200:133 # tranche insécable > 500 : tronquée à pgsize (cas rarissime —134 # plus de 500 fiches au même prix à 200 $ près)135 out.append((lo or None, hi))136 continue137 # scission au point médian géométrique (les prix sont log-normaux)138 mid = int((max(lo, 10_000) * hi) ** 0.5)139 if mid <= lo or mid >= hi:140 mid = (lo + hi) // 2141 stack.append((lo, mid))142 stack.append((mid + 1, hi))143 return out144145 # ------------------------------------------------------------- mapping --146 def _to_listing(self, r: dict) -> PropertyListing | None:147 loc = r.get("location") or {}148 state = (loc.get("state") or "").upper()149 if state == "QC": # périmètre House-Ka : hors Québec150 return None151 if self.state_code and state and state != self.state_code:152 return None153 lid = r.get("listingid")154 if not lid:155 return None156 slug = r.get("url_slug") or ""157 url = f"https://www.c21.ca/listing{slug}" if slug else "https://www.c21.ca"158159 # salles de bains Moxi : « 6.1 » = 6 complètes + 1 d'eau160 baths_raw = _num(r.get("bathrooms"))161 baths = powder = None162 if baths_raw is not None:163 baths = int(baths_raw)164 dec = round((baths_raw - baths) * 10)165 powder = dec if 0 < dec <= 5 else None166167 images = []168 for img in (r.get("image") or []):169 u = img.get("full_url") or img.get("gallery_url")170 if u:171 images.append(u)172173 details = {k: v for k, v in {174 "Property Type": r.get("property_type"),175 "County": loc.get("county"),176 "Postal code": loc.get("zip"),177 "Listed date": r.get("listed_date"),178 "Subdivision": r.get("subdivision"),179 "MLS® Number": r.get("mlsnumber"),180 "Listing office": r.get("officename") or r.get("listing_office"),181 }.items() if v}182183 lst = PropertyListing(184 source=self.source_id,185 external_id=f"c21{lid}",186 url=url,187 address=(loc.get("address") or "").strip(),188 city=(loc.get("city") or "").strip(),189 region=self.province,190 property_type=r.get("property_type") or "",191 price=_num(r.get("list_price")),192 price_label=(f"${int(r['list_price']):,}"193 if _num(r.get("list_price")) else ""),194 bedrooms=int(_num(r.get("bedrooms")) or 0) or None,195 bathrooms=baths,196 powder_rooms=powder,197 area_sqft=_num(r.get("sqr_footage")) or _num(r.get("living_area")),198 lot_sqft=_num(r.get("lot_sqr_footage")),199 year_built=int(_num(r.get("year_build")) or 0) or None,200 mls=str(r.get("mlsnumber") or ""),201 broker_name=(r.get("agentname") or r.get("officename") or202 "Century 21").strip(),203 agency=(r.get("officename") or "Century 21 Canada").strip(),204 description=(r.get("comments") or "").strip()[:6000],205 details=details,206 images=images,207 lat=_num(loc.get("latitude")),208 lng=_num(loc.get("longitude")),209 )210 return lst211212 # --------------------------------------------------------------- fetch --213 def fetch(self) -> list[PropertyListing]:214 by_id: dict[str, PropertyListing] = {}215 for loc in (self.locations or (None,)):216 for lo, hi in self._slices(location=loc):217 d = self._query(lo, hi, pgsize=PGSIZE, location=loc)218 for r in (d.get("result_list") or []):219 lst = self._to_listing(r)220 if lst is not None and lst.external_id not in by_id:221 by_id[lst.external_id] = lst222 time.sleep(self.request_delay)223 return list(by_id.values())224225226# --- une source par province (le Québec vit sur immo-ka) ----------------------227_PROVINCES = [228 ("c21_bc", "British Columbia", "BC"),229 ("c21_ab", "Alberta", "AB"),230 ("c21_sk", "Saskatchewan", "SK"),231 ("c21_mb", "Manitoba", "MB"),232 ("c21_on", "Ontario", "ON"),233 ("c21_nb", "New Brunswick", "NB"),234 ("c21_ns", "Nova Scotia", "NS"),235 ("c21_pe", "Prince Edward Island", "PE"),236 ("c21_nl", "Newfoundland and Labrador", "NL"),237 ("c21_yt", "Yukon", "YT"),238 ("c21_nt", "Northwest Territories", "NT"),239]240241# Saskatchewan : pas de polygone provincial côté API → villes principales242_SK_CITIES = tuple(f"{c}, SK" for c in (243 "Saskatoon", "Regina", "Prince Albert", "Moose Jaw", "Swift Current",244 "Yorkton", "North Battleford", "Estevan", "Weyburn", "Warman",245 "Martensville", "Melfort", "Humboldt", "Meadow Lake", "Kindersley",246 "Melville", "Nipawin", "Tisdale", "La Ronge", "Battleford", "Rosetown",247 "Assiniboia", "Unity", "Canora", "Esterhazy", "Outlook", "Watrous",248 "Moosomin", "Shellbrook", "Fort Qu'Appelle"))249250for _sid, _prov, _code in _PROVINCES:251 _extra = {"locations": _SK_CITIES} if _code == "SK" else {}252 globals()[f"C21_{_code}"] = type(253 f"C21{_code}",254 (_C21Province,),255 {"source_id": _sid, "province": _prov, "state_code": _code, **_extra},256 )257