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%
3.7 KB · 97 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Home-Ka — US real-estate aggregator (Groupe KA)3# Author: Simon-Pierre Boucher — contact@spboucher.ai4# connectors/xml/feed.py : generic XML feed connector (brokerage exports,5# franchise syndication feeds, legacy XML drops). Config-driven:6#7#   {"id": "acme_xml", "connector_type": "xml", "config": {8#       "url": "https://feeds.acmerealty.com/listings.xml",9#       "item_tag": "listing",                 # repeated element10#       "mapping": {"external_id": "mlsNumber", "list_price": "price",11#                   "street_address": "address/street", "city": "address/city",12#                   "state": "address/state", "zip_code": "address/zip",13#                   "images": "photos/photo", ...},   # ElementTree paths14#       "static": {"state": "TX"},15#       "headers": {}, "auth_user": "", "auth_pass": ""}}16# -----------------------------------------------------------------------------17from __future__ import annotations1819import xml.etree.ElementTree as ET2021from ..base import BaseConnector22from ...schema import Listing2324_LIST_FIELDS = {"images", "features"}252627def _strip_ns(tag: str) -> str:28    return tag.rsplit("}", 1)[-1]293031class XMLFeedConnector(BaseConnector):32    family = "xml"33    request_delay = 0.23435    def fetch(self) -> list[Listing]:36        cfg = self.config37        kw = {}38        if cfg.get("headers"):39            kw["headers"] = cfg["headers"]40        if cfg.get("auth_user"):41            kw["auth"] = (cfg["auth_user"], cfg.get("auth_pass", ""))42        if cfg.get("path"):43            body = open(cfg["path"], "rb").read()44        else:45            body = self.get(cfg["url"], **kw).content46        root = ET.fromstring(body)47        item_tag = cfg.get("item_tag", "listing")48        mapping = cfg.get("mapping") or {}49        static = cfg.get("static") or {}50        out: list[Listing] = []51        for el in root.iter():52            if _strip_ns(el.tag) != item_tag:53                continue54            lst = self._to_listing(el, mapping, static)55            if lst is not None:56                out.append(lst)57        return out5859    def _find(self, el: ET.Element, path: str):60        """Namespace-tolerant lookup ('address/city' or '@attr' or 'tag@attr')."""61        if path.startswith("@"):62            return [el.get(path[1:])]63        attr = None64        if "@" in path:65            path, _, attr = path.partition("@")66        parts = path.split("/")67        nodes = [el]68        for p in parts:69            nxt = []70            for n in nodes:71                nxt.extend(c for c in n if _strip_ns(c.tag) == p)72            nodes = nxt73        if attr:74            return [n.get(attr) for n in nodes]75        return [(n.text or "").strip() for n in nodes]7677    def _to_listing(self, el: ET.Element, mapping: dict,78                    static: dict) -> Listing | None:79        kw: dict = {"source": self.source_id, "external_id": "", "url": "",80                    "details": {}}81        for field, path in mapping.items():82            vals = [v for v in self._find(el, path) if v]83            if not vals:84                continue85            kw[field] = vals if field in _LIST_FIELDS else vals[0]86        for field, v in static.items():87            kw.setdefault(field, v)88        if not kw.get("external_id"):89            return None90        kw["external_id"] = str(kw["external_id"])91        # flat leaf values preserved for debugging/passthrough92        for c in el:93            if len(c) == 0 and c.text and c.text.strip():94                kw["details"].setdefault(_strip_ns(c.tag), c.text.strip())95        allowed = set(Listing.__dataclass_fields__)96        return Listing(**{k: v for k, v in kw.items() if k in allowed})97