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%
8.9 KB · 210 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Home-Ka — US real-estate aggregator (Groupe KA)3# Author: Simon-Pierre Boucher — contact@spboucher.ai4# schema.py : standardized data model (Listing), RESO Data Dictionary aligned5#6# Same philosophy as immo-ka/schema.py: every connector, whatever the source7# (RESO Web API, RETS, XML/JSON feed, CSV drop, county records, brokerage8# site), must produce `Listing` objects conforming to this schema. `finalize()`9# then applies the shared normalization layer (homeka/normalize.py) so the10# connectors stay simple and fill raw fields only.11#12# Home-Ka separates PROPERTY from LISTING:13#   - a Listing is one publication of a sale by one source (this dataclass);14#   - a Property is the physical asset, persisted in the `properties` table15#     and matched by address/APN (homeka/propertymatch.py). A property keeps16#     existing after its listing goes off-market.17# -----------------------------------------------------------------------------18from __future__ import annotations1920import hashlib21import json22from dataclasses import dataclass, field, asdict2324from .normalize import (25    clean_address,26    clean_description,27    clean_title,28    extract_beds_baths,29    normalize_property_type,30    normalize_state,31    normalize_status,32    normalize_zip,33    parse_area_sqft,34    parse_int,35    parse_lot_sqft,36    parse_price,37    parse_year,38    price_is_from,39)4041__all__ = ["Listing"]4243# RESO Data Dictionary fields commonly promoted from `details` when a44# connector passed them raw (Web API / RETS payloads keep original keys).45_RESO_ALIASES = {46    "list_price": ("ListPrice",),47    "street_address": ("UnparsedAddress", "StreetAddressFull"),48    "city": ("City",),49    "state": ("StateOrProvince",),50    "zip_code": ("PostalCode",),51    "county": ("CountyOrParish",),52    "property_type": ("PropertySubType", "PropertyType"),53    "bedrooms": ("BedroomsTotal",),54    "bathrooms_full": ("BathroomsFull",),55    "bathrooms_half": ("BathroomsHalf",),56    "living_area_sqft": ("LivingArea", "BuildingAreaTotal"),57    "lot_size_sqft": ("LotSizeSquareFeet",),58    "year_built": ("YearBuilt",),59    "apn": ("ParcelNumber",),60    "mls_id": ("ListingId",),61    "status": ("StandardStatus", "MlsStatus"),62    "lat": ("Latitude",),63    "lng": ("Longitude",),64}656667@dataclass68class Listing:69    """Standardized Home-Ka for-sale listing (one publication at one source)."""7071    source: str                        # source id (row in the `sources` table)72    external_id: str                   # id at the source (often the MLS number)73    url: str                           # listing page at the source74    title: str = ""                    # e.g. "Craftsman bungalow — Austin, TX"75    street_address: str = ""           # street address (no city/state/zip)76    unit: str = ""                     # apt/unit/suite77    city: str = ""78    state: str = ""                    # 2-letter USPS code79    zip_code: str = ""                 # 5-digit ZIP80    county: str = ""81    property_type: str = ""            # canonical (Single Family, Condo, …)82    property_subtype: str = ""         # raw RESO PropertySubType if available83    list_price: float | None = None    # asking price ($ USD)84    price_label: str = ""              # original text (e.g. "$459,000")85    bedrooms: int | None = None86    bathrooms_full: int | None = None87    bathrooms_half: int | None = None88    bathrooms: float | None = None     # total (full + 0.5*half) when known89    living_area_sqft: float | None = None90    lot_size_sqft: float | None = None91    year_built: int | None = None92    apn: str = ""                      # assessor parcel number (property match)93    mls_id: str = ""                   # MLS listing number if displayed94    mls_name: str = ""                 # originating MLS (e.g. "ACTRIS")95    status: str = "active"             # active | pending | sold | withdrawn | coming-soon96    listed_at: str = ""                # listing/contract date if known (ISO)97    brokerage_name: str = ""           # listing brokerage98    office_name: str = ""              # office/branch99    agent_name: str = ""100    agent_phone: str = ""101    agent_email: str = ""102    description: str = ""103    features: list[str] = field(default_factory=list)  # source feature texts104    details: dict = field(default_factory=dict)        # structured fields (JSON)105    images: list[str] = field(default_factory=list)    # absolute URLs106    lat: float | None = None107    lng: float | None = None108109    @property110    def uid(self) -> str:111        return f"{self.source}:{self.external_id}"112113    def content_hash(self) -> str:114        """Content hash for change detection (pseudo-webhook, as in immo-ka)."""115        payload = asdict(self)116        blob = json.dumps(payload, sort_keys=True, ensure_ascii=False, default=str)117        return hashlib.sha256(blob.encode("utf-8")).hexdigest()118119    # -- normalization ---------------------------------------------------------120    def finalize(self) -> "Listing":121        """Apply the shared normalization. Called by the ingestion pipeline.122123        Idempotent; never overrides an explicit connector value.124        """125        # RESO passthrough: promote Data Dictionary keys left in `details`126        for attr, keys in _RESO_ALIASES.items():127            if getattr(self, attr, None) in (None, "", 0):128                for k in keys:129                    v = self.details.get(k)130                    if v not in (None, "", [], {}):131                        setattr(self, attr, v)132                        break133134        self.title = clean_title(self.title)135        self.street_address = clean_address(str(self.street_address))136        self.city = clean_title(str(self.city or "").strip())137        self.state = normalize_state(str(self.state))138        self.zip_code = normalize_zip(self.zip_code)139        self.county = clean_title(str(self.county or "").replace(" County", "").strip())140        self.status = normalize_status(self.status)141        if self.property_subtype and not self.property_type:142            self.property_type = self.property_subtype143        self.property_type = normalize_property_type(str(self.property_type))144        self.description = clean_description(self.description)145        self.mls_id = str(self.mls_id or "").strip()146        self.apn = str(self.apn or "").strip().replace(" ", "")147148        # gallery: valid absolute URLs only, dedup (see homeka/imgaudit.py)149        try:150            from .imgaudit import clean_gallery151            self.images = clean_gallery(self.images)152        except Exception:153            self.images = [u for u in (self.images or [])154                           if isinstance(u, str) and u.startswith("http")]155156        if self.list_price is None:157            self.list_price = parse_price(self.price_label)158        else:159            self.list_price = parse_price(self.list_price)160        if self.price_label and price_is_from(self.price_label):161            self.details.setdefault("price_from", True)162163        # numeric coercion (RESO passthrough may leave strings/decimals)164        self.bedrooms = parse_int(self.bedrooms)165        self.bathrooms_full = parse_int(self.bathrooms_full)166        self.bathrooms_half = parse_int(self.bathrooms_half)167        self.living_area_sqft = parse_area_sqft(self.living_area_sqft)168        self.lot_size_sqft = parse_lot_sqft(self.lot_size_sqft)169        self.year_built = parse_year(self.year_built)170        try:171            self.lat = float(self.lat) if self.lat is not None else None172            self.lng = float(self.lng) if self.lng is not None else None173        except (TypeError, ValueError):174            self.lat = self.lng = None175176        # total bathrooms177        if self.bathrooms is None:178            if self.bathrooms_full is not None:179                self.bathrooms = self.bathrooms_full + 0.5 * (self.bathrooms_half or 0)180        else:181            try:182                self.bathrooms = float(self.bathrooms)183            except (TypeError, ValueError):184                self.bathrooms = None185186        # free-text extraction fallback187        text = " ".join(filter(None, (self.title, self.description,188                                      " ".join(self.features))))189        if self.bedrooms is None or self.bathrooms is None:190            beds, baths = extract_beds_baths(text)191            if self.bedrooms is None:192                self.bedrooms = beds193            if self.bathrooms is None:194                self.bathrooms = baths195        if self.living_area_sqft is None:196            self.living_area_sqft = parse_area_sqft(text)197198        # office fallback: office/agent name when brokerage is missing199        if not self.brokerage_name:200            self.brokerage_name = self.office_name or self.agent_name201202        # source coordinates: reject anything outside the covered territory —203        # contiguous US + Alaska + Hawaii (swapped lat/lng, 0/0, typos)204        if self.lat is not None and self.lng is not None:205            ok = (18.5 <= self.lat <= 71.5 and -180.0 <= self.lng <= -66.0)206            if not ok:207                self.lat = self.lng = None208209        return self210