# ----------------------------------------------------------------------------- # 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", "parse_year", "normalize_property_type", "extract_bedrooms_bathrooms", "clean_title", "clean_description", ] def strip_accents(text: str) -> str: return "".join(c for c in unicodedata.normalize("NFD", text or "") if unicodedata.category(c) != "Mn") _SMALL_WORDS = {"a", "à", "au", "aux", "avec", "de", "des", "du", "en", "et", "la", "le", "les", "ou", "pour", "sur", "sous", "un", "une"} def clean_title(text: str) -> str: """Titre propre : espaces normalisés, et les titres CRIÉS EN MAJUSCULES (fréquents chez certaines sources) ramenés en casse naturelle — chaque mot capitalisé sauf les mots-outils (les noms de villes restent capitalisés).""" 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 sans HTML brut visible : balises retirées (sauts de ligne préservés), entités décodées, espaces/blancs normalisés.""" 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() 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) # « 1959 », « 2018 (Neuf) », « 1975, rénové »… mais JAMAIS « 20' X 34' irr. » # ni « À construire » : la valeur doit COMMENCER par une année plausible. _YEAR_RE = re.compile(r"^\s*(1[6-9]\d{2}|20[0-4]\d)\s*(?:$|[(,])") def parse_year(text) -> int | None: """Année de construction plausible (1600-2049) depuis une valeur `details`. Volontairement strict (année en tête de valeur, seule ou suivie d'une parenthèse/virgule) pour ne jamais promouvoir un libellé parasite vers la colonne year_built.""" 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 # --------------------------------------------------------------------------- # Type de propriété # --------------------------------------------------------------------------- # House-Ka canonical vocabulary (frontend filters) — ENGLISH. # Sources are CREA DDF sites (English labels), plus the odd French label. _TYPE_MAP = [ # (keywords in the normalized source text, canonical type) (("maison mobile", "unimodulaire", "mobile home", "manufactured home", "modular",), "Mobile home"), (("jumele", "semi-detache", "semi detache", "semi-detached", "semi detached",), "Semi-detached"), (("maison de ville", "townhouse", "town house", "en rangee", "row house", "row / town",), "Townhouse"), (("condo", "copropriete", "appartement", "apartment", "loft", "penthouse", "studio", "strata",), "Condo"), (("duplex",), "Duplex"), (("triplex",), "Triplex"), (("quadruplex", "quintuplex", "multiplex", "multilogement", "multi-logement", "immeuble a revenus", "revenus", "multi-family", "multi family", "multifamily", "fourplex",), "Multi-family"), (("chalet", "cottage 4 saisons", "acces au plan d'eau", "bord de l'eau", "recreational", "cabin",), "Cottage"), (("terre", "terrain", "lot ", "vacant land", "land",), "Land"), (("ferme", "fermette", "agricole", "agriculture", "hobby farm", "farm", "acreage",), "Farm"), (("plain-pied", "bungalow",), "House"), (("maison a etages", "a etage", "deux etages", "cottage",), "House"), (("unifamiliale", "maison", "house", "residence", "residential", "split", "detached", "single family", "single-family",), "House"), # enriched-sheet vocabulary: « 4 logements », « propriété à revenu » (("logements", "logement/", "unites et +", "revenu",), "Multi-family"), (("bi generation", "bi-generation", "bigeneration", "intergeneration",), "House"), (("domaine et villa", "villa", "domaine",), "House"), (("parking",), "Parking"), (("commercial", "commerce", "industriel", "industrie", "bureau", "local", "entreprise", "batisse", "restaurant", "depanneur", "hotel", "motel", "garage/", "concessionnaire", "coiffure", "esthetique", "camping", "retail", "office", "industrial", "warehouse", "business", "institutional",), "Commercial"), ] def normalize_property_type(text: str) -> str: import html as _html key = strip_accents(_html.unescape(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(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)