spb/immo-ka Public
Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)
Python 64%
TypeScript 21.1%
CSS 14.3%
HTML 0.6%
1# -----------------------------------------------------------------------------2# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# normalize.py : couche de normalisation commune (prix, types, adresses…)5# -----------------------------------------------------------------------------6"""Fonctions de normalisation partagées par tous les connecteurs.78Les connecteurs remplissent les champs bruts tels que vus sur le site source ;9`PropertyListing.finalize()` (schema.py) appelle ces fonctions pour produire10des valeurs canoniques comparables entre agences.11"""12from __future__ import annotations1314import re15import unicodedata1617__all__ = [18 "strip_accents", "clean_address", "parse_price", "price_is_from",19 "parse_int", "parse_float", "parse_area_sqft", "parse_lot_sqft",20 "normalize_property_type", "extract_bedrooms_bathrooms",21]222324def strip_accents(text: str) -> str:25 return "".join(c for c in unicodedata.normalize("NFD", text or "")26 if unicodedata.category(c) != "Mn")272829def clean_address(text: str) -> str:30 """Nettoie une adresse civique (espaces, virgules doublées, apostrophes)."""31 t = re.sub(r"\s+", " ", (text or "").replace("’", "'")).strip()32 t = re.sub(r"\s*,\s*", ", ", t)33 t = re.sub(r"(, )+", ", ", t).strip(", ")34 return t353637# ---------------------------------------------------------------------------38# Prix39# ---------------------------------------------------------------------------4041_PRICE_RE = re.compile(r"(\d[\d\s .,]*)\s*(?:\$|CAD)?", re.UNICODE)424344def parse_price(label: str) -> float | None:45 """Extrait un prix de vente d'un libellé source.4647 Gère « 459 000 $ », « $459,000 », « 1 249 000$ +tx », « À partir de 399 900 $ ».48 Retourne None si aucun montant plausible (>= 10 000 $) n'est trouvé.49 """50 if not label:51 return None52 m = _PRICE_RE.search(label.replace(" ", " ").replace(" ", " "))53 if not m:54 return None55 raw = m.group(1).strip()56 # « 459 000 » / « 459,000 » / « 459000.00 » — retirer les séparateurs de milliers57 raw = raw.replace(" ", "")58 if "," in raw and "." in raw:59 raw = raw.replace(",", "") # 459,000.0060 elif raw.count(",") == 1 and len(raw.split(",")[1]) == 2:61 raw = raw.replace(",", ".") # 459000,00 (décimale FR)62 else:63 raw = raw.replace(",", "")64 try:65 value = float(raw)66 except ValueError:67 return None68 return value if value >= 10_000 else None697071def price_is_from(label: str) -> bool:72 key = strip_accents((label or "").lower())73 return any(k in key for k in ("a partir", "starting", "from", "des "))747576# ---------------------------------------------------------------------------77# Nombres génériques78# ---------------------------------------------------------------------------7980def parse_int(text) -> int | None:81 if text is None:82 return None83 if isinstance(text, (int, float)):84 return int(text)85 m = re.search(r"\d+", str(text))86 return int(m.group()) if m else None878889def parse_float(text) -> float | None:90 if text is None:91 return None92 if isinstance(text, (int, float)):93 return float(text)94 m = re.search(r"\d[\d\s]*(?:[.,]\d+)?", str(text))95 if not m:96 return None97 try:98 return float(m.group().replace(" ", "").replace(",", "."))99 except ValueError:100 return None101102103_SQFT_RE = re.compile(r"([\d\s ,.]+)\s*(pi2|pi²|pc|sq\.?\s*?ft|ft2|ft²)",104 re.IGNORECASE)105_SQM_RE = re.compile(r"([\d\s ,.]+)\s*(m2|m²|mc)", re.IGNORECASE)106107108def parse_area_sqft(text: str) -> float | None:109 """Superficie habitable en pi² (convertit les m² au besoin)."""110 if not text:111 return None112 t = text.replace(" ", " ")113 m = _SQFT_RE.search(t)114 if m:115 v = parse_float(m.group(1))116 return round(v) if v and v > 50 else None117 m = _SQM_RE.search(t)118 if m:119 v = parse_float(m.group(1))120 return round(v * 10.7639) if v and v > 5 else None121 return None122123124def parse_lot_sqft(text: str) -> float | None:125 """Superficie de terrain en pi² — mêmes unités que parse_area_sqft."""126 return parse_area_sqft(text)127128129# ---------------------------------------------------------------------------130# Type de propriété131# ---------------------------------------------------------------------------132133# Vocabulaire canonique d'Immo-Ka (affiché dans les filtres du frontend)134_TYPE_MAP = [135 # (mots-clés dans le texte source normalisé, type canonique)136 (("plain-pied", "bungalow",), "Maison"),137 (("maison a etages", "a etage", "deux etages", "cottage",), "Maison"),138 (("maison mobile", "unimodulaire",), "Maison mobile"),139 (("jumele", "semi-detache", "semi detache",), "Jumelé"),140 (("maison de ville", "townhouse", "en rangee",), "Maison de ville"),141 (("unifamiliale", "maison", "house", "residence", "split",), "Maison"),142 (("condo", "copropriete", "appartement", "apartment", "loft", "penthouse",143 "studio",), "Condo"),144 (("duplex",), "Duplex"),145 (("triplex",), "Triplex"),146 (("quadruplex", "quintuplex", "multiplex", "multilogement", "multi-logement",147 "immeuble a revenus", "revenus",), "Multiplex"),148 (("chalet", "cottage 4 saisons", "acces au plan d'eau", "bord de l'eau",),149 "Chalet"),150 (("terre", "terrain", "lot ", "land",), "Terrain"),151 (("ferme", "fermette", "agricole", "hobby farm",), "Fermette/Agricole"),152 (("commercial", "commerce", "industriel", "bureau", "local",), "Commercial"),153]154155156def normalize_property_type(text: str) -> str:157 key = strip_accents((text or "").strip().lower())158 if not key:159 return ""160 for keywords, canon in _TYPE_MAP:161 if any(k in key for k in keywords):162 return canon163 return text.strip().capitalize()164165166# ---------------------------------------------------------------------------167# Chambres / salles de bains depuis du texte libre168# ---------------------------------------------------------------------------169170_BED_RE = re.compile(r"(\d+)\s*(?:ch(?:ambre)?s?|cac|bed(?:room)?s?)\b",171 re.IGNORECASE)172_BATH_RE = re.compile(r"(\d+)\s*(?:sdb|salle?s?\s+de\s+bains?|bath(?:room)?s?)",173 re.IGNORECASE)174175176def extract_bedrooms_bathrooms(text: str) -> tuple[int | None, int | None]:177 if not text:178 return None, None179 beds = _BED_RE.search(text)180 baths = _BATH_RE.search(text)181 return (int(beds.group(1)) if beds else None,182 int(baths.group(1)) if baths else None)183