Lou·Ka — tous les logements à louer du Québec, un seul endroit.
- 264commits
- 1branches
- 0releases
- 180.7 MBon disk
- maindefault branch
- 5 days agolast push
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : 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|\bmdv\b"), "Maison"),149 (re.compile(r"\bmaison\b|\bhouse\b|\bcottage\b"), "Maison"),150 (re.compile(r"\bchambre\b|\broom\b(?!s)"), "Chambre"),151]152153154def normalize_unit_type(raw: str) -> str:155 """Standardise le type d'unité vers l'énumération Lou-Ka.156157 '4 1/2', '4½', '4,5', '4.5' -> '4½' ; '2 chambres' -> '4½' (n cc + 2) ;158 '6½' et plus -> '6½+' ; 'bachelor' -> 'Studio' ; 'MDV' -> 'Maison'.159 """160 if not raw:161 return ""162 s = _key(str(raw))163164 # « 4 1/2 », « 4½ », « 4,5 », « 4.5 », « 5 1/5 » (coquille fréquente)165 m = re.search(r"(\d+)\s*(?:½|1/2|1/5|[.,]5)", s)166 if not m:167 # « 4 pièces et demie »168 m = re.search(r"(\d+)\s*pieces?\s*(?:et\s*demie?)?", s)169 if m:170 n = int(m.group(1))171 if n < 1:172 return ""173 return "6½+" if n >= 6 else f"{n}½"174175 # « 2 chambres », « 3 cc », « 2 bedrooms » -> n + 2 pièces et demie176 m = re.search(r"(\d+)\s*(?:chambres?|\bcc\b|\bcac\b|bed(?:room)?s?\b|\bbdr\b)", s)177 if not m:178 # nombres en toutes lettres : « une chambre », « two bedrooms »179 mots = {"une": 1, "un": 1, "one": 1, "deux": 2, "two": 2,180 "trois": 3, "three": 3, "quatre": 4, "four": 4}181 m2 = re.search(r"\b(une?|one|deux|two|trois|three|quatre|four)\s+(?:chambres?|bed(?:room)?s?)\b", s)182 if m2:183 n = mots[m2.group(1)] + 2184 return "6½+" if n >= 6 else f"{n}½"185 if m:186 n = int(m.group(1)) + 2187 return "6½+" if n >= 6 else f"{n}½"188189 for rx, label in _UNIT_WORDS:190 if rx.search(s):191 # « chambre » précédé d'un nombre a déjà été traité ci-dessus192 return label193 return _clean_text(str(raw))194195196# ---------------------------------------------------------------------------197# Chambres / salles de bain198# ---------------------------------------------------------------------------199200_BEDROOMS_TXT_RE = re.compile(201 r"(\d+)\s*(?:chambres?(?:\s+a\s+coucher)?\b|\bcc\b|\bcac\b|bed(?:room)?s?\b|\bbdr\b)")202_BATHROOMS_TXT_RE = re.compile(203 r"(\d+(?:[.,]5)?)\s*(?:salles?\s+de\s+bains?\b|\bsdb\b|bath(?:room)?s?\b)")204205206def bedrooms_from_unit_type(unit_type: str) -> float | None:207 """Chambres fermées selon la convention québécoise des « pièces et demie ».208209 n½ = n pièces -> n-2 chambres (3½→1, 4½→2, 5½→3) ; 1½/2½ -> 0 ;210 6½+ -> 4 (borne basse connue) ; Studio/Loft -> 0 ; Chambre -> 1.211 Types sans nombre de pièces (Maison, Condo…) -> None (inconnu).212 """213 if not unit_type:214 return None215 s = unit_type.strip()216 if s == "6½+":217 return 4.0218 m = re.match(r"^(\d+)½$", s)219 if m:220 return float(max(int(m.group(1)) - 2, 0))221 key = _key(s)222 if key in ("studio", "loft"):223 return 0.0224 if key == "chambre":225 return 1.0226 return None227228229def parse_bedrooms(*texts: str) -> float | None:230 """Nombre de chambres explicite dans un texte : « 3 chambres », « 2 bedrooms ».231232 Jamais de valeur inventée : None si aucun nombre plausible (0-12).233 """234 for t in texts:235 if not t:236 continue237 m = _BEDROOMS_TXT_RE.search(_key(str(t)))238 if m:239 n = int(m.group(1))240 if 0 <= n <= 12:241 return float(n)242 return None243244245def parse_bathrooms(*texts: str) -> float | None:246 """Nombre de salles de bain : « 2 salles de bain », « 1.5 bath », « 1 sdb »."""247 for t in texts:248 if not t:249 continue250 m = _BATHROOMS_TXT_RE.search(_key(str(t)))251 if m:252 try:253 v = float(m.group(1).replace(",", "."))254 except ValueError:255 continue256 if 0.5 <= v <= 10:257 return v258 return None259260261def coerce_count(raw) -> float | None:262 """« 2 », « 1,5 », 2, 2.0 -> float ; sinon None (valeurs de details JSON)."""263 if raw is None or isinstance(raw, bool):264 return None265 if isinstance(raw, (int, float)):266 v = float(raw)267 return v if 0 <= v <= 20 else None268 m = re.match(r"^\s*(\d+(?:[.,]5)?)\s*$", str(raw))269 if not m:270 return None271 v = float(m.group(1).replace(",", "."))272 return v if 0 <= v <= 20 else None273274275# ---------------------------------------------------------------------------276# Date de disponibilité277# ---------------------------------------------------------------------------278279_MONTHS = {280 "janvier": 1, "jan": 1, "january": 1,281 "fevrier": 2, "fev": 2, "feb": 2, "february": 2,282 "mars": 3, "mar": 3, "march": 3,283 "avril": 4, "avr": 4, "apr": 4, "april": 4,284 "mai": 5, "may": 5,285 "juin": 6, "jun": 6, "june": 6,286 "juillet": 7, "juil": 7, "jul": 7, "july": 7,287 "aout": 8, "aug": 8, "august": 8,288 "septembre": 9, "sept": 9, "sep": 9, "september": 9,289 "octobre": 10, "oct": 10, "october": 10,290 "novembre": 11, "nov": 11, "november": 11,291 "decembre": 12, "dec": 12, "december": 12,292}293_MONTH_RE = "|".join(sorted(_MONTHS, key=len, reverse=True))294295_NOW_RE = re.compile(296 r"immediat|maintenant|\bnow\b|des maintenant|\blibre\b|disponible|available|occupation immediate|vacant")297298299def parse_availability_date(raw: str, today: date | None = None) -> str | None:300 """Normalise une disponibilité en date ISO, « now », ou None.301302 « Libre immédiatement » -> "now" ; « 1er juillet » -> prochaine occurrence303 (ou "now" si déjà passée) ; « Disponibilité : 01 décembre 2026 » ->304 "2026-12-01" ; texte inconnu -> None (jamais de date inventée).305 """306 if not raw:307 return None308 today = today or date.today()309 s = _key(str(raw))310311 def _build(y: int | None, m: int, d: int) -> str:312 if y is None:313 y = today.year314 if date(y, m, min(d, 28)) < today:315 # date sans année déjà passée : le logement est libre316 return "now"317 try:318 dt = date(y, m, d)319 except ValueError:320 dt = date(y, m, 1)321 return "now" if dt <= today else dt.isoformat()322323 # ISO : 2026-07-01324 m = re.search(r"(20\d{2})-(\d{1,2})-(\d{1,2})", s)325 if m:326 return _build(int(m.group(1)), int(m.group(2)), int(m.group(3)))327328 # anglais « April 1st, 2027 » (mois d'abord ; « juillet 2026 » exclu329 # par le (?!\d) — un jour ne peut pas être le début d'une année)330 m = re.search(331 rf"\b({_MONTH_RE})\b\.?\s+(\d{{1,2}})(?!\d)(?:st|nd|rd|th)?\s*,?\s*(20\d{{2}})?", s)332 if m and m.group(2) and 1 <= int(m.group(2)) <= 31:333 y = int(m.group(3)) if m.group(3) else None334 return _build(y, _MONTHS[m.group(1)], int(m.group(2)))335336 # 01/07/2026 ou 2026/07/01 (format jour/mois/année au Québec)337 m = re.search(r"(\d{1,2})[/.](\d{1,2})[/.](20\d{2})", s)338 if m:339 d, mo = int(m.group(1)), int(m.group(2))340 if mo > 12 and d <= 12:341 d, mo = mo, d342 if 1 <= mo <= 12:343 return _build(int(m.group(3)), mo, min(d, 28) if d > 31 else d)344 m = re.search(r"(20\d{2})[/.](\d{1,2})[/.](\d{1,2})", s)345 if m:346 return _build(int(m.group(1)), int(m.group(2)), int(m.group(3)))347348 # « 1er juillet 2026 », « 01 décembre », « juillet 2026 », « déc. 2026 »349 # \b : « rénové » contient « nov », « maison » contient « mai » — sans350 # frontières de mots, le normaliseur inventait des dates351 m = re.search(352 rf"(?:(\d{{1,2}})(?:er|e|st|nd|rd|th)?\s+)?\b({_MONTH_RE})\b\.?\s*(20\d{{2}})?", s)353 if m and m.group(2):354 d = int(m.group(1)) if m.group(1) else 1355 mo = _MONTHS[m.group(2)]356 y = int(m.group(3)) if m.group(3) else None357 if 1 <= d <= 31:358 return _build(y, mo, d)359360 # « July 1, 2026 » (mois déjà couvert ci-dessus) / « 2026 » seul : ignoré361 if _NOW_RE.search(s):362 return "now"363 return None364365366# ---------------------------------------------------------------------------367# Superficie368# ---------------------------------------------------------------------------369370_AREA_SQFT_RE = re.compile(371 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)372_AREA_SQM_RE = re.compile(373 r"(\d[\d\s,]*(?:\.\d+)?)\s*(?:m\s*[²2]|metres?\s*carres?|sq\.?\s*m\b)", re.I)374# superficies qui ne sont PAS celles du logement (balcon, terrasse, rangement)375_AREA_SKIP_CTX = re.compile(r"balcon|terrass|loggia|patio|rangement|locker|garage|cour\b")376377378def parse_area_sqft(raw: str) -> float | None:379 """Superficie en pi². Gère « 850 pi² », « 1,607 sq. ft. », « 78 m² ».380381 Ignore les superficies de balcon/terrasse/rangement (contexte) ; retourne382 la plus petite valeur restante (cohérent avec un prix « à partir de »).383 """384 if not raw:385 return None386 s = _key(str(raw))387388 def _num(txt: str) -> float | None:389 txt = txt.replace(" ", "")390 # « 1,607 » = virgule de milliers ; sinon virgule décimale391 if re.search(r",\d{3}(?:\D|$)", txt + " "):392 txt = txt.replace(",", "")393 else:394 txt = txt.replace(",", ".")395 try:396 return float(txt)397 except ValueError:398 return None399400 def _skip(start: int) -> bool:401 return bool(_AREA_SKIP_CTX.search(s[max(0, start - 30):start]))402403 vals = []404 for m in _AREA_SQFT_RE.finditer(s):405 v = _num(m.group(1))406 if v and 80 <= v <= 20000 and not _skip(m.start()):407 vals.append(v)408 for m in _AREA_SQM_RE.finditer(s):409 v = _num(m.group(1))410 if v and 8 <= v <= 2000 and not _skip(m.start()):411 vals.append(round(v * 10.7639))412 return min(vals) if vals else None413414415# ---------------------------------------------------------------------------416# Commodités -> tags canoniques (details JSON)417# ---------------------------------------------------------------------------418# Chaque règle : clé canonique -> (motifs positifs, motifs négatifs).419# Les motifs sont matchés sur du texte sans accents en minuscules.420# Absence de match = inconnu (la clé n'apparaît pas), jamais « false » deviné.421422_AMENITY_RULES: dict[str, tuple[str, str | None]] = {423 # inclusions424 "heating": (r"chauffage|chauffe[e]? |chauffe[e]?$|heating|heat included|\bheat\b",425 r"chauffage non inclus|sans chauffage|non[- ]chauffe|pas chauffe|heat pump"426 r"|chauffage[^|.]{0,60}charge d(?:u|es) locataires?"),427 "electricity": (r"electricite|hydro inclus|electricity|eclaire\b|\bhydro\b",428 r"electricite non incluse?|non[- ]eclaire"429 r"|electricite[^|.]{0,60}charge d(?:u|es) locataires?"),430 "hot_water": (r"eau chaude|hot water",431 r"eau chaude non incluse?|eau chaude[^|.]{0,60}charge d(?:u|es) locataires?"),432 "internet": (r"internet|wi[- ]?fi", r"internet non inclus"),433 "cable": (r"\bcable\b|television|telus|videotron|bell fibe", None),434 # électroménagers435 "fridge": (r"refrigerateur|refrigerator|\bfrigo\b|\bfridge\b|electromenagers?( inclus)?|appliances", None),436 "stove": (r"cuisiniere|\bstove\b|\bfour\b|plaques? de cuisson", None),437 "dishwasher": (r"lave[- ]vaisselle|dishwasher", r"entrees? (?:pour |de )?lave[- ]vaisselle|dishwasher hookup"),438 "dishwasher_hookup": (r"entrees? (?:pour |de )?lave[- ]vaisselle|dishwasher hookups?", None),439 "washer_dryer": (r"laveuse[- /]secheuse|laveuse et secheuse|\bwasher\b|\bdryer\b",440 r"entrees?[^|.]{0,35}laveuse|washer.{0,10}hookup"),441 "washer_dryer_hookup": (r"entrees?[^|.]{0,35}laveuse|sorties? laveuse|washer.{0,10}hookups?", None),442 "ac": (r"climatis|air climatise|\ba/?c\b|air conditioning|central air|cooling\s*:|thermopompe", None),443 # immeuble444 "elevator": (r"ascen[cs]eur|elevator", None),445 "balcony": (r"balcon|terrasse|terrace|patio|loggia",446 r"pas de balcon|sans balcon|no balcony|aucun balcon"),447 "pool": (r"piscine|\bpool\b|\bspa\b", None),448 "gym": (r"\bgym\b|salle d.?entrainement|entrainement|fitness|salle de sport", None),449 "laundry": (r"buanderie|salle de lavage|laundry (?:room|facilit)", None),450 "storage": (r"rangement|\blocker\b|espace de rangement|storage", None),451 "furnished_kw": (r"\bmeuble\b|\bmeublee?s?\b|furnished", r"non[- ]meuble|unfurnished"),452 "parking_kw": (r"stationnement|parking|garage", None),453}454455_PETS_YES_RE = re.compile(456 r"animaux (?:de compagnie )?(?:acceptes|admis|bienvenus|permis|autorises|ok)"457 r"|pet[s]?[- ]friendly|pets? (?:allowed|welcome)"458 r"|chats? (?:et chiens? )?(?:acceptes?|admis)"459 r"|chiens? (?:et chats? )?(?:permis|acceptes?|admis)"460 r"|anima(?:l|ux)[^|.]{0,15}(?:bienvenus?|acceptes?|admis|permis)")461# les deux espèces explicitement acceptées : « oui » sans ambiguïté,462# testé AVANT les motifs « conditions » (sinon « chats acceptés » l'emporte)463_PETS_BOTH_RE = re.compile(464 r"(?:chiens? et chats?|chats? et chiens?) (?:acceptes|admis|bienvenus|permis|autorises)")465_PETS_NO_RE = re.compile(466 r"(?:pas d.?animaux|aucun animal|animaux (?:refuses|interdits|non admis|non acceptes|non autorises|non permis))|no pets?"467 r"|animaux (?:permis|acceptes|admis|autorises)?\s*:\s*non"468 r"|animaux[^|.]{0,15}interdits")469_PETS_COND_RE = re.compile(470 r"animaux (?:sous conditions?|sur approbation)|petits? animaux|petits? chiens?"471 r"|chats? (?:seulement|acceptes?|autorises?|admis)\b|cats? only|avec restrictions"472 r"|(?:animaux|chats?|chiens?|pets?)[^|.]{0,30}sous conditions?"473 r"|chiens?[^|.]{0,30}(?:pas|non|ne sont pas) (?:permis|admis|acceptes)"474 r"|chiens? interdits?|no dogs?\b"475 r"|animau?x?[^|.]{0,30}\(?\d+\s*(?:lb|livres?|kg)" # limite de poids476 r"|animaux[^|.]{0,20}sur demande"477 r"|animaux[^|.]{0,20}refuses?[^|.]{0,30}(?:sauf|a l.?exception)")478_SMOKING_NO_RE = re.compile(r"non[- ]fumeurs?|sans fumee|smoke[- ]free|no smoking|interdiction de fumer")479_SMOKING_YES_RE = re.compile(r"fumeurs? (?:accepte|permis|autorise)")480481_PARKING_INT_RE = re.compile(r"(?:stationnement|garage|parking)[^.]{0,30}(?:interieur|souterrain|sous[- ]terrain|indoor)|garage")482_PARKING_EXT_RE = re.compile(r"(?:stationnement|parking)[^.]{0,30}(?:exterieur|outdoor)")483_PARKING_INCL_RE = re.compile(r"(?:stationnement|parking)[^.]{0,30}inclus")484_PARKING_EXTRA_RE = re.compile(485 r"(?:stationnement|parking)[^.]{0,60}?(?:en sus|en option|disponible)")486_PARKING_PRICE_RE = re.compile(487 r"(?:stationnement|parking)[^.]{0,60}?"488 r"(?:(?<!\d)(\d(?:[\d\s]{0,6}\d)?)\s*\$|\$\s*(\d+))")489490_PHONE_RE = re.compile(r"\(?\b([2-9]\d{2})\)?[\s.\-]?(\d{3})[\s.\-](\d{4})\b")491_EMAIL_RE = re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.]+\b")492493_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")494495# Segments à neutraliser avant le matching : options payantes (« sur demande »,496# « en option », « en sus », « optionnel ») et fiches techniques (« Heating:497# Forced Air » décrit le type de chauffage, pas son inclusion). Retirés du498# texte, jamais interprétés. Bornés par « | » (séparateur d'items) ou « . ».499_NEUTRAL_RE = re.compile(500 r"electromenagers?[^|.]{0,50}(?:sur demande|en option|selon|\$)[^|.]*"501 r"|[^|.]*(?:\boptionnel(?:le)?s?\b|en option|en sus|moyennant (?:des )?frais)[^|.]*"502 # équipements « sur demande » (clim, wifi, stationnement…) — pas inclus503 r"|[^|.]*(?:climatis[^|.]{0,20}|air climatise|wi[- ]?fi|internet|cable"504 r"|stationnement|parking)[^|.]{0,25}sur demande[^|.]*"505 # stationnement/rangement à VÉLO : pas un stationnement auto506 r"|[^|.]*(?:stationnement|support|rangement|espace)s?[^|.]{0,12}(?:a |pour )?velos?[^|.]*"507 r"|[^|.]*bike (?:parking|storage|racks?)[^|.]*"508 # commodités payantes « ($) » ou restrictives « (sans utilisation) »509 r"|[^|.]*\(\s*\$\s*\)[^|.]*"510 r"|[^|.]*\(\s*sans [^)|.]{0,30}\)[^|.]*"511 # promotions : « Crédit de 1 000 $ Éclaire », « 1 mois gratuit » — un nom512 # de promo ne décrit pas les inclusions du logement513 r"|[^|.]*(?:credit de|rabais|promotion|mois gratuits?|offre speciale)[^|.]*"514 # aires communes de l'immeuble : ne décrivent pas l'unité515 r"|[^|.]*aires? communes?[^|.]*"516 r"|[^|.]*(?:terrasse|salle|cuisine|salon|piscine) communa?u?[a-z]*[^|.]*"517 r"|heating\s*:[^|.]*")518519# « chauffé » qualifiant un stationnement/garage/piscine : on retire le520# qualificatif (pas le segment entier — le stationnement reste une info).521_HEAT_CTX_RE = re.compile(522 r"((?:stationnement|garage|piscine|pool)[^|.]{0,30}?)(?:non |pas )?chauffee?s?\b")523524525def extract_details(amenities: list[str], description: str = "",526 title: str = "") -> dict:527 """Dérive les champs structurés depuis les commodités libres + description.528529 Retourne un dict « sparse » : une clé absente = information inconnue.530 Clés : inclusions{}, appliances{}, pets, smoking, furnished, parking{},531 elevator, balcony, pool, gym, laundry, storage, ac, floor, contact{}.532 """533 items = [_key(a) for a in (amenities or []) if a]534 desc = _key(description or "")535 all_text = " | ".join(items + ([desc] if desc else []) +536 ([_key(title)] if title else []))537 all_text = _NEUTRAL_RE.sub(" ", all_text)538 all_text = _HEAT_CTX_RE.sub(r"\1", all_text)539 if not all_text.strip(" |"):540 return {}541542 flags: dict[str, bool] = {}543 for cle, (pos, neg) in _AMENITY_RULES.items():544 neg_rx = re.compile(neg) if neg else None545 pos_rx = re.compile(pos)546 # commodités (listes courtes) : match libéral ; description : aussi,547 # les négatifs l'emportent sur les positifs.548 if neg_rx and neg_rx.search(all_text):549 flags[cle] = False550 elif pos_rx.search(all_text):551 flags[cle] = True552553 details: dict = {}554 inclusions = {k: v for k, v in flags.items()555 if k in ("heating", "electricity", "hot_water", "internet", "cable")}556 if re.search(r"rien (?:n.est )?d.?inclus|aucune inclusion|nothing included", all_text):557 inclusions.update({"heating": False, "electricity": False, "hot_water": False})558 if inclusions:559 details["inclusions"] = inclusions560 appliances = {k: v for k, v in flags.items()561 if k in ("fridge", "stove", "dishwasher", "washer_dryer")}562 if flags.get("washer_dryer_hookup"):563 appliances["washer_dryer_hookup"] = True564 # « entrées laveuse-sécheuse » n'implique pas les appareils fournis565 if not re.search(r"laveuse[- /]secheuse (?:incluse?s?|fournies?)", all_text):566 appliances.pop("washer_dryer", None)567 if flags.get("dishwasher_hookup"):568 appliances["dishwasher_hookup"] = True569 appliances.pop("dishwasher", None) # branchement ≠ appareil fourni570 if appliances:571 details["appliances"] = appliances572 for cle in ("ac", "elevator", "balcony", "pool", "gym", "laundry", "storage"):573 if cle in flags:574 details[cle] = flags[cle]575576 # animaux / fumeur577 if _PETS_BOTH_RE.search(all_text):578 details["pets"] = "oui"579 elif _PETS_COND_RE.search(all_text):580 details["pets"] = "conditions"581 elif _PETS_NO_RE.search(all_text):582 details["pets"] = "non"583 elif _PETS_YES_RE.search(all_text):584 details["pets"] = "oui"585 if _SMOKING_NO_RE.search(all_text):586 details["smoking"] = False587 elif _SMOKING_YES_RE.search(all_text):588 details["smoking"] = True589590 # meublé (« semi-meublé » : nuance conservée, colonne booléenne à None)591 if re.search(r"semi[- ](?:meuble|furnished)", all_text):592 details["furnished"] = "semi"593 elif "furnished_kw" in flags:594 details["furnished"] = flags["furnished_kw"]595596 # stationnement (« intérieur et extérieur » possible)597 if flags.get("parking_kw"):598 parking: dict = {"available": True}599 p_int = bool(_PARKING_INT_RE.search(all_text))600 p_ext = bool(_PARKING_EXT_RE.search(all_text))601 if p_int and p_ext:602 parking["type"] = "intérieur et extérieur"603 elif p_int:604 parking["type"] = "intérieur"605 elif p_ext:606 parking["type"] = "extérieur"607 if _PARKING_INCL_RE.search(all_text):608 parking["included"] = True609 elif _PARKING_EXTRA_RE.search(all_text) or _PARKING_PRICE_RE.search(all_text):610 parking["included"] = False611 m = _PARKING_PRICE_RE.search(all_text)612 if m:613 prix = float((m.group(1) or m.group(2)).replace(" ", ""))614 if 10 <= prix <= 1000: # plausible pour un stationnement615 parking["price"] = prix616 details["parking"] = parking617618 # étage — depuis les items de commodités seulement : une description619 # longue peut mentionner « 3e étage » pour un immeuble multi-unités.620 # Les items décrivant une installation (« salon au 25e étage ») sont exclus.621 floor_items = [it for it in items622 if not re.search(r"salon|lounge|piscine|gym|terrasse|rooftop|chalet|spa", it)]623 m = _FLOOR_RE.search(" | ".join(floor_items))624 if m:625 floor = next((g for g in m.groups() if g), None)626 if floor and 0 < int(floor) <= 60:627 details["floor"] = int(floor)628629 # contact (depuis la description seulement — les listes n'en ont pas)630 contact: dict = {}631 m = _PHONE_RE.search(description or "")632 if m:633 contact["phone"] = f"{m.group(1)}-{m.group(2)}-{m.group(3)}"634 m = _EMAIL_RE.search(description or "")635 if m:636 contact["email"] = m.group(0)637 if contact:638 details["contact"] = contact639640 return details641642643def merge_details(base: dict, derived: dict) -> dict:644 """Fusionne les détails dérivés sous ceux fournis par le connecteur.645646 Les valeurs explicites du connecteur ont priorité ; les sous-dicts647 (inclusions, appliances, parking, contact) sont fusionnés clé par clé.648 """649 out = dict(derived)650 for k, v in (base or {}).items():651 if isinstance(v, dict) and isinstance(out.get(k), dict):652 out[k] = {**out[k], **v}653 else:654 out[k] = v655 return out656