# ----------------------------------------------------------------------------- # Rent-Ka — Rental listings aggregator (Canada, outside Québec) # Author: 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() # valeur « ville » qui est en réalité une rue / route / adresse partielle _STREET_IN_CITY = re.compile( r"(?i)^\s*(\d+[a-z]?\s|\d+e\s)?(rue|av(enue)?|boul(evard)?|chemin|ch\.|" r"route|rang|place|allée|allee|montée|montee|croissant|impasse|terrasse|" r"cours|carré|carre|côte|cote|promenade|sentier|street|road|drive|lane)\b") _POSTAL_IN_CITY = re.compile(r"(?i)\s*[a-z]\d[a-z]\s?\d[a-z]\d\s*$") def clean_city(raw: str) -> str: """Nettoie un nom de ville : entités HTML décodées, code postal retiré, et VIDE si la valeur est manifestement un nom de rue (certains portails mettent la rue dans le champ ville) — mieux vaut aucune ville qu'une fausse ville qui pollue les filtres.""" import html as _html if not raw: return "" s = _clean_text(_html.unescape(_html.unescape(raw))) s = _POSTAL_IN_CITY.sub("", s).strip(" ,;-") if _STREET_IN_CITY.match(s): return "" if re.search(r"\d", s) and not re.search(r"(?i)saint|st[- ]", s): return "" # « Route 132 », « 6e Avenue »… (chiffres = suspect) return s _BAD_SECTOR = re.compile( r"(?i)^(rue|av(enue)?|boul(evard)?|blvd|chemin|route)\b" # rue en tête r"|\b(rue|boul(evard)?|blvd|av\.?)\b.{0,30}\b(et|and|/|&)\b" # intersection r"|minutes?\s+d[ue]|\bhttp") def clean_sector(raw: str) -> str: """Nettoie un quartier : entités décodées ; VIDE si la valeur est une rue, une intersection ou une phrase marketing (pollue le filtre Quartier).""" import html as _html if not raw: return "" s = _clean_text(_html.unescape(_html.unescape(raw))) if len(s) > 40 or _BAD_SECTOR.search(s): return "" return s 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|town home|townhome|row ?house|" r"\bmdv\b"), "Townhouse"), (re.compile(r"\bmaison\b|\bhouse\b|\bcottage\b|\bbungalow\b"), "House"), (re.compile(r"\bchambre\b|\broom\b(?!s)|roommate|colocation"), "Room"), ] def _bedrooms_label(n: int) -> str: """0 -> Studio ; 1 -> 1 bedroom ; 2..4 -> n bedrooms ; 5+ -> 5+ bedrooms.""" if n <= 0: return "Studio" if n >= 5: return "5+ bedrooms" return f"{n} bedroom" + ("s" if n > 1 else "") def normalize_unit_type(raw: str) -> str: """Standardize the unit type to the Rent-Ka English enum. Canon: Studio | 1 bedroom | 2 bedrooms | 3 bedrooms | 4 bedrooms | 5+ bedrooms | Loft | Penthouse | Condo | Townhouse | House | Room. Québec "pièces et demie" input converts the other way: '3½' -> 1 bedroom, '4½' -> 2 bedrooms (n½ = n-2 bedrooms), '6½+' -> 4 bedrooms; 'bachelor' -> Studio; 'MDV'/'maison de ville' -> Townhouse. """ if not raw: return "" s = _key(str(raw)) # «4 1/2», «4½», «4,5», «4.5», «5 1/5» (frequent typo): n½ -> n-2 bedrooms 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 _bedrooms_label(min(max(n - 2, 0), 5)) # «2 bedrooms», «3 cc», «2 chambres» -> n bedrooms m = re.search(r"(\d+)\s*(?:chambres?|\bcc\b|\bcac\b|bed(?:room)?s?\b|\bbdr\b|\bbr\b)", s) if not m: # spelled-out numbers: «one bedroom», «deux chambres» mots = {"une": 1, "un": 1, "one": 1, "deux": 2, "two": 2, "trois": 3, "three": 3, "quatre": 4, "four": 4, "cinq": 5, "five": 5} m2 = re.search(r"\b(une?|one|deux|two|trois|three|quatre|four|cinq|" r"five)\s+(?:chambres?|bed(?:room)?s?)\b", s) if m2: return _bedrooms_label(mots[m2.group(1)]) if m: n = int(m.group(1)) if 0 <= n <= 12: return _bedrooms_label(min(n, 5)) for rx, label in _UNIT_WORDS: if rx.search(s): # «room»/«chambre» preceded by a number was handled above return label # already-canonical English labels pass through unchanged canon = _clean_text(str(raw)) return canon # --------------------------------------------------------------------------- # Chambres / salles de bain # --------------------------------------------------------------------------- _BEDROOMS_TXT_RE = re.compile( r"(\d+)\s*(?:chambres?(?:\s+a\s+coucher)?\b|\bcc\b|\bcac\b|bed(?:room)?s?\b|\bbdr\b)") _BATHROOMS_TXT_RE = re.compile( r"(\d+(?:[.,]5)?)\s*(?:salles?\s+de\s+bains?\b|\bsdb\b|bath(?:room)?s?\b)") def bedrooms_from_unit_type(unit_type: str) -> float | None: """Closed bedrooms from the canonical English unit type. 'n bedroom(s)' -> n ; '5+ bedrooms' -> 5 (known lower bound) ; Studio/Loft -> 0 ; Room -> 1. Types without a bedroom count (House, Condo, Townhouse, Penthouse…) -> None (unknown). Legacy QC labels still convert (n½ -> n-2, 6½+ -> 4). """ if not unit_type: return None s = unit_type.strip() m = re.match(r"^(\d+)\s*bedrooms?$", s, re.I) if m: return float(m.group(1)) if re.match(r"^5\+\s*bedrooms$", s, re.I): return 5.0 # legacy Québec labels (seeded DB rows ingested before the fork) if s == "6½+": return 4.0 m = re.match(r"^(\d+)½$", s) if m: return float(max(int(m.group(1)) - 2, 0)) key = _key(s) if key in ("studio", "loft"): return 0.0 if key in ("chambre", "room"): return 1.0 return None def parse_bedrooms(*texts: str) -> float | None: """Nombre de chambres explicite dans un texte : « 3 chambres », « 2 bedrooms ». Jamais de valeur inventée : None si aucun nombre plausible (0-12). """ for t in texts: if not t: continue m = _BEDROOMS_TXT_RE.search(_key(str(t))) if m: n = int(m.group(1)) if 0 <= n <= 12: return float(n) return None def parse_bathrooms(*texts: str) -> float | None: """Nombre de salles de bain : « 2 salles de bain », « 1.5 bath », « 1 sdb ».""" for t in texts: if not t: continue m = _BATHROOMS_TXT_RE.search(_key(str(t))) if m: try: v = float(m.group(1).replace(",", ".")) except ValueError: continue if 0.5 <= v <= 10: return v return None def coerce_count(raw) -> float | None: """« 2 », « 1,5 », 2, 2.0 -> float ; sinon None (valeurs de details JSON).""" if raw is None or isinstance(raw, bool): return None if isinstance(raw, (int, float)): v = float(raw) return v if 0 <= v <= 20 else None m = re.match(r"^\s*(\d+(?:[.,]5)?)\s*$", str(raw)) if not m: return None v = float(m.group(1).replace(",", ".")) return v if 0 <= v <= 20 else None # --------------------------------------------------------------------------- # 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] # pets / smoking (canonical values: yes | no | conditions) if _PETS_BOTH_RE.search(all_text): details["pets"] = "yes" elif _PETS_COND_RE.search(all_text): details["pets"] = "conditions" elif _PETS_NO_RE.search(all_text): details["pets"] = "no" elif _PETS_YES_RE.search(all_text): details["pets"] = "yes" 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 # --------------------------------------------------------------------------- # Central FR -> EN label translation (Rent-Ka) # --------------------------------------------------------------------------- # Many kept connectors read French page templates (fr-ca sites, Québec-built # platform generators). Rather than translating each connector, finalize() # funnels amenities / availability / price labels / pets through here so the # DB is uniformly English. Unknown strings pass through untouched. _LABEL_MAP_EN = { # amenities / inclusions "chauffage inclus": "Heat included", "eau chaude incluse": "Hot water included", "eau incluse": "Water included", "electricite incluse": "Electricity included", "électricité incluse": "Electricity included", "internet inclus": "Internet included", "cable/tele inclus": "Cable TV included", "câble/télé inclus": "Cable TV included", "gaz inclus": "Gas included", "climatisation": "Air conditioning", "climatisation centrale": "Central A/C", "air climatise": "Air conditioning", "air climatisé": "Air conditioning", "balcon": "Balcony", "terrasse": "Terrace", "ascenseur": "Elevator", "piscine": "Pool", "piscine interieure": "Indoor pool", "piscine intérieure": "Indoor pool", "piscine exterieure": "Outdoor pool", "piscine extérieure": "Outdoor pool", "salle d'entrainement": "Gym", "salle d'entraînement": "Gym", "buanderie": "Laundry facilities", "buanderie dans l'immeuble": "Laundry in building", "laveuse/secheuse dans l'unite": "In-unit laundry", "laveuse/sécheuse dans l'unité": "In-unit laundry", "laveuse-secheuse": "Washer/dryer", "laveuse-sécheuse": "Washer/dryer", "laveuse": "Washer", "secheuse": "Dryer", "sécheuse": "Dryer", "lave-vaisselle": "Dishwasher", "micro-ondes": "Microwave", "cuisiniere": "Stove", "cuisinière": "Stove", "refrigerateur": "Fridge", "réfrigérateur": "Fridge", "refrigerateur/congelateur": "Fridge/freezer", "réfrigérateur/congélateur": "Fridge/freezer", "meuble": "Furnished", "meublé": "Furnished", "non-fumeur": "No smoking", "stationnement": "Parking", "stationnement interieur": "Indoor parking", "stationnement intérieur": "Indoor parking", "stationnement pour velo": "Bicycle parking", "stationnement pour vélo": "Bicycle parking", "supports a velos": "Bike racks", "supports à vélos": "Bike racks", "espace de rangement": "Storage locker", "rangement": "Storage", "concierge": "Concierge", "securite 24 h": "24-hour security", "sécurité 24 h": "24-hour security", "sécurité sur place": "On-site security", "securite sur place": "On-site security", "cour": "Yard", "accessible en fauteuil roulant": "Wheelchair accessible", "animaux acceptes": "Pets allowed", "animaux acceptés": "Pets allowed", "renove": "Renovated", "rénové": "Renovated", "sauna": "Sauna", "plancher de salle de bain chauffant": "Heated bathroom floor", "cuisine a aire ouverte": "Open-plan kitchen", "cuisine à aire ouverte": "Open-plan kitchen", "cellier a vin": "Wine fridge", "cellier à vin": "Wine fridge", "aspirateur central": "Central vacuum", "casier a colis postes canada": "Canada Post parcel locker", "casier à colis postes canada": "Canada Post parcel locker", "electromenagers encastres bosch": "Bosch built-in appliances", "électroménagers encastrés bosch": "Bosch built-in appliances", "stores": "Blinds", "personnel sur place": "On-site staff", "transport en commun a proximite": "Public transit nearby", "transport en commun à proximité": "Public transit nearby", "thermostats individuels": "Individual thermostats", "logement intelligent": "Smart home", "chauffage": "Heating", "eau chaude": "Hot water", # availability "libre maintenant": "Available now", "libre immediatement": "Available now", "libre immédiatement": "Available now", "disponible maintenant": "Available now", "disponible": "Available", "sur la liste d'attente": "Waiting list", "liste d'attente": "Waiting list", "loue": "Rented", "loué": "Rented", "sur demande": "On request", # pets "oui": "yes", "non": "no", } _LABEL_PREFIX_EN = [ (re.compile(r"^\s*à partir de\s+", re.I), "From "), (re.compile(r"^\s*a partir de\s+", re.I), "From "), (re.compile(r"^\s*disponible le\s+", re.I), "Available "), (re.compile(r"^\s*libre le\s+", re.I), "Available "), (re.compile(r"^\s*promotion\s*:\s*", re.I), "Promotion: "), ] _LABEL_INFIX_EN = [ (re.compile(r"\$\s*/\s*mois|\s*\$/mois", re.I), "$/month"), (re.compile(r"/\s*mois\b", re.I), "/month"), (re.compile(r"\bpi\s*ca\b|\bpi²\b|\bpi2\b", re.I), "sq ft"), (re.compile(r"(\d+)\s*logement\(s\) disponible\(s\)", re.I), r"\1 unit(s) available"), (re.compile(r"(\d+)\s*unité\(s\) disponible\(s\)", re.I), r"\1 unit(s) available"), ] def translate_label_en(text: str) -> str: """Translate a short French display label to English; pass-through for anything unknown (never invents content).""" if not text: return text key = str(text).strip().lower() mapped = _LABEL_MAP_EN.get(key) if mapped: return mapped out = str(text) for rx, rep in _LABEL_PREFIX_EN: out = rx.sub(rep, out) for rx, rep in _LABEL_INFIX_EN: out = rx.sub(rep, out) return out