# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/siagi.py : connecteur SIA Gestion Immobilière (siagi.ca) # ~240 logements en gestion — Estrie (Sherbrooke) + Grand Montréal (Laval, # La Prairie). Site Next.js (App Router) : la page /listings est rendue # serveur et son flux RSC (self.__next_f.push) embarque le tableau JSON # "listings" — id (cuid stable), title « Ville · App. N », subtitle (adresse # civique), rentLabel (« 1 200,00 $ / mois ») et mapEmbedUrl Google Maps # (adresse complète avec code postal). Une seule requête par sync ; aucun # rendu JavaScript nécessaire (patron louis14/somex). Les annonces n'ont pas # de page propre ni de photo/type/disponibilité publiés — champs laissés # vides, rien d'inventé. # ----------------------------------------------------------------------------- from __future__ import annotations import codecs import json import re from urllib.parse import parse_qs, unquote, urlparse from ..schema import Listing, parse_price from .base import BaseConnector BASE = "https://www.siagi.ca" LIST_URL = f"{BASE}/listings" # fragments RSC de Next.js : self.__next_f.push([1,"...payload échappé..."]) _NEXT_F_RE = re.compile(r'self\.__next_f\.push\(\[1,"((?:[^"\\]|\\.)*)"\]\)') _POSTAL_RE = re.compile(r"\b([A-Z]\d[A-Z]\s?\d[A-Z]\d)\b") def _flight_blob(html: str) -> str: """Concatène et désérialise les fragments RSC (voir somex_saintnicolas).""" blob = "".join(codecs.decode(c, "unicode_escape") for c in _NEXT_F_RE.findall(html)) return blob.encode("latin-1", "ignore").decode("utf-8", "ignore") def _clean_price_label(label: str) -> str: """« 1 200,00 $ / mois » -> compatible parse_price (virgule décimale).""" s = re.sub(r"(\d)\s(\d{3})", r"\1\2", label) # espaces de milliers return re.sub(r",(\d{2})\b", r".\1", s) # virgule décimale class SiagiConnector(BaseConnector): source_id = "siagi" request_delay = 0.6 def fetch(self) -> list[Listing]: html = self.get(LIST_URL).text blob = _flight_blob(html) i = blob.find('"listings":[') if i < 0: raise RuntimeError("tableau listings introuvable dans le flux RSC") arr, _ = json.JSONDecoder().raw_decode(blob[i + len('"listings":'):]) listings: list[Listing] = [] seen: set[str] = set() for it in arr: if not isinstance(it, dict) or not it.get("id"): continue ext_id = str(it["id"]) if ext_id in seen: continue seen.add(ext_id) title = (it.get("title") or "").strip() street = (it.get("subtitle") or "").strip() rent_label = (it.get("rentLabel") or "").strip() # « Sherbrooke · App. 12 » -> ville + numéro d'unité city = "" m = re.match(r"^(.*?)\s*·", title) if m: city = m.group(1).strip().title() # adresse complète (avec code postal) dans l'URL Google Maps postal = "" try: q = parse_qs(urlparse(it.get("mapEmbedUrl") or "").query) full = unquote(q.get("q", [""])[0]) pm = _POSTAL_RE.search(full.upper()) if pm: postal = pm.group(1) except Exception: pass address = ", ".join(x for x in (street, postal) if x) listings.append(Listing( source=self.source_id, external_id=ext_id, url=LIST_URL, # les annonces n'ont pas de page propre title=title, address=address, sector="", # non publié par la source city=city, unit_type="", # non publié par la source price=parse_price(_clean_price_label(rent_label)), price_label=rent_label, availability="", # non publié (les annonces affichées # sont les logements disponibles) )) return listings