# ----------------------------------------------------------------------------- # Home-Ka — US real-estate aggregator (Groupe KA) # Author: Simon-Pierre Boucher — contact@spboucher.ai # normalize.py : shared normalization layer (prices, types, addresses, areas) # # US counterpart of immo-ka/normalize.py. Every connector fills raw fields as # seen at the source; Listing.finalize() (schema.py) calls these helpers to # produce canonical values comparable across sources. Canonical vocabulary is # aligned on the RESO Data Dictionary (PropertyType / PropertySubType, # StandardStatus) while keeping the common Groupe KA internal model. # ----------------------------------------------------------------------------- from __future__ import annotations import re import unicodedata __all__ = [ "strip_accents", "clean_address", "clean_title", "clean_description", "parse_price", "price_is_from", "parse_int", "parse_float", "parse_area_sqft", "parse_lot_sqft", "parse_lot_acres", "parse_year", "normalize_property_type", "normalize_status", "normalize_state", "normalize_zip", "extract_beds_baths", "STATE_NAMES", ] def strip_accents(text: str) -> str: return "".join(c for c in unicodedata.normalize("NFD", text or "") if unicodedata.category(c) != "Mn") # --------------------------------------------------------------------------- # Text cleanup # --------------------------------------------------------------------------- _SMALL_WORDS = {"a", "an", "and", "at", "by", "for", "in", "of", "on", "or", "the", "to", "with"} def clean_title(text: str) -> str: """Normalized title: whitespace collapsed, ALL-CAPS titles (frequent on MLS remarks) brought back to natural title case.""" t = re.sub(r"\s+", " ", (text or "")).strip() letters = [c for c in t if c.isalpha()] if len(letters) >= 8 and sum(c.isupper() for c in letters) / len(letters) > 0.85: words = [] for i, w in enumerate(t.lower().split(" ")): words.append(w if (i and w in _SMALL_WORDS) else w[:1].upper() + w[1:]) t = " ".join(words) return t _TAG_RE = re.compile(r"<[^>]+>") _BR_RE = re.compile(r"|

