# ----------------------------------------------------------------------------- # Home-Ka — US real-estate aggregator (Groupe KA) # Author: Simon-Pierre Boucher — contact@spboucher.ai # connectors/rets/client.py : legacy RETS connector (login + DMQL2 search, # COMPACT-DECODED). Many small MLSs still hand out RETS credentials faster # than RESO Web API access — this keeps those feeds first-class. # # {"id": "rets_example", "connector_type": "rets", "config": { # "login_url": "https://rets.example-mls.com/rets/login.ashx", # "username": "$ENV:RETS_EXAMPLE_USER", "password": "$ENV:RETS_EXAMPLE_PASS", # "search_url": "", # discovered from login when empty # "resource": "Property", "class": "Residential", # "query": "(Status=|A)", "select": "", # "limit": 5000, "mapping": { ... Listing field -> RETS system name ... }, # "user_agent": "HomeKa/1.0"}} # ----------------------------------------------------------------------------- from __future__ import annotations import re import xml.etree.ElementTree as ET import requests from requests.auth import HTTPBasicAuth, HTTPDigestAuth from ..base import BaseConnector from .._maputil import apply_mapping from ...schema import Listing _CAP_RE = re.compile(r"(.*?)", re.S | re.I) class RETSConnector(BaseConnector): family = "rets" request_delay = 1.0 def _login(self) -> str: """RETS Login transaction — returns the Search capability URL.""" cfg = self.config if cfg.get("user_agent"): self.session.headers["User-Agent"] = cfg["user_agent"] self.session.headers["RETS-Version"] = cfg.get("rets_version", "RETS/1.7.2") auth_cls = HTTPDigestAuth if cfg.get("auth", "digest") == "digest" else HTTPBasicAuth self.session.auth = auth_cls(cfg.get("username", ""), cfg.get("password", "")) resp = self.get(cfg["login_url"]) if cfg.get("search_url"): return cfg["search_url"] m = _CAP_RE.search(resp.text) caps = {} if m: for line in m.group(1).splitlines(): if "=" in line: k, _, v = line.partition("=") caps[k.strip().lower()] = v.strip() search = caps.get("search") if not search: raise RuntimeError(f"{self.source_id}: no Search capability in RETS login") if search.startswith("/"): from urllib.parse import urlsplit u = urlsplit(cfg["login_url"]) search = f"{u.scheme}://{u.netloc}{search}" return search def fetch(self) -> list[Listing]: cfg = self.config search_url = self._login() params = { "SearchType": cfg.get("resource", "Property"), "Class": cfg.get("class", "Residential"), "Query": cfg.get("query", "(Status=|A)"), "QueryType": "DMQL2", "Format": "COMPACT-DECODED", "Limit": str(cfg.get("limit", 5000)), "StandardNames": cfg.get("standard_names", "0"), } if cfg.get("select"): params["Select"] = cfg["select"] resp = self.get(search_url, params=params) return self._parse_compact(resp.text) def _parse_compact(self, body: str) -> list[Listing]: """Parse a COMPACT-DECODED RETS payload into Listings via the mapping.""" try: root = ET.fromstring(body) except ET.ParseError as exc: raise RuntimeError(f"{self.source_id}: invalid RETS XML: {exc}") delim = "\t" d = root.find("DELIMITER") if d is not None and d.get("value"): delim = chr(int(d.get("value"))) cols_el = root.find("COLUMNS") if cols_el is None or not cols_el.text: return [] cols = cols_el.text.strip(delim).split(delim) mapping = self.config.get("mapping") or _DEFAULT_RETS_MAPPING static = self.config.get("static") or {} out: list[Listing] = [] for row in root.findall("DATA"): vals = (row.text or "").strip("\n").strip(delim).split(delim) rec = {c: v for c, v in zip(cols, vals)} lst = apply_mapping(self.source_id, rec, mapping, static) if lst is not None: out.append(lst) return out # RETS StandardNames (StandardNames=1) default mapping; system-name feeds # override it entirely in the source config. _DEFAULT_RETS_MAPPING = { "external_id": "ListingID", "mls_id": "ListingID", "list_price": "ListPrice", "street_address": "StreetAddress", "city": "City", "state": "StateOrProvince", "zip_code": "PostalCode", "county": "County", "property_type": "PropertyType", "bedrooms": "Beds", "bathrooms": "Baths", "living_area_sqft": "LivingArea", "year_built": "YearBuilt", "status": "Status", "agent_name": "ListAgentName", "brokerage_name": "ListOfficeName", "description": "PublicRemarks", "lat": "Latitude", "lng": "Longitude", }