# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/gestion_tb.py : connecteur Gestion TB (Drummondville) # Portail d'annonces appartementstb.ca (aussi servi sur # gestiontb.ca/logements-a-louer) : page unique Nuxt SSR (constructeur # GoHighLevel). Tout le contenu est dans le payload JSON # ', re.S) # enveloppes « devalue » de Nuxt 3 : ["Reactive", 12] -> valeur à l'index 12 _WRAPPERS = {"Reactive", "ShallowReactive", "Ref", "ShallowRef", "EmptyRef", "EmptyShallowRef"} # titres de colonnes génériques du constructeur (pas des logements) _GENERIC_COL = re.compile(r"^\s*\d+(?:st|nd|rd|th)?\s*Column\s*$", re.I) # graphies de villes vues sur le portail -> toponymes officiels _CITY_CANON = { "ndbc": "Notre-Dame-du-Bon-Conseil", "notre-dame-du-bon-conseil": "Notre-Dame-du-Bon-Conseil", "saint-leonard d'aston": "Saint-Léonard-d'Aston", "saint-léonard d'aston": "Saint-Léonard-d'Aston", "saint-cyrille": "Saint-Cyrille-de-Wendover", } _TAG_RE = re.compile(r"<[^>]+>") def _decode_nuxt(html: str): """Décode le payload __NUXT_DATA__ (tableau plat auto-référencé).""" m = _NUXT_RE.search(html) if not m: return None data = json.loads(m.group(1)) def res(i, depth=0): if not isinstance(i, int) or i < 0 or i >= len(data) or depth > 200: return i v = data[i] if isinstance(v, dict): return {k: res(ix, depth + 1) for k, ix in v.items()} if isinstance(v, list): if len(v) == 2 and isinstance(v[0], str) and v[0] in _WRAPPERS: return res(v[1], depth + 1) return [res(ix, depth + 1) for ix in v] return v return res(0) def _lines(html_fragment: str) -> list[str]: """Un fragment riche («

») -> lignes de texte.""" out = [] for piece in re.split(r"|", html_fragment or ""): t = re.sub(r"\s+", " ", _TAG_RE.sub(" ", piece)).replace("\u202f", " ").strip() if t: out.append(t) return out class GestionTBConnector(BaseConnector): source_id = "gestion_tb" request_delay = 0.7 # -- parcours de l'arbre d'éléments ------------------------------------------- @staticmethod def _collect(el_id: str, idx: dict, subs: list[str], imgs: list[str], depth: int = 0) -> None: """Descend un élément : accumule textes des sous-titres et photos.""" el = idx.get(el_id) if not isinstance(el, dict) or depth > 12: return extra = el.get("extra") if isinstance(el.get("extra"), dict) else {} tag = str(el.get("tagName") or "") if tag in ("c-sub-heading", "c-heading", "c-paragraph"): for t in _lines(((extra.get("text") or {}).get("value") or "")): # recolle les libellés coupés sur deux titres # (« Secteur Notre-Dame-Du- » + « Bon-Conseil ») if subs and subs[-1].endswith("-"): subs[-1] += t else: subs.append(t) if tag == "c-image-slider": for s in ((extra.get("sliderList") or {}).get("value") or []): u = str((s or {}).get("backgroundImage") or "") if u.startswith("http") and u not in imgs: imgs.append(u) for cid in el.get("child") or []: if isinstance(cid, str): GestionTBConnector._collect(cid, idx, subs, imgs, depth + 1) def fetch(self) -> list[Listing]: html = self.get(PORTAIL + "/").text root = _decode_nuxt(html) try: elements = root["data"]["pageData"]["elements"] except (TypeError, KeyError): return [] idx = {e.get("id"): e for e in elements if isinstance(e, dict)} # sections par typologie, dans l'ordre du document listings: list[Listing] = [] section_type = "" card_ids: set[str] = set() for el in elements: if not isinstance(el, dict): continue if el.get("type") == "section": m = re.search(r"Section\s+(\d)\s*1/2", str(el.get("title") or ""), re.I) section_type = f"{m.group(1)}½" if m else "" continue if el.get("type") != "col": continue title = str(el.get("title") or "").strip() if not title or _GENERIC_COL.match(title): continue col_id = str(el.get("id") or "") if not col_id or col_id in card_ids: continue subs: list[str] = [] imgs: list[str] = [] self._collect(col_id, idx, subs, imgs) blob = " | ".join(subs) if "$" not in blob and not re.search(r"disponible", blob, re.I): continue # colonne décorative, pas une annonce card_ids.add(col_id) # prix : sous-titre « 900 $ / mois », sinon « … à 900$ » du titre price_label = "" for t in subs: if re.search(r"\d\s*\$\s*/\s*mois", t): price_label = t break if not price_label: m = re.search(r"à\s+([\d\s,]+\$)", blob) if m: price_label = m.group(1).strip() # typologie : en-tête de carte (« 3 ½ rue Alexandre à 900$ »), # sinon la section courante unit_type = section_type m = re.search(r"(\d)\s*½", blob) if m: unit_type = f"{m.group(1)}½" # en-tête affiché de la carte (« 3 ½ rue Alexandre à 900$ ») — # plus fiable que le titre interne du constructeur, parfois # périmé quand une carte est dupliquée dans l'éditeur headline = "" for t in subs: if re.search(r"\d\s*½.*\$|\$.*\d\s*½", t) or \ re.search(r"à\s+(?:partir\s+de\s+)?[\d\s,]+\$", t): headline = t break # disponibilité (texte source) availability = "" m = re.search(r"(?:\d+\s+)?Disponible[^|]*", blob, re.I) if m: availability = m.group(0).strip() # ville : mention entre parenthèses « (Drummondville) », # sinon secteur NDBC ; défaut = Drummondville (siège du parc) city = "Drummondville" m = re.search(r"\(\s*([A-ZÀ-Ü][^)|]{2,35}?)\s*\)", blob) if m: city = m.group(1).strip() elif re.search(r"Bon[\s-]Conseil|NDBC", blob + " " + title, re.I): city = "Notre-Dame-du-Bon-Conseil" city = _CITY_CANON.get( re.sub(r"\s+", " ", city).lower().replace("st-", "saint-"), city) # secteur : ligne « Secteur … » sector = "" m = re.search(r"Secteur\s+([^|(]+)", blob, re.I) if m: sector = m.group(1).strip().rstrip(" -–") if sector.lower() == city.lower(): sector = "" # étage : sous-titre « 1er étage » details: dict = {} m = re.search(r"\b(\d+(?:er|e|ème)\s+étage)\b", blob, re.I) if m: details["floor_label"] = m.group(1) listings.append(Listing( source=self.source_id, external_id=col_id.removeprefix("col-"), url=f"{PORTAIL}/#{col_id}", title=headline or title, address="", # jamais affichée en entier sur le portail sector=sector, city=city, unit_type=unit_type, price_label=price_label, availability=availability, description=" — ".join(dict.fromkeys(subs))[:600], details=details, images=imgs[:15], )) return listings