SPB Git forge

spb/rent-ka

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