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/siagi.py : connecteur SIA Gestion Immobilière (siagi.ca)5# ~240 logements en gestion — Estrie (Sherbrooke) + Grand Montréal (Laval,6# La Prairie). Site Next.js (App Router) : la page /listings est rendue7# serveur et son flux RSC (self.__next_f.push) embarque le tableau JSON8# "listings" — id (cuid stable), title « Ville · App. N », subtitle (adresse9# civique), rentLabel (« 1 200,00 $ / mois ») et mapEmbedUrl Google Maps10# (adresse complète avec code postal). Une seule requête par sync ; aucun11# rendu JavaScript nécessaire (patron louis14/somex). Les annonces n'ont pas12# de page propre ni de photo/type/disponibilité publiés — champs laissés13# vides, rien d'inventé.14# -----------------------------------------------------------------------------15from __future__ import annotations1617import codecs18import json19import re20from urllib.parse import parse_qs, unquote, urlparse2122from ..schema import Listing, parse_price23from .base import BaseConnector2425BASE = "https://www.siagi.ca"26LIST_URL = f"{BASE}/listings"2728# fragments RSC de Next.js : self.__next_f.push([1,"...payload échappé..."])29_NEXT_F_RE = re.compile(r'self\.__next_f\.push\(\[1,"((?:[^"\\]|\\.)*)"\]\)')30_POSTAL_RE = re.compile(r"\b([A-Z]\d[A-Z]\s?\d[A-Z]\d)\b")313233def _flight_blob(html: str) -> str:34 """Concatène et désérialise les fragments RSC (voir somex_saintnicolas)."""35 blob = "".join(codecs.decode(c, "unicode_escape")36 for c in _NEXT_F_RE.findall(html))37 return blob.encode("latin-1", "ignore").decode("utf-8", "ignore")383940def _clean_price_label(label: str) -> str:41 """« 1 200,00 $ / mois » -> compatible parse_price (virgule décimale)."""42 s = re.sub(r"(\d)\s(\d{3})", r"\1\2", label) # espaces de milliers43 return re.sub(r",(\d{2})\b", r".\1", s) # virgule décimale444546class SiagiConnector(BaseConnector):47 source_id = "siagi"48 request_delay = 0.64950 def fetch(self) -> list[Listing]:51 html = self.get(LIST_URL).text52 blob = _flight_blob(html)5354 i = blob.find('"listings":[')55 if i < 0:56 raise RuntimeError("tableau listings introuvable dans le flux RSC")57 arr, _ = json.JSONDecoder().raw_decode(blob[i + len('"listings":'):])5859 listings: list[Listing] = []60 seen: set[str] = set()61 for it in arr:62 if not isinstance(it, dict) or not it.get("id"):63 continue64 ext_id = str(it["id"])65 if ext_id in seen:66 continue67 seen.add(ext_id)6869 title = (it.get("title") or "").strip()70 street = (it.get("subtitle") or "").strip()71 rent_label = (it.get("rentLabel") or "").strip()7273 # « Sherbrooke · App. 12 » -> ville + numéro d'unité74 city = ""75 m = re.match(r"^(.*?)\s*·", title)76 if m:77 city = m.group(1).strip().title()7879 # adresse complète (avec code postal) dans l'URL Google Maps80 postal = ""81 try:82 q = parse_qs(urlparse(it.get("mapEmbedUrl") or "").query)83 full = unquote(q.get("q", [""])[0])84 pm = _POSTAL_RE.search(full.upper())85 if pm:86 postal = pm.group(1)87 except Exception:88 pass89 address = ", ".join(x for x in (street, postal) if x)9091 listings.append(Listing(92 source=self.source_id,93 external_id=ext_id,94 url=LIST_URL, # les annonces n'ont pas de page propre95 title=title,96 address=address,97 sector="", # non publié par la source98 city=city,99 unit_type="", # non publié par la source100 price=parse_price(_clean_price_label(rent_label)),101 price_label=rent_label,102 availability="", # non publié (les annonces affichées103 # sont les logements disponibles)104 ))105 return listings106