Python 49.6%
TypeScript 25.5%
CSS 24.1%
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