# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # normalize.py : couche de normalisation commune — chaque connecteur retourne # les champs bruts de sa source, ce module uniformise tout : # prix, dates de disponibilité, types d'unité, superficies, # commodités (tags canoniques), adresses, contacts. # ----------------------------------------------------------------------------- from __future__ import annotations import re import unicodedata from datetime import date # --------------------------------------------------------------------------- # Utilitaires texte # --------------------------------------------------------------------------- _NBSP = (" ", " ", " ") def strip_accents(s: str) -> str: return "".join(c for c in unicodedata.normalize("NFD", s) if unicodedata.category(c) != "Mn") def _clean_text(s: str) -> str: """Espaces insécables -> espaces, espaces multiples réduits.""" for ch in _NBSP: s = s.replace(ch, " ") return re.sub(r"\s+", " ", s).strip() def _key(s: str) -> str: """Texte en minuscules sans accents, pour matcher des mots-clés.""" return strip_accents(_clean_text(s or "")).lower() def clean_address(raw: str) -> str: """Nettoie une adresse : espaces, virgules orphelines, casse conservée.""" if not raw: return "" s = _clean_text(raw) s = re.sub(r"\s*,\s*", ", ", s) # virgules uniformes s = re.sub(r"(, )+", ", ", s) # virgules doublées return s.strip(" ,;-") # --------------------------------------------------------------------------- # Prix # --------------------------------------------------------------------------- _PRICE_FROM_RE = re.compile( r"a partir|des\s+\d|starting|\bfrom\b|\bdes\b\s*$|\+\s*$" r"|de\s+\d[\d\s]*\s*\$?\s*a\s+\d", re.I) # fourchette « de X $ à Y $ » def parse_price(raw: str) -> float | None: """Extrait un loyer mensuel : '1 195 $/mois', '$1,195', 'À partir de 995$'. Retourne le montant le plus bas si plusieurs, None si aucun montant plausible (100–20 000 $). Jamais de valeur inventée. """ if not raw: return None s = _clean_text(str(raw)) if "$" not in s: return None nums = [] for m in re.finditer(r"(?:\$\s*)?(\d[\d\s]*(?:[.,]\d+)?)(?:\s*\$)?", s): # le nombre doit toucher un « $ » (avant ou après) start, end = m.span() near = s[max(0, start - 2):start] + m.group(0) + s[end:end + 2] if "$" not in near: continue num = m.group(1).replace(" ", "") # « 1,195 » = virgule de milliers ; « 1195,00 » = décimales if re.search(r",\d{3}(?:\D|$)", num + " "): num = num.replace(",", "") else: num = num.replace(",", ".") try: val = float(num) except ValueError: continue if 100 <= val <= 20000: nums.append(val) return min(nums) if nums else None def price_is_from(raw: str) -> bool: """Vrai si le prix est annoncé « à partir de ».""" return bool(raw) and bool(_PRICE_FROM_RE.search(_key(raw))) # --------------------------------------------------------------------------- # Type d'unité # --------------------------------------------------------------------------- _UNIT_WORDS = [ (re.compile(r"\bstudio\b|\bbachelor\b"), "Studio"), (re.compile(r"\bloft\b"), "Loft"), (re.compile(r"\bpenthouse\b"), "Penthouse"), (re.compile(r"\bcondo\b"), "Condo"), (re.compile(r"maison de ville|townhouse|\bmdv\b"), "Maison"), (re.compile(r"\bmaison\b|\bhouse\b|\bcottage\b"), "Maison"), (re.compile(r"\bchambre\b|\broom\b(?!s)"), "Chambre"), ] def normalize_unit_type(raw: str) -> str: """Standardise le type d'unité vers l'énumération Lou-Ka. '4 1/2', '4½', '4,5', '4.5' -> '4½' ; '2 chambres' -> '4½' (n cc + 2) ; '6½' et plus -> '6½+' ; 'bachelor' -> 'Studio' ; 'MDV' -> 'Maison'. """ if not raw: return "" s = _key(str(raw)) # « 4 1/2 », « 4½ », « 4,5 », « 4.5 », « 5 1/5 » (coquille fréquente) m = re.search(r"(\d+)\s*(?:½|1/2|1/5|[.,]5)", s) if not m: # « 4 pièces et demie » m = re.search(r"(\d+)\s*pieces?\s*(?:et\s*demie?)?", s) if m: n = int(m.group(1)) if n < 1: return "" return "6½+" if n >= 6 else f"{n}½" # « 2 chambres », « 3 cc », « 2 bedrooms » -> n + 2 pièces et demie m = re.search(r"(\d+)\s*(?:chambres?|\bcc\b|\bcac\b|bed(?:room)?s?\b|\bbdr\b)", s) if not m: # nombres en toutes lettres : « une chambre », « two bedrooms » mots = {"une": 1, "un": 1, "one": 1, "deux": 2, "two": 2, "trois": 3, "three": 3, "quatre": 4, "four": 4} m2 = re.search(r"\b(une?|one|deux|two|trois|three|quatre|four)\s+(?:chambres?|bed(?:room)?s?)\b", s) if m2: n = mots[m2.group(1)] + 2 return "6½+" if n >= 6 else f"{n}½" if m: n = int(m.group(1)) + 2 return "6½+" if n >= 6 else f"{n}½" for rx, label in _UNIT_WORDS: if rx.search(s): # « chambre » précédé d'un nombre a déjà été traité ci-dessus return label return _clean_text(str(raw)) # --------------------------------------------------------------------------- # Date de disponibilité # --------------------------------------------------------------------------- _MONTHS = { "janvier": 1, "jan": 1, "january": 1, "fevrier": 2, "fev": 2, "feb": 2, "february": 2, "mars": 3, "mar": 3, "march": 3, "avril": 4, "avr": 4, "apr": 4, "april": 4, "mai": 5, "may": 5, "juin": 6, "jun": 6, "june": 6, "juillet": 7, "juil": 7, "jul": 7, "july": 7, "aout": 8, "aug": 8, "august": 8, "septembre": 9, "sept": 9, "sep": 9, "september": 9, "octobre": 10, "oct": 10, "october": 10, "novembre": 11, "nov": 11, "november": 11, "decembre": 12, "dec": 12, "december": 12, } _MONTH_RE = "|".join(sorted(_MONTHS, key=len, reverse=True)) _NOW_RE = re.compile( r"immediat|maintenant|\bnow\b|des maintenant|\blibre\b|disponible|available|occupation immediate|vacant") def parse_availability_date(raw: str, today: date | None = None) -> str | None: """Normalise une disponibilité en date ISO, « now », ou None. « Libre immédiatement » -> "now" ; « 1er juillet » -> prochaine occurrence (ou "now" si déjà passée) ; « Disponibilité : 01 décembre 2026 » -> "2026-12-01" ; texte inconnu -> None (jamais de date inventée). """ if not raw: return None today = today or date.today() s = _key(str(raw)) def _build(y: int | None, m: int, d: int) -> str: if y is None: y = today.year if date(y, m, min(d, 28)) < today: # date sans année déjà passée : le logement est libre return "now" try: dt = date(y, m, d) except ValueError: dt = date(y, m, 1) return "now" if dt <= today else dt.isoformat() # ISO : 2026-07-01 m = re.search(r"(20\d{2})-(\d{1,2})-(\d{1,2})", s) if m: return _build(int(m.group(1)), int(m.group(2)), int(m.group(3))) # anglais « April 1st, 2027 » (mois d'abord ; « juillet 2026 » exclu # par le (?!\d) — un jour ne peut pas être le début d'une année) m = re.search( rf"\b({_MONTH_RE})\b\.?\s+(\d{{1,2}})(?!\d)(?:st|nd|rd|th)?\s*,?\s*(20\d{{2}})?", s) if m and m.group(2) and 1 <= int(m.group(2)) <= 31: y = int(m.group(3)) if m.group(3) else None return _build(y, _MONTHS[m.group(1)], int(m.group(2))) # 01/07/2026 ou 2026/07/01 (format jour/mois/année au Québec) m = re.search(r"(\d{1,2})[/.](\d{1,2})[/.](20\d{2})", s) if m: d, mo = int(m.group(1)), int(m.group(2)) if mo > 12 and d <= 12: d, mo = mo, d if 1 <= mo <= 12: return _build(int(m.group(3)), mo, min(d, 28) if d > 31 else d) m = re.search(r"(20\d{2})[/.](\d{1,2})[/.](\d{1,2})", s) if m: return _build(int(m.group(1)), int(m.group(2)), int(m.group(3))) # « 1er juillet 2026 », « 01 décembre », « juillet 2026 », « déc. 2026 » # \b : « rénové » contient « nov », « maison » contient « mai » — sans # frontières de mots, le normaliseur inventait des dates m = re.search( rf"(?:(\d{{1,2}})(?:er|e|st|nd|rd|th)?\s+)?\b({_MONTH_RE})\b\.?\s*(20\d{{2}})?", s) if m and m.group(2): d = int(m.group(1)) if m.group(1) else 1 mo = _MONTHS[m.group(2)] y = int(m.group(3)) if m.group(3) else None if 1 <= d <= 31: return _build(y, mo, d) # « July 1, 2026 » (mois déjà couvert ci-dessus) / « 2026 » seul : ignoré if _NOW_RE.search(s): return "now" return None # --------------------------------------------------------------------------- # Superficie # --------------------------------------------------------------------------- _AREA_SQFT_RE = re.compile( r"(\d[\d\s,]*(?:\.\d+)?)\s*(?:pi\s*[²2]|pi\.?\s*ca|pieds?\s*carres?|sq\.?\s*-?\s*ft|ft\s*[²2]|sqft|pc\b|p[²2])", re.I) _AREA_SQM_RE = re.compile( r"(\d[\d\s,]*(?:\.\d+)?)\s*(?:m\s*[²2]|metres?\s*carres?|sq\.?\s*m\b)", re.I) # superficies qui ne sont PAS celles du logement (balcon, terrasse, rangement) _AREA_SKIP_CTX = re.compile(r"balcon|terrass|loggia|patio|rangement|locker|garage|cour\b") def parse_area_sqft(raw: str) -> float | None: """Superficie en pi². Gère « 850 pi² », « 1,607 sq. ft. », « 78 m² ». Ignore les superficies de balcon/terrasse/rangement (contexte) ; retourne la plus petite valeur restante (cohérent avec un prix « à partir de »). """ if not raw: return None s = _key(str(raw)) def _num(txt: str) -> float | None: txt = txt.replace(" ", "") # « 1,607 » = virgule de milliers ; sinon virgule décimale if re.search(r",\d{3}(?:\D|$)", txt + " "): txt = txt.replace(",", "") else: txt = txt.replace(",", ".") try: return float(txt) except ValueError: return None def _skip(start: int) -> bool: return bool(_AREA_SKIP_CTX.search(s[max(0, start - 30):start])) vals = [] for m in _AREA_SQFT_RE.finditer(s): v = _num(m.group(1)) if v and 80 <= v <= 20000 and not _skip(m.start()): vals.append(v) for m in _AREA_SQM_RE.finditer(s): v = _num(m.group(1)) if v and 8 <= v <= 2000 and not _skip(m.start()): vals.append(round(v * 10.7639)) return min(vals) if vals else None # --------------------------------------------------------------------------- # Commodités -> tags canoniques (details JSON) # --------------------------------------------------------------------------- # Chaque règle : clé canonique -> (motifs positifs, motifs négatifs). # Les motifs sont matchés sur du texte sans accents en minuscules. # Absence de match = inconnu (la clé n'apparaît pas), jamais « false » deviné. _AMENITY_RULES: dict[str, tuple[str, str | None]] = { # inclusions "heating": (r"chauffage|chauffe[e]? |chauffe[e]?$|heating|heat included|\bheat\b", r"chauffage non inclus|sans chauffage|non[- ]chauffe|pas chauffe|heat pump" r"|chauffage[^|.]{0,60}charge d(?:u|es) locataires?"), "electricity": (r"electricite|hydro inclus|electricity|eclaire\b|\bhydro\b", r"electricite non incluse?|non[- ]eclaire" r"|electricite[^|.]{0,60}charge d(?:u|es) locataires?"), "hot_water": (r"eau chaude|hot water", r"eau chaude non incluse?|eau chaude[^|.]{0,60}charge d(?:u|es) locataires?"), "internet": (r"internet|wi[- ]?fi", r"internet non inclus"), "cable": (r"\bcable\b|television|telus|videotron|bell fibe", None), # électroménagers "fridge": (r"refrigerateur|refrigerator|\bfrigo\b|\bfridge\b|electromenagers?( inclus)?|appliances", None), "stove": (r"cuisiniere|\bstove\b|\bfour\b|plaques? de cuisson", None), "dishwasher": (r"lave[- ]vaisselle|dishwasher", r"entrees? (?:pour |de )?lave[- ]vaisselle|dishwasher hookup"), "dishwasher_hookup": (r"entrees? (?:pour |de )?lave[- ]vaisselle|dishwasher hookups?", None), "washer_dryer": (r"laveuse[- /]secheuse|laveuse et secheuse|\bwasher\b|\bdryer\b", r"entrees?[^|.]{0,35}laveuse|washer.{0,10}hookup"), "washer_dryer_hookup": (r"entrees?[^|.]{0,35}laveuse|sorties? laveuse|washer.{0,10}hookups?", None), "ac": (r"climatis|air climatise|\ba/?c\b|air conditioning|central air|cooling\s*:|thermopompe", None), # immeuble "elevator": (r"ascen[cs]eur|elevator", None), "balcony": (r"balcon|terrasse|terrace|patio|loggia", r"pas de balcon|sans balcon|no balcony|aucun balcon"), "pool": (r"piscine|\bpool\b|\bspa\b", None), "gym": (r"\bgym\b|salle d.?entrainement|entrainement|fitness|salle de sport", None), "laundry": (r"buanderie|salle de lavage|laundry (?:room|facilit)", None), "storage": (r"rangement|\blocker\b|espace de rangement|storage", None), "furnished_kw": (r"\bmeuble\b|\bmeublee?s?\b|furnished", r"non[- ]meuble|unfurnished"), "parking_kw": (r"stationnement|parking|garage", None), } _PETS_YES_RE = re.compile( r"animaux (?:de compagnie )?(?:acceptes|admis|bienvenus|permis|autorises|ok)" r"|pet[s]?[- ]friendly|pets? (?:allowed|welcome)" r"|chats? (?:et chiens? )?(?:acceptes?|admis)" r"|chiens? (?:et chats? )?(?:permis|acceptes?|admis)" r"|anima(?:l|ux)[^|.]{0,15}(?:bienvenus?|acceptes?|admis|permis)") # les deux espèces explicitement acceptées : « oui » sans ambiguïté, # testé AVANT les motifs « conditions » (sinon « chats acceptés » l'emporte) _PETS_BOTH_RE = re.compile( r"(?:chiens? et chats?|chats? et chiens?) (?:acceptes|admis|bienvenus|permis|autorises)") _PETS_NO_RE = re.compile( r"(?:pas d.?animaux|aucun animal|animaux (?:refuses|interdits|non admis|non acceptes|non autorises|non permis))|no pets?" r"|animaux (?:permis|acceptes|admis|autorises)?\s*:\s*non" r"|animaux[^|.]{0,15}interdits") _PETS_COND_RE = re.compile( r"animaux (?:sous conditions?|sur approbation)|petits? animaux|petits? chiens?" r"|chats? (?:seulement|acceptes?|autorises?|admis)\b|cats? only|avec restrictions" r"|(?:animaux|chats?|chiens?|pets?)[^|.]{0,30}sous conditions?" r"|chiens?[^|.]{0,30}(?:pas|non|ne sont pas) (?:permis|admis|acceptes)" r"|chiens? interdits?|no dogs?\b" r"|animau?x?[^|.]{0,30}\(?\d+\s*(?:lb|livres?|kg)" # limite de poids r"|animaux[^|.]{0,20}sur demande" r"|animaux[^|.]{0,20}refuses?[^|.]{0,30}(?:sauf|a l.?exception)") _SMOKING_NO_RE = re.compile(r"non[- ]fumeurs?|sans fumee|smoke[- ]free|no smoking|interdiction de fumer") _SMOKING_YES_RE = re.compile(r"fumeurs? (?:accepte|permis|autorise)") _PARKING_INT_RE = re.compile(r"(?:stationnement|garage|parking)[^.]{0,30}(?:interieur|souterrain|sous[- ]terrain|indoor)|garage") _PARKING_EXT_RE = re.compile(r"(?:stationnement|parking)[^.]{0,30}(?:exterieur|outdoor)") _PARKING_INCL_RE = re.compile(r"(?:stationnement|parking)[^.]{0,30}inclus") _PARKING_EXTRA_RE = re.compile( r"(?:stationnement|parking)[^.]{0,60}?(?:en sus|en option|disponible)") _PARKING_PRICE_RE = re.compile( r"(?:stationnement|parking)[^.]{0,60}?" r"(?:(? dict: """Dérive les champs structurés depuis les commodités libres + description. Retourne un dict « sparse » : une clé absente = information inconnue. Clés : inclusions{}, appliances{}, pets, smoking, furnished, parking{}, elevator, balcony, pool, gym, laundry, storage, ac, floor, contact{}. """ items = [_key(a) for a in (amenities or []) if a] desc = _key(description or "") all_text = " | ".join(items + ([desc] if desc else []) + ([_key(title)] if title else [])) all_text = _NEUTRAL_RE.sub(" ", all_text) all_text = _HEAT_CTX_RE.sub(r"\1", all_text) if not all_text.strip(" |"): return {} flags: dict[str, bool] = {} for cle, (pos, neg) in _AMENITY_RULES.items(): neg_rx = re.compile(neg) if neg else None pos_rx = re.compile(pos) # commodités (listes courtes) : match libéral ; description : aussi, # les négatifs l'emportent sur les positifs. if neg_rx and neg_rx.search(all_text): flags[cle] = False elif pos_rx.search(all_text): flags[cle] = True details: dict = {} inclusions = {k: v for k, v in flags.items() if k in ("heating", "electricity", "hot_water", "internet", "cable")} if re.search(r"rien (?:n.est )?d.?inclus|aucune inclusion|nothing included", all_text): inclusions.update({"heating": False, "electricity": False, "hot_water": False}) if inclusions: details["inclusions"] = inclusions appliances = {k: v for k, v in flags.items() if k in ("fridge", "stove", "dishwasher", "washer_dryer")} if flags.get("washer_dryer_hookup"): appliances["washer_dryer_hookup"] = True # « entrées laveuse-sécheuse » n'implique pas les appareils fournis if not re.search(r"laveuse[- /]secheuse (?:incluse?s?|fournies?)", all_text): appliances.pop("washer_dryer", None) if flags.get("dishwasher_hookup"): appliances["dishwasher_hookup"] = True appliances.pop("dishwasher", None) # branchement ≠ appareil fourni if appliances: details["appliances"] = appliances for cle in ("ac", "elevator", "balcony", "pool", "gym", "laundry", "storage"): if cle in flags: details[cle] = flags[cle] # animaux / fumeur if _PETS_BOTH_RE.search(all_text): details["pets"] = "oui" elif _PETS_COND_RE.search(all_text): details["pets"] = "conditions" elif _PETS_NO_RE.search(all_text): details["pets"] = "non" elif _PETS_YES_RE.search(all_text): details["pets"] = "oui" if _SMOKING_NO_RE.search(all_text): details["smoking"] = False elif _SMOKING_YES_RE.search(all_text): details["smoking"] = True # meublé (« semi-meublé » : nuance conservée, colonne booléenne à None) if re.search(r"semi[- ](?:meuble|furnished)", all_text): details["furnished"] = "semi" elif "furnished_kw" in flags: details["furnished"] = flags["furnished_kw"] # stationnement (« intérieur et extérieur » possible) if flags.get("parking_kw"): parking: dict = {"available": True} p_int = bool(_PARKING_INT_RE.search(all_text)) p_ext = bool(_PARKING_EXT_RE.search(all_text)) if p_int and p_ext: parking["type"] = "intérieur et extérieur" elif p_int: parking["type"] = "intérieur" elif p_ext: parking["type"] = "extérieur" if _PARKING_INCL_RE.search(all_text): parking["included"] = True elif _PARKING_EXTRA_RE.search(all_text) or _PARKING_PRICE_RE.search(all_text): parking["included"] = False m = _PARKING_PRICE_RE.search(all_text) if m: prix = float((m.group(1) or m.group(2)).replace(" ", "")) if 10 <= prix <= 1000: # plausible pour un stationnement parking["price"] = prix details["parking"] = parking # étage — depuis les items de commodités seulement : une description # longue peut mentionner « 3e étage » pour un immeuble multi-unités. # Les items décrivant une installation (« salon au 25e étage ») sont exclus. floor_items = [it for it in items if not re.search(r"salon|lounge|piscine|gym|terrasse|rooftop|chalet|spa", it)] m = _FLOOR_RE.search(" | ".join(floor_items)) if m: floor = next((g for g in m.groups() if g), None) if floor and 0 < int(floor) <= 60: details["floor"] = int(floor) # contact (depuis la description seulement — les listes n'en ont pas) contact: dict = {} m = _PHONE_RE.search(description or "") if m: contact["phone"] = f"{m.group(1)}-{m.group(2)}-{m.group(3)}" m = _EMAIL_RE.search(description or "") if m: contact["email"] = m.group(0) if contact: details["contact"] = contact return details def merge_details(base: dict, derived: dict) -> dict: """Fusionne les détails dérivés sous ceux fournis par le connecteur. Les valeurs explicites du connecteur ont priorité ; les sous-dicts (inclusions, appliances, parking, contact) sont fusionnés clé par clé. """ out = dict(derived) for k, v in (base or {}).items(): if isinstance(v, dict) and isinstance(out.get(k), dict): out[k] = {**out[k], **v} else: out[k] = v return out