# ----------------------------------------------------------------------------- # Immo-Ka — Agrégateur de maisons à vendre (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # normalize.py : couche de normalisation commune (prix, types, adresses…) # ----------------------------------------------------------------------------- """Fonctions de normalisation partagées par tous les connecteurs. Les connecteurs remplissent les champs bruts tels que vus sur le site source ; `PropertyListing.finalize()` (schema.py) appelle ces fonctions pour produire des valeurs canoniques comparables entre agences. """ from __future__ import annotations import re import unicodedata __all__ = [ "strip_accents", "clean_address", "parse_price", "price_is_from", "parse_int", "parse_float", "parse_area_sqft", "parse_lot_sqft", "normalize_property_type", "extract_bedrooms_bathrooms", ] def strip_accents(text: str) -> str: return "".join(c for c in unicodedata.normalize("NFD", text or "") if unicodedata.category(c) != "Mn") def clean_address(text: str) -> str: """Nettoie une adresse civique (espaces, virgules doublées, apostrophes).""" t = re.sub(r"\s+", " ", (text or "").replace("’", "'")).strip() t = re.sub(r"\s*,\s*", ", ", t) t = re.sub(r"(, )+", ", ", t).strip(", ") return t # --------------------------------------------------------------------------- # Prix # --------------------------------------------------------------------------- _PRICE_RE = re.compile(r"(\d[\d\s  .,]*)\s*(?:\$|CAD)?", re.UNICODE) def parse_price(label: str) -> float | None: """Extrait un prix de vente d'un libellé source. Gère « 459 000 $ », « $459,000 », « 1 249 000$ +tx », « À partir de 399 900 $ ». Retourne None si aucun montant plausible (>= 10 000 $) n'est trouvé. """ if not label: return None m = _PRICE_RE.search(label.replace(" ", " ").replace(" ", " ")) if not m: return None raw = m.group(1).strip() # « 459 000 » / « 459,000 » / « 459000.00 » — retirer les séparateurs de milliers raw = raw.replace(" ", "") if "," in raw and "." in raw: raw = raw.replace(",", "") # 459,000.00 elif raw.count(",") == 1 and len(raw.split(",")[1]) == 2: raw = raw.replace(",", ".") # 459000,00 (décimale FR) else: raw = raw.replace(",", "") try: value = float(raw) except ValueError: return None return value if value >= 10_000 else None def price_is_from(label: str) -> bool: key = strip_accents((label or "").lower()) return any(k in key for k in ("a partir", "starting", "from", "des ")) # --------------------------------------------------------------------------- # Nombres génériques # --------------------------------------------------------------------------- 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*(pi2|pi²|pc|sq\.?\s*?ft|ft2|ft²)", re.IGNORECASE) _SQM_RE = re.compile(r"([\d\s ,.]+)\s*(m2|m²|mc)", re.IGNORECASE) def parse_area_sqft(text: str) -> float | None: """Superficie habitable en pi² (convertit les m² au besoin).""" if not text: return None t = text.replace(" ", " ") m = _SQFT_RE.search(t) if m: v = parse_float(m.group(1)) return round(v) if v and v > 50 else None m = _SQM_RE.search(t) if m: v = parse_float(m.group(1)) return round(v * 10.7639) if v and v > 5 else None return None def parse_lot_sqft(text: str) -> float | None: """Superficie de terrain en pi² — mêmes unités que parse_area_sqft.""" return parse_area_sqft(text) # --------------------------------------------------------------------------- # Type de propriété # --------------------------------------------------------------------------- # Vocabulaire canonique d'Immo-Ka (affiché dans les filtres du frontend) _TYPE_MAP = [ # (mots-clés dans le texte source normalisé, type canonique) (("plain-pied", "bungalow",), "Maison"), (("maison a etages", "a etage", "deux etages", "cottage",), "Maison"), (("maison mobile", "unimodulaire",), "Maison mobile"), (("jumele", "semi-detache", "semi detache",), "Jumelé"), (("maison de ville", "townhouse", "en rangee",), "Maison de ville"), (("unifamiliale", "maison", "house", "residence", "split",), "Maison"), (("condo", "copropriete", "appartement", "apartment", "loft", "penthouse", "studio",), "Condo"), (("duplex",), "Duplex"), (("triplex",), "Triplex"), (("quadruplex", "quintuplex", "multiplex", "multilogement", "multi-logement", "immeuble a revenus", "revenus",), "Multiplex"), (("chalet", "cottage 4 saisons", "acces au plan d'eau", "bord de l'eau",), "Chalet"), (("terre", "terrain", "lot ", "land",), "Terrain"), (("ferme", "fermette", "agricole", "hobby farm",), "Fermette/Agricole"), (("commercial", "commerce", "industriel", "bureau", "local",), "Commercial"), ] def normalize_property_type(text: str) -> str: key = strip_accents((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 text.strip().capitalize() # --------------------------------------------------------------------------- # Chambres / salles de bains depuis du texte libre # --------------------------------------------------------------------------- _BED_RE = re.compile(r"(\d+)\s*(?:ch(?:ambre)?s?|cac|bed(?:room)?s?)\b", re.IGNORECASE) _BATH_RE = re.compile(r"(\d+)\s*(?:sdb|salle?s?\s+de\s+bains?|bath(?:room)?s?)", re.IGNORECASE) def extract_bedrooms_bathrooms(text: str) -> tuple[int | None, int | 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, int(baths.group(1)) if baths else None)