# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (Québec + expansion Ontario) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/rentsyncgw.py : connecteur GÉNÉRIQUE passerelle Rentsync # « nouvelle génération » (website-gateway.rentsync.com) # Plusieurs gros gestionnaires ontariens ont migré leur site vers la SPA # Rentsync nouvelle génération (bundles cdn.rentsync.com/site/) qui # parle à la passerelle JSON PUBLIQUE — aucune auth, aucun anti-bot : # https://website-gateway.rentsync.com/v1// # properties?limit=500 → immeubles (adresse, GPS, cityId, permaLink, # buildingTypeId, animaux, description, modified) ; paginé meta.totalPages # cities?where=id~in:X|Y&relations=province:p → nom de ville + province # (filtre ON strict ; séparateur multi-valeurs « | ») # units?where=status:enabled,available~in:1&limit=750&page=N # → types d'unités AVEC vacance (bed/bath/pi²/loyer/date), paginés # photos?relations=buildingsHasPhotos:bhp&where=bhp.buildingId~in:… # → galeries par lots d'immeubles (images S3 lws_lift, taille « full ») # Images : https://s3.amazonaws.com/lws_lift//images/gallery/full/… # — la clé S3 peut différer du siteKey (drewlo_rebuild → drewlo, # panoramic_properties → panoramic ; validé en live HEAD 200 vs 403), d'où # la clé `img_key` du registre. # Plutôt que N modules copiés-collés (homestead.py et skyline.py sont les # connecteurs canoniques de cette passerelle), ce module lit le registre # data/rentsyncgw_clients.json ({id, name, site, site_key, img_key…}, # validés en live le 2026-08-27) et GÉNÈRE une sous-classe par client # (source_id = "rsgw_", ex. "rsgw_drewlo"), déposée dans les globals du # module pour le registre auto-découvrant (connectors/__init__.py). # Extraction modelée sur skyline.py : UNE annonce par type d'unité disponible, # regroupée par (immeuble, typeName, cc, sdb) — certains flux publient une # rangée PAR APPARTEMENT du même plan (champ `number`) : loyer plancher # « à partir de », date la plus proche, identifiant stable immeuble+plan. # Filtre province ON strict via /cities, filtre types non résidentiels # (office/retail/industrial/construction/subdivision — Sterling Karamar et # Drewlo mélangent commercial et chantiers dans le même flux). Tout vient de # l'API par lots : aucune page détail à visiter, pas de self.detail(). # Clients au 2026-08-27 : Sterling Karamar (~15 000 unités, 217 propriétés), # Drewlo Holdings (~10 000, 108), Panoramic Properties (Sudbury, 49). # Expansion Ontario — activer via LOUKA_ONTARIO=1 : sans la variable, toutes # les classes générées sont `disabled` (exclues du registre), prod QC intacte. # ----------------------------------------------------------------------------- from __future__ import annotations import html as htmllib import json import os import re from pathlib import Path from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, strip_accents from .base import BaseConnector # Gate expansion Ontario : rien ne s'active en prod sans LOUKA_ONTARIO=1 _ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1" REGISTRY_PATH = Path(__file__).resolve().parents[2] / "data" / \ "rentsyncgw_clients.json" GATEWAY = "https://website-gateway.rentsync.com/v1" IMG_BASE = "https://s3.amazonaws.com/lws_lift" # Types d'immeubles NON résidentiels du flux (buildingTypeId) — Sterling # Karamar publie 46 locaux commerciaux, Drewlo des chantiers (« coming-soon ») # et des subdivisions de maisons neuves À VENDRE : hors sujet pour Lou-Ka. _NON_RESIDENTIAL = { "office", "retail", "warehouse", "industrial", "commercial", "land", "construction", "subdivision", "motel", "hotel", "parking", "storage", } # secteur : le champ `neighbourhood` contient parfois le comté (« Grey # County », « Niagara Region ») — pas un quartier, on l'écarte (cf. skyline) _COUNTY_RE = re.compile(r"\b(county|region|district|municipality)\b", re.I) _ISO_DATE_RE = re.compile(r"^20\d{2}-\d{2}-\d{2}") def _load_registry() -> list[dict]: """Entrées validées du registre (site_key présent, status = valide).""" try: data = json.loads(REGISTRY_PATH.read_text("utf-8")) except (OSError, ValueError): return [] return [c for c in data.get("clients") or [] if c.get("site_key") and c.get("status") == "valide"] class RentsyncGatewayConnector(BaseConnector): """Base commune des connecteurs passerelle Rentsync générés — non enregistrée elle-même (source_id vide) ; chaque sous-classe reçoit son entrée de registre dans l'attribut de classe `client`.""" source_id = "" # les sous-classes générées le définissent client: dict = {} # entrée du registre (name, site, site_key…) request_delay = 0.8 # politesse — même rythme que skyline/homestead max_properties = 300 # garde-fou immeubles (plus gros : 217 au 2026-08) max_listings = 1500 # garde-fou global d'annonces max_images = 15 page_limit = 750 # taille de page maximale de la passerelle photo_chunk = 40 # immeubles par requête /photos # -- accès passerelle ------------------------------------------------------- def _get_json(self, path: str, params: dict) -> dict: site = (self.client.get("site") or "").rstrip("/") resp = self.get(f"{GATEWAY}/{self.client['site_key']}{path}", params=params, headers={ "Accept": "application/json", "Origin": site, "Referer": site + "/", "rs-lang": "en", }) return resp.json() def _paged(self, path: str, params: dict, max_pages: int = 20) -> list[dict]: """Concatène toutes les pages d'un endpoint (meta.totalPages).""" out: list[dict] = [] page = 1 while page <= max_pages: data = self._get_json(path, {**params, "page": page}) out.extend(data.get("data") or []) meta = data.get("meta") or {} if page >= int(meta.get("totalPages") or 1): break page += 1 return out # -- villes : cityId -> (nom, code province) -------------------------------- def _cities(self, city_ids: set[int]) -> dict[int, tuple[str, str]]: if not city_ids: return {} ids = "|".join(str(i) for i in sorted(city_ids)) rows = self._paged("/cities", { "where": f"id~in:{ids}", "relations": "province:p", "limit": self.page_limit, }) out: dict[int, tuple[str, str]] = {} for c in rows: prov = c.get("province") or {} out[int(c["id"])] = ((c.get("cityName") or "").strip(), (prov.get("provinceCode") or "").strip().upper()) return out # -- galeries photo par lots d'immeubles ------------------------------------ def _photos(self, building_ids: list[int]) -> dict[int, list[str]]: img_base = f"{IMG_BASE}/{self.client.get('img_key') or self.client['site_key']}" \ "/images/gallery/full" out: dict[int, list[str]] = {} for i in range(0, len(building_ids), self.photo_chunk): chunk = building_ids[i:i + self.photo_chunk] ids = "|".join(str(b) for b in chunk) try: rows = self._paged("/photos", { "relations": "buildingsHasPhotos:bhp", "where": f"bhp.buildingId~in:{ids}", "orderBy": "bhp.orderBy~asc", "limit": self.page_limit, }) except Exception: continue # galerie manquante : annonces sans photo plutôt que rien for ph in rows: bid = ph.get("buildingId") img = (ph.get("image") or "").strip() if not bid or not img: continue urls = out.setdefault(int(bid), []) if len(urls) < self.max_images: u = f"{img_base}/{img}" if u not in urls: urls.append(u) return out # -- collecte --------------------------------------------------------------- def fetch(self) -> list[Listing]: props = self._paged("/properties", {"limit": 500})[: self.max_properties] # écarter commerces/chantiers/subdivisions du flux (buildingTypeId) props = [p for p in props if str(p.get("buildingTypeId") or "").strip().lower() not in _NON_RESIDENTIAL] # immeubles gérés pour un tiers déjà couvert par un connecteur dédié # (ex. Greenrock via sterlingkaramar) — exclus par importId excl = {s.strip() for s in (self.client.get("exclude_import_ids") or [])} if excl: props = [p for p in props if (p.get("importId") or "").strip() not in excl] # villes -> filtre Ontario strict (certains flux débordent de l'ON) cities = self._cities({int(p["cityId"]) for p in props if p.get("cityId")}) on_props: dict[int, dict] = {} for p in props: name, prov = cities.get(int(p.get("cityId") or 0), ("", "")) if prov != "ON" or not name: continue p["_city"] = name on_props[int(p["id"])] = p # types d'unités avec vacance : certains flux publient une rangée PAR # APPARTEMENT (champ `number`) du même plan — regrouper par # (immeuble, plan, cc, sdb) pour une annonce par type d'unité units = self._paged("/units", { "where": "status:enabled,available~in:1", "limit": self.page_limit, }) by_building: dict[int, dict[tuple, list[dict]]] = {} for u in units: if u.get("hideSuiteTypeWebsite"): continue bid = int(u.get("buildingId") or 0) if bid not in on_props: continue key = ((u.get("typeName") or "").strip().lower(), u.get("bedMin", u.get("bed")), u.get("bathMin", u.get("bath"))) by_building.setdefault(bid, {}).setdefault(key, []).append(u) # galeries des seuls immeubles qui produisent des annonces photos = self._photos(sorted(by_building)) listings: list[Listing] = [] for bid, groups in by_building.items(): if len(listings) >= self.max_listings: break p = on_props[bid] try: base = self._building_ctx(p, photos.get(bid) or []) except Exception: continue for rows in groups.values(): if len(listings) >= self.max_listings: break try: listings.append(self._listing(bid, rows, base)) except Exception: continue return listings # -- contexte immeuble (partagé entre ses annonces) -------------------------- def _building_ctx(self, p: dict, images: list[str]) -> dict: city = p["_city"] street = " ".join(x for x in ( (p.get("streetNumber") or "").strip(), (p.get("streetName") or "").strip()) if x) postal = (p.get("postal") or "").strip() address = ", ".join(x for x in (street, city) if x) if address: address += f", ON {postal}".rstrip() # secteur : quartier seulement (écarter comtés/« regions » du flux) sector = (p.get("neighbourhood") or "").strip() if _COUNTY_RE.search(sector): sector = "" try: lat = float(p["latitude"]) if p.get("latitude") else None lng = float(p["longitude"]) if p.get("longitude") else None except (TypeError, ValueError): lat = lng = None # caractéristiques de l'immeuble : HTML doublement échappé dans le flux amenities: list[str] = [] feats = htmllib.unescape(p.get("buildingFeatures") or "") for li in BeautifulSoup(feats, "html.parser").find_all("li"): t = li.get_text(" ", strip=True) if t and t not in amenities: amenities.append(t) # description : aperçu de l'immeuble (+ détails des suites si présents) desc_html = (p.get("buildingOverview") or "") + " " + \ (p.get("suiteDetails") or "") desc = BeautifulSoup(htmllib.unescape(desc_html), "html.parser").get_text(" ", strip=True) # animaux : drapeaux structurés du flux pets = None if p.get("petsNotAllowed") == 1: pets = "non" elif p.get("petFriendly") == 1: pets = "oui" details: dict = {} contact: dict = {} if (p.get("phone") or "").strip(): contact["phone"] = p["phone"].strip() email = (p.get("email") or "").split(",")[0].strip() # adresses de tracking (leadmanaging…) : pas un vrai contact public if email and "leadmanaging" not in email.lower(): contact["email"] = email if contact: details["contact"] = contact try: if int(p.get("yearBuilt") or 0) > 1800: details["Année de construction"] = int(p["yearBuilt"]) if int(p.get("floorCount") or 0) > 0: details["Étages"] = int(p["floorCount"]) except (TypeError, ValueError): pass # fiche : site du client + fullPermaLink (validé 200 sur les 3 clients) site = (self.client.get("site") or "").rstrip("/") perma = (p.get("fullPermaLink") or "").strip().strip("/") url = f"{site}/{perma}" if perma else \ (self.client.get("listing_url") or site) return {"city": city, "address": address, "sector": sector, "lat": lat, "lng": lng, "amenities": amenities, "desc": desc, "pets": pets, "details": details, "url": url, "images": images[: self.max_images], # nom : entités HTML dans certains flux (« & » chez Sterling) "name": htmllib.unescape(p.get("buildingName") or "").strip()} # -- une annonce par type d'unité disponible (rangées regroupées) ------------ def _listing(self, bid: int, rows: list[dict], b: dict) -> Listing: u = rows[0] type_name = (u.get("typeName") or "").strip() unit_type = normalize_unit_type(type_name) bed = u.get("bedMin", u.get("bed")) bath = u.get("bathMin", u.get("bath")) if bed is not None and (not unit_type or unit_type == type_name): # plans nommés (« Elgin », « Tempo »…) : dériver des chambres unit_type = ("Studio" if int(bed) == 0 else normalize_unit_type(f"{int(bed)} chambres")) # loyer : plancher du groupe (masqué sur le site si hideRateWebsites=1) rates: list[float] = [] for r in rows: if r.get("hideRateWebsites"): continue try: lo = float(r.get("rateMin") or r.get("rate") or 0) hi = float(r.get("rateMax") or lo) except (TypeError, ValueError): continue if lo > 0: rates += [lo, max(hi, lo)] price = min(rates) if rates else None price_label = "" if price is not None: price_label = (f"À partir de {price:.0f} $" if max(rates) > price else f"{price:.0f} $ /mois") # superficie plausible seulement (le flux publie parfois 0) area = None for r in rows: try: v = float(r.get("sqFtMin") or r.get("sqFt") or 0) except (TypeError, ValueError): continue if 80 <= v <= 20000 and (area is None or v < area): area = v # disponibilité : date structurée la plus proche du groupe dates = sorted(str(r.get("availabilityDate") or "").strip()[:10] for r in rows if _ISO_DATE_RE.match(str(r.get("availabilityDate") or ""))) avail_date = dates[0] if dates else None availability = f"Disponible le {avail_date}" if avail_date \ else "Disponible" if len(rows) > 1: availability += f" — {len(rows)} unités" details = dict(b["details"]) if (u.get("leaseTerm") or "").strip(): details["Bail"] = u["leaseTerm"].strip() # identifiant stable : immeuble + plan (les ids d'appartements roulent) slug = re.sub(r"[^a-z0-9]+", "-", strip_accents(type_name.lower())).strip("-") ext = f"{bid}-{slug or 'u'}" if bed is not None or bath is not None: ext += f"-{bed}-{bath}" return Listing( source=self.source_id, external_id=ext, url=b["url"], title=f"{b['name']} — {type_name}" if type_name else b["name"], address=b["address"], sector=b["sector"], city=b["city"], province="ON", unit_type=unit_type, bedrooms=float(bed) if bed is not None else None, bathrooms=float(bath) if bath else None, price=price, price_label=price_label, availability=availability, availability_date=avail_date, area_sqft=area, pets=b["pets"], furnished=True if rows and all(r.get("furnished") == 1 for r in rows) else None, description=b["desc"][:600], amenities=b["amenities"][:25], details=details, images=b["images"], lat=b["lat"], lng=b["lng"], ) # ----------------------------------------------------------------------------- # Génération : une sous-classe par client du registre, déposée dans les globals # du module — connectors/__init__.py (scan de vars(module)) les découvre alors # comme n'importe quel connecteur écrit à la main. # ----------------------------------------------------------------------------- def _make_connector(entry: dict) -> type[RentsyncGatewayConnector]: cls = type( f"Rsgw{re.sub(r'[^A-Za-z0-9]', '', entry['id']).capitalize()}Connector", (RentsyncGatewayConnector,), { "source_id": f"rsgw_{entry['id']}", "client": entry, # Expansion Ontario — activer via LOUKA_ONTARIO=1 # (voir gestion-immobiliere-ontario.md) "disabled": not _ONTARIO, "__doc__": f"Connecteur passerelle Rentsync généré — " f"{entry.get('name')} (siteKey {entry.get('site_key')}).", }, ) return cls def _register_all() -> None: for entry in _load_registry(): cls = _make_connector(entry) globals()[cls.__name__] = cls _register_all()