# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/gestion_cem.py : Gestion CEM (gestioncem.com — Montréal, Longueuil, # LaSalle/Sud-Ouest…) via son portail Eversa (gestioncem.eversa.app). # Techno : React Router v7 (Remix) « turbo-stream » — les routes exposent un # endpoint JSON `.data` contenant un TABLEAU PLAT à références # indexées : {"_1":2} = clé à l'index 1, valeur à l'index 2 ; entier négatif # = null/undefined ; ["D", ] = date epoch. On décode récursivement. # Granularité : UNITÉ (page détail par immeuble -> units[] avec listed=true, # prix, pi², chambres, sdb, photos, date de disponibilité). Repli sur la # carte de l'immeuble si la page détail est indisponible (404 transitoire). # Depuis ~2026-09-17 les routes exigent un préfixe de locale (/fr, /en) : # sans préfixe, `.data` renvoie un SingleFetchRedirect 301 (payload liste). # ----------------------------------------------------------------------------- from __future__ import annotations import datetime import json import re import requests from .base import BaseConnector from ..schema import Listing BASE = "https://gestioncem.eversa.app" # chunks turbo-stream inlinés dans le HTML (repli quand `.data` renvoie 404) _ENQUEUE_RE = re.compile(r'streamController\.enqueue\((".*?")\);', re.S) def _decode(raw: str): """Décode un document turbo-stream (tableau plat à références indexées).""" # Depuis ~2026-08-23 le flux ajoute des lignes différées (`P14:[…]`, promesses # résolues — ex. walkscore) après le document principal : ne garder que la # 1re ligne, sinon json.loads échoue en « Extra data ». arr = json.loads(raw.split("\n", 1)[0]) memo: dict = {} def dec(i): if isinstance(i, int) and i < 0: # codes spéciaux (null/undefined…) return None if not isinstance(i, int) or i >= len(arr): return i if i in memo: return memo[i] v = arr[i] if isinstance(v, dict): out: dict = {} memo[i] = out for k, val in v.items(): key = dec(int(k[1:])) if k.startswith("_") else k out[key] = dec(val) if isinstance(val, int) else val return out if isinstance(v, list): # ["D", ] = date sérialisée (le millis est LITTÉRAL, # pas une référence — souvent hors du tableau) if len(v) == 2 and v[0] == "D" and isinstance(v[1], (int, float)): iso = datetime.datetime.fromtimestamp( v[1] / 1000, tz=datetime.timezone.utc).date().isoformat() memo[i] = iso return iso out2: list = [] memo[i] = out2 for e in v: out2.append(dec(e) if isinstance(e, int) else e) return out2 memo[i] = v return v return dec(0) def _num(v) -> float | None: """'1695' / 1695 / '2 000' -> float (None si vide/0).""" if isinstance(v, (int, float)): return float(v) if v > 0 else None if isinstance(v, str): s = v.replace(" ", "").replace(" ", "").replace(",", ".") try: f = float(s) return f if f > 0 else None except ValueError: return None return None def _unit_type(bedrooms) -> str: """Chambres -> typologie QC (0 ch = studio, n ch = (n+2)½).""" try: bd = int(float(bedrooms)) except (TypeError, ValueError): return "" if bd <= 0: return "Studio" if bd >= 5: return "6½+" return f"{bd + 2}½" def _images(items, uid: str = "") -> list[str]: out: list[str] = [] for i in items or []: if not isinstance(i, str) or not i: continue url = i if i.startswith("http") else BASE + i if url not in out: out.append(url) return out class GestionCemConnector(BaseConnector): """Gestion CEM via Eversa — annonces à l'unité (repli : immeuble).""" source_id = "gestion_cem" request_delay = 0.8 # -- helpers --------------------------------------------------------------- def _route_data(self, path: str): """Décode `.data` ; repli sur les chunks inlinés du HTML.""" try: resp = self.get(f"{BASE}{path}.data") resp.encoding = "utf-8" # turbo-stream sans charset déclaré return _decode(resp.text) except requests.HTTPError: pass resp = self.get(f"{BASE}{path}") resp.encoding = "utf-8" chunks = _ENQUEUE_RE.findall(resp.text) if not chunks: return None raw = "".join(json.loads(c) for c in chunks) payload = _decode(raw) # le HTML enveloppe la même donnée sous loaderData if isinstance(payload, dict) and "loaderData" in payload: return payload["loaderData"] return payload def _detail_listing(self, slug: str) -> dict | None: data = self._route_data(f"/fr/listings/{slug}/details") if not isinstance(data, dict): return None page = data.get("PublicListingDetailPage") or {} if "data" in page: page = page.get("data") or {} listing = page.get("listing") return listing if isinstance(listing, dict) else None def _building_fallback(self, b: dict, page_url: str) -> Listing | None: """Carte de la liste -> une annonce « immeuble » (détail indisponible).""" price = _num(b.get("minPricePerMonth") or b.get("price")) bd = b.get("bedrooms") avail = str(b.get("availability") or "")[:10] return Listing( source=self.source_id, external_id=b.get("slug") or b.get("listingId") or "", url=page_url, title=(b.get("title") or b.get("street") or "").strip(), address=(b.get("street") or "").strip(), city=(b.get("city") or "").strip(), unit_type=_unit_type(bd), bedrooms=_num(bd), bathrooms=_num(b.get("bathrooms")), price=price, price_label=f"{price:.0f} $ /mois" if price else "", availability=f"Disponible le {avail}" if avail else "Disponible", availability_date=avail or None, images=_images(b.get("images") or b.get("buildingImages")), lat=b.get("latitude"), lng=b.get("longitude"), ) # -- contrat --------------------------------------------------------------- def fetch(self) -> list[Listing]: data = self._route_data("/fr/listings/list") page = (data or {}).get("PublicListingsLayoutPage") or {} if "data" in page: page = page.get("data") or {} buildings = page.get("listings") or [] out: list[Listing] = [] seen: set[str] = set() for b in buildings: if not isinstance(b, dict): continue if b.get("isFullyBooked") or b.get("isAllCommercial"): continue if (b.get("spaceType") or "apartment") not in ("apartment", "condo", "house", "loft"): continue # résidentiel au mois seulement slug = b.get("slug") or "" if not slug: continue page_url = f"{BASE}/fr/listings/{slug}/details" # détail (cache BD : clé = contenu de la carte liste) key = json.dumps({k: b.get(k) for k in ("price", "availability", "listedUnitCount", "bedrooms", "isFullyBooked")}, sort_keys=True, default=str) try: detail = self.detail(slug, key, lambda s=slug: self._detail_listing(s) or {}) except requests.RequestException: detail = {} units = [u for u in (detail.get("units") or []) if isinstance(u, dict) and u.get("listed")] if not units: # détail 404/vide -> carte liste lst = self._building_fallback(b, page_url) if lst and lst.external_id and lst.external_id not in seen: seen.add(lst.external_id) out.append(lst) continue street = (detail.get("street") or b.get("street") or "").strip() city = (detail.get("city") or b.get("city") or "").strip() lat = detail.get("latitude") or b.get("latitude") lng = detail.get("longitude") or b.get("longitude") bldg_imgs = _images(b.get("images") or b.get("buildingImages")) for u in units: uid = u.get("slug") or "" # uuid stable de l'unité if not uid or uid in seen: continue seen.add(uid) price = _num(u.get("price")) avail = str(u.get("availability") or "")[:10] alias = str(u.get("alias") or "").strip() imgs = _images(u.get("images")) or bldg_imgs details: dict = {} if u.get("floor") is not None: details["floor"] = u["floor"] if alias: details["unit_number"] = alias out.append(Listing( source=self.source_id, external_id=uid, url=page_url, title=f"{street} — app. {alias}" if alias else street, address=street, city=city, unit_type=_unit_type(u.get("bedrooms")), bedrooms=_num(u.get("bedrooms")), bathrooms=_num(u.get("bathrooms")), price=price, price_label=f"{price:.0f} $ /mois" if price else "", availability=(f"Disponible le {avail}" if avail else "Disponible"), availability_date=avail or None, area_sqft=_num(u.get("area")), details=details, images=imgs[:30], lat=lat, lng=lng, )) return out