spb/lou-ka Public
Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 99.7%
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()373839def clean_address(raw: str) -> str:40 """Nettoie une adresse : espaces, virgules orphelines, casse conservée."""41 if not raw:42 return ""43 s = _clean_text(raw)44 s = re.sub(r"\s*,\s*", ", ", s) # virgules uniformes45 s = re.sub(r"(, )+", ", ", s) # virgules doublées46 return s.strip(" ,;-")474849# ---------------------------------------------------------------------------50# Prix51# ---------------------------------------------------------------------------5253_PRICE_FROM_RE = re.compile(54 r"a partir|des\s+\d|starting|\bfrom\b|\bdes\b\s*$|\+\s*$"55 r"|de\s+\d[\d\s]*\s*\$?\s*a\s+\d", re.I) # fourchette « de X $ à Y $ »565758def parse_price(raw: str) -> float | None:59 """Extrait un loyer mensuel : '1 195 $/mois', '$1,195', 'À partir de 995$'.6061 Retourne le montant le plus bas si plusieurs, None si aucun montant62 plausible (100–20 000 $). Jamais de valeur inventée.63 """64 if not raw:65 return None66 s = _clean_text(str(raw))67 if "$" not in s:68 return None69 nums = []70 for m in re.finditer(r"(?:\$\s*)?(\d[\d\s]*(?:[.,]\d+)?)(?:\s*\$)?", s):71 # le nombre doit toucher un « $ » (avant ou après)72 start, end = m.span()73 near = s[max(0, start - 2):start] + m.group(0) + s[end:end + 2]74 if "$" not in near:75 continue76 num = m.group(1).replace(" ", "")77 # « 1,195 » = virgule de milliers ; « 1195,00 » = décimales78 if re.search(r",\d{3}(?:\D|$)", num + " "):79 num = num.replace(",", "")80 else:81 num = num.replace(",", ".")82 try:83 val = float(num)84 except ValueError:85 continue86 if 100 <= val <= 20000:87 nums.append(val)88 return min(nums) if nums else None899091def price_is_from(raw: str) -> bool:92 """Vrai si le prix est annoncé « à partir de »."""93 return bool(raw) and bool(_PRICE_FROM_RE.search(_key(raw)))949596# ---------------------------------------------------------------------------97# Type d'unité98# ---------------------------------------------------------------------------99100_UNIT_WORDS = [101 (re.compile(r"\bstudio\b|\bbachelor\b"), "Studio"),102 (re.compile(r"\bloft\b"), "Loft"),103 (re.compile(r"\bpenthouse\b"), "Penthouse"),104 (re.compile(r"\bcondo\b"), "Condo"),105 (re.compile(r"maison de ville|townhouse|\bmdv\b"), "Maison"),106 (re.compile(r"\bmaison\b|\bhouse\b|\bcottage\b"), "Maison"),107 (re.compile(r"\bchambre\b|\broom\b(?!s)"), "Chambre"),108]109110111def normalize_unit_type(raw: str) -> str:112 """Standardise le type d'unité vers l'énumération Lou-Ka.113114 '4 1/2', '4½', '4,5', '4.5' -> '4½' ; '2 chambres' -> '4½' (n cc + 2) ;115 '6½' et plus -> '6½+' ; 'bachelor' -> 'Studio' ; 'MDV' -> 'Maison'.116 """117 if not raw:118 return ""119 s = _key(str(raw))120121 # « 4 1/2 », « 4½ », « 4,5 », « 4.5 », « 5 1/5 » (coquille fréquente)122 m = re.search(r"(\d+)\s*(?:½|1/2|1/5|[.,]5)", s)123 if not m:124 # « 4 pièces et demie »125 m = re.search(r"(\d+)\s*pieces?\s*(?:et\s*demie?)?", s)126 if m:127 n = int(m.group(1))128 if n < 1:129 return ""130 return "6½+" if n >= 6 else f"{n}½"131132 # « 2 chambres », « 3 cc », « 2 bedrooms » -> n + 2 pièces et demie133 m = re.search(r"(\d+)\s*(?:chambres?|\bcc\b|\bcac\b|bed(?:room)?s?\b|\bbdr\b)", s)134 if not m:135 # nombres en toutes lettres : « une chambre », « two bedrooms »136 mots = {"une": 1, "un": 1, "one": 1, "deux": 2, "two": 2,137 "trois": 3, "three": 3, "quatre": 4, "four": 4}138 m2 = re.search(r"\b(une?|one|deux|two|trois|three|quatre|four)\s+(?:chambres?|bed(?:room)?s?)\b", s)139 if m2:140 n = mots[m2.group(1)] + 2141 return "6½+" if n >= 6 else f"{n}½"142 if m:143 n = int(m.group(1)) + 2144 return "6½+" if n >= 6 else f"{n}½"145146 for rx, label in _UNIT_WORDS:147 if rx.search(s):148 # « chambre » précédé d'un nombre a déjà été traité ci-dessus149 return label150 return _clean_text(str(raw))151152153# ---------------------------------------------------------------------------154# Date de disponibilité155# ---------------------------------------------------------------------------156157_MONTHS = {158 "janvier": 1, "jan": 1, "january": 1,159 "fevrier": 2, "fev": 2, "feb": 2, "february": 2,160 "mars": 3, "mar": 3, "march": 3,161 "avril": 4, "avr": 4, "apr": 4, "april": 4,162 "mai": 5, "may": 5,163 "juin": 6, "jun": 6, "june": 6,164 "juillet": 7, "juil": 7, "jul": 7, "july": 7,165 "aout": 8, "aug": 8, "august": 8,166 "septembre": 9, "sept": 9, "sep": 9, "september": 9,167 "octobre": 10, "oct": 10, "october": 10,168 "novembre": 11, "nov": 11, "november": 11,169 "decembre": 12, "dec": 12, "december": 12,170}171_MONTH_RE = "|".join(sorted(_MONTHS, key=len, reverse=True))172173_NOW_RE = re.compile(174 r"immediat|maintenant|\bnow\b|des maintenant|\blibre\b|disponible|available|occupation immediate|vacant")175176177def parse_availability_date(raw: str, today: date | None = None) -> str | None:178 """Normalise une disponibilité en date ISO, « now », ou None.179180 « Libre immédiatement » -> "now" ; « 1er juillet » -> prochaine occurrence181 (ou "now" si déjà passée) ; « Disponibilité : 01 décembre 2026 » ->182 "2026-12-01" ; texte inconnu -> None (jamais de date inventée).183 """184 if not raw:185 return None186 today = today or date.today()187 s = _key(str(raw))188189 def _build(y: int | None, m: int, d: int) -> str:190 if y is None:191 y = today.year192 if date(y, m, min(d, 28)) < today:193 # date sans année déjà passée : le logement est libre194 return "now"195 try:196 dt = date(y, m, d)197 except ValueError:198 dt = date(y, m, 1)199 return "now" if dt <= today else dt.isoformat()200201 # ISO : 2026-07-01202 m = re.search(r"(20\d{2})-(\d{1,2})-(\d{1,2})", s)203 if m:204 return _build(int(m.group(1)), int(m.group(2)), int(m.group(3)))205206 # anglais « April 1st, 2027 » (mois d'abord ; « juillet 2026 » exclu207 # par le (?!\d) — un jour ne peut pas être le début d'une année)208 m = re.search(209 rf"\b({_MONTH_RE})\b\.?\s+(\d{{1,2}})(?!\d)(?:st|nd|rd|th)?\s*,?\s*(20\d{{2}})?", s)210 if m and m.group(2) and 1 <= int(m.group(2)) <= 31:211 y = int(m.group(3)) if m.group(3) else None212 return _build(y, _MONTHS[m.group(1)], int(m.group(2)))213214 # 01/07/2026 ou 2026/07/01 (format jour/mois/année au Québec)215 m = re.search(r"(\d{1,2})[/.](\d{1,2})[/.](20\d{2})", s)216 if m:217 d, mo = int(m.group(1)), int(m.group(2))218 if mo > 12 and d <= 12:219 d, mo = mo, d220 if 1 <= mo <= 12:221 return _build(int(m.group(3)), mo, min(d, 28) if d > 31 else d)222 m = re.search(r"(20\d{2})[/.](\d{1,2})[/.](\d{1,2})", s)223 if m:224 return _build(int(m.group(1)), int(m.group(2)), int(m.group(3)))225226 # « 1er juillet 2026 », « 01 décembre », « juillet 2026 », « déc. 2026 »227 # \b : « rénové » contient « nov », « maison » contient « mai » — sans228 # frontières de mots, le normaliseur inventait des dates229 m = re.search(230 rf"(?:(\d{{1,2}})(?:er|e|st|nd|rd|th)?\s+)?\b({_MONTH_RE})\b\.?\s*(20\d{{2}})?", s)231 if m and m.group(2):232 d = int(m.group(1)) if m.group(1) else 1233 mo = _MONTHS[m.group(2)]234 y = int(m.group(3)) if m.group(3) else None235 if 1 <= d <= 31:236 return _build(y, mo, d)237238 # « July 1, 2026 » (mois déjà couvert ci-dessus) / « 2026 » seul : ignoré239 if _NOW_RE.search(s):240 return "now"241 return None242243244# ---------------------------------------------------------------------------245# Superficie246# ---------------------------------------------------------------------------247248_AREA_SQFT_RE = re.compile(249 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)250_AREA_SQM_RE = re.compile(251 r"(\d[\d\s,]*(?:\.\d+)?)\s*(?:m\s*[²2]|metres?\s*carres?|sq\.?\s*m\b)", re.I)252# superficies qui ne sont PAS celles du logement (balcon, terrasse, rangement)253_AREA_SKIP_CTX = re.compile(r"balcon|terrass|loggia|patio|rangement|locker|garage|cour\b")254255256def parse_area_sqft(raw: str) -> float | None:257 """Superficie en pi². Gère « 850 pi² », « 1,607 sq. ft. », « 78 m² ».258259 Ignore les superficies de balcon/terrasse/rangement (contexte) ; retourne260 la plus petite valeur restante (cohérent avec un prix « à partir de »).261 """262 if not raw:263 return None264 s = _key(str(raw))265266 def _num(txt: str) -> float | None:267 txt = txt.replace(" ", "")268 # « 1,607 » = virgule de milliers ; sinon virgule décimale269 if re.search(r",\d{3}(?:\D|$)", txt + " "):270 txt = txt.replace(",", "")271 else:272 txt = txt.replace(",", ".")273 try:274 return float(txt)275 except ValueError:276 return None277278 def _skip(start: int) -> bool:279 return bool(_AREA_SKIP_CTX.search(s[max(0, start - 30):start]))280281 vals = []282 for m in _AREA_SQFT_RE.finditer(s):283 v = _num(m.group(1))284 if v and 80 <= v <= 20000 and not _skip(m.start()):285 vals.append(v)286 for m in _AREA_SQM_RE.finditer(s):287 v = _num(m.group(1))288 if v and 8 <= v <= 2000 and not _skip(m.start()):289 vals.append(round(v * 10.7639))290 return min(vals) if vals else None291292293# ---------------------------------------------------------------------------294# Commodités -> tags canoniques (details JSON)295# ---------------------------------------------------------------------------296# Chaque règle : clé canonique -> (motifs positifs, motifs négatifs).297# Les motifs sont matchés sur du texte sans accents en minuscules.298# Absence de match = inconnu (la clé n'apparaît pas), jamais « false » deviné.299300_AMENITY_RULES: dict[str, tuple[str, str | None]] = {301 # inclusions302 "heating": (r"chauffage|chauffe[e]? |chauffe[e]?$|heating|heat included|\bheat\b",303 r"chauffage non inclus|sans chauffage|non[- ]chauffe|pas chauffe|heat pump"304 r"|chauffage[^|.]{0,60}charge d(?:u|es) locataires?"),305 "electricity": (r"electricite|hydro inclus|electricity|eclaire\b|\bhydro\b",306 r"electricite non incluse?|non[- ]eclaire"307 r"|electricite[^|.]{0,60}charge d(?:u|es) locataires?"),308 "hot_water": (r"eau chaude|hot water",309 r"eau chaude non incluse?|eau chaude[^|.]{0,60}charge d(?:u|es) locataires?"),310 "internet": (r"internet|wi[- ]?fi", r"internet non inclus"),311 "cable": (r"\bcable\b|television|telus|videotron|bell fibe", None),312 # électroménagers313 "fridge": (r"refrigerateur|refrigerator|\bfrigo\b|\bfridge\b|electromenagers?( inclus)?|appliances", None),314 "stove": (r"cuisiniere|\bstove\b|\bfour\b|plaques? de cuisson", None),315 "dishwasher": (r"lave[- ]vaisselle|dishwasher", r"entrees? (?:pour |de )?lave[- ]vaisselle|dishwasher hookup"),316 "dishwasher_hookup": (r"entrees? (?:pour |de )?lave[- ]vaisselle|dishwasher hookups?", None),317 "washer_dryer": (r"laveuse[- /]secheuse|laveuse et secheuse|\bwasher\b|\bdryer\b",318 r"entrees?[^|.]{0,35}laveuse|washer.{0,10}hookup"),319 "washer_dryer_hookup": (r"entrees?[^|.]{0,35}laveuse|sorties? laveuse|washer.{0,10}hookups?", None),320 "ac": (r"climatis|air climatise|\ba/?c\b|air conditioning|central air|cooling\s*:|thermopompe", None),321 # immeuble322 "elevator": (r"ascen[cs]eur|elevator", None),323 "balcony": (r"balcon|terrasse|terrace|patio|loggia",324 r"pas de balcon|sans balcon|no balcony|aucun balcon"),325 "pool": (r"piscine|\bpool\b|\bspa\b", None),326 "gym": (r"\bgym\b|salle d.?entrainement|entrainement|fitness|salle de sport", None),327 "laundry": (r"buanderie|salle de lavage|laundry (?:room|facilit)", None),328 "storage": (r"rangement|\blocker\b|espace de rangement|storage", None),329 "furnished_kw": (r"\bmeuble\b|\bmeublee?s?\b|furnished", r"non[- ]meuble|unfurnished"),330 "parking_kw": (r"stationnement|parking|garage", None),331}332333_PETS_YES_RE = re.compile(334 r"animaux (?:de compagnie )?(?:acceptes|admis|bienvenus|permis|autorises|ok)"335 r"|pet[s]?[- ]friendly|pets? (?:allowed|welcome)"336 r"|chats? (?:et chiens? )?(?:acceptes?|admis)"337 r"|chiens? (?:et chats? )?(?:permis|acceptes?|admis)"338 r"|anima(?:l|ux)[^|.]{0,15}(?:bienvenus?|acceptes?|admis|permis)")339# les deux espèces explicitement acceptées : « oui » sans ambiguïté,340# testé AVANT les motifs « conditions » (sinon « chats acceptés » l'emporte)341_PETS_BOTH_RE = re.compile(342 r"(?:chiens? et chats?|chats? et chiens?) (?:acceptes|admis|bienvenus|permis|autorises)")343_PETS_NO_RE = re.compile(344 r"(?:pas d.?animaux|aucun animal|animaux (?:refuses|interdits|non admis|non acceptes|non autorises|non permis))|no pets?"345 r"|animaux (?:permis|acceptes|admis|autorises)?\s*:\s*non"346 r"|animaux[^|.]{0,15}interdits")347_PETS_COND_RE = re.compile(348 r"animaux (?:sous conditions?|sur approbation)|petits? animaux|petits? chiens?"349 r"|chats? (?:seulement|acceptes?|autorises?|admis)\b|cats? only|avec restrictions"350 r"|(?:animaux|chats?|chiens?|pets?)[^|.]{0,30}sous conditions?"351 r"|chiens?[^|.]{0,30}(?:pas|non|ne sont pas) (?:permis|admis|acceptes)"352 r"|chiens? interdits?|no dogs?\b"353 r"|animau?x?[^|.]{0,30}\(?\d+\s*(?:lb|livres?|kg)" # limite de poids354 r"|animaux[^|.]{0,20}sur demande"355 r"|animaux[^|.]{0,20}refuses?[^|.]{0,30}(?:sauf|a l.?exception)")356_SMOKING_NO_RE = re.compile(r"non[- ]fumeurs?|sans fumee|smoke[- ]free|no smoking|interdiction de fumer")357_SMOKING_YES_RE = re.compile(r"fumeurs? (?:accepte|permis|autorise)")358359_PARKING_INT_RE = re.compile(r"(?:stationnement|garage|parking)[^.]{0,30}(?:interieur|souterrain|sous[- ]terrain|indoor)|garage")360_PARKING_EXT_RE = re.compile(r"(?:stationnement|parking)[^.]{0,30}(?:exterieur|outdoor)")361_PARKING_INCL_RE = re.compile(r"(?:stationnement|parking)[^.]{0,30}inclus")362_PARKING_EXTRA_RE = re.compile(363 r"(?:stationnement|parking)[^.]{0,60}?(?:en sus|en option|disponible)")364_PARKING_PRICE_RE = re.compile(365 r"(?:stationnement|parking)[^.]{0,60}?"366 r"(?:(?<!\d)(\d(?:[\d\s]{0,6}\d)?)\s*\$|\$\s*(\d+))")367368_PHONE_RE = re.compile(r"\(?\b([2-9]\d{2})\)?[\s.\-]?(\d{3})[\s.\-](\d{4})\b")369_EMAIL_RE = re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.]+\b")370371_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")372373# Segments à neutraliser avant le matching : options payantes (« sur demande »,374# « en option », « en sus », « optionnel ») et fiches techniques (« Heating:375# Forced Air » décrit le type de chauffage, pas son inclusion). Retirés du376# texte, jamais interprétés. Bornés par « | » (séparateur d'items) ou « . ».377_NEUTRAL_RE = re.compile(378 r"electromenagers?[^|.]{0,50}(?:sur demande|en option|selon|\$)[^|.]*"379 r"|[^|.]*(?:\boptionnel(?:le)?s?\b|en option|en sus|moyennant (?:des )?frais)[^|.]*"380 # équipements « sur demande » (clim, wifi, stationnement…) — pas inclus381 r"|[^|.]*(?:climatis[^|.]{0,20}|air climatise|wi[- ]?fi|internet|cable"382 r"|stationnement|parking)[^|.]{0,25}sur demande[^|.]*"383 # stationnement/rangement à VÉLO : pas un stationnement auto384 r"|[^|.]*(?:stationnement|support|rangement|espace)s?[^|.]{0,12}(?:a |pour )?velos?[^|.]*"385 r"|[^|.]*bike (?:parking|storage|racks?)[^|.]*"386 # commodités payantes « ($) » ou restrictives « (sans utilisation) »387 r"|[^|.]*\(\s*\$\s*\)[^|.]*"388 r"|[^|.]*\(\s*sans [^)|.]{0,30}\)[^|.]*"389 # promotions : « Crédit de 1 000 $ Éclaire », « 1 mois gratuit » — un nom390 # de promo ne décrit pas les inclusions du logement391 r"|[^|.]*(?:credit de|rabais|promotion|mois gratuits?|offre speciale)[^|.]*"392 # aires communes de l'immeuble : ne décrivent pas l'unité393 r"|[^|.]*aires? communes?[^|.]*"394 r"|[^|.]*(?:terrasse|salle|cuisine|salon|piscine) communa?u?[a-z]*[^|.]*"395 r"|heating\s*:[^|.]*")396397# « chauffé » qualifiant un stationnement/garage/piscine : on retire le398# qualificatif (pas le segment entier — le stationnement reste une info).399_HEAT_CTX_RE = re.compile(400 r"((?:stationnement|garage|piscine|pool)[^|.]{0,30}?)(?:non |pas )?chauffee?s?\b")401402403def extract_details(amenities: list[str], description: str = "",404 title: str = "") -> dict:405 """Dérive les champs structurés depuis les commodités libres + description.406407 Retourne un dict « sparse » : une clé absente = information inconnue.408 Clés : inclusions{}, appliances{}, pets, smoking, furnished, parking{},409 elevator, balcony, pool, gym, laundry, storage, ac, floor, contact{}.410 """411 items = [_key(a) for a in (amenities or []) if a]412 desc = _key(description or "")413 all_text = " | ".join(items + ([desc] if desc else []) +414 ([_key(title)] if title else []))415 all_text = _NEUTRAL_RE.sub(" ", all_text)416 all_text = _HEAT_CTX_RE.sub(r"\1", all_text)417 if not all_text.strip(" |"):418 return {}419420 flags: dict[str, bool] = {}421 for cle, (pos, neg) in _AMENITY_RULES.items():422 neg_rx = re.compile(neg) if neg else None423 pos_rx = re.compile(pos)424 # commodités (listes courtes) : match libéral ; description : aussi,425 # les négatifs l'emportent sur les positifs.426 if neg_rx and neg_rx.search(all_text):427 flags[cle] = False428 elif pos_rx.search(all_text):429 flags[cle] = True430431 details: dict = {}432 inclusions = {k: v for k, v in flags.items()433 if k in ("heating", "electricity", "hot_water", "internet", "cable")}434 if re.search(r"rien (?:n.est )?d.?inclus|aucune inclusion|nothing included", all_text):435 inclusions.update({"heating": False, "electricity": False, "hot_water": False})436 if inclusions:437 details["inclusions"] = inclusions438 appliances = {k: v for k, v in flags.items()439 if k in ("fridge", "stove", "dishwasher", "washer_dryer")}440 if flags.get("washer_dryer_hookup"):441 appliances["washer_dryer_hookup"] = True442 # « entrées laveuse-sécheuse » n'implique pas les appareils fournis443 if not re.search(r"laveuse[- /]secheuse (?:incluse?s?|fournies?)", all_text):444 appliances.pop("washer_dryer", None)445 if flags.get("dishwasher_hookup"):446 appliances["dishwasher_hookup"] = True447 appliances.pop("dishwasher", None) # branchement ≠ appareil fourni448 if appliances:449 details["appliances"] = appliances450 for cle in ("ac", "elevator", "balcony", "pool", "gym", "laundry", "storage"):451 if cle in flags:452 details[cle] = flags[cle]453454 # animaux / fumeur455 if _PETS_BOTH_RE.search(all_text):456 details["pets"] = "oui"457 elif _PETS_COND_RE.search(all_text):458 details["pets"] = "conditions"459 elif _PETS_NO_RE.search(all_text):460 details["pets"] = "non"461 elif _PETS_YES_RE.search(all_text):462 details["pets"] = "oui"463 if _SMOKING_NO_RE.search(all_text):464 details["smoking"] = False465 elif _SMOKING_YES_RE.search(all_text):466 details["smoking"] = True467468 # meublé (« semi-meublé » : nuance conservée, colonne booléenne à None)469 if re.search(r"semi[- ](?:meuble|furnished)", all_text):470 details["furnished"] = "semi"471 elif "furnished_kw" in flags:472 details["furnished"] = flags["furnished_kw"]473474 # stationnement (« intérieur et extérieur » possible)475 if flags.get("parking_kw"):476 parking: dict = {"available": True}477 p_int = bool(_PARKING_INT_RE.search(all_text))478 p_ext = bool(_PARKING_EXT_RE.search(all_text))479 if p_int and p_ext:480 parking["type"] = "intérieur et extérieur"481 elif p_int:482 parking["type"] = "intérieur"483 elif p_ext:484 parking["type"] = "extérieur"485 if _PARKING_INCL_RE.search(all_text):486 parking["included"] = True487 elif _PARKING_EXTRA_RE.search(all_text) or _PARKING_PRICE_RE.search(all_text):488 parking["included"] = False489 m = _PARKING_PRICE_RE.search(all_text)490 if m:491 prix = float((m.group(1) or m.group(2)).replace(" ", ""))492 if 10 <= prix <= 1000: # plausible pour un stationnement493 parking["price"] = prix494 details["parking"] = parking495496 # étage — depuis les items de commodités seulement : une description497 # longue peut mentionner « 3e étage » pour un immeuble multi-unités.498 # Les items décrivant une installation (« salon au 25e étage ») sont exclus.499 floor_items = [it for it in items500 if not re.search(r"salon|lounge|piscine|gym|terrasse|rooftop|chalet|spa", it)]501 m = _FLOOR_RE.search(" | ".join(floor_items))502 if m:503 floor = next((g for g in m.groups() if g), None)504 if floor and 0 < int(floor) <= 60:505 details["floor"] = int(floor)506507 # contact (depuis la description seulement — les listes n'en ont pas)508 contact: dict = {}509 m = _PHONE_RE.search(description or "")510 if m:511 contact["phone"] = f"{m.group(1)}-{m.group(2)}-{m.group(3)}"512 m = _EMAIL_RE.search(description or "")513 if m:514 contact["email"] = m.group(0)515 if contact:516 details["contact"] = contact517518 return details519520521def merge_details(base: dict, derived: dict) -> dict:522 """Fusionne les détails dérivés sous ceux fournis par le connecteur.523524 Les valeurs explicites du connecteur ont priorité ; les sous-dicts525 (inclusions, appliances, parking, contact) sont fusionnés clé par clé.526 """527 out = dict(derived)528 for k, v in (base or {}).items():529 if isinstance(v, dict) and isinstance(out.get(k), dict):530 out[k] = {**out[k], **v}531 else:532 out[k] = v533 return out534