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%
9.1 KB · 231 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, 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