SPB Git

spb/lou-ka Public

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

HTML 99.7%
9.0 KB · 222 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/gestion_tb.py : connecteur Gestion TB (Drummondville)5#   Portail d'annonces appartementstb.ca (aussi servi sur6#   gestiontb.ca/logements-a-louer) : page unique Nuxt SSR (constructeur7#   GoHighLevel). Tout le contenu est dans le payload JSON8#   <script id="__NUXT_DATA__"> (format « devalue » : tableau plat dont les9#   valeurs se référencent par index — patron voisin du payload flight10#   Next.js de msi.py). L'arbre de la page contient des sections par11#   typologie (« Section 3 1/2 »…) et une colonne (col) par logement dont le12#   TITRE est l'adresse (« 900-3 RUE ALEXANDRE ») avec des sous-titres13#   (secteur, ville, disponibilité, étage, prix « 900 $ / mois ») et un14#   carrousel de photos (sliderList). Pas de robots.txt (tout permis).15#   Une seule requête HTTP par synchronisation.16# -----------------------------------------------------------------------------17from __future__ import annotations1819import json20import re2122from ..schema import Listing23from .base import BaseConnector2425PORTAIL = "https://appartementstb.ca"2627_NUXT_RE = re.compile(28    r'<script[^>]+id="__NUXT_DATA__"[^>]*>(.*?)</script>', re.S)29# enveloppes « devalue » de Nuxt 3 : ["Reactive", 12] -> valeur à l'index 1230_WRAPPERS = {"Reactive", "ShallowReactive", "Ref", "ShallowRef",31             "EmptyRef", "EmptyShallowRef"}32# titres de colonnes génériques du constructeur (pas des logements)33_GENERIC_COL = re.compile(r"^\s*\d+(?:st|nd|rd|th)?\s*Column\s*$", re.I)34# graphies de villes vues sur le portail -> toponymes officiels35_CITY_CANON = {36    "ndbc": "Notre-Dame-du-Bon-Conseil",37    "notre-dame-du-bon-conseil": "Notre-Dame-du-Bon-Conseil",38    "saint-leonard d'aston": "Saint-Léonard-d'Aston",39    "saint-léonard d'aston": "Saint-Léonard-d'Aston",40    "saint-cyrille": "Saint-Cyrille-de-Wendover",41}42_TAG_RE = re.compile(r"<[^>]+>")434445def _decode_nuxt(html: str):46    """Décode le payload __NUXT_DATA__ (tableau plat auto-référencé)."""47    m = _NUXT_RE.search(html)48    if not m:49        return None50    data = json.loads(m.group(1))5152    def res(i, depth=0):53        if not isinstance(i, int) or i < 0 or i >= len(data) or depth > 200:54            return i55        v = data[i]56        if isinstance(v, dict):57            return {k: res(ix, depth + 1) for k, ix in v.items()}58        if isinstance(v, list):59            if len(v) == 2 and isinstance(v[0], str) and v[0] in _WRAPPERS:60                return res(v[1], depth + 1)61            return [res(ix, depth + 1) for ix in v]62        return v6364    return res(0)656667def _lines(html_fragment: str) -> list[str]:68    """Un fragment riche (« <h2>…</h2><h2>…</h2> ») -> lignes de texte."""69    out = []70    for piece in re.split(r"</(?:h\d|p|div|li)>|<br\s*/?>", html_fragment or ""):71        t = re.sub(r"\s+", " ", _TAG_RE.sub(" ", piece)).replace("\u202f", " ").strip()72        if t:73            out.append(t)74    return out757677class GestionTBConnector(BaseConnector):78    source_id = "gestion_tb"79    request_delay = 0.78081    # -- parcours de l'arbre d'éléments -------------------------------------------82    @staticmethod83    def _collect(el_id: str, idx: dict, subs: list[str], imgs: list[str],84                 depth: int = 0) -> None:85        """Descend un élément : accumule textes des sous-titres et photos."""86        el = idx.get(el_id)87        if not isinstance(el, dict) or depth > 12:88            return89        extra = el.get("extra") if isinstance(el.get("extra"), dict) else {}90        tag = str(el.get("tagName") or "")91        if tag in ("c-sub-heading", "c-heading", "c-paragraph"):92            for t in _lines(((extra.get("text") or {}).get("value") or "")):93                # recolle les libellés coupés sur deux titres94                # (« Secteur Notre-Dame-Du- » + « Bon-Conseil »)95                if subs and subs[-1].endswith("-"):96                    subs[-1] += t97                else:98                    subs.append(t)99        if tag == "c-image-slider":100            for s in ((extra.get("sliderList") or {}).get("value") or []):101                u = str((s or {}).get("backgroundImage") or "")102                if u.startswith("http") and u not in imgs:103                    imgs.append(u)104        for cid in el.get("child") or []:105            if isinstance(cid, str):106                GestionTBConnector._collect(cid, idx, subs, imgs, depth + 1)107108    def fetch(self) -> list[Listing]:109        html = self.get(PORTAIL + "/").text110        root = _decode_nuxt(html)111        try:112            elements = root["data"]["pageData"]["elements"]113        except (TypeError, KeyError):114            return []115        idx = {e.get("id"): e for e in elements if isinstance(e, dict)}116117        # sections par typologie, dans l'ordre du document118        listings: list[Listing] = []119        section_type = ""120        card_ids: set[str] = set()121        for el in elements:122            if not isinstance(el, dict):123                continue124            if el.get("type") == "section":125                m = re.search(r"Section\s+(\d)\s*1/2",126                              str(el.get("title") or ""), re.I)127                section_type = f"{m.group(1)}½" if m else ""128                continue129            if el.get("type") != "col":130                continue131            title = str(el.get("title") or "").strip()132            if not title or _GENERIC_COL.match(title):133                continue134            col_id = str(el.get("id") or "")135            if not col_id or col_id in card_ids:136                continue137138            subs: list[str] = []139            imgs: list[str] = []140            self._collect(col_id, idx, subs, imgs)141            blob = " | ".join(subs)142            if "$" not in blob and not re.search(r"disponible", blob, re.I):143                continue                    # colonne décorative, pas une annonce144            card_ids.add(col_id)145146            # prix : sous-titre « 900 $ / mois », sinon « … à 900$ » du titre147            price_label = ""148            for t in subs:149                if re.search(r"\d\s*\$\s*/\s*mois", t):150                    price_label = t151                    break152            if not price_label:153                m = re.search(r"à\s+([\d\s,]+\$)", blob)154                if m:155                    price_label = m.group(1).strip()156157            # typologie : en-tête de carte (« 3 ½ rue Alexandre à 900$ »),158            # sinon la section courante159            unit_type = section_type160            m = re.search(r"(\d)\s*½", blob)161            if m:162                unit_type = f"{m.group(1)}½"163164            # en-tête affiché de la carte (« 3 ½ rue Alexandre à 900$ ») —165            # plus fiable que le titre interne du constructeur, parfois166            # périmé quand une carte est dupliquée dans l'éditeur167            headline = ""168            for t in subs:169                if re.search(r"\d\s*½.*\$|\$.*\d\s*½", t) or \170                        re.search(r"à\s+(?:partir\s+de\s+)?[\d\s,]+\$", t):171                    headline = t172                    break173174            # disponibilité (texte source)175            availability = ""176            m = re.search(r"(?:\d+\s+)?Disponible[^|]*", blob, re.I)177            if m:178                availability = m.group(0).strip()179180            # ville : mention entre parenthèses « (Drummondville) »,181            # sinon secteur NDBC ; défaut = Drummondville (siège du parc)182            city = "Drummondville"183            m = re.search(r"\(\s*([A-ZÀ-Ü][^)|]{2,35}?)\s*\)", blob)184            if m:185                city = m.group(1).strip()186            elif re.search(r"Bon[\s-]Conseil|NDBC", blob + " " + title, re.I):187                city = "Notre-Dame-du-Bon-Conseil"188            city = _CITY_CANON.get(189                re.sub(r"\s+", " ", city).lower().replace("st-", "saint-"),190                city)191192            # secteur : ligne « Secteur … »193            sector = ""194            m = re.search(r"Secteur\s+([^|(]+)", blob, re.I)195            if m:196                sector = m.group(1).strip().rstrip(" -–")197            if sector.lower() == city.lower():198                sector = ""199200            # étage : sous-titre « 1er étage »201            details: dict = {}202            m = re.search(r"\b(\d+(?:er|e|ème)\s+étage)\b", blob, re.I)203            if m:204                details["floor_label"] = m.group(1)205206            listings.append(Listing(207                source=self.source_id,208                external_id=col_id.removeprefix("col-"),209                url=f"{PORTAIL}/#{col_id}",210                title=headline or title,211                address="",         # jamais affichée en entier sur le portail212                sector=sector,213                city=city,214                unit_type=unit_type,215                price_label=price_label,216                availability=availability,217                description=" — ".join(dict.fromkeys(subs))[:600],218                details=details,219                images=imgs[:15],220            ))221        return listings222