# ----------------------------------------------------------------------------- # House-Ka — Agrégateur de maisons à vendre (Canada hors Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/c21_canada.py : Century 21 Canada — API MoxiWorks corporative. # c21.ca (tous les bureaux C21 du pays) sert sa recherche via l'API JSONP # svc.moxiworks.com `/service/v1/listing/search_v2`. La réponse LISTE contient # déjà tout (prix, chambres, GPS, photo, description, MLS®) : aucune passe # détail nécessaire. Un connecteur par PROVINCE (source c21_bc, c21_ab, …). # # Rétro-ingénierie 2026-08-27 (sniff Playwright sur c21.ca/search) : # - les paramètres d'ATTRIBUTION (`send_from_agent`, `from_aws`, `from_app`, # `site_owner_uuid`) sont OBLIGATOIRES — sans eux `result_list` reste vide ; # - `location_search_field=` filtre par province ; # - `startidx` est IGNORÉ par l'API (toutes les « pages » renvoient les mêmes # fiches) et `pgsize` plafonne à 500 → découpage ADAPTATIF par tranches de # prix (`pricemin`/`pricemax`) jusqu'à number_found ≤ SLICE_MAX, puis UNE # requête pgsize=500 récupère la tranche entière ; # - `pstatus=1,11` (active + coming soon), `ptype=1..9` (tous types résidentiels). # Fiche publique : https://www.c21.ca/listing. # ----------------------------------------------------------------------------- from __future__ import annotations import json import re import time import requests from .base import BaseConnector from ..schema import PropertyListing API = "https://svc.moxiworks.com/service/v1/listing/search_v2" COMPANY_UUID = "3341197" # CENTURY 21 Canada SITE_OWNER = "825ef2f8-0716-4a50-862c-ddad45234e9c" # c21.ca (config publique) SLICE_MAX = 480 # pgsize plafonne à 500 : la tranche doit tenir en 1 requête PGSIZE = 500 PRICE_CEIL = 100_000_000 _CB_RE = re.compile(r"^/\*\*/cb\(|\)\s*$") BASE_PARAMS = { "status": "active", "pstatus": "1,11", "sort_by": "10", "company_uuid": COMPANY_UUID, "ptype": "1,2,3,4,5,7,9,8", "searchType": "criteria", "omit_hidden": "true", "ex_pend": "true", "currency": "CAD", "callback": "cb", # attribution : indispensable pour obtenir result_list "send_from_agent": "true", "from_aws": "true", "from_app": "aws:https://www.c21.ca", "source": "agent website", "site_type": "Brokerage Website", "site_owner_uuid": SITE_OWNER, } def _num(v) -> float | None: try: f = float(v) return f if f == f else None except (TypeError, ValueError): return None class _C21Province(BaseConnector): """Century 21 Canada — une province (voir les sous-classes en bas).""" province = "" # libellé exact pour location_search_field state_code = "" # code à 2 lettres attendu dans location.state locations: tuple = () # localités interrogées (défaut : la province) — # l'API n'a PAS de polygone pour certaines # provinces (Saskatchewan : « Unable to locate # GeoLayer ») → liste de villes à la place request_delay = 0.35 # ------------------------------------------------------------------ API -- def _query(self, pricemin: int | None, pricemax: int | None, startidx: int = 0, pgsize: int = PGSIZE, location: str | None = None) -> dict: params = dict(BASE_PARAMS) params["location_search_field"] = location or self.province params["pgsize"] = str(pgsize) params["startidx"] = str(startidx) if pricemin is not None and pricemin > 0: params["pricemin"] = str(pricemin) if pricemax is not None: params["pricemax"] = str(pricemax) # Un fetch provincial = des centaines de requêtes sur ~45 min (Ontario) : # Moxi throttle parfois, ou l'escalade anti-bot renvoie un 200 au corps # non-JSONP. Sans retry, UNE réponse pourrie avortait tout le sync # (cause du stale c21_on 2026-08-29/30) → on retente avec backoff. last_exc: Exception | None = None for attempt in range(4): if attempt: time.sleep(min(5.0 * 2 ** (attempt - 1), 20.0)) try: resp = self.get(API, params=params) body = _CB_RE.sub("", resp.text.strip()) d = json.loads(body) if d.get("status") != "success": raise RuntimeError( f"c21 API: {d.get('message', d.get('status'))}") return d["data"] except (json.JSONDecodeError, RuntimeError, requests.RequestException) as exc: last_exc = exc raise last_exc # type: ignore[misc] def _count(self, lo: int | None, hi: int | None, location: str | None = None) -> int: d = self._query(lo, hi, startidx=0, pgsize=1, location=location) return int(d.get("number_found") or 0) # ------------------------------------------------------ tranches de prix -- def _slices(self, location: str | None = None ) -> list[tuple[int | None, int | None]]: """Tranches [lo, hi] dont number_found ≤ SLICE_MAX (scission dichotomique).""" out: list[tuple[int | None, int | None]] = [] stack: list[tuple[int, int]] = [(0, PRICE_CEIL)] while stack: lo, hi = stack.pop() n = self._count(lo or None, hi, location=location) time.sleep(self.request_delay) if n == 0: continue if n <= SLICE_MAX or hi - lo <= 200: # tranche insécable > 500 : tronquée à pgsize (cas rarissime — # plus de 500 fiches au même prix à 200 $ près) out.append((lo or None, hi)) continue # scission au point médian géométrique (les prix sont log-normaux) mid = int((max(lo, 10_000) * hi) ** 0.5) if mid <= lo or mid >= hi: mid = (lo + hi) // 2 stack.append((lo, mid)) stack.append((mid + 1, hi)) return out # ------------------------------------------------------------- mapping -- def _to_listing(self, r: dict) -> PropertyListing | None: loc = r.get("location") or {} state = (loc.get("state") or "").upper() if state == "QC": # périmètre House-Ka : hors Québec return None if self.state_code and state and state != self.state_code: return None lid = r.get("listingid") if not lid: return None slug = r.get("url_slug") or "" url = f"https://www.c21.ca/listing{slug}" if slug else "https://www.c21.ca" # salles de bains Moxi : « 6.1 » = 6 complètes + 1 d'eau baths_raw = _num(r.get("bathrooms")) baths = powder = None if baths_raw is not None: baths = int(baths_raw) dec = round((baths_raw - baths) * 10) powder = dec if 0 < dec <= 5 else None images = [] for img in (r.get("image") or []): u = img.get("full_url") or img.get("gallery_url") if u: images.append(u) details = {k: v for k, v in { "Property Type": r.get("property_type"), "County": loc.get("county"), "Postal code": loc.get("zip"), "Listed date": r.get("listed_date"), "Subdivision": r.get("subdivision"), "MLS® Number": r.get("mlsnumber"), "Listing office": r.get("officename") or r.get("listing_office"), }.items() if v} lst = PropertyListing( source=self.source_id, external_id=f"c21{lid}", url=url, address=(loc.get("address") or "").strip(), city=(loc.get("city") or "").strip(), region=self.province, property_type=r.get("property_type") or "", price=_num(r.get("list_price")), price_label=(f"${int(r['list_price']):,}" if _num(r.get("list_price")) else ""), bedrooms=int(_num(r.get("bedrooms")) or 0) or None, bathrooms=baths, powder_rooms=powder, area_sqft=_num(r.get("sqr_footage")) or _num(r.get("living_area")), lot_sqft=_num(r.get("lot_sqr_footage")), year_built=int(_num(r.get("year_build")) or 0) or None, mls=str(r.get("mlsnumber") or ""), broker_name=(r.get("agentname") or r.get("officename") or "Century 21").strip(), agency=(r.get("officename") or "Century 21 Canada").strip(), description=(r.get("comments") or "").strip()[:6000], details=details, images=images, lat=_num(loc.get("latitude")), lng=_num(loc.get("longitude")), ) return lst # --------------------------------------------------------------- fetch -- def fetch(self) -> list[PropertyListing]: by_id: dict[str, PropertyListing] = {} for loc in (self.locations or (None,)): for lo, hi in self._slices(location=loc): d = self._query(lo, hi, pgsize=PGSIZE, location=loc) for r in (d.get("result_list") or []): lst = self._to_listing(r) if lst is not None and lst.external_id not in by_id: by_id[lst.external_id] = lst time.sleep(self.request_delay) return list(by_id.values()) # --- une source par province (le Québec vit sur immo-ka) ---------------------- _PROVINCES = [ ("c21_bc", "British Columbia", "BC"), ("c21_ab", "Alberta", "AB"), ("c21_sk", "Saskatchewan", "SK"), ("c21_mb", "Manitoba", "MB"), ("c21_on", "Ontario", "ON"), ("c21_nb", "New Brunswick", "NB"), ("c21_ns", "Nova Scotia", "NS"), ("c21_pe", "Prince Edward Island", "PE"), ("c21_nl", "Newfoundland and Labrador", "NL"), ("c21_yt", "Yukon", "YT"), ("c21_nt", "Northwest Territories", "NT"), ] # Saskatchewan : pas de polygone provincial côté API → villes principales _SK_CITIES = tuple(f"{c}, SK" for c in ( "Saskatoon", "Regina", "Prince Albert", "Moose Jaw", "Swift Current", "Yorkton", "North Battleford", "Estevan", "Weyburn", "Warman", "Martensville", "Melfort", "Humboldt", "Meadow Lake", "Kindersley", "Melville", "Nipawin", "Tisdale", "La Ronge", "Battleford", "Rosetown", "Assiniboia", "Unity", "Canora", "Esterhazy", "Outlook", "Watrous", "Moosomin", "Shellbrook", "Fort Qu'Appelle")) for _sid, _prov, _code in _PROVINCES: _extra = {"locations": _SK_CITIES} if _code == "SK" else {} globals()[f"C21_{_code}"] = type( f"C21{_code}", (_C21Province,), {"source_id": _sid, "province": _prov, "state_code": _code, **_extra}, )