SPB Git

spb/lou-ka Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

HTML 99.7%
4.2 KB · 106 lines python
Raw Blame History
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