# ----------------------------------------------------------------------------- # 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, pagination # `pgsize` (200 max utile) + `startidx` ; # - la liste n'est retournée QUE si `number_found` ≲ 6-10 k → découpage # ADAPTATIF par tranches de prix (`pricemin`/`pricemax`) jusqu'à ≤ SLICE_MAX, # puis pagination de chaque tranche ; # - `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 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 = 5500 # au-delà, l'API ne retourne plus la liste → scinder PGSIZE = 200 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 request_delay = 0.35 # ------------------------------------------------------------------ API -- def _query(self, pricemin: int | None, pricemax: int | None, startidx: int = 0, pgsize: int = PGSIZE) -> dict: params = dict(BASE_PARAMS) params["location_search_field"] = 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) 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"] def _count(self, lo: int | None, hi: int | None) -> int: d = self._query(lo, hi, startidx=0, pgsize=1) return int(d.get("number_found") or 0) # ------------------------------------------------------ tranches de prix -- def _slices(self) -> 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) time.sleep(self.request_delay) if n == 0: continue if n <= SLICE_MAX or hi - lo <= 5000: 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 lo, hi in self._slices(): start = 0 while True: d = self._query(lo, hi, startidx=start) rows = d.get("result_list") or [] if not rows: break for r in rows: lst = self._to_listing(r) if lst is not None and lst.external_id not in by_id: by_id[lst.external_id] = lst n_found = int(d.get("number_found") or 0) start += len(rows) if start >= min(n_found, 100_000) or len(rows) < PGSIZE: break time.sleep(self.request_delay) 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"), ] for _sid, _prov, _code in _PROVINCES: globals()[f"C21_{_code}"] = type( f"C21{_code}", (_C21Province,), {"source_id": _sid, "province": _prov, "state_code": _code}, )