# -----------------------------------------------------------------------------
# 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"(?:h\d|p|div|li)>|
", 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