Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (Québec + expansion Ontario)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/rentsyncgw.py : connecteur GÉNÉRIQUE passerelle Rentsync5# « nouvelle génération » (website-gateway.rentsync.com)6# Plusieurs gros gestionnaires ontariens ont migré leur site vers la SPA7# Rentsync nouvelle génération (bundles cdn.rentsync.com/site/<siteKey>) qui8# parle à la passerelle JSON PUBLIQUE — aucune auth, aucun anti-bot :9# https://website-gateway.rentsync.com/v1/<siteKey>/10# properties?limit=500 → immeubles (adresse, GPS, cityId, permaLink,11# buildingTypeId, animaux, description, modified) ; paginé meta.totalPages12# cities?where=id~in:X|Y&relations=province:p → nom de ville + province13# (filtre ON strict ; séparateur multi-valeurs « | »)14# units?where=status:enabled,available~in:1&limit=750&page=N15# → types d'unités AVEC vacance (bed/bath/pi²/loyer/date), paginés16# photos?relations=buildingsHasPhotos:bhp&where=bhp.buildingId~in:…17# → galeries par lots d'immeubles (images S3 lws_lift, taille « full »)18# Images : https://s3.amazonaws.com/lws_lift/<img_key>/images/gallery/full/…19# — la clé S3 peut différer du siteKey (drewlo_rebuild → drewlo,20# panoramic_properties → panoramic ; validé en live HEAD 200 vs 403), d'où21# la clé `img_key` du registre.22# Plutôt que N modules copiés-collés (homestead.py et skyline.py sont les23# connecteurs canoniques de cette passerelle), ce module lit le registre24# data/rentsyncgw_clients.json ({id, name, site, site_key, img_key…},25# validés en live le 2026-08-27) et GÉNÈRE une sous-classe par client26# (source_id = "rsgw_<id>", ex. "rsgw_drewlo"), déposée dans les globals du27# module pour le registre auto-découvrant (connectors/__init__.py).28# Extraction modelée sur skyline.py : UNE annonce par type d'unité disponible,29# regroupée par (immeuble, typeName, cc, sdb) — certains flux publient une30# rangée PAR APPARTEMENT du même plan (champ `number`) : loyer plancher31# « à partir de », date la plus proche, identifiant stable immeuble+plan.32# Filtre province ON strict via /cities, filtre types non résidentiels33# (office/retail/industrial/construction/subdivision — Sterling Karamar et34# Drewlo mélangent commercial et chantiers dans le même flux). Tout vient de35# l'API par lots : aucune page détail à visiter, pas de self.detail().36# Clients au 2026-08-27 : Sterling Karamar (~15 000 unités, 217 propriétés),37# Drewlo Holdings (~10 000, 108), Panoramic Properties (Sudbury, 49).38# Expansion Ontario — activer via LOUKA_ONTARIO=1 : sans la variable, toutes39# les classes générées sont `disabled` (exclues du registre), prod QC intacte.40# -----------------------------------------------------------------------------41from __future__ import annotations4243import html as htmllib44import json45import os46import re47from pathlib import Path4849from bs4 import BeautifulSoup5051from ..schema import Listing, normalize_unit_type, strip_accents52from .base import BaseConnector5354# Gate expansion Ontario : rien ne s'active en prod sans LOUKA_ONTARIO=155_ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1"5657REGISTRY_PATH = Path(__file__).resolve().parents[2] / "data" / \58 "rentsyncgw_clients.json"59GATEWAY = "https://website-gateway.rentsync.com/v1"60IMG_BASE = "https://s3.amazonaws.com/lws_lift"6162# Types d'immeubles NON résidentiels du flux (buildingTypeId) — Sterling63# Karamar publie 46 locaux commerciaux, Drewlo des chantiers (« coming-soon »)64# et des subdivisions de maisons neuves À VENDRE : hors sujet pour Lou-Ka.65_NON_RESIDENTIAL = {66 "office", "retail", "warehouse", "industrial", "commercial", "land",67 "construction", "subdivision", "motel", "hotel", "parking", "storage",68}6970# secteur : le champ `neighbourhood` contient parfois le comté (« Grey71# County », « Niagara Region ») — pas un quartier, on l'écarte (cf. skyline)72_COUNTY_RE = re.compile(r"\b(county|region|district|municipality)\b", re.I)73_ISO_DATE_RE = re.compile(r"^20\d{2}-\d{2}-\d{2}")747576def _load_registry() -> list[dict]:77 """Entrées validées du registre (site_key présent, status = valide)."""78 try:79 data = json.loads(REGISTRY_PATH.read_text("utf-8"))80 except (OSError, ValueError):81 return []82 return [c for c in data.get("clients") or []83 if c.get("site_key") and c.get("status") == "valide"]848586class RentsyncGatewayConnector(BaseConnector):87 """Base commune des connecteurs passerelle Rentsync générés — non88 enregistrée elle-même (source_id vide) ; chaque sous-classe reçoit son89 entrée de registre dans l'attribut de classe `client`."""9091 source_id = "" # les sous-classes générées le définissent92 client: dict = {} # entrée du registre (name, site, site_key…)93 request_delay = 0.8 # politesse — même rythme que skyline/homestead94 max_properties = 300 # garde-fou immeubles (plus gros : 217 au 2026-08)95 max_listings = 1500 # garde-fou global d'annonces96 max_images = 1597 page_limit = 750 # taille de page maximale de la passerelle98 photo_chunk = 40 # immeubles par requête /photos99100 # -- accès passerelle -------------------------------------------------------101 def _get_json(self, path: str, params: dict) -> dict:102 site = (self.client.get("site") or "").rstrip("/")103 resp = self.get(f"{GATEWAY}/{self.client['site_key']}{path}",104 params=params, headers={105 "Accept": "application/json",106 "Origin": site,107 "Referer": site + "/",108 "rs-lang": "en",109 })110 return resp.json()111112 def _paged(self, path: str, params: dict, max_pages: int = 20) -> list[dict]:113 """Concatène toutes les pages d'un endpoint (meta.totalPages)."""114 out: list[dict] = []115 page = 1116 while page <= max_pages:117 data = self._get_json(path, {**params, "page": page})118 out.extend(data.get("data") or [])119 meta = data.get("meta") or {}120 if page >= int(meta.get("totalPages") or 1):121 break122 page += 1123 return out124125 # -- villes : cityId -> (nom, code province) --------------------------------126 def _cities(self, city_ids: set[int]) -> dict[int, tuple[str, str]]:127 if not city_ids:128 return {}129 ids = "|".join(str(i) for i in sorted(city_ids))130 rows = self._paged("/cities", {131 "where": f"id~in:{ids}",132 "relations": "province:p",133 "limit": self.page_limit,134 })135 out: dict[int, tuple[str, str]] = {}136 for c in rows:137 prov = c.get("province") or {}138 out[int(c["id"])] = ((c.get("cityName") or "").strip(),139 (prov.get("provinceCode") or "").strip().upper())140 return out141142 # -- galeries photo par lots d'immeubles ------------------------------------143 def _photos(self, building_ids: list[int]) -> dict[int, list[str]]:144 img_base = f"{IMG_BASE}/{self.client.get('img_key') or self.client['site_key']}" \145 "/images/gallery/full"146 out: dict[int, list[str]] = {}147 for i in range(0, len(building_ids), self.photo_chunk):148 chunk = building_ids[i:i + self.photo_chunk]149 ids = "|".join(str(b) for b in chunk)150 try:151 rows = self._paged("/photos", {152 "relations": "buildingsHasPhotos:bhp",153 "where": f"bhp.buildingId~in:{ids}",154 "orderBy": "bhp.orderBy~asc",155 "limit": self.page_limit,156 })157 except Exception:158 continue # galerie manquante : annonces sans photo plutôt que rien159 for ph in rows:160 bid = ph.get("buildingId")161 img = (ph.get("image") or "").strip()162 if not bid or not img:163 continue164 urls = out.setdefault(int(bid), [])165 if len(urls) < self.max_images:166 u = f"{img_base}/{img}"167 if u not in urls:168 urls.append(u)169 return out170171 # -- collecte ---------------------------------------------------------------172 def fetch(self) -> list[Listing]:173 props = self._paged("/properties",174 {"limit": 500})[: self.max_properties]175176 # écarter commerces/chantiers/subdivisions du flux (buildingTypeId)177 props = [p for p in props178 if str(p.get("buildingTypeId") or "").strip().lower()179 not in _NON_RESIDENTIAL]180181 # immeubles gérés pour un tiers déjà couvert par un connecteur dédié182 # (ex. Greenrock via sterlingkaramar) — exclus par importId183 excl = {s.strip() for s in (self.client.get("exclude_import_ids") or [])}184 if excl:185 props = [p for p in props186 if (p.get("importId") or "").strip() not in excl]187188 # villes -> filtre Ontario strict (certains flux débordent de l'ON)189 cities = self._cities({int(p["cityId"]) for p in props190 if p.get("cityId")})191 on_props: dict[int, dict] = {}192 for p in props:193 name, prov = cities.get(int(p.get("cityId") or 0), ("", ""))194 if prov != "ON" or not name:195 continue196 p["_city"] = name197 on_props[int(p["id"])] = p198199 # types d'unités avec vacance : certains flux publient une rangée PAR200 # APPARTEMENT (champ `number`) du même plan — regrouper par201 # (immeuble, plan, cc, sdb) pour une annonce par type d'unité202 units = self._paged("/units", {203 "where": "status:enabled,available~in:1",204 "limit": self.page_limit,205 })206 by_building: dict[int, dict[tuple, list[dict]]] = {}207 for u in units:208 if u.get("hideSuiteTypeWebsite"):209 continue210 bid = int(u.get("buildingId") or 0)211 if bid not in on_props:212 continue213 key = ((u.get("typeName") or "").strip().lower(),214 u.get("bedMin", u.get("bed")),215 u.get("bathMin", u.get("bath")))216 by_building.setdefault(bid, {}).setdefault(key, []).append(u)217218 # galeries des seuls immeubles qui produisent des annonces219 photos = self._photos(sorted(by_building))220221 listings: list[Listing] = []222 for bid, groups in by_building.items():223 if len(listings) >= self.max_listings:224 break225 p = on_props[bid]226 try:227 base = self._building_ctx(p, photos.get(bid) or [])228 except Exception:229 continue230 for rows in groups.values():231 if len(listings) >= self.max_listings:232 break233 try:234 listings.append(self._listing(bid, rows, base))235 except Exception:236 continue237 return listings238239 # -- contexte immeuble (partagé entre ses annonces) --------------------------240 def _building_ctx(self, p: dict, images: list[str]) -> dict:241 city = p["_city"]242 street = " ".join(x for x in (243 (p.get("streetNumber") or "").strip(),244 (p.get("streetName") or "").strip()) if x)245 postal = (p.get("postal") or "").strip()246 address = ", ".join(x for x in (street, city) if x)247 if address:248 address += f", ON {postal}".rstrip()249250 # secteur : quartier seulement (écarter comtés/« regions » du flux)251 sector = (p.get("neighbourhood") or "").strip()252 if _COUNTY_RE.search(sector):253 sector = ""254255 try:256 lat = float(p["latitude"]) if p.get("latitude") else None257 lng = float(p["longitude"]) if p.get("longitude") else None258 except (TypeError, ValueError):259 lat = lng = None260261 # caractéristiques de l'immeuble : HTML doublement échappé dans le flux262 amenities: list[str] = []263 feats = htmllib.unescape(p.get("buildingFeatures") or "")264 for li in BeautifulSoup(feats, "html.parser").find_all("li"):265 t = li.get_text(" ", strip=True)266 if t and t not in amenities:267 amenities.append(t)268269 # description : aperçu de l'immeuble (+ détails des suites si présents)270 desc_html = (p.get("buildingOverview") or "") + " " + \271 (p.get("suiteDetails") or "")272 desc = BeautifulSoup(htmllib.unescape(desc_html),273 "html.parser").get_text(" ", strip=True)274275 # animaux : drapeaux structurés du flux276 pets = None277 if p.get("petsNotAllowed") == 1:278 pets = "non"279 elif p.get("petFriendly") == 1:280 pets = "oui"281282 details: dict = {}283 contact: dict = {}284 if (p.get("phone") or "").strip():285 contact["phone"] = p["phone"].strip()286 email = (p.get("email") or "").split(",")[0].strip()287 # adresses de tracking (leadmanaging…) : pas un vrai contact public288 if email and "leadmanaging" not in email.lower():289 contact["email"] = email290 if contact:291 details["contact"] = contact292 try:293 if int(p.get("yearBuilt") or 0) > 1800:294 details["Année de construction"] = int(p["yearBuilt"])295 if int(p.get("floorCount") or 0) > 0:296 details["Étages"] = int(p["floorCount"])297 except (TypeError, ValueError):298 pass299300 # fiche : site du client + fullPermaLink (validé 200 sur les 3 clients)301 site = (self.client.get("site") or "").rstrip("/")302 perma = (p.get("fullPermaLink") or "").strip().strip("/")303 url = f"{site}/{perma}" if perma else \304 (self.client.get("listing_url") or site)305306 return {"city": city, "address": address, "sector": sector,307 "lat": lat, "lng": lng, "amenities": amenities, "desc": desc,308 "pets": pets, "details": details, "url": url,309 "images": images[: self.max_images],310 # nom : entités HTML dans certains flux (« & » chez Sterling)311 "name": htmllib.unescape(p.get("buildingName") or "").strip()}312313 # -- une annonce par type d'unité disponible (rangées regroupées) ------------314 def _listing(self, bid: int, rows: list[dict], b: dict) -> Listing:315 u = rows[0]316 type_name = (u.get("typeName") or "").strip()317 unit_type = normalize_unit_type(type_name)318 bed = u.get("bedMin", u.get("bed"))319 bath = u.get("bathMin", u.get("bath"))320 if bed is not None and (not unit_type or unit_type == type_name):321 # plans nommés (« Elgin », « Tempo »…) : dériver des chambres322 unit_type = ("Studio" if int(bed) == 0323 else normalize_unit_type(f"{int(bed)} chambres"))324325 # loyer : plancher du groupe (masqué sur le site si hideRateWebsites=1)326 rates: list[float] = []327 for r in rows:328 if r.get("hideRateWebsites"):329 continue330 try:331 lo = float(r.get("rateMin") or r.get("rate") or 0)332 hi = float(r.get("rateMax") or lo)333 except (TypeError, ValueError):334 continue335 if lo > 0:336 rates += [lo, max(hi, lo)]337 price = min(rates) if rates else None338 price_label = ""339 if price is not None:340 price_label = (f"À partir de {price:.0f} $" if max(rates) > price341 else f"{price:.0f} $ /mois")342343 # superficie plausible seulement (le flux publie parfois 0)344 area = None345 for r in rows:346 try:347 v = float(r.get("sqFtMin") or r.get("sqFt") or 0)348 except (TypeError, ValueError):349 continue350 if 80 <= v <= 20000 and (area is None or v < area):351 area = v352353 # disponibilité : date structurée la plus proche du groupe354 dates = sorted(str(r.get("availabilityDate") or "").strip()[:10]355 for r in rows356 if _ISO_DATE_RE.match(str(r.get("availabilityDate")357 or "")))358 avail_date = dates[0] if dates else None359 availability = f"Disponible le {avail_date}" if avail_date \360 else "Disponible"361 if len(rows) > 1:362 availability += f" — {len(rows)} unités"363364 details = dict(b["details"])365 if (u.get("leaseTerm") or "").strip():366 details["Bail"] = u["leaseTerm"].strip()367368 # identifiant stable : immeuble + plan (les ids d'appartements roulent)369 slug = re.sub(r"[^a-z0-9]+", "-",370 strip_accents(type_name.lower())).strip("-")371 ext = f"{bid}-{slug or 'u'}"372 if bed is not None or bath is not None:373 ext += f"-{bed}-{bath}"374375 return Listing(376 source=self.source_id,377 external_id=ext,378 url=b["url"],379 title=f"{b['name']} — {type_name}" if type_name else b["name"],380 address=b["address"],381 sector=b["sector"],382 city=b["city"],383 province="ON",384 unit_type=unit_type,385 bedrooms=float(bed) if bed is not None else None,386 bathrooms=float(bath) if bath else None,387 price=price,388 price_label=price_label,389 availability=availability,390 availability_date=avail_date,391 area_sqft=area,392 pets=b["pets"],393 furnished=True if rows and all(r.get("furnished") == 1394 for r in rows) else None,395 description=b["desc"][:600],396 amenities=b["amenities"][:25],397 details=details,398 images=b["images"],399 lat=b["lat"],400 lng=b["lng"],401 )402403404# -----------------------------------------------------------------------------405# Génération : une sous-classe par client du registre, déposée dans les globals406# du module — connectors/__init__.py (scan de vars(module)) les découvre alors407# comme n'importe quel connecteur écrit à la main.408# -----------------------------------------------------------------------------409def _make_connector(entry: dict) -> type[RentsyncGatewayConnector]:410 cls = type(411 f"Rsgw{re.sub(r'[^A-Za-z0-9]', '', entry['id']).capitalize()}Connector",412 (RentsyncGatewayConnector,),413 {414 "source_id": f"rsgw_{entry['id']}",415 "client": entry,416 # Expansion Ontario — activer via LOUKA_ONTARIO=1417 # (voir gestion-immobiliere-ontario.md)418 "disabled": not _ONTARIO,419 "__doc__": f"Connecteur passerelle Rentsync généré — "420 f"{entry.get('name')} (siteKey {entry.get('site_key')}).",421 },422 )423 return cls424425426def _register_all() -> None:427 for entry in _load_registry():428 cls = _make_connector(entry)429 globals()[cls.__name__] = cls430431432_register_all()433