SPB Git forge

spb/lou-ka

Public

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

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
21.6 KB · 496 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# connectors/sutton.py : Groupe Sutton Québec (suttonquebec.com) — LOCATIONS5#   Couverture exhaustive via le sitemap : l'index /sitemap.cfm pointe vers des6#   sitemaps d'inscriptions paginés où chaque URL porte noInscription (numéro7#   Centris) et typeInscription (1 = à vendre, 2 = À LOUER — on ne garde que8#   les 2). Le slug donne type/adresse/ville approximatifs ; la fiche détail9#   fournit le loyer (« 1 950 $ / mois »), la galerie immo.vrtx.co, la10#   description, les pièces et l'adresse exacte (H1). Adapté du connecteur11#   « à vendre » d'Immo-Ka (agent-courtage/immoka).12# -----------------------------------------------------------------------------13from __future__ import annotations1415import html as _html16import os17import re1819from ..normalize import parse_area_sqft, strip_accents20from ..schema import Listing21from .base import BaseConnector2223from . import _detailutil as du2425BASE = "https://www.suttonquebec.com"26DETAIL_LIMIT = int(os.environ.get("LOUKA_SUTTON_DETAIL_LIMIT", "400"))2728# Sitemap : l'index /sitemap.cfm pointe vers des sitemaps d'inscriptions.29SITEMAP_INDEX = f"{BASE}/sitemap.cfm"30SITEMAP_MAX_PAGES = 40                  # garde-fou (≈13 pages réelles × ~1000)31_SM_SUB_RE = re.compile(r'sitemap_inscription\.cfm\?[^<"\s]+', re.I)32_SM_LOC_RE = re.compile(33    r'https://www\.suttonquebec\.com/fr/inscription/([^<"?]+)\.html\?'34    r'noInscription=(\d+)&(?:amp;)?typeInscription=(\d)', re.I)3536# toutes les photos de la fiche (normalisées pic450, dédoublonnées id-index)37_DET_IMG_RE = re.compile(38    r'https://immo\.vrtx\.co/pic\d+/(\d+(?:-\d+)?)\.(?:jpg|jpeg|png|webp)', re.I)39_DESC_RE = re.compile(40    r'Description de la propri[ée]t[ée](?:\s+[àa]\s+(?:vendre|louer))?[^A-Za-z0-9]*(.*?)'41    r'(?:Caract[ée]ristiques|Pi[èe]ce\s+Dimension|Inclusions|Exclusions|Addenda|$)',42    re.I | re.S)43# loyer : montant suivi de « / mois » / « par mois » (le prix demandé précède44# les loyers des « prochaines propriétés » en bas de page)45_RENT_RE = re.compile(r"(?<!\d)(\d{1,2}\s\d{3}|\d{3,5})\s*\$\s*(?:/\s*|par\s+)mois",46                      re.I)47_ROOM_ROW_RE = re.compile(48    r'([A-ZÀ-Ÿ][^\d|]{2,38}?)\s+(\d[\d.,]*\s*[xX]\s*\d[\d.,]*)\s*P?\s+'49    r'([A-Za-zÀ-ÿ/ -]{3,30}?)\s+(RC|SS|AU|\d[eE]?r?e?)\b')50_AREA_RE = re.compile(r"Aire habitable\s*:?\s*([\d.,]+)\s*(mc|m2|m²|pc|pi)", re.I)51_H1_RE = re.compile(r"<h1[^>]*>(.*?)</h1>", re.S)52_BROKER_RE = re.compile(53    r'divResume courtier[^"]*">.*?class="h4">\s*([^<]{3,60}?)\s*</p>', re.S)54_PHONE_RE = re.compile(r"\d{3}[-\s]\d{3}-\d{4}")55_AGENCY_RE = re.compile(r'/agence-immobiliere/\d+-([a-z0-9-]+)\.html', re.I)56_PAREN_RE = re.compile(r"\(([^)]*)\)")57_POSTAL_RE = re.compile(r"[GHJ]\d[A-Z]\s?\d[A-Z]\d", re.I)5859# types de slug qui ne sont pas des logements (lou-ka = résidentiel locatif)60_EXCLUDE_TYPE_RE = re.compile(61    r"commercial|bureau|industriel|terrain|entrep[ôo]t|local\b", re.I)6263# Sur les fiches LOCATION, le tableau des caractéristiques est en ordre64# libellé | valeur : on privilégie cet ordre, du.centris_details en repli.65_RENT_LABELS = [66    "Type de propriété", "Genre de propriété", "Style de bâtiment",67    "Année de construction", "Aire habitable", "Superficie habitable",68    "Superficie du terrain", "Nombre de pièces", "Stationnement",69    "Garage", "Mode de chauffage", "Système de chauffage",70    "Énergie pour le chauffage", "Équipement disponible", "Zonage",71    "Approvisionnement en eau", "Système d'égouts", "Piscine",72    "Déménagement", "Date d'emménagement", "Disponibilité", "Bail",73    "Durée du bail", "Meublé", "Animaux", "Inclus dans le loyer", "Cuisine",74]757677class SuttonConnector(BaseConnector):78    source_id = "sutton"79    request_delay = 0.58081    def fetch(self) -> list[Listing]:82        by_id: dict[str, Listing] = {}83        # 1) couverture exhaustive via le sitemap (type/adresse depuis le slug)84        for slug, centris, url in self._sitemap_listings():85            if centris in by_id:86                continue87            ptype, addr, city = _from_slug(slug)88            if ptype is None:          # commercial/bureau : pas un logement89                continue90            details = {"MLS": centris}91            if ptype:92                details["Type"] = ptype93            by_id[centris] = Listing(94                source=self.source_id, external_id=centris, url=url,95                title=addr or _deslug(slug), address=addr, city=city,96                unit_type="Maison" if ptype.startswith("Maison") else "",97                details=details,98            )99        listings = list(by_id.values())100        # fiche détail : loyer, chambres, galerie complète, description,101        # pièces, inclusions, adresse exacte — plafonné, cache accumulé entre102        # cycles (tout le parc finit enrichi au fil des syncs).103        self._purge_empty_details()104        du.enrich(self, listings, DETAIL_LIMIT, parse_sutton_detail, key="v1",105                  fetch_html=self._fetch_detail_html)106        for lst in listings:107            # adresse exacte du H1 de la fiche : remplace l'approx. du slug108            for det_key, attr in (("__adresse", "address"), ("__ville", "city"),109                                  ("__secteur", "sector")):110                val = lst.details.pop(det_key, "")111                if val:112                    if attr == "address":113                        val = _expand_abbrev(val)114                    setattr(lst, attr, val)115            if lst.address:116                lst.title = lst.address117            _unit_from_bedrooms(lst)118        return listings119120    def _purge_empty_details(self) -> None:121        """Retire du cache les payloads vides « {} » (échecs réseau passés122        figés par enrich) : sans clé fraîche ces fiches ne seraient JAMAIS123        re-visitées alors que la page répond très bien aujourd'hui — c'était124        la cause des annonces sans prix/photos/description. Les fiches ainsi125        purgées repassent dans le budget DETAIL_LIMIT au prochain enrich."""126        from .. import db127        con = db.connect()128        try:129            con.execute(130                "DELETE FROM detail_cache WHERE source=? AND"131                " (payload IS NULL OR payload='' OR payload='{}')",132                (self.source_id,))133            con.commit()134        except Exception:135            pass136        finally:137            con.close()138139    def _fetch_detail_html(self, url: str) -> str:140        """GET de la fiche avec UNE reprise : un échec ponctuel (timeout,141        hoquet serveur) serait sinon mis en cache comme payload vide."""142        import time as _time143        try:144            return self.get(url).text145        except Exception:146            _time.sleep(2.0)147            return self.get(url).text148149    def _sitemap_listings(self):150        """Itère (slug, centris, url) de toutes les inscriptions À LOUER."""151        try:152            index = self.get(SITEMAP_INDEX).text153        except Exception:154            return155        subs = []156        for s in _SM_SUB_RE.findall(index):157            s = s.replace("&amp;", "&")158            if s not in subs:159                subs.append(s)160        if not subs:                       # repli : pagination directe161            subs = [f"sitemap_inscription.cfm?page={p}&platine=0"162                    for p in range(1, 14)] + ["sitemap_inscription.cfm?platine=1"]163        seen = set()164        for sub in subs[:SITEMAP_MAX_PAGES]:165            try:166                xml = self.get(f"{BASE}/{sub}").text167            except Exception:168                continue169            for m in _SM_LOC_RE.finditer(xml):170                slug, centris, typ = m.group(1), m.group(2), m.group(3)171                if typ != "2" or centris in seen:      # 2 = à louer uniquement172                    continue173                seen.add(centris)174                url = (f"{BASE}/fr/inscription/{slug}.html"175                       f"?noInscription={centris}&typeInscription=2")176                yield slug, centris, url177178179def _unit_from_bedrooms(lst: Listing) -> None:180    """« Chambres » de la fiche détail -> unit_type « n chambres »181    (la normalisation lou-ka convertit en n+2½)."""182    if lst.unit_type:183        return184    m = re.match(r"\d+", str(lst.details.get("Chambres", "")))185    if m:186        n = int(m.group(0))187        lst.unit_type = "Studio" if n == 0 else f"{n} chambres"188    else:189        lst.unit_type = lst.details.get("Type", "")190191192# Le H1 Sutton abrège les odonymes (« 3762 Rue St-Dominique », « Av. »,193# « Boul. ») : le géocodeur Adresses Québec les score 0 (vérifié) alors que194# la forme en toutes lettres score 85+. On livre donc l'adresse canonique.195_ABBREV = [196    (re.compile(r"\bSt-"), "Saint-"),197    (re.compile(r"\bSte-"), "Sainte-"),198    (re.compile(r"\bAv\.\s"), "Avenue "),199    (re.compile(r"\bBoul\.\s"), "Boulevard "),200    (re.compile(r"\bCh\.\s"), "Chemin "),201    (re.compile(r"\bPl\.\s"), "Place "),202    (re.compile(r"\bCr\.\s"), "Croissant "),203    (re.compile(r"\bCrois\.\s"), "Croissant "),204    (re.compile(r"\bProm\.\s"), "Promenade "),205    (re.compile(r"\bTerr\.\s"), "Terrasse "),206    (re.compile(r"\bMt[ée]e\s"), "Montée "),207    (re.compile(r"\bRte\s"), "Route "),208]209210211def _expand_abbrev(addr: str) -> str:212    for rx, rep in _ABBREV:213        addr = rx.sub(rep, addr)214    return addr215216217def _deslug(s: str) -> str:218    return re.sub(r"\s+", " ", s.replace("-", " ")).strip().title()219220221# villes/arrondissements composés fréquents dans les slugs Sutton (fin du slug)222_CITY_HINTS = (223    "montreal-le-plateau-mont-royal", "montreal-ville-marie", "montreal-rosemont-la-petite-patrie",224    "montreal-le-sud-ouest", "montreal-cote-des-neiges-notre-dame-de-grace", "montreal-ahuntsic-cartierville",225    "montreal-villeray-saint-michel-parc-extension", "montreal-mercier-hochelaga-maisonneuve",226    "montreal-riviere-des-prairies-pointe-aux-trembles", "montreal-verdun-ile-des-soeurs",227    "montreal-saint-laurent", "montreal-lasalle", "montreal-outremont", "montreal-anjou",228    "montreal-lachine", "montreal-pierrefonds-roxboro", "montreal-saint-leonard",229    "montreal-montreal-nord", "montreal-l-ile-bizard-sainte-genevieve",230    "longueuil-le-vieux-longueuil", "longueuil-saint-hubert", "longueuil-greenfield-park",231    "laval-chomedey", "laval-sainte-dorothee", "laval-sainte-rose", "laval-duvernay",232    "laval-fabreville", "laval-vimont", "laval-auteuil", "laval-laval-des-rapides",233    "laval-pont-viau", "laval-laval-ouest", "laval-saint-francois", "laval-saint-vincent-de-paul",234    "quebec-la-cite-limoilou", "quebec-sainte-foy-sillery-cap-rouge", "quebec-charlesbourg",235    "quebec-beauport", "quebec-les-rivieres", "quebec-la-haute-saint-charles",236    "sherbrooke-les-nations", "sherbrooke-fleurimont", "saguenay-chicoutimi",237    "saguenay-jonquiere", "saguenay-la-baie", "trois-rivieres", "saint-jean-sur-richelieu",238    "salaberry-de-valleyfield", "mont-saint-hilaire", "notre-dame-de-l-ile-perrot",239    "vaudreuil-dorion", "sainte-adele", "sainte-therese", "saint-jerome", "levis", "quebec",240    "laval", "gatineau", "longueuil", "brossard", "sherbrooke", "granby", "magog",241    "sorel-tracy", "sainte-catherine", "saint-constant", "chateauguay", "coteau-du-lac",242    "saint-zotique", "sainte-anne-de-bellevue", "sainte-anne-des-lacs", "pointe-claire",243    "dorval", "beaconsfield", "kirkland", "dollard-des-ormeaux", "cote-saint-luc",244    "mont-royal", "westmount", "saint-lambert", "boucherville", "sainte-julie", "varennes",245    "saint-bruno-de-montarville", "candiac", "la-prairie", "chambly", "repentigny",246    "terrebonne", "mascouche", "blainville", "boisbriand", "rosemere", "mirabel",247    "deux-montagnes", "pincourt",248)249# accents perdus dans les slugs (villes fréquentes)250_CITY_ACCENTS = {251    "montreal": "Montréal", "quebec": "Québec", "levis": "Lévis",252    "trois-rivieres": "Trois-Rivières", "saint-jerome": "Saint-Jérôme",253    "sainte-adele": "Sainte-Adèle", "sainte-therese": "Sainte-Thérèse",254    "chateauguay": "Châteauguay", "rosemere": "Rosemère", "sainte-julie": "Sainte-Julie",255}256# arrondissement/secteur préfixé par sa grande ville dans le slug257_BOROUGH_PREFIXES = {258    "montreal-": "Montréal", "longueuil-": "Longueuil", "laval-": "Laval",259    "quebec-": "Québec", "sherbrooke-": "Sherbrooke", "saguenay-": "Saguenay",260    "levis-": "Lévis",261}262263264def _hint_city(hint: str) -> str:265    """Ville lisible depuis un indice de slug ; les arrondissements composés266    « montreal-… » deviennent simplement « Montréal » (le secteur exact vient267    de la fiche détail)."""268    for prefix, city in _BOROUGH_PREFIXES.items():269        if hint.startswith(prefix):270            return city271    return _CITY_ACCENTS.get(hint, _deslug(hint))272273274def _from_slug(slug: str) -> tuple[str | None, str, str]:275    """« appartement-a-louer-3762-rue-st-dominique-montreal-le-plateau-mont-royal »276    -> (type, adresse, ville). Best-effort : l'adresse exacte vient de la fiche.277    Type None = pas un logement (espace commercial, bureau…)."""278    # quelques slugs de location portent le libellé anglais, voire « for-sale »279    # par coquille : typeInscription=2 (sitemap) fait foi, pas le slug.280    m = re.search(r"^(.*?)-(?:a-louer|for-rent|a-vendre|for-sale)-(.+)$", slug, re.I)281    if not m:282        return "", _deslug(slug), ""283    if _EXCLUDE_TYPE_RE.search(m.group(1)):284        return None, "", ""285    ptype = _deslug(m.group(1))286    rest = m.group(2).lower()287    city = ""288    for hint in _CITY_HINTS:289        if rest.endswith("-" + hint) or rest == hint:290            city = _hint_city(hint)291            rest = rest[: -len(hint)].rstrip("-")292            break293    else:294        # sinon : dernier token comme ville (approx ; corrigé par la fiche détail)295        parts = rest.rsplit("-", 1)296        if len(parts) == 2 and not parts[1].isdigit():297            city, rest = _hint_city(parts[1]), parts[0]298    return ptype, _deslug(rest), city299300301# régions administratives : parfois entre parenthèses dans le H1 après le302# secteur — « Longueuil (Le Vieux-Longueuil) ( Montérégie ) » — à distinguer303_REGIONS = {304    "monteregie", "estrie", "laurentides", "lanaudiere", "outaouais",305    "mauricie", "bas-saint-laurent", "gaspesie", "gaspesie--iles-de-la-madeleine",306    "abitibi-temiscamingue", "cote-nord", "saguenay--lac-saint-jean",307    "centre-du-quebec", "chaudiere-appalaches", "capitale-nationale",308    "nord-du-quebec",309}310_APT_RE = re.compile(r"(?i)^(?:app|apt|suite|local|unit[ée]|#)")311312313def _split_h1(full: str) -> tuple[str, str, str, str]:314    """« 4781 Rue St-Ambroise, app. 201, Montréal (Le Sud-Ouest), H4C2E8 »315    -> (adresse, ville, secteur, région)."""316    parts = [p.strip() for p in full.split(",") if p.strip()]317    if parts and _POSTAL_RE.fullmatch(parts[-1].replace(" ", "")):318        parts = parts[:-1]319    address = parts[0] if parts else ""320    rest = parts[1:]321    # « app. 201 » collé après la rue : fait partie de l'adresse322    while rest and _APT_RE.match(rest[0]):323        address += ", " + rest.pop(0)324    city = sector = region = ""325    if rest:326        muni = rest[0]327        city = _PAREN_RE.sub("", muni).strip()328        for p in (p.strip() for p in _PAREN_RE.findall(muni)):329            if strip_accents(p.lower()).replace(" ", "-") in _REGIONS:330                region = p331            elif p:332                sector = p333    return address, city, sector, region334335336def parse_sutton_detail(html: str) -> dict:337    """Fiche Sutton location : loyer, galerie complète, description, chambres,338    pièces (dimensions), inclusions, caractéristiques, agence, adresse exacte."""339    out: dict = {}340    details: dict = {}341342    # photos : normaliser toute taille -> pic450, dédoublonner par id-index343    seen, imgs = set(), []344    for m in _DET_IMG_RE.finditer(html):345        key = m.group(1)346        if key not in seen:347            seen.add(key)348            imgs.append(f"https://immo.vrtx.co/pic450/{key}.jpg")349    if imgs:350        out["images"] = imgs351352    # adresse exacte (H1 : « 3762 Rue St-Dominique, Montréal (…), H2W0A2 »)353    mh = _H1_RE.search(html)354    if mh:355        h1 = re.sub(r"\s+", " ", _html.unescape(re.sub(r"<[^>]+>", " ", mh.group(1)))).strip()356        address, city, sector, region = _split_h1(h1)357        if address and re.match(r"\d", address):358            details["__adresse"] = address359            if city:360                details["__ville"] = city361            if sector:362                details["__secteur"] = sector363            if region:364                details["Région"] = region365366    text = re.sub(r"\s+", " ", _html.unescape(re.sub(r"<[^>]+>", " ", html)))367368    # loyer : premier « N NNN $ / mois » plausible (100–20 000 $)369    for m in _RENT_RE.finditer(text):370        try:371            val = int(re.sub(r"\s", "", m.group(1)))372        except ValueError:373            continue374        if 100 <= val <= 20000:375            out["price"] = float(val)376            out["price_label"] = f"{val:,.0f} $/mois".replace(",", " ")377            break378379    mb = re.search(r"(\d+)\s*chambres?\b(?!\s*[àa]\s*coucher)", text, re.I)380    if mb:381        details["Chambres"] = mb.group(1)382    ms = re.search(r"(\d+)\s*salles?\s*de\s*bain", text, re.I)383    if ms:384        details["Salles de bain"] = ms.group(1)385386    md = _DESC_RE.search(text)387    if md:388        desc = md.group(1).strip()389        if len(desc) > 30:390            out["description"] = desc[:4000]391392    # aire habitable : « 76.9 MC (828 pi²) » -> pi²393    ma = _AREA_RE.search(text)394    if ma:395        unit = ma.group(2).lower()396        raw = f"{ma.group(1)} {'m²' if unit in ('mc', 'm2', 'm²') else 'pi²'}"397        area = parse_area_sqft(raw)398        if area:399            out["area_sqft"] = area400401    # pièces : « Nom  DimxDim P  Revêtement  Niveau »402    rooms = []403    for m in _ROOM_ROW_RE.finditer(text):404        nom = m.group(1).strip(" -")405        # retirer l'en-tête de tableau qui peut coller au 1er nom406        nom = re.sub(r"^.*(?:Niveau|Rev[êe]tement|Dimensions?)\s+", "", nom).strip(" -")407        if not nom or nom.lower() in ("pièce", "piece"):408            continue409        rooms.append({"nom": nom[:40], "dimensions": m.group(2).replace(" ", ""),410                      "revetement": m.group(3).strip(), "niveau": m.group(4)})411    if rooms:412        details["pieces"] = rooms[:20]413414    # inclusions -> commodités affichables415    mi = re.search(r"Inclusions?\s*:?(.*?)(?:Exclusions|Addenda|Financ|$)", text, re.I | re.S)416    if mi:417        inc = mi.group(1).strip()418        feats = [s.strip() for s in re.split(r"[,;•]", inc) if 3 <= len(s.strip()) <= 90]419        if feats:420            out["amenities"] = feats[:20]421422    # caractéristiques : repli valeur|libellé (du), écrasé par libellé|valeur423    flat = du.flatten(html)424    details.update(du.centris_details(flat))425    details.update(_labels_first(flat, _RENT_LABELS))426    _drop_section_values(details)427    # « Cuisine » happé depuis le tableau des pièces (dimensions) : retirer428    if re.search(r"\d\s*[xX]\s*\d", str(details.get("Cuisine", ""))):429        details.pop("Cuisine", None)430    # « 76.9 MC (828 pi » : l'exposant ² coupe la valeur à l'aplatissement431    if str(details.get("Aire habitable", "")).endswith("pi"):432        details["Aire habitable"] += "²)"433434    # date d'emménagement / déménagement -> disponibilité — garde-fou : la435    # valeur doit ressembler à une date/mention de dispo, pas à un bout de436    # phrase happé du texte libre (« …parties de la ville sont pratiques »)437    for k in ("Date d'emménagement", "Déménagement", "Disponibilité"):438        val = str(details.get(k) or "").strip()439        if val and re.search(r"(?i)\d|imm[ée]diat|maintenant|libre|now"440                             r"|n[ée]gociable|signature|entente", val):441            out["availability"] = val442            break443        if val and k == "Disponibilité" and not re.search(r"\d", val):444            details.pop(k, None)      # valeur happée : on la retire aussi445446    # chambres -> type d'unité (rempli si la fiche liste n'en avait pas)447    if details.get("Chambres"):448        try:449            n = int(details["Chambres"])450            out["unit_type"] = "Studio" if n == 0 else f"{n} chambres"451        except ValueError:452            pass453454    # courtier + sous-agence (bureau) : carte « divResume courtier »455    mc = _BROKER_RE.search(html)456    if mc:457        details["Courtier"] = _html.unescape(mc.group(1)).strip()458        mp = _PHONE_RE.search(html[mc.start():mc.start() + 1500])459        if mp:460            details["Téléphone"] = mp.group(0)461    mo = _AGENCY_RE.search(html)462    if mo:463        details["Agence"] = _deslug(mo.group(1))464465    if details:466        out["details"] = details467    return out468469470# une « valeur » qui est en fait un titre de section = extraction décalée471_SECTION_VAL_RE = re.compile(472    r"^(?:Particularit[ée]s|Caract[ée]ristiques|D[ée]tails des|Inclusions"473    r"|Exclusions|Addenda|Publicit[ée])", re.I)474475476def _labels_first(text: str, labels: list[str]) -> dict:477    """Extraction « libellé | valeur » (ordre des fiches location Sutton)."""478    out: dict = {}479    for label in labels:480        boundary = r"\b" if label[-1].isalnum() else ""481        m = re.search(re.escape(label) + boundary + r"\s*\|\s*([^|]{1,55})", text)482        if m:483            val = m.group(1).strip(" |")484            if (val and 1 <= len(val) <= 55 and val.lower() != label.lower()485                    and val not in labels and not _SECTION_VAL_RE.match(val)):486                out[label] = val487    return out488489490def _drop_section_values(details: dict) -> None:491    """Purge les valeurs qui sont des titres de section (repli valeur|libellé492    de du.centris_details décalé sur les fiches en ordre libellé|valeur)."""493    for k in [k for k, v in details.items()494              if isinstance(v, str) and _SECTION_VAL_RE.match(v)]:495        del details[k]496