SPB Git

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%
6.5 KB · 183 lines python
Raw Blame History
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||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