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 time2627from .base import BaseConnector28from ..schema import PropertyListing2930API = "https://svc.moxiworks.com/service/v1/listing/search_v2"31COMPANY_UUID = "3341197" # CENTURY 21 Canada32SITE_OWNER = "825ef2f8-0716-4a50-862c-ddad45234e9c" # c21.ca (config publique)3334SLICE_MAX = 480 # pgsize plafonne à 500 : la tranche doit tenir en 1 requête35PGSIZE = 50036PRICE_CEIL = 100_000_0003738_CB_RE = re.compile(r"^/\*\*/cb\(|\)\s*$")3940BASE_PARAMS = {41 "status": "active",42 "pstatus": "1,11",43 "sort_by": "10",44 "company_uuid": COMPANY_UUID,45 "ptype": "1,2,3,4,5,7,9,8",46 "searchType": "criteria",47 "omit_hidden": "true",48 "ex_pend": "true",49 "currency": "CAD",50 "callback": "cb",51 # attribution : indispensable pour obtenir result_list52 "send_from_agent": "true",53 "from_aws": "true",54 "from_app": "aws:https://www.c21.ca",55 "source": "agent website",56 "site_type": "Brokerage Website",57 "site_owner_uuid": SITE_OWNER,58}596061def _num(v) -> float | None:62 try:63 f = float(v)64 return f if f == f else None65 except (TypeError, ValueError):66 return None676869class _C21Province(BaseConnector):70 """Century 21 Canada — une province (voir les sous-classes en bas)."""7172 province = "" # libellé exact pour location_search_field73 state_code = "" # code à 2 lettres attendu dans location.state74 request_delay = 0.357576 # ------------------------------------------------------------------ API --77 def _query(self, pricemin: int | None, pricemax: int | None,78 startidx: int = 0, pgsize: int = PGSIZE) -> dict:79 params = dict(BASE_PARAMS)80 params["location_search_field"] = self.province81 params["pgsize"] = str(pgsize)82 params["startidx"] = str(startidx)83 if pricemin is not None and pricemin > 0:84 params["pricemin"] = str(pricemin)85 if pricemax is not None:86 params["pricemax"] = str(pricemax)87 resp = self.get(API, params=params)88 body = _CB_RE.sub("", resp.text.strip())89 d = json.loads(body)90 if d.get("status") != "success":91 raise RuntimeError(f"c21 API: {d.get('message', d.get('status'))}")92 return d["data"]9394 def _count(self, lo: int | None, hi: int | None) -> int:95 d = self._query(lo, hi, startidx=0, pgsize=1)96 return int(d.get("number_found") or 0)9798 # ------------------------------------------------------ tranches de prix --99 def _slices(self) -> list[tuple[int | None, int | None]]:100 """Tranches [lo, hi] dont number_found ≤ SLICE_MAX (scission dichotomique)."""101 out: list[tuple[int | None, int | None]] = []102 stack: list[tuple[int, int]] = [(0, PRICE_CEIL)]103 while stack:104 lo, hi = stack.pop()105 n = self._count(lo or None, hi)106 time.sleep(self.request_delay)107 if n == 0:108 continue109 if n <= SLICE_MAX or hi - lo <= 200:110 # tranche insécable > 500 : tronquée à pgsize (cas rarissime —111 # plus de 500 fiches au même prix à 200 $ près)112 out.append((lo or None, hi))113 continue114 # scission au point médian géométrique (les prix sont log-normaux)115 mid = int((max(lo, 10_000) * hi) ** 0.5)116 if mid <= lo or mid >= hi:117 mid = (lo + hi) // 2118 stack.append((lo, mid))119 stack.append((mid + 1, hi))120 return out121122 # ------------------------------------------------------------- mapping --123 def _to_listing(self, r: dict) -> PropertyListing | None:124 loc = r.get("location") or {}125 state = (loc.get("state") or "").upper()126 if state == "QC": # périmètre House-Ka : hors Québec127 return None128 if self.state_code and state and state != self.state_code:129 return None130 lid = r.get("listingid")131 if not lid:132 return None133 slug = r.get("url_slug") or ""134 url = f"https://www.c21.ca/listing{slug}" if slug else "https://www.c21.ca"135136 # salles de bains Moxi : « 6.1 » = 6 complètes + 1 d'eau137 baths_raw = _num(r.get("bathrooms"))138 baths = powder = None139 if baths_raw is not None:140 baths = int(baths_raw)141 dec = round((baths_raw - baths) * 10)142 powder = dec if 0 < dec <= 5 else None143144 images = []145 for img in (r.get("image") or []):146 u = img.get("full_url") or img.get("gallery_url")147 if u:148 images.append(u)149150 details = {k: v for k, v in {151 "Property Type": r.get("property_type"),152 "County": loc.get("county"),153 "Postal code": loc.get("zip"),154 "Listed date": r.get("listed_date"),155 "Subdivision": r.get("subdivision"),156 "MLS® Number": r.get("mlsnumber"),157 "Listing office": r.get("officename") or r.get("listing_office"),158 }.items() if v}159160 lst = PropertyListing(161 source=self.source_id,162 external_id=f"c21{lid}",163 url=url,164 address=(loc.get("address") or "").strip(),165 city=(loc.get("city") or "").strip(),166 region=self.province,167 property_type=r.get("property_type") or "",168 price=_num(r.get("list_price")),169 price_label=(f"${int(r['list_price']):,}"170 if _num(r.get("list_price")) else ""),171 bedrooms=int(_num(r.get("bedrooms")) or 0) or None,172 bathrooms=baths,173 powder_rooms=powder,174 area_sqft=_num(r.get("sqr_footage")) or _num(r.get("living_area")),175 lot_sqft=_num(r.get("lot_sqr_footage")),176 year_built=int(_num(r.get("year_build")) or 0) or None,177 mls=str(r.get("mlsnumber") or ""),178 broker_name=(r.get("agentname") or r.get("officename") or179 "Century 21").strip(),180 agency=(r.get("officename") or "Century 21 Canada").strip(),181 description=(r.get("comments") or "").strip()[:6000],182 details=details,183 images=images,184 lat=_num(loc.get("latitude")),185 lng=_num(loc.get("longitude")),186 )187 return lst188189 # --------------------------------------------------------------- fetch --190 def fetch(self) -> list[PropertyListing]:191 by_id: dict[str, PropertyListing] = {}192 for lo, hi in self._slices():193 d = self._query(lo, hi, pgsize=PGSIZE)194 for r in (d.get("result_list") or []):195 lst = self._to_listing(r)196 if lst is not None and lst.external_id not in by_id:197 by_id[lst.external_id] = lst198 time.sleep(self.request_delay)199 return list(by_id.values())200201202# --- une source par province (le Québec vit sur immo-ka) ----------------------203_PROVINCES = [204 ("c21_bc", "British Columbia", "BC"),205 ("c21_ab", "Alberta", "AB"),206 ("c21_sk", "Saskatchewan", "SK"),207 ("c21_mb", "Manitoba", "MB"),208 ("c21_on", "Ontario", "ON"),209 ("c21_nb", "New Brunswick", "NB"),210 ("c21_ns", "Nova Scotia", "NS"),211 ("c21_pe", "Prince Edward Island", "PE"),212 ("c21_nl", "Newfoundland and Labrador", "NL"),213 ("c21_yt", "Yukon", "YT"),214 ("c21_nt", "Northwest Territories", "NT"),215]216217for _sid, _prov, _code in _PROVINCES:218 globals()[f"C21_{_code}"] = type(219 f"C21{_code}",220 (_C21Province,),221 {"source_id": _sid, "province": _prov, "state_code": _code},222 )223