Python 68.8%
TypeScript 18.6%
CSS 8.7%
JavaScript 3.3%
HTML 0.6%
1# -----------------------------------------------------------------------------2# Rent-Ka — Rental listings aggregator (Canada, outside Québec)3# Author: Simon-Pierre Boucher — contact@spboucher.ai4# normalize.py : couche de normalisation commune — chaque connecteur retourne5# les champs bruts de sa source, ce module uniformise tout :6# prix, dates de disponibilité, types d'unité, superficies,7# commodités (tags canoniques), adresses, contacts.8# -----------------------------------------------------------------------------9from __future__ import annotations1011import re12import unicodedata13from datetime import date1415# ---------------------------------------------------------------------------16# Utilitaires texte17# ---------------------------------------------------------------------------1819_NBSP = (" ", " ", " ")202122def strip_accents(s: str) -> str:23 return "".join(c for c in unicodedata.normalize("NFD", s)24 if unicodedata.category(c) != "Mn")252627def _clean_text(s: str) -> str:28 """Espaces insécables -> espaces, espaces multiples réduits."""29 for ch in _NBSP:30 s = s.replace(ch, " ")31 return re.sub(r"\s+", " ", s).strip()323334def _key(s: str) -> str:35 """Texte en minuscules sans accents, pour matcher des mots-clés."""36 return strip_accents(_clean_text(s or "")).lower()373839# valeur « ville » qui est en réalité une rue / route / adresse partielle40_STREET_IN_CITY = re.compile(41 r"(?i)^\s*(\d+[a-z]?\s|\d+e\s)?(rue|av(enue)?|boul(evard)?|chemin|ch\.|"42 r"route|rang|place|allée|allee|montée|montee|croissant|impasse|terrasse|"43 r"cours|carré|carre|côte|cote|promenade|sentier|street|road|drive|lane)\b")44_POSTAL_IN_CITY = re.compile(r"(?i)\s*[a-z]\d[a-z]\s?\d[a-z]\d\s*$")454647def clean_city(raw: str) -> str:48 """Nettoie un nom de ville : entités HTML décodées, code postal retiré,49 et VIDE si la valeur est manifestement un nom de rue (certains portails50 mettent la rue dans le champ ville) — mieux vaut aucune ville qu'une51 fausse ville qui pollue les filtres."""52 import html as _html53 if not raw:54 return ""55 s = _clean_text(_html.unescape(_html.unescape(raw)))56 s = _POSTAL_IN_CITY.sub("", s).strip(" ,;-")57 if _STREET_IN_CITY.match(s):58 return ""59 if re.search(r"\d", s) and not re.search(r"(?i)saint|st[- ]", s):60 return "" # « Route 132 », « 6e Avenue »… (chiffres = suspect)61 return s626364_BAD_SECTOR = re.compile(65 r"(?i)^(rue|av(enue)?|boul(evard)?|blvd|chemin|route)\b" # rue en tête66 r"|\b(rue|boul(evard)?|blvd|av\.?)\b.{0,30}\b(et|and|/|&)\b" # intersection67 r"|minutes?\s+d[ue]|\bhttp")686970def clean_sector(raw: str) -> str:71 """Nettoie un quartier : entités décodées ; VIDE si la valeur est une rue,72 une intersection ou une phrase marketing (pollue le filtre Quartier)."""73 import html as _html74 if not raw:75 return ""76 s = _clean_text(_html.unescape(_html.unescape(raw)))77 if len(s) > 40 or _BAD_SECTOR.search(s):78 return ""79 return s808182def clean_address(raw: str) -> str:83 """Nettoie une adresse : espaces, virgules orphelines, casse conservée."""84 if not raw:85 return ""86 s = _clean_text(raw)87 s = re.sub(r"\s*,\s*", ", ", s) # virgules uniformes88 s = re.sub(r"(, )+", ", ", s) # virgules doublées89 return s.strip(" ,;-")909192# ---------------------------------------------------------------------------93# Prix94# ---------------------------------------------------------------------------9596_PRICE_FROM_RE = re.compile(97 r"a partir|des\s+\d|starting|\bfrom\b|\bdes\b\s*$|\+\s*$"98 r"|de\s+\d[\d\s]*\s*\$?\s*a\s+\d", re.I) # fourchette « de X $ à Y $ »99100101def parse_price(raw: str) -> float | None:102 """Extrait un loyer mensuel : '1 195 $/mois', '$1,195', 'À partir de 995$'.103104 Retourne le montant le plus bas si plusieurs, None si aucun montant105 plausible (100–20 000 $). Jamais de valeur inventée.106 """107 if not raw:108 return None109 s = _clean_text(str(raw))110 if "$" not in s:111 return None112 nums = []113 for m in re.finditer(r"(?:\$\s*)?(\d[\d\s]*(?:[.,]\d+)?)(?:\s*\$)?", s):114 # le nombre doit toucher un « $ » (avant ou après)115 start, end = m.span()116 near = s[max(0, start - 2):start] + m.group(0) + s[end:end + 2]117 if "$" not in near:118 continue119 num = m.group(1).replace(" ", "")120 # « 1,195 » = virgule de milliers ; « 1195,00 » = décimales121 if re.search(r",\d{3}(?:\D|$)", num + " "):122 num = num.replace(",", "")123 else:124 num = num.replace(",", ".")125 try:126 val = float(num)127 except ValueError:128 continue129 if 100 <= val <= 20000:130 nums.append(val)131 return min(nums) if nums else None132133134def price_is_from(raw: str) -> bool:135 """Vrai si le prix est annoncé « à partir de »."""136 return bool(raw) and bool(_PRICE_FROM_RE.search(_key(raw)))137138139# ---------------------------------------------------------------------------140# Type d'unité141# ---------------------------------------------------------------------------142143_UNIT_WORDS = [144 (re.compile(r"\bstudio\b|\bbachelor\b"), "Studio"),145 (re.compile(r"\bloft\b"), "Loft"),146 (re.compile(r"\bpenthouse\b"), "Penthouse"),147 (re.compile(r"\bcondo\b"), "Condo"),148 (re.compile(r"maison de ville|townhouse|town home|townhome|row ?house|"149 r"\bmdv\b"), "Townhouse"),150 (re.compile(r"\bmaison\b|\bhouse\b|\bcottage\b|\bbungalow\b"), "House"),151 (re.compile(r"\bchambre\b|\broom\b(?!s)|roommate|colocation"), "Room"),152]153154155def _bedrooms_label(n: int) -> str:156 """0 -> Studio ; 1 -> 1 bedroom ; 2..4 -> n bedrooms ; 5+ -> 5+ bedrooms."""157 if n <= 0:158 return "Studio"159 if n >= 5:160 return "5+ bedrooms"161 return f"{n} bedroom" + ("s" if n > 1 else "")162163164def normalize_unit_type(raw: str) -> str:165 """Standardize the unit type to the Rent-Ka English enum.166167 Canon: Studio | 1 bedroom | 2 bedrooms | 3 bedrooms | 4 bedrooms |168 5+ bedrooms | Loft | Penthouse | Condo | Townhouse | House | Room.169 Québec "pièces et demie" input converts the other way: '3½' -> 1 bedroom,170 '4½' -> 2 bedrooms (n½ = n-2 bedrooms), '6½+' -> 4 bedrooms;171 'bachelor' -> Studio; 'MDV'/'maison de ville' -> Townhouse.172 """173 if not raw:174 return ""175 s = _key(str(raw))176177 # «4 1/2», «4½», «4,5», «4.5», «5 1/5» (frequent typo): n½ -> n-2 bedrooms178 m = re.search(r"(\d+)\s*(?:½|1/2|1/5|[.,]5)", s)179 if not m:180 # «4 pièces et demie»181 m = re.search(r"(\d+)\s*pieces?\s*(?:et\s*demie?)?", s)182 if m:183 n = int(m.group(1))184 if n < 1:185 return ""186 return _bedrooms_label(min(max(n - 2, 0), 5))187188 # «2 bedrooms», «3 cc», «2 chambres» -> n bedrooms189 m = re.search(r"(\d+)\s*(?:chambres?|\bcc\b|\bcac\b|bed(?:room)?s?\b|\bbdr\b|\bbr\b)", s)190 if not m:191 # spelled-out numbers: «one bedroom», «deux chambres»192 mots = {"une": 1, "un": 1, "one": 1, "deux": 2, "two": 2,193 "trois": 3, "three": 3, "quatre": 4, "four": 4,194 "cinq": 5, "five": 5}195 m2 = re.search(r"\b(une?|one|deux|two|trois|three|quatre|four|cinq|"196 r"five)\s+(?:chambres?|bed(?:room)?s?)\b", s)197 if m2:198 return _bedrooms_label(mots[m2.group(1)])199 if m:200 n = int(m.group(1))201 if 0 <= n <= 12:202 return _bedrooms_label(min(n, 5))203204 for rx, label in _UNIT_WORDS:205 if rx.search(s):206 # «room»/«chambre» preceded by a number was handled above207 return label208 # already-canonical English labels pass through unchanged209 canon = _clean_text(str(raw))210 return canon211212213# ---------------------------------------------------------------------------214# Chambres / salles de bain215# ---------------------------------------------------------------------------216217_BEDROOMS_TXT_RE = re.compile(218 r"(\d+)\s*(?:chambres?(?:\s+a\s+coucher)?\b|\bcc\b|\bcac\b|bed(?:room)?s?\b|\bbdr\b)")219_BATHROOMS_TXT_RE = re.compile(220 r"(\d+(?:[.,]5)?)\s*(?:salles?\s+de\s+bains?\b|\bsdb\b|bath(?:room)?s?\b)")221222223def bedrooms_from_unit_type(unit_type: str) -> float | None:224 """Closed bedrooms from the canonical English unit type.225226 'n bedroom(s)' -> n ; '5+ bedrooms' -> 5 (known lower bound) ;227 Studio/Loft -> 0 ; Room -> 1. Types without a bedroom count228 (House, Condo, Townhouse, Penthouse…) -> None (unknown).229 Legacy QC labels still convert (n½ -> n-2, 6½+ -> 4).230 """231 if not unit_type:232 return None233 s = unit_type.strip()234 m = re.match(r"^(\d+)\s*bedrooms?$", s, re.I)235 if m:236 return float(m.group(1))237 if re.match(r"^5\+\s*bedrooms$", s, re.I):238 return 5.0239 # legacy Québec labels (seeded DB rows ingested before the fork)240 if s == "6½+":241 return 4.0242 m = re.match(r"^(\d+)½$", s)243 if m:244 return float(max(int(m.group(1)) - 2, 0))245 key = _key(s)246 if key in ("studio", "loft"):247 return 0.0248 if key in ("chambre", "room"):249 return 1.0250 return None251252253def parse_bedrooms(*texts: str) -> float | None:254 """Nombre de chambres explicite dans un texte : « 3 chambres », « 2 bedrooms ».255256 Jamais de valeur inventée : None si aucun nombre plausible (0-12).257 """258 for t in texts:259 if not t:260 continue261 m = _BEDROOMS_TXT_RE.search(_key(str(t)))262 if m:263 n = int(m.group(1))264 if 0 <= n <= 12:265 return float(n)266 return None267268269def parse_bathrooms(*texts: str) -> float | None:270 """Nombre de salles de bain : « 2 salles de bain », « 1.5 bath », « 1 sdb »."""271 for t in texts:272 if not t:273 continue274 m = _BATHROOMS_TXT_RE.search(_key(str(t)))275 if m:276 try:277 v = float(m.group(1).replace(",", "."))278 except ValueError:279 continue280 if 0.5 <= v <= 10:281 return v282 return None283284285def coerce_count(raw) -> float | None:286 """« 2 », « 1,5 », 2, 2.0 -> float ; sinon None (valeurs de details JSON)."""287 if raw is None or isinstance(raw, bool):288 return None289 if isinstance(raw, (int, float)):290 v = float(raw)291 return v if 0 <= v <= 20 else None292 m = re.match(r"^\s*(\d+(?:[.,]5)?)\s*$", str(raw))293 if not m:294 return None295 v = float(m.group(1).replace(",", "."))296 return v if 0 <= v <= 20 else None297298299# ---------------------------------------------------------------------------300# Date de disponibilité301# ---------------------------------------------------------------------------302303_MONTHS = {304 "janvier": 1, "jan": 1, "january": 1,305 "fevrier": 2, "fev": 2, "feb": 2, "february": 2,306 "mars": 3, "mar": 3, "march": 3,307 "avril": 4, "avr": 4, "apr": 4, "april": 4,308 "mai": 5, "may": 5,309 "juin": 6, "jun": 6, "june": 6,310 "juillet": 7, "juil": 7, "jul": 7, "july": 7,311 "aout": 8, "aug": 8, "august": 8,312 "septembre": 9, "sept": 9, "sep": 9, "september": 9,313 "octobre": 10, "oct": 10, "october": 10,314 "novembre": 11, "nov": 11, "november": 11,315 "decembre": 12, "dec": 12, "december": 12,316}317_MONTH_RE = "|".join(sorted(_MONTHS, key=len, reverse=True))318319_NOW_RE = re.compile(320 r"immediat|maintenant|\bnow\b|des maintenant|\blibre\b|disponible|available|occupation immediate|vacant")321322323def parse_availability_date(raw: str, today: date | None = None) -> str | None:324 """Normalise une disponibilité en date ISO, « now », ou None.325326 « Libre immédiatement » -> "now" ; « 1er juillet » -> prochaine occurrence327 (ou "now" si déjà passée) ; « Disponibilité : 01 décembre 2026 » ->328 "2026-12-01" ; texte inconnu -> None (jamais de date inventée).329 """330 if not raw:331 return None332 today = today or date.today()333 s = _key(str(raw))334335 def _build(y: int | None, m: int, d: int) -> str:336 if y is None:337 y = today.year338 if date(y, m, min(d, 28)) < today:339 # date sans année déjà passée : le logement est libre340 return "now"341 try:342 dt = date(y, m, d)343 except ValueError:344 dt = date(y, m, 1)345 return "now" if dt <= today else dt.isoformat()346347 # ISO : 2026-07-01348 m = re.search(r"(20\d{2})-(\d{1,2})-(\d{1,2})", s)349 if m:350 return _build(int(m.group(1)), int(m.group(2)), int(m.group(3)))351352 # anglais « April 1st, 2027 » (mois d'abord ; « juillet 2026 » exclu353 # par le (?!\d) — un jour ne peut pas être le début d'une année)354 m = re.search(355 rf"\b({_MONTH_RE})\b\.?\s+(\d{{1,2}})(?!\d)(?:st|nd|rd|th)?\s*,?\s*(20\d{{2}})?", s)356 if m and m.group(2) and 1 <= int(m.group(2)) <= 31:357 y = int(m.group(3)) if m.group(3) else None358 return _build(y, _MONTHS[m.group(1)], int(m.group(2)))359360 # 01/07/2026 ou 2026/07/01 (format jour/mois/année au Québec)361 m = re.search(r"(\d{1,2})[/.](\d{1,2})[/.](20\d{2})", s)362 if m:363 d, mo = int(m.group(1)), int(m.group(2))364 if mo > 12 and d <= 12:365 d, mo = mo, d366 if 1 <= mo <= 12:367 return _build(int(m.group(3)), mo, min(d, 28) if d > 31 else d)368 m = re.search(r"(20\d{2})[/.](\d{1,2})[/.](\d{1,2})", s)369 if m:370 return _build(int(m.group(1)), int(m.group(2)), int(m.group(3)))371372 # « 1er juillet 2026 », « 01 décembre », « juillet 2026 », « déc. 2026 »373 # \b : « rénové » contient « nov », « maison » contient « mai » — sans374 # frontières de mots, le normaliseur inventait des dates375 m = re.search(376 rf"(?:(\d{{1,2}})(?:er|e|st|nd|rd|th)?\s+)?\b({_MONTH_RE})\b\.?\s*(20\d{{2}})?", s)377 if m and m.group(2):378 d = int(m.group(1)) if m.group(1) else 1379 mo = _MONTHS[m.group(2)]380 y = int(m.group(3)) if m.group(3) else None381 if 1 <= d <= 31:382 return _build(y, mo, d)383384 # « July 1, 2026 » (mois déjà couvert ci-dessus) / « 2026 » seul : ignoré385 if _NOW_RE.search(s):386 return "now"387 return None388389390# ---------------------------------------------------------------------------391# Superficie392# ---------------------------------------------------------------------------393394_AREA_SQFT_RE = re.compile(395 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)396_AREA_SQM_RE = re.compile(397 r"(\d[\d\s,]*(?:\.\d+)?)\s*(?:m\s*[²2]|metres?\s*carres?|sq\.?\s*m\b)", re.I)398# superficies qui ne sont PAS celles du logement (balcon, terrasse, rangement)399_AREA_SKIP_CTX = re.compile(r"balcon|terrass|loggia|patio|rangement|locker|garage|cour\b")400401402def parse_area_sqft(raw: str) -> float | None:403 """Superficie en pi². Gère « 850 pi² », « 1,607 sq. ft. », « 78 m² ».404405 Ignore les superficies de balcon/terrasse/rangement (contexte) ; retourne406 la plus petite valeur restante (cohérent avec un prix « à partir de »).407 """408 if not raw:409 return None410 s = _key(str(raw))411412 def _num(txt: str) -> float | None:413 txt = txt.replace(" ", "")414 # « 1,607 » = virgule de milliers ; sinon virgule décimale415 if re.search(r",\d{3}(?:\D|$)", txt + " "):416 txt = txt.replace(",", "")417 else:418 txt = txt.replace(",", ".")419 try:420 return float(txt)421 except ValueError:422 return None423424 def _skip(start: int) -> bool:425 return bool(_AREA_SKIP_CTX.search(s[max(0, start - 30):start]))426427 vals = []428 for m in _AREA_SQFT_RE.finditer(s):429 v = _num(m.group(1))430 if v and 80 <= v <= 20000 and not _skip(m.start()):431 vals.append(v)432 for m in _AREA_SQM_RE.finditer(s):433 v = _num(m.group(1))434 if v and 8 <= v <= 2000 and not _skip(m.start()):435 vals.append(round(v * 10.7639))436 return min(vals) if vals else None437438439# ---------------------------------------------------------------------------440# Commodités -> tags canoniques (details JSON)441# ---------------------------------------------------------------------------442# Chaque règle : clé canonique -> (motifs positifs, motifs négatifs).443# Les motifs sont matchés sur du texte sans accents en minuscules.444# Absence de match = inconnu (la clé n'apparaît pas), jamais « false » deviné.445446_AMENITY_RULES: dict[str, tuple[str, str | None]] = {447 # inclusions448 "heating": (r"chauffage|chauffe[e]? |chauffe[e]?$|heating|heat included|\bheat\b",449 r"chauffage non inclus|sans chauffage|non[- ]chauffe|pas chauffe|heat pump"450 r"|chauffage[^|.]{0,60}charge d(?:u|es) locataires?"),451 "electricity": (r"electricite|hydro inclus|electricity|eclaire\b|\bhydro\b",452 r"electricite non incluse?|non[- ]eclaire"453 r"|electricite[^|.]{0,60}charge d(?:u|es) locataires?"),454 "hot_water": (r"eau chaude|hot water",455 r"eau chaude non incluse?|eau chaude[^|.]{0,60}charge d(?:u|es) locataires?"),456 "internet": (r"internet|wi[- ]?fi", r"internet non inclus"),457 "cable": (r"\bcable\b|television|telus|videotron|bell fibe", None),458 # électroménagers459 "fridge": (r"refrigerateur|refrigerator|\bfrigo\b|\bfridge\b|electromenagers?( inclus)?|appliances", None),460 "stove": (r"cuisiniere|\bstove\b|\bfour\b|plaques? de cuisson", None),461 "dishwasher": (r"lave[- ]vaisselle|dishwasher", r"entrees? (?:pour |de )?lave[- ]vaisselle|dishwasher hookup"),462 "dishwasher_hookup": (r"entrees? (?:pour |de )?lave[- ]vaisselle|dishwasher hookups?", None),463 "washer_dryer": (r"laveuse[- /]secheuse|laveuse et secheuse|\bwasher\b|\bdryer\b",464 r"entrees?[^|.]{0,35}laveuse|washer.{0,10}hookup"),465 "washer_dryer_hookup": (r"entrees?[^|.]{0,35}laveuse|sorties? laveuse|washer.{0,10}hookups?", None),466 "ac": (r"climatis|air climatise|\ba/?c\b|air conditioning|central air|cooling\s*:|thermopompe", None),467 # immeuble468 "elevator": (r"ascen[cs]eur|elevator", None),469 "balcony": (r"balcon|terrasse|terrace|patio|loggia",470 r"pas de balcon|sans balcon|no balcony|aucun balcon"),471 "pool": (r"piscine|\bpool\b|\bspa\b", None),472 "gym": (r"\bgym\b|salle d.?entrainement|entrainement|fitness|salle de sport", None),473 "laundry": (r"buanderie|salle de lavage|laundry (?:room|facilit)", None),474 "storage": (r"rangement|\blocker\b|espace de rangement|storage", None),475 "furnished_kw": (r"\bmeuble\b|\bmeublee?s?\b|furnished", r"non[- ]meuble|unfurnished"),476 "parking_kw": (r"stationnement|parking|garage", None),477}478479_PETS_YES_RE = re.compile(480 r"animaux (?:de compagnie )?(?:acceptes|admis|bienvenus|permis|autorises|ok)"481 r"|pet[s]?[- ]friendly|pets? (?:allowed|welcome)"482 r"|chats? (?:et chiens? )?(?:acceptes?|admis)"483 r"|chiens? (?:et chats? )?(?:permis|acceptes?|admis)"484 r"|anima(?:l|ux)[^|.]{0,15}(?:bienvenus?|acceptes?|admis|permis)")485# les deux espèces explicitement acceptées : « oui » sans ambiguïté,486# testé AVANT les motifs « conditions » (sinon « chats acceptés » l'emporte)487_PETS_BOTH_RE = re.compile(488 r"(?:chiens? et chats?|chats? et chiens?) (?:acceptes|admis|bienvenus|permis|autorises)")489_PETS_NO_RE = re.compile(490 r"(?:pas d.?animaux|aucun animal|animaux (?:refuses|interdits|non admis|non acceptes|non autorises|non permis))|no pets?"491 r"|animaux (?:permis|acceptes|admis|autorises)?\s*:\s*non"492 r"|animaux[^|.]{0,15}interdits")493_PETS_COND_RE = re.compile(494 r"animaux (?:sous conditions?|sur approbation)|petits? animaux|petits? chiens?"495 r"|chats? (?:seulement|acceptes?|autorises?|admis)\b|cats? only|avec restrictions"496 r"|(?:animaux|chats?|chiens?|pets?)[^|.]{0,30}sous conditions?"497 r"|chiens?[^|.]{0,30}(?:pas|non|ne sont pas) (?:permis|admis|acceptes)"498 r"|chiens? interdits?|no dogs?\b"499 r"|animau?x?[^|.]{0,30}\(?\d+\s*(?:lb|livres?|kg)" # limite de poids500 r"|animaux[^|.]{0,20}sur demande"501 r"|animaux[^|.]{0,20}refuses?[^|.]{0,30}(?:sauf|a l.?exception)")502_SMOKING_NO_RE = re.compile(r"non[- ]fumeurs?|sans fumee|smoke[- ]free|no smoking|interdiction de fumer")503_SMOKING_YES_RE = re.compile(r"fumeurs? (?:accepte|permis|autorise)")504505_PARKING_INT_RE = re.compile(r"(?:stationnement|garage|parking)[^.]{0,30}(?:interieur|souterrain|sous[- ]terrain|indoor)|garage")506_PARKING_EXT_RE = re.compile(r"(?:stationnement|parking)[^.]{0,30}(?:exterieur|outdoor)")507_PARKING_INCL_RE = re.compile(r"(?:stationnement|parking)[^.]{0,30}inclus")508_PARKING_EXTRA_RE = re.compile(509 r"(?:stationnement|parking)[^.]{0,60}?(?:en sus|en option|disponible)")510_PARKING_PRICE_RE = re.compile(511 r"(?:stationnement|parking)[^.]{0,60}?"512 r"(?:(?<!\d)(\d(?:[\d\s]{0,6}\d)?)\s*\$|\$\s*(\d+))")513514_PHONE_RE = re.compile(r"\(?\b([2-9]\d{2})\)?[\s.\-]?(\d{3})[\s.\-](\d{4})\b")515_EMAIL_RE = re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.]+\b")516517_FLOOR_RE = re.compile(r"(\d{1,2})\s*(?:e|er|eme|ieme|th|nd|rd|st)?\s*etage|etage\s*:?\s*(\d{1,2})|(\d{1,2})(?:e|er|eme)\s+et")518519# Segments à neutraliser avant le matching : options payantes (« sur demande »,520# « en option », « en sus », « optionnel ») et fiches techniques (« Heating:521# Forced Air » décrit le type de chauffage, pas son inclusion). Retirés du522# texte, jamais interprétés. Bornés par « | » (séparateur d'items) ou « . ».523_NEUTRAL_RE = re.compile(524 r"electromenagers?[^|.]{0,50}(?:sur demande|en option|selon|\$)[^|.]*"525 r"|[^|.]*(?:\boptionnel(?:le)?s?\b|en option|en sus|moyennant (?:des )?frais)[^|.]*"526 # équipements « sur demande » (clim, wifi, stationnement…) — pas inclus527 r"|[^|.]*(?:climatis[^|.]{0,20}|air climatise|wi[- ]?fi|internet|cable"528 r"|stationnement|parking)[^|.]{0,25}sur demande[^|.]*"529 # stationnement/rangement à VÉLO : pas un stationnement auto530 r"|[^|.]*(?:stationnement|support|rangement|espace)s?[^|.]{0,12}(?:a |pour )?velos?[^|.]*"531 r"|[^|.]*bike (?:parking|storage|racks?)[^|.]*"532 # commodités payantes « ($) » ou restrictives « (sans utilisation) »533 r"|[^|.]*\(\s*\$\s*\)[^|.]*"534 r"|[^|.]*\(\s*sans [^)|.]{0,30}\)[^|.]*"535 # promotions : « Crédit de 1 000 $ Éclaire », « 1 mois gratuit » — un nom536 # de promo ne décrit pas les inclusions du logement537 r"|[^|.]*(?:credit de|rabais|promotion|mois gratuits?|offre speciale)[^|.]*"538 # aires communes de l'immeuble : ne décrivent pas l'unité539 r"|[^|.]*aires? communes?[^|.]*"540 r"|[^|.]*(?:terrasse|salle|cuisine|salon|piscine) communa?u?[a-z]*[^|.]*"541 r"|heating\s*:[^|.]*")542543# « chauffé » qualifiant un stationnement/garage/piscine : on retire le544# qualificatif (pas le segment entier — le stationnement reste une info).545_HEAT_CTX_RE = re.compile(546 r"((?:stationnement|garage|piscine|pool)[^|.]{0,30}?)(?:non |pas )?chauffee?s?\b")547548549def extract_details(amenities: list[str], description: str = "",550 title: str = "") -> dict:551 """Dérive les champs structurés depuis les commodités libres + description.552553 Retourne un dict « sparse » : une clé absente = information inconnue.554 Clés : inclusions{}, appliances{}, pets, smoking, furnished, parking{},555 elevator, balcony, pool, gym, laundry, storage, ac, floor, contact{}.556 """557 items = [_key(a) for a in (amenities or []) if a]558 desc = _key(description or "")559 all_text = " | ".join(items + ([desc] if desc else []) +560 ([_key(title)] if title else []))561 all_text = _NEUTRAL_RE.sub(" ", all_text)562 all_text = _HEAT_CTX_RE.sub(r"\1", all_text)563 if not all_text.strip(" |"):564 return {}565566 flags: dict[str, bool] = {}567 for cle, (pos, neg) in _AMENITY_RULES.items():568 neg_rx = re.compile(neg) if neg else None569 pos_rx = re.compile(pos)570 # commodités (listes courtes) : match libéral ; description : aussi,571 # les négatifs l'emportent sur les positifs.572 if neg_rx and neg_rx.search(all_text):573 flags[cle] = False574 elif pos_rx.search(all_text):575 flags[cle] = True576577 details: dict = {}578 inclusions = {k: v for k, v in flags.items()579 if k in ("heating", "electricity", "hot_water", "internet", "cable")}580 if re.search(r"rien (?:n.est )?d.?inclus|aucune inclusion|nothing included", all_text):581 inclusions.update({"heating": False, "electricity": False, "hot_water": False})582 if inclusions:583 details["inclusions"] = inclusions584 appliances = {k: v for k, v in flags.items()585 if k in ("fridge", "stove", "dishwasher", "washer_dryer")}586 if flags.get("washer_dryer_hookup"):587 appliances["washer_dryer_hookup"] = True588 # « entrées laveuse-sécheuse » n'implique pas les appareils fournis589 if not re.search(r"laveuse[- /]secheuse (?:incluse?s?|fournies?)", all_text):590 appliances.pop("washer_dryer", None)591 if flags.get("dishwasher_hookup"):592 appliances["dishwasher_hookup"] = True593 appliances.pop("dishwasher", None) # branchement ≠ appareil fourni594 if appliances:595 details["appliances"] = appliances596 for cle in ("ac", "elevator", "balcony", "pool", "gym", "laundry", "storage"):597 if cle in flags:598 details[cle] = flags[cle]599600 # pets / smoking (canonical values: yes | no | conditions)601 if _PETS_BOTH_RE.search(all_text):602 details["pets"] = "yes"603 elif _PETS_COND_RE.search(all_text):604 details["pets"] = "conditions"605 elif _PETS_NO_RE.search(all_text):606 details["pets"] = "no"607 elif _PETS_YES_RE.search(all_text):608 details["pets"] = "yes"609 if _SMOKING_NO_RE.search(all_text):610 details["smoking"] = False611 elif _SMOKING_YES_RE.search(all_text):612 details["smoking"] = True613614 # meublé (« semi-meublé » : nuance conservée, colonne booléenne à None)615 if re.search(r"semi[- ](?:meuble|furnished)", all_text):616 details["furnished"] = "semi"617 elif "furnished_kw" in flags:618 details["furnished"] = flags["furnished_kw"]619620 # stationnement (« intérieur et extérieur » possible)621 if flags.get("parking_kw"):622 parking: dict = {"available": True}623 p_int = bool(_PARKING_INT_RE.search(all_text))624 p_ext = bool(_PARKING_EXT_RE.search(all_text))625 if p_int and p_ext:626 parking["type"] = "intérieur et extérieur"627 elif p_int:628 parking["type"] = "intérieur"629 elif p_ext:630 parking["type"] = "extérieur"631 if _PARKING_INCL_RE.search(all_text):632 parking["included"] = True633 elif _PARKING_EXTRA_RE.search(all_text) or _PARKING_PRICE_RE.search(all_text):634 parking["included"] = False635 m = _PARKING_PRICE_RE.search(all_text)636 if m:637 prix = float((m.group(1) or m.group(2)).replace(" ", ""))638 if 10 <= prix <= 1000: # plausible pour un stationnement639 parking["price"] = prix640 details["parking"] = parking641642 # étage — depuis les items de commodités seulement : une description643 # longue peut mentionner « 3e étage » pour un immeuble multi-unités.644 # Les items décrivant une installation (« salon au 25e étage ») sont exclus.645 floor_items = [it for it in items646 if not re.search(r"salon|lounge|piscine|gym|terrasse|rooftop|chalet|spa", it)]647 m = _FLOOR_RE.search(" | ".join(floor_items))648 if m:649 floor = next((g for g in m.groups() if g), None)650 if floor and 0 < int(floor) <= 60:651 details["floor"] = int(floor)652653 # contact (depuis la description seulement — les listes n'en ont pas)654 contact: dict = {}655 m = _PHONE_RE.search(description or "")656 if m:657 contact["phone"] = f"{m.group(1)}-{m.group(2)}-{m.group(3)}"658 m = _EMAIL_RE.search(description or "")659 if m:660 contact["email"] = m.group(0)661 if contact:662 details["contact"] = contact663664 return details665666667def merge_details(base: dict, derived: dict) -> dict:668 """Fusionne les détails dérivés sous ceux fournis par le connecteur.669670 Les valeurs explicites du connecteur ont priorité ; les sous-dicts671 (inclusions, appliances, parking, contact) sont fusionnés clé par clé.672 """673 out = dict(derived)674 for k, v in (base or {}).items():675 if isinstance(v, dict) and isinstance(out.get(k), dict):676 out[k] = {**out[k], **v}677 else:678 out[k] = v679 return out680681682# ---------------------------------------------------------------------------683# Central FR -> EN label translation (Rent-Ka)684# ---------------------------------------------------------------------------685# Many kept connectors read French page templates (fr-ca sites, Québec-built686# platform generators). Rather than translating each connector, finalize()687# funnels amenities / availability / price labels / pets through here so the688# DB is uniformly English. Unknown strings pass through untouched.689690_LABEL_MAP_EN = {691 # amenities / inclusions692 "chauffage inclus": "Heat included", "eau chaude incluse": "Hot water included",693 "eau incluse": "Water included", "electricite incluse": "Electricity included",694 "électricité incluse": "Electricity included", "internet inclus": "Internet included",695 "cable/tele inclus": "Cable TV included", "câble/télé inclus": "Cable TV included",696 "gaz inclus": "Gas included",697 "climatisation": "Air conditioning", "climatisation centrale": "Central A/C",698 "air climatise": "Air conditioning", "air climatisé": "Air conditioning",699 "balcon": "Balcony", "terrasse": "Terrace", "ascenseur": "Elevator",700 "piscine": "Pool", "piscine interieure": "Indoor pool",701 "piscine intérieure": "Indoor pool", "piscine exterieure": "Outdoor pool",702 "piscine extérieure": "Outdoor pool",703 "salle d'entrainement": "Gym", "salle d'entraînement": "Gym",704 "buanderie": "Laundry facilities", "buanderie dans l'immeuble": "Laundry in building",705 "laveuse/secheuse dans l'unite": "In-unit laundry",706 "laveuse/sécheuse dans l'unité": "In-unit laundry",707 "laveuse-secheuse": "Washer/dryer", "laveuse-sécheuse": "Washer/dryer",708 "laveuse": "Washer", "secheuse": "Dryer", "sécheuse": "Dryer",709 "lave-vaisselle": "Dishwasher", "micro-ondes": "Microwave",710 "cuisiniere": "Stove", "cuisinière": "Stove",711 "refrigerateur": "Fridge", "réfrigérateur": "Fridge",712 "refrigerateur/congelateur": "Fridge/freezer",713 "réfrigérateur/congélateur": "Fridge/freezer",714 "meuble": "Furnished", "meublé": "Furnished", "non-fumeur": "No smoking",715 "stationnement": "Parking", "stationnement interieur": "Indoor parking",716 "stationnement intérieur": "Indoor parking",717 "stationnement pour velo": "Bicycle parking",718 "stationnement pour vélo": "Bicycle parking",719 "supports a velos": "Bike racks", "supports à vélos": "Bike racks",720 "espace de rangement": "Storage locker", "rangement": "Storage",721 "concierge": "Concierge", "securite 24 h": "24-hour security",722 "sécurité 24 h": "24-hour security", "sécurité sur place": "On-site security",723 "securite sur place": "On-site security", "cour": "Yard",724 "accessible en fauteuil roulant": "Wheelchair accessible",725 "animaux acceptes": "Pets allowed", "animaux acceptés": "Pets allowed",726 "renove": "Renovated", "rénové": "Renovated", "sauna": "Sauna",727 "plancher de salle de bain chauffant": "Heated bathroom floor",728 "cuisine a aire ouverte": "Open-plan kitchen",729 "cuisine à aire ouverte": "Open-plan kitchen",730 "cellier a vin": "Wine fridge", "cellier à vin": "Wine fridge",731 "aspirateur central": "Central vacuum",732 "casier a colis postes canada": "Canada Post parcel locker",733 "casier à colis postes canada": "Canada Post parcel locker",734 "electromenagers encastres bosch": "Bosch built-in appliances",735 "électroménagers encastrés bosch": "Bosch built-in appliances",736 "stores": "Blinds", "personnel sur place": "On-site staff",737 "transport en commun a proximite": "Public transit nearby",738 "transport en commun à proximité": "Public transit nearby",739 "thermostats individuels": "Individual thermostats",740 "logement intelligent": "Smart home",741 "chauffage": "Heating", "eau chaude": "Hot water",742 # availability743 "libre maintenant": "Available now",744 "libre immediatement": "Available now",745 "libre immédiatement": "Available now",746 "disponible maintenant": "Available now",747 "disponible": "Available", "sur la liste d'attente": "Waiting list",748 "liste d'attente": "Waiting list", "loue": "Rented", "loué": "Rented",749 "sur demande": "On request",750 # pets751 "oui": "yes", "non": "no",752}753754_LABEL_PREFIX_EN = [755 (re.compile(r"^\s*à partir de\s+", re.I), "From "),756 (re.compile(r"^\s*a partir de\s+", re.I), "From "),757 (re.compile(r"^\s*disponible le\s+", re.I), "Available "),758 (re.compile(r"^\s*libre le\s+", re.I), "Available "),759 (re.compile(r"^\s*promotion\s*:\s*", re.I), "Promotion: "),760]761_LABEL_INFIX_EN = [762 (re.compile(r"\$\s*/\s*mois|\s*\$/mois", re.I), "$/month"),763 (re.compile(r"/\s*mois\b", re.I), "/month"),764 (re.compile(r"\bpi\s*ca\b|\bpi²\b|\bpi2\b", re.I), "sq ft"),765 (re.compile(r"(\d+)\s*logement\(s\) disponible\(s\)", re.I),766 r"\1 unit(s) available"),767 (re.compile(r"(\d+)\s*unité\(s\) disponible\(s\)", re.I),768 r"\1 unit(s) available"),769]770771772def translate_label_en(text: str) -> str:773 """Translate a short French display label to English; pass-through774 for anything unknown (never invents content)."""775 if not text:776 return text777 key = str(text).strip().lower()778 mapped = _LABEL_MAP_EN.get(key)779 if mapped:780 return mapped781 out = str(text)782 for rx, rep in _LABEL_PREFIX_EN:783 out = rx.sub(rep, out)784 for rx, rep in _LABEL_INFIX_EN:785 out = rx.sub(rep, out)786 return out787