SPB Git

spb/lou-ka

Public

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

27.2 KB · 656 lines python
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
Files · 7dd94df429ff2d81e9c59a619402d2ddf82f596b
Menu

Theme