SPB Git forge

spb/house-ka

Public
18commits 1branches 0releases
1.9 MBsize
maindefault branch
19 days agolast push
Python 67% TypeScript 18.2% CSS 14.4%
10.9 KB · 257 lines python
Raw Blame History
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