SPB Git

spb/lou-ka Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

HTML 99.7%
23.1 KB · 534 lines python
Raw Blame History
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