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/greenrock.py : connecteur Greenrock (Davisville Village, Toronto)5# IMPASSE VÉRIFIÉE 2026-08-27 sur les sites Greenrock eux-mêmes :6# - greenrock.ca : NXDOMAIN (n'existe plus) ;7# - greenrockrsca.com (ancien site locatif Rentsync, vivant encore le8# 2026-06-13 d'après la Wayback Machine) : 522 Cloudflare persistant,9# origine décommissionnée ; sa clé de passerelle Rentsync `greenrock`10# répond encore mais avec 0 propriété (portefeuille vidé) ;11# - Village Green (40/50 Alexander, 55 Maitland…) : VENDU — désormais12# commercialisé par Brookfield Properties (hors périmètre de ce fichier).13# Ce qui RESTE à Greenrock (greenrockreal.ca, « Greenrock Portfolio ») :14# Davisville Village à Toronto — 45 Balliol Street, 225 Davisville Avenue,15# Balliol & Davisville Townhomes (226-228 Balliol n'a pas de page locative).16# Ces immeubles sont maintenant GÉRÉS/AFFICHÉS PAR STERLING KARAMAR : on17# les lit sur la passerelle JSON publique Rentsync nouvelle génération18# `website-gateway-cdn.rentsync.com/v1/sterlingkaramar/…` (zéro auth, zéro19# anti-bot — même mécanique que skyline.py), STRICTEMENT limités au20# portefeuille Greenrock par une liste blanche d'importId Yardi stables21# (yardi:ball0045, yardi:davi0225, yardi:davi0207). Une annonce par type22# d'unité disponible (rangées /units regroupées par plan, loyer plancher).23# ⚠️ Si un connecteur Sterling Karamar complet est écrit un jour (216+24# immeubles sur la même passerelle, suite notée dans project_ontario.md),25# retirer ce fichier ou y exclure ces trois importId pour éviter le doublon.26# Expansion Ontario — gaté LOUKA_ONTARIO=1 : sans la variable, disabled.27# -----------------------------------------------------------------------------28from __future__ import annotations2930import html as htmllib31import os32import re3334from bs4 import BeautifulSoup3536from ..schema import Listing, normalize_unit_type, strip_accents37from .base import BaseConnector3839SITE = "https://www.sterlingkaramar.com"40GATEWAY = "https://website-gateway-cdn.rentsync.com/v1/sterlingkaramar"41IMG_BASE = ("https://s3.amazonaws.com/lws_lift/sterlingkaramar/images/"42 "gallery/full")4344# Gate expansion Ontario : hors registre tant que LOUKA_ONTARIO=1 absent45_ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1"4647# Portefeuille Greenrock (greenrockreal.ca) sur la passerelle Sterling48# Karamar : identifiants d'import Yardi (stables, contrairement aux ids)49_GREENROCK_IMPORT_IDS = {50 "yardi:ball0045", # 45 Balliol Street51 "yardi:davi0225", # 225 Davisville Avenue52 "yardi:davi0207", # Balliol & Davisville Townhomes53}5455_ISO_DATE_RE = re.compile(r"^20\d{2}-\d{2}-\d{2}")56_COUNTY_RE = re.compile(r"\b(county|region|district|municipality)\b", re.I)575859class GreenrockConnector(BaseConnector):60 source_id = "greenrock"61 request_delay = 1.062 disabled = not _ONTARIO # gate Ontario (voir en-tête)63 max_properties = 10 # garde-fou (3 immeubles au 2026-08)64 max_listings = 100 # garde-fou global (20 rangées au 2026-08)65 max_images = 1566 page_limit = 2006768 # -- accès passerelle ---------------------------------------------------------69 def _get_json(self, path: str, params: dict) -> dict:70 resp = self.get(GATEWAY + path, params=params, headers={71 "Accept": "application/json",72 "Origin": SITE,73 "Referer": SITE + "/",74 "rs-lang": "en",75 })76 return resp.json()7778 def _paged(self, path: str, params: dict, max_pages: int = 5) -> list[dict]:79 out: list[dict] = []80 page = 181 while page <= max_pages:82 data = self._get_json(path, {**params, "page": page})83 out.extend(data.get("data") or [])84 meta = data.get("meta") or {}85 if page >= int(meta.get("totalPages") or 1):86 break87 page += 188 return out8990 # -- collecte -----------------------------------------------------------------91 def fetch(self) -> list[Listing]:92 # immeubles du portefeuille Greenrock seulement (liste blanche)93 props = [p for p in self._paged("/properties",94 {"limit": self.page_limit})95 if (p.get("importId") or "").strip()96 in _GREENROCK_IMPORT_IDS][: self.max_properties]97 if not props:98 return []99 by_id = {int(p["id"]): p for p in props}100 ids = "|".join(str(i) for i in sorted(by_id))101102 # villes -> nom + garde-fou provincial (Toronto ON attendu)103 cities: dict[int, tuple[str, str]] = {}104 for c in self._paged("/cities", {105 "where": "id~in:" + "|".join(106 sorted({str(p.get("cityId")) for p in props107 if p.get("cityId")})),108 "relations": "province:p",109 "limit": self.page_limit,110 }):111 prov = c.get("province") or {}112 cities[int(c["id"])] = (113 (c.get("cityName") or "").strip(),114 (prov.get("provinceCode") or "").strip().upper())115116 # rangées d'unités disponibles des seuls immeubles Greenrock,117 # regroupées par (immeuble, plan, cc, sdb) = une annonce par type118 units = self._paged("/units", {119 "where": f"status:enabled,available~in:1,buildingId~in:{ids}",120 "limit": self.page_limit,121 })122 by_building: dict[int, dict[tuple, list[dict]]] = {}123 for u in units:124 bid = int(u.get("buildingId") or 0)125 if bid not in by_id:126 continue127 key = ((u.get("typeName") or "").strip().lower(),128 u.get("bedMin", u.get("bed")),129 u.get("bathMin", u.get("bath")))130 by_building.setdefault(bid, {}).setdefault(key, []).append(u)131132 # galeries photo par immeuble133 photos: dict[int, list[str]] = {}134 try:135 for ph in self._paged("/photos", {136 "relations": "buildingsHasPhotos:bhp",137 "where": f"bhp.buildingId~in:{ids}",138 "orderBy": "bhp.orderBy~asc",139 "limit": self.page_limit,140 }):141 bid, img = ph.get("buildingId"), (ph.get("image") or "").strip()142 if not bid or not img:143 continue144 urls = photos.setdefault(int(bid), [])145 u = f"{IMG_BASE}/{img}"146 if len(urls) < self.max_images and u not in urls:147 urls.append(u)148 except Exception:149 pass # galerie manquante : annonces sans photo plutôt que rien150151 listings: list[Listing] = []152 for bid, groups in by_building.items():153 p = by_id[bid]154 city, prov = cities.get(int(p.get("cityId") or 0), ("", ""))155 if prov != "ON" or not city:156 continue157 try:158 base = self._building_ctx(p, city, photos.get(bid) or [])159 except Exception:160 continue161 for rows in groups.values():162 if len(listings) >= self.max_listings:163 break164 try:165 listings.append(self._listing(bid, rows, base))166 except Exception:167 continue168 return listings169170 # -- contexte immeuble (partagé entre ses annonces) -----------------------------171 def _building_ctx(self, p: dict, city: str, images: list[str]) -> dict:172 street = " ".join(x for x in (173 (p.get("streetNumber") or "").strip(),174 (p.get("streetName") or "").strip()) if x)175 postal = (p.get("postal") or "").strip()176 address = ", ".join(x for x in (street, city) if x)177 if address:178 address += f", ON {postal}".rstrip()179180 sector = (p.get("neighbourhood") or "").strip()181 if not sector or _COUNTY_RE.search(sector):182 sector = "Davisville Village" # quartier réel du portefeuille183184 try:185 lat = float(p["latitude"]) if p.get("latitude") else None186 lng = float(p["longitude"]) if p.get("longitude") else None187 except (TypeError, ValueError):188 lat = lng = None189190 amenities: list[str] = []191 feats = htmllib.unescape(p.get("buildingFeatures") or "")192 for li in BeautifulSoup(feats, "html.parser").find_all("li"):193 t = li.get_text(" ", strip=True)194 if t and t not in amenities:195 amenities.append(t)196197 desc_html = (p.get("buildingOverview") or "") + " " + \198 (p.get("suiteDetails") or "")199 desc = BeautifulSoup(htmllib.unescape(desc_html),200 "html.parser").get_text(" ", strip=True)201202 pets = None203 if p.get("petsNotAllowed") == 1:204 pets = "non"205 elif p.get("petFriendly") == 1:206 pets = "oui"207208 details: dict = {"Gestion": "Sterling Karamar (portefeuille Greenrock)"}209 contact: dict = {}210 if (p.get("phone") or "").strip():211 contact["phone"] = p["phone"].strip()212 email = (p.get("email") or "").split(",")[0].strip()213 if email:214 contact["email"] = email215 if contact:216 details["contact"] = contact217 try:218 if int(p.get("yearBuilt") or 0) > 1800:219 details["Année de construction"] = int(p["yearBuilt"])220 if int(p.get("floorCount") or 0) > 0:221 details["Étages"] = int(p["floorCount"])222 except (TypeError, ValueError):223 pass224225 perma = (p.get("fullPermaLink") or "").strip().strip("/")226 url = f"{SITE}/{perma}" if perma else SITE227228 return {"city": city, "address": address, "sector": sector,229 "lat": lat, "lng": lng, "amenities": amenities, "desc": desc,230 "pets": pets, "details": details, "url": url,231 "images": images[: self.max_images],232 "name": htmllib.unescape((p.get("buildingName") or "")233 .strip())}234235 # -- une annonce par type d'unité disponible (rangées regroupées) ---------------236 def _listing(self, bid: int, rows: list[dict], b: dict) -> Listing:237 u = rows[0]238 type_name = (u.get("typeName") or "").strip()239 unit_type = normalize_unit_type(type_name)240 bed = u.get("bedMin", u.get("bed"))241 bath = u.get("bathMin", u.get("bath"))242 if bed is not None and (not unit_type or unit_type == type_name):243 unit_type = ("Studio" if int(bed) == 0244 else normalize_unit_type(f"{int(bed)} chambres"))245246 # loyer : plancher du groupe (masqué si hideRateWebsites=1)247 rates: list[float] = []248 for r in rows:249 if r.get("hideRateWebsites"):250 continue251 try:252 lo = float(r.get("rateMin") or r.get("rate") or 0)253 hi = float(r.get("rateMax") or lo)254 except (TypeError, ValueError):255 continue256 if lo > 0:257 rates += [lo, max(hi, lo)]258 price = min(rates) if rates else None259 price_label = ""260 if price is not None:261 price_label = (f"À partir de {price:.0f} $" if max(rates) > price262 else f"{price:.0f} $ /mois")263264 # superficie plancher plausible (le flux publie parfois 0)265 area = None266 for r in rows:267 try:268 v = float(r.get("sqFtMin") or r.get("sqFt") or 0)269 except (TypeError, ValueError):270 continue271 if 80 <= v <= 20000 and (area is None or v < area):272 area = v273274 dates = sorted(str(r.get("availabilityDate") or "").strip()[:10]275 for r in rows276 if _ISO_DATE_RE.match(str(r.get("availabilityDate")277 or "")))278 avail_date = dates[0] if dates else None279 availability = f"Disponible le {avail_date}" if avail_date \280 else "Disponible"281 if len(rows) > 1:282 availability += f" — {len(rows)} unités"283284 details = dict(b["details"])285 if (u.get("leaseTerm") or "").strip():286 details["Bail"] = u["leaseTerm"].strip()287288 slug = re.sub(r"[^a-z0-9]+", "-",289 strip_accents(type_name.lower())).strip("-")290 ext = f"{bid}-{slug or 'u'}"291 if bed is not None or bath is not None:292 ext += f"-{bed}-{bath}"293294 return Listing(295 source=self.source_id,296 external_id=ext,297 url=b["url"],298 title=f"{b['name']} — {type_name}" if type_name else b["name"],299 address=b["address"],300 sector=b["sector"],301 city=b["city"],302 province="ON",303 unit_type=unit_type,304 bedrooms=float(bed) if bed is not None else None,305 bathrooms=float(bath) if bath else None,306 price=price,307 price_label=price_label,308 availability=availability,309 availability_date=avail_date,310 area_sqft=area,311 pets=b["pets"],312 furnished=True if rows and all(r.get("furnished") == 1313 for r in rows) else None,314 description=b["desc"][:600],315 amenities=b["amenities"][:25],316 details=details,317 images=b["images"],318 lat=b["lat"],319 lng=b["lng"],320 )321