spb/lou-ka Public
Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 99.7%
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