||", re.I) def clean_description(text: str) -> str: """Description without raw HTML: tags stripped (line breaks preserved), entities decoded, whitespace normalized.""" import html as _html t = text or "" if "<" in t and ">" in t: t = _BR_RE.sub("\n", t) t = _TAG_RE.sub(" ", t) t = _html.unescape(t) t = re.sub(r"[ \t]+", " ", t) t = re.sub(r" ?\n ?", "\n", t) t = re.sub(r"\n{3,}", "\n\n", t) return t.strip() _STREET_ABBR = { "street": "St", "avenue": "Ave", "boulevard": "Blvd", "drive": "Dr", "court": "Ct", "circle": "Cir", "lane": "Ln", "road": "Rd", "place": "Pl", "terrace": "Ter", "parkway": "Pkwy", "highway": "Hwy", "trail": "Trl", "square": "Sq", } def clean_address(text: str) -> str: """Clean a street address (whitespace, doubled commas, smart quotes).""" t = re.sub(r"\s+", " ", (text or "").replace("’", "'")).strip() t = re.sub(r"\s*,\s*", ", ", t) t = re.sub(r"(, )+", ", ", t).strip(", ") return t # --------------------------------------------------------------------------- # US states # --------------------------------------------------------------------------- STATE_NAMES = { "AL": "Alabama", "AK": "Alaska", "AZ": "Arizona", "AR": "Arkansas", "CA": "California", "CO": "Colorado", "CT": "Connecticut", "DE": "Delaware", "FL": "Florida", "GA": "Georgia", "HI": "Hawaii", "ID": "Idaho", "IL": "Illinois", "IN": "Indiana", "IA": "Iowa", "KS": "Kansas", "KY": "Kentucky", "LA": "Louisiana", "ME": "Maine", "MD": "Maryland", "MA": "Massachusetts", "MI": "Michigan", "MN": "Minnesota", "MS": "Mississippi", "MO": "Missouri", "MT": "Montana", "NE": "Nebraska", "NV": "Nevada", "NH": "New Hampshire", "NJ": "New Jersey", "NM": "New Mexico", "NY": "New York", "NC": "North Carolina", "ND": "North Dakota", "OH": "Ohio", "OK": "Oklahoma", "OR": "Oregon", "PA": "Pennsylvania", "RI": "Rhode Island", "SC": "South Carolina", "SD": "South Dakota", "TN": "Tennessee", "TX": "Texas", "UT": "Utah", "VT": "Vermont", "VA": "Virginia", "WA": "Washington", "WV": "West Virginia", "WI": "Wisconsin", "WY": "Wyoming", "DC": "District of Columbia", } _NAME_TO_ABBR = {v.lower(): k for k, v in STATE_NAMES.items()} def normalize_state(text: str) -> str: """State → 2-letter USPS code ('Texas'/'texas'/'TX'/'tx.' → 'TX').""" t = (text or "").strip().strip(".").strip() if not t: return "" if len(t) == 2 and t.upper() in STATE_NAMES: return t.upper() return _NAME_TO_ABBR.get(t.lower(), "") _ZIP_RE = re.compile(r"\b(\d{5})(?:-\d{4})?\b") def normalize_zip(text: str) -> str: """5-digit ZIP (ZIP+4 truncated).""" m = _ZIP_RE.search(str(text or "")) return m.group(1) if m else "" # --------------------------------------------------------------------------- # Price (USD) # --------------------------------------------------------------------------- _PRICE_RE = re.compile(r"\$?\s*(\d[\d,\.\s]*)\s*([kKmM])?") def parse_price(label) -> float | None: """Extract a sale price from a source label. Handles '$459,000', '459000', '$1.25M', '750K', 'From $399,900'. Returns None when no plausible amount (>= $5,000) is found. """ if label is None: return None if isinstance(label, (int, float)): return float(label) if label >= 5_000 else None m = _PRICE_RE.search(str(label)) if not m: return None raw = m.group(1).strip().replace(" ", "").rstrip(".") mult = {"k": 1e3, "m": 1e6}.get((m.group(2) or "").lower(), 1) if "," in raw and "." in raw: raw = raw.replace(",", "") elif raw.count(",") >= 1: raw = raw.replace(",", "") try: value = float(raw) * mult except ValueError: return None return value if value >= 5_000 else None def price_is_from(label: str) -> bool: key = (label or "").lower() return any(k in key for k in ("from ", "starting at", "starting from", "priced from")) # --------------------------------------------------------------------------- # Generic numbers # --------------------------------------------------------------------------- def parse_int(text) -> int | None: if text is None: return None if isinstance(text, (int, float)): return int(text) m = re.search(r"\d+", str(text)) return int(m.group()) if m else None def parse_float(text) -> float | None: if text is None: return None if isinstance(text, (int, float)): return float(text) m = re.search(r"\d[\d,\s]*(?:\.\d+)?", str(text)) if not m: return None try: return float(m.group().replace(",", "").replace(" ", "")) except ValueError: return None _SQFT_RE = re.compile(r"([\d,\.\s]+)\s*(?:sq\.?\s*ft|sqft|ft2|ft²|square\s+feet)", re.IGNORECASE) _ACRE_RE = re.compile(r"([\d,\.\s]+)\s*acres?\b", re.IGNORECASE) def parse_area_sqft(text) -> float | None: """Living area in sq ft.""" if text is None: return None if isinstance(text, (int, float)): return float(text) if text > 50 else None m = _SQFT_RE.search(str(text)) if m: v = parse_float(m.group(1)) return round(v) if v and v > 50 else None return None def parse_lot_sqft(text) -> float | None: """Lot size in sq ft (acres converted: 1 acre = 43,560 sq ft).""" if text is None: return None if isinstance(text, (int, float)): return float(text) if text > 100 else None t = str(text) m = _ACRE_RE.search(t) if m: v = parse_float(m.group(1)) return round(v * 43_560) if v and 0.005 <= v <= 50_000 else None m = _SQFT_RE.search(t) if m: v = parse_float(m.group(1)) return round(v) if v and v > 100 else None return None def parse_lot_acres(text) -> float | None: sqft = parse_lot_sqft(text) return round(sqft / 43_560, 3) if sqft else None _YEAR_RE = re.compile(r"^\s*(1[6-9]\d{2}|20[0-4]\d)\s*(?:$|[(,])") def parse_year(text) -> int | None: """Plausible year built (1600-2049); strict on purpose — the value must START with the year so stray labels never reach the year_built column.""" if text is None: return None if isinstance(text, (int, float)): y = int(text) return y if 1600 <= y <= 2049 else None m = _YEAR_RE.match(str(text)) return int(m.group(1)) if m else None # --------------------------------------------------------------------------- # Property type — canonical Home-Ka vocabulary (RESO-informed), shown in the # frontend filters. Maps both RESO enumerations (PropertyType/PropertySubType) # and free-text labels seen on brokerage sites. # --------------------------------------------------------------------------- _TYPE_MAP = [ # (keywords found in the normalized source text, canonical type) (("singlefamilyresidence", "single family", "single-family", "detached", "sfr", "residential - single", "house"), "Single Family"), (("townhouse", "townhome", "town house", "row house", "attached", "end unit"), "Townhouse"), (("condominium", "condo", "co-op", "coop", "stock cooperative", "apartment", "loft", "penthouse", "high rise", "flat"), "Condo"), (("duplex", "triplex", "quadruplex", "fourplex", "multi family", "multi-family", "multifamily", "income property", "residential income", "2 units", "3 units", "4 units"), "Multi-Family"), (("manufactured", "mobile home", "manufactured home", "modular", "manufacturedhome"), "Manufactured"), (("unimproved land", "vacant land", "land", "lot ", "lots and land", "acreage"), "Land"), (("farm", "ranch", "agricultural", "agriculture", "equestrian", "hobby farm"), "Farm/Ranch"), (("commercial", "office", "retail", "industrial", "warehouse", "business", "mixed use", "hotel", "motel", "commercialsale"), "Commercial"), (("cabin", "recreational", "waterfront", "lake house"), "Single Family"), (("residential",), "Single Family"), # RESO catch-all, after subtypes ] def normalize_property_type(text: str) -> str: import html as _html key = strip_accents(_html.unescape(str(text or "")).strip().lower()) if not key: return "" for keywords, canon in _TYPE_MAP: if any(k in key for k in keywords): return canon return _html.unescape(str(text)).strip().title() # --------------------------------------------------------------------------- # Listing status — RESO StandardStatus → canonical Home-Ka statuses # --------------------------------------------------------------------------- _STATUS_MAP = [ (("coming soon",), "coming-soon"), (("pending", "under contract", "active under contract", "contingent", "backup", "in contract"), "pending"), (("closed", "sold"), "sold"), (("withdrawn", "canceled", "cancelled", "delisted", "off market", "off-market", "expired", "hold", "temporarily off"), "withdrawn"), (("active", "for sale", "new", "price change", "back on market", "re-activated", "extended"), "active"), ] def normalize_status(text: str) -> str: key = (str(text or "")).strip().lower() if not key: return "active" for keywords, canon in _STATUS_MAP: if any(k in key for k in keywords): return canon return "active" # --------------------------------------------------------------------------- # Beds / baths from free text # --------------------------------------------------------------------------- _BED_RE = re.compile(r"(\d+)\s*(?:bed(?:room)?s?|br|bd)\b", re.IGNORECASE) _BATH_RE = re.compile(r"(\d+(?:\.\d+)?)\s*(?:bath(?:room)?s?|ba)\b", re.IGNORECASE) def extract_beds_baths(text: str) -> tuple[int | None, float | None]: if not text: return None, None beds = _BED_RE.search(text) baths = _BATH_RE.search(text) return (int(beds.group(1)) if beds else None, float(baths.group(1)) if baths else None)