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# 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