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, pagination14# `pgsize` (200 max utile) + `startidx` ;15# - la liste n'est retournée QUE si `number_found` ≲ 6-10 k → découpage16# ADAPTATIF par tranches de prix (`pricemin`/`pricemax`) jusqu'à ≤ SLICE_MAX,17# puis pagination de chaque tranche ;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 = 5500 # au-delà, l'API ne retourne plus la liste → scinder35PGSIZE = 20036PRICE_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 <= 5000:110 out.append((lo or None, hi))111 continue112 # scission au point médian géométrique (les prix sont log-normaux)113 mid = int((max(lo, 10_000) * hi) ** 0.5)114 if mid <= lo or mid >= hi:115 mid = (lo + hi) // 2116 stack.append((lo, mid))117 stack.append((mid + 1, hi))118 return out119120 # ------------------------------------------------------------- mapping --121 def _to_listing(self, r: dict) -> PropertyListing | None:122 loc = r.get("location") or {}123 state = (loc.get("state") or "").upper()124 if state == "QC": # périmètre House-Ka : hors Québec125 return None126 if self.state_code and state and state != self.state_code:127 return None128 lid = r.get("listingid")129 if not lid:130 return None131 slug = r.get("url_slug") or ""132 url = f"https://www.c21.ca/listing{slug}" if slug else "https://www.c21.ca"133134 # salles de bains Moxi : « 6.1 » = 6 complètes + 1 d'eau135 baths_raw = _num(r.get("bathrooms"))136 baths = powder = None137 if baths_raw is not None:138 baths = int(baths_raw)139 dec = round((baths_raw - baths) * 10)140 powder = dec if 0 < dec <= 5 else None141142 images = []143 for img in (r.get("image") or []):144 u = img.get("full_url") or img.get("gallery_url")145 if u:146 images.append(u)147148 details = {k: v for k, v in {149 "Property Type": r.get("property_type"),150 "County": loc.get("county"),151 "Postal code": loc.get("zip"),152 "Listed date": r.get("listed_date"),153 "Subdivision": r.get("subdivision"),154 "MLS® Number": r.get("mlsnumber"),155 "Listing office": r.get("officename") or r.get("listing_office"),156 }.items() if v}157158 lst = PropertyListing(159 source=self.source_id,160 external_id=f"c21{lid}",161 url=url,162 address=(loc.get("address") or "").strip(),163 city=(loc.get("city") or "").strip(),164 region=self.province,165 property_type=r.get("property_type") or "",166 price=_num(r.get("list_price")),167 price_label=(f"${int(r['list_price']):,}"168 if _num(r.get("list_price")) else ""),169 bedrooms=int(_num(r.get("bedrooms")) or 0) or None,170 bathrooms=baths,171 powder_rooms=powder,172 area_sqft=_num(r.get("sqr_footage")) or _num(r.get("living_area")),173 lot_sqft=_num(r.get("lot_sqr_footage")),174 year_built=int(_num(r.get("year_build")) or 0) or None,175 mls=str(r.get("mlsnumber") or ""),176 broker_name=(r.get("agentname") or r.get("officename") or177 "Century 21").strip(),178 agency=(r.get("officename") or "Century 21 Canada").strip(),179 description=(r.get("comments") or "").strip()[:6000],180 details=details,181 images=images,182 lat=_num(loc.get("latitude")),183 lng=_num(loc.get("longitude")),184 )185 return lst186187 # --------------------------------------------------------------- fetch --188 def fetch(self) -> list[PropertyListing]:189 by_id: dict[str, PropertyListing] = {}190 for lo, hi in self._slices():191 start = 0192 while True:193 d = self._query(lo, hi, startidx=start)194 rows = d.get("result_list") or []195 if not rows:196 break197 for r in rows:198 lst = self._to_listing(r)199 if lst is not None and lst.external_id not in by_id:200 by_id[lst.external_id] = lst201 n_found = int(d.get("number_found") or 0)202 start += len(rows)203 if start >= min(n_found, 100_000) or len(rows) < PGSIZE:204 break205 time.sleep(self.request_delay)206 time.sleep(self.request_delay)207 return list(by_id.values())208209210# --- une source par province (le Québec vit sur immo-ka) ----------------------211_PROVINCES = [212 ("c21_bc", "British Columbia", "BC"),213 ("c21_ab", "Alberta", "AB"),214 ("c21_sk", "Saskatchewan", "SK"),215 ("c21_mb", "Manitoba", "MB"),216 ("c21_on", "Ontario", "ON"),217 ("c21_nb", "New Brunswick", "NB"),218 ("c21_ns", "Nova Scotia", "NS"),219 ("c21_pe", "Prince Edward Island", "PE"),220 ("c21_nl", "Newfoundland and Labrador", "NL"),221 ("c21_yt", "Yukon", "YT"),222 ("c21_nt", "Northwest Territories", "NT"),223]224225for _sid, _prov, _code in _PROVINCES:226 globals()[f"C21_{_code}"] = type(227 f"C21{_code}",228 (_C21Province,),229 {"source_id": _sid, "province": _prov, "state_code": _code},230 )231