SPB Git

spb/house-ka

Public
9.0 KB · 223 lines python
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 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 = 480         # pgsize plafonne à 500 : la tranche doit tenir en 1 requête35PGSIZE = 50036PRICE_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 <= 200:110                # tranche insécable > 500 : tronquée à pgsize (cas rarissime —111                # plus de 500 fiches au même prix à 200 $ près)112                out.append((lo or None, hi))113                continue114            # scission au point médian géométrique (les prix sont log-normaux)115            mid = int((max(lo, 10_000) * hi) ** 0.5)116            if mid <= lo or mid >= hi:117                mid = (lo + hi) // 2118            stack.append((lo, mid))119            stack.append((mid + 1, hi))120        return out121122    # ------------------------------------------------------------- mapping --123    def _to_listing(self, r: dict) -> PropertyListing | None:124        loc = r.get("location") or {}125        state = (loc.get("state") or "").upper()126        if state == "QC":                      # périmètre House-Ka : hors Québec127            return None128        if self.state_code and state and state != self.state_code:129            return None130        lid = r.get("listingid")131        if not lid:132            return None133        slug = r.get("url_slug") or ""134        url = f"https://www.c21.ca/listing{slug}" if slug else "https://www.c21.ca"135136        # salles de bains Moxi : « 6.1 » = 6 complètes + 1 d'eau137        baths_raw = _num(r.get("bathrooms"))138        baths = powder = None139        if baths_raw is not None:140            baths = int(baths_raw)141            dec = round((baths_raw - baths) * 10)142            powder = dec if 0 < dec <= 5 else None143144        images = []145        for img in (r.get("image") or []):146            u = img.get("full_url") or img.get("gallery_url")147            if u:148                images.append(u)149150        details = {k: v for k, v in {151            "Property Type": r.get("property_type"),152            "County": loc.get("county"),153            "Postal code": loc.get("zip"),154            "Listed date": r.get("listed_date"),155            "Subdivision": r.get("subdivision"),156            "MLS® Number": r.get("mlsnumber"),157            "Listing office": r.get("officename") or r.get("listing_office"),158        }.items() if v}159160        lst = PropertyListing(161            source=self.source_id,162            external_id=f"c21{lid}",163            url=url,164            address=(loc.get("address") or "").strip(),165            city=(loc.get("city") or "").strip(),166            region=self.province,167            property_type=r.get("property_type") or "",168            price=_num(r.get("list_price")),169            price_label=(f"${int(r['list_price']):,}"170                         if _num(r.get("list_price")) else ""),171            bedrooms=int(_num(r.get("bedrooms")) or 0) or None,172            bathrooms=baths,173            powder_rooms=powder,174            area_sqft=_num(r.get("sqr_footage")) or _num(r.get("living_area")),175            lot_sqft=_num(r.get("lot_sqr_footage")),176            year_built=int(_num(r.get("year_build")) or 0) or None,177            mls=str(r.get("mlsnumber") or ""),178            broker_name=(r.get("agentname") or r.get("officename") or179                         "Century 21").strip(),180            agency=(r.get("officename") or "Century 21 Canada").strip(),181            description=(r.get("comments") or "").strip()[:6000],182            details=details,183            images=images,184            lat=_num(loc.get("latitude")),185            lng=_num(loc.get("longitude")),186        )187        return lst188189    # --------------------------------------------------------------- fetch --190    def fetch(self) -> list[PropertyListing]:191        by_id: dict[str, PropertyListing] = {}192        for lo, hi in self._slices():193            d = self._query(lo, hi, pgsize=PGSIZE)194            for r in (d.get("result_list") or []):195                lst = self._to_listing(r)196                if lst is not None and lst.external_id not in by_id:197                    by_id[lst.external_id] = lst198            time.sleep(self.request_delay)199        return list(by_id.values())200201202# --- une source par province (le Québec vit sur immo-ka) ----------------------203_PROVINCES = [204    ("c21_bc", "British Columbia", "BC"),205    ("c21_ab", "Alberta", "AB"),206    ("c21_sk", "Saskatchewan", "SK"),207    ("c21_mb", "Manitoba", "MB"),208    ("c21_on", "Ontario", "ON"),209    ("c21_nb", "New Brunswick", "NB"),210    ("c21_ns", "Nova Scotia", "NS"),211    ("c21_pe", "Prince Edward Island", "PE"),212    ("c21_nl", "Newfoundland and Labrador", "NL"),213    ("c21_yt", "Yukon", "YT"),214    ("c21_nt", "Northwest Territories", "NT"),215]216217for _sid, _prov, _code in _PROVINCES:218    globals()[f"C21_{_code}"] = type(219        f"C21{_code}",220        (_C21Province,),221        {"source_id": _sid, "province": _prov, "state_code": _code},222    )223
Files · 852c914b66dca5351aaa5df6ece6070a6e3d64c5
Menu

Theme