SPB Git forge

spb/home-ka

Public
10commits 1branches 0releases
793.0 KBsize
maindefault branch
20 days agolast push
Python 49.6% TypeScript 25.5% CSS 24.1%
6.9 KB · 159 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Home-Ka — US real-estate aggregator (Groupe KA)3# Author: Simon-Pierre Boucher — contact@spboucher.ai4# connectors/reso/webapi.py : RESO Web API (OData) connector — the PREFERRED5# feed type. Works with MLS Grid, Trestle (CoreLogic), Bridge Interactive,6# Spark/FBS, RMLS direct RESO endpoints, and any Data Dictionary compliant7# server. One `sources` row per feed:8#9#   {"id": "mlsgrid_actris", "connector_type": "reso", "config": {10#       "base_url": "https://api.mlsgrid.com/v2",11#       "token": "$ENV:MLSGRID_TOKEN",              # or OAuth2 below12#       "token_url": "", "client_id": "", "client_secret": "",13#       "resource": "Property",14#       "filter": "StandardStatus eq 'Active' and PropertyType eq 'Residential'",15#       "expand": "Media",16#       "page_size": 1000, "max_records": 50000,17#       "originating_system": "ACTRIS"}}18#19# Field mapping is RESO Data Dictionary — schema.Listing understands the20# standard names natively (details passthrough), with the explicit mapping21# below for the core columns.22# -----------------------------------------------------------------------------23from __future__ import annotations2425import time2627import requests2829from ..base import BaseConnector30from ...schema import Listing313233class RESOWebAPIConnector(BaseConnector):34    family = "reso"35    request_delay = 0.33637    # -- auth -------------------------------------------------------------38    def _token(self) -> str:39        cfg = self.config40        if cfg.get("token"):41            return cfg["token"]42        if cfg.get("token_url"):43            resp = requests.post(cfg["token_url"], data={44                "grant_type": "client_credentials",45                "client_id": cfg.get("client_id", ""),46                "client_secret": cfg.get("client_secret", ""),47                "scope": cfg.get("scope", "api"),48            }, timeout=30)49            resp.raise_for_status()50            return resp.json()["access_token"]51        raise RuntimeError(f"{self.source_id}: no RESO token/credentials configured")5253    # -- fetch ------------------------------------------------------------54    def fetch(self) -> list[Listing]:55        cfg = self.config56        base = (cfg.get("base_url") or "").rstrip("/")57        if not base:58            raise RuntimeError(f"{self.source_id}: base_url missing")59        resource = cfg.get("resource", "Property")60        page_size = int(cfg.get("page_size", 1000))61        max_records = int(cfg.get("max_records", 100_000))62        params = {"$top": page_size}63        if cfg.get("filter"):64            params["$filter"] = cfg["filter"]65        if cfg.get("select"):66            params["$select"] = cfg["select"]67        if cfg.get("expand"):68            params["$expand"] = cfg["expand"]69        self.session.headers["Authorization"] = f"Bearer {self._token()}"70        self.session.headers["Accept"] = "application/json"7172        out: list[Listing] = []73        url: str | None = f"{base}/{resource}"74        first = True75        while url and len(out) < max_records:76            resp = self.get(url, params=params if first else None)77            first = False78            data = resp.json()79            for rec in data.get("value", []):80                lst = self._to_listing(rec)81                if lst is not None:82                    out.append(lst)83            url = data.get("@odata.nextLink")84        return out8586    def _to_listing(self, rec: dict) -> Listing | None:87        key = rec.get("ListingKey") or rec.get("ListingId")88        if not key:89            return None90        media = rec.get("Media") or []91        images = [m.get("MediaURL") for m in media92                  if isinstance(m, dict) and m.get("MediaURL")93                  and (m.get("MediaCategory") in (None, "Photo"))]94        images.sort(key=lambda u: u or "")95        if media and all(isinstance(m, dict) for m in media):96            ordered = sorted(media, key=lambda m: m.get("Order") or 0)97            images = [m.get("MediaURL") for m in ordered if m.get("MediaURL")]98        details = {k: v for k, v in rec.items()99                   if isinstance(v, (str, int, float, bool))}100        street = rec.get("UnparsedAddress") or " ".join(101            str(rec.get(k) or "") for k in102            ("StreetNumber", "StreetDirPrefix", "StreetName", "StreetSuffix")103        ).strip()104        return Listing(105            source=self.source_id,106            external_id=str(key),107            url=self.config.get("listing_url_template", "").format(**rec)108                if self.config.get("listing_url_template") else "",109            street_address=street,110            unit=str(rec.get("UnitNumber") or ""),111            city=str(rec.get("City") or ""),112            state=str(rec.get("StateOrProvince") or ""),113            zip_code=str(rec.get("PostalCode") or ""),114            county=str(rec.get("CountyOrParish") or ""),115            property_type=str(rec.get("PropertySubType")116                              or rec.get("PropertyType") or ""),117            property_subtype=str(rec.get("PropertySubType") or ""),118            list_price=rec.get("ListPrice"),119            bedrooms=rec.get("BedroomsTotal"),120            bathrooms_full=rec.get("BathroomsFull"),121            bathrooms_half=rec.get("BathroomsHalf"),122            bathrooms=rec.get("BathroomsTotalInteger"),123            living_area_sqft=rec.get("LivingArea"),124            lot_size_sqft=rec.get("LotSizeSquareFeet"),125            year_built=rec.get("YearBuilt"),126            apn=str(rec.get("ParcelNumber") or ""),127            mls_id=str(rec.get("ListingId") or ""),128            mls_name=str(rec.get("OriginatingSystemName")129                         or self.config.get("originating_system") or ""),130            status=str(rec.get("StandardStatus") or "Active"),131            listed_at=str(rec.get("ListingContractDate") or ""),132            brokerage_name=str(rec.get("ListOfficeName") or ""),133            office_name=str(rec.get("ListOfficeName") or ""),134            agent_name=str(rec.get("ListAgentFullName") or ""),135            agent_phone=str(rec.get("ListAgentPreferredPhone")136                            or rec.get("ListAgentDirectPhone") or ""),137            description=str(rec.get("PublicRemarks") or ""),138            details=details,139            images=images,140            lat=rec.get("Latitude"),141            lng=rec.get("Longitude"),142        )143144145def probe(base_url: str, token: str = "") -> dict:146    """Quick health probe of a RESO endpoint ($metadata reachability) — used147    by the admin and the brokerage discovery pipeline."""148    headers = {"Accept": "application/json"}149    if token:150        headers["Authorization"] = f"Bearer {token}"151    t0 = time.time()152    try:153        resp = requests.get(base_url.rstrip("/") + "/$metadata",154                            headers=headers, timeout=20)155        return {"ok": resp.status_code < 500, "status": resp.status_code,156                "seconds": round(time.time() - t0, 2)}157    except requests.RequestException as exc:158        return {"ok": False, "error": str(exc)}159