Python 68.8%
TypeScript 18.6%
CSS 8.7%
JavaScript 3.3%
HTML 0.6%
1# -----------------------------------------------------------------------------2# Rent-Ka — Agrégateur de logements à louer (Québec + expansion Ontario)3# Author: 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# -----------------------------------------------------------------------------27from __future__ import annotations2829import html as htmllib30import os31import re3233from bs4 import BeautifulSoup3435from ..schema import Listing, normalize_unit_type, strip_accents36from .base import BaseConnector3738SITE = "https://www.sterlingkaramar.com"39GATEWAY = "https://website-gateway-cdn.rentsync.com/v1/sterlingkaramar"40IMG_BASE = ("https://s3.amazonaws.com/lws_lift/sterlingkaramar/images/"41 "gallery/full")4243# Rent-Ka: connector always active.44_ONTARIO = True # Rent-Ka: always on (ROC scope)4546# Portefeuille Greenrock (greenrockreal.ca) sur la passerelle Sterling47# Karamar : identifiants d'import Yardi (stables, contrairement aux ids)48_GREENROCK_IMPORT_IDS = {49 "yardi:ball0045", # 45 Balliol Street50 "yardi:davi0225", # 225 Davisville Avenue51 "yardi:davi0207", # Balliol & Davisville Townhomes52}5354_ISO_DATE_RE = re.compile(r"^20\d{2}-\d{2}-\d{2}")55_COUNTY_RE = re.compile(r"\b(county|region|district|municipality)\b", re.I)565758class GreenrockConnector(BaseConnector):59 source_id = "greenrock"60 request_delay = 1.061 disabled = False62 max_properties = 10 # garde-fou (3 immeubles au 2026-08)63 max_listings = 100 # garde-fou global (20 rangées au 2026-08)64 max_images = 1565 page_limit = 2006667 # -- accès passerelle ---------------------------------------------------------68 def _get_json(self, path: str, params: dict) -> dict:69 resp = self.get(GATEWAY + path, params=params, headers={70 "Accept": "application/json",71 "Origin": SITE,72 "Referer": SITE + "/",73 "rs-lang": "en",74 })75 return resp.json()7677 def _paged(self, path: str, params: dict, max_pages: int = 5) -> list[dict]:78 out: list[dict] = []79 page = 180 while page <= max_pages:81 data = self._get_json(path, {**params, "page": page})82 out.extend(data.get("data") or [])83 meta = data.get("meta") or {}84 if page >= int(meta.get("totalPages") or 1):85 break86 page += 187 return out8889 # -- collecte -----------------------------------------------------------------90 def fetch(self) -> list[Listing]:91 # immeubles du portefeuille Greenrock seulement (liste blanche)92 props = [p for p in self._paged("/properties",93 {"limit": self.page_limit})94 if (p.get("importId") or "").strip()95 in _GREENROCK_IMPORT_IDS][: self.max_properties]96 if not props:97 return []98 by_id = {int(p["id"]): p for p in props}99 ids = "|".join(str(i) for i in sorted(by_id))100101 # villes -> nom + garde-fou provincial (Toronto ON attendu)102 cities: dict[int, tuple[str, str]] = {}103 for c in self._paged("/cities", {104 "where": "id~in:" + "|".join(105 sorted({str(p.get("cityId")) for p in props106 if p.get("cityId")})),107 "relations": "province:p",108 "limit": self.page_limit,109 }):110 prov = c.get("province") or {}111 cities[int(c["id"])] = (112 (c.get("cityName") or "").strip(),113 (prov.get("provinceCode") or "").strip().upper())114115 # rangées d'unités disponibles des seuls immeubles Greenrock,116 # regroupées par (immeuble, plan, cc, sdb) = une annonce par type117 units = self._paged("/units", {118 "where": f"status:enabled,available~in:1,buildingId~in:{ids}",119 "limit": self.page_limit,120 })121 by_building: dict[int, dict[tuple, list[dict]]] = {}122 for u in units:123 bid = int(u.get("buildingId") or 0)124 if bid not in by_id:125 continue126 key = ((u.get("typeName") or "").strip().lower(),127 u.get("bedMin", u.get("bed")),128 u.get("bathMin", u.get("bath")))129 by_building.setdefault(bid, {}).setdefault(key, []).append(u)130131 # galeries photo par immeuble132 photos: dict[int, list[str]] = {}133 try:134 for ph in self._paged("/photos", {135 "relations": "buildingsHasPhotos:bhp",136 "where": f"bhp.buildingId~in:{ids}",137 "orderBy": "bhp.orderBy~asc",138 "limit": self.page_limit,139 }):140 bid, img = ph.get("buildingId"), (ph.get("image") or "").strip()141 if not bid or not img:142 continue143 urls = photos.setdefault(int(bid), [])144 u = f"{IMG_BASE}/{img}"145 if len(urls) < self.max_images and u not in urls:146 urls.append(u)147 except Exception:148 pass # galerie manquante : annonces sans photo plutôt que rien149150 listings: list[Listing] = []151 for bid, groups in by_building.items():152 p = by_id[bid]153 city, prov = cities.get(int(p.get("cityId") or 0), ("", ""))154 if prov != "ON" or not city:155 continue156 try:157 base = self._building_ctx(p, city, photos.get(bid) or [])158 except Exception:159 continue160 for rows in groups.values():161 if len(listings) >= self.max_listings:162 break163 try:164 listings.append(self._listing(bid, rows, base))165 except Exception:166 continue167 return listings168169 # -- contexte immeuble (partagé entre ses annonces) -----------------------------170 def _building_ctx(self, p: dict, city: str, images: list[str]) -> dict:171 street = " ".join(x for x in (172 (p.get("streetNumber") or "").strip(),173 (p.get("streetName") or "").strip()) if x)174 postal = (p.get("postal") or "").strip()175 address = ", ".join(x for x in (street, city) if x)176 if address:177 address += f", ON {postal}".rstrip()178179 sector = (p.get("neighbourhood") or "").strip()180 if not sector or _COUNTY_RE.search(sector):181 sector = "Davisville Village" # quartier réel du portefeuille182183 try:184 lat = float(p["latitude"]) if p.get("latitude") else None185 lng = float(p["longitude"]) if p.get("longitude") else None186 except (TypeError, ValueError):187 lat = lng = None188189 amenities: list[str] = []190 feats = htmllib.unescape(p.get("buildingFeatures") or "")191 for li in BeautifulSoup(feats, "html.parser").find_all("li"):192 t = li.get_text(" ", strip=True)193 if t and t not in amenities:194 amenities.append(t)195196 desc_html = (p.get("buildingOverview") or "") + " " + \197 (p.get("suiteDetails") or "")198 desc = BeautifulSoup(htmllib.unescape(desc_html),199 "html.parser").get_text(" ", strip=True)200201 pets = None202 if p.get("petsNotAllowed") == 1:203 pets = "non"204 elif p.get("petFriendly") == 1:205 pets = "oui"206207 details: dict = {"Gestion": "Sterling Karamar (portefeuille Greenrock)"}208 contact: dict = {}209 if (p.get("phone") or "").strip():210 contact["phone"] = p["phone"].strip()211 email = (p.get("email") or "").split(",")[0].strip()212 if email:213 contact["email"] = email214 if contact:215 details["contact"] = contact216 try:217 if int(p.get("yearBuilt") or 0) > 1800:218 details["Année de construction"] = int(p["yearBuilt"])219 if int(p.get("floorCount") or 0) > 0:220 details["Étages"] = int(p["floorCount"])221 except (TypeError, ValueError):222 pass223224 perma = (p.get("fullPermaLink") or "").strip().strip("/")225 url = f"{SITE}/{perma}" if perma else SITE226227 return {"city": city, "address": address, "sector": sector,228 "lat": lat, "lng": lng, "amenities": amenities, "desc": desc,229 "pets": pets, "details": details, "url": url,230 "images": images[: self.max_images],231 "name": htmllib.unescape((p.get("buildingName") or "")232 .strip())}233234 # -- une annonce par type d'unité disponible (rangées regroupées) ---------------235 def _listing(self, bid: int, rows: list[dict], b: dict) -> Listing:236 u = rows[0]237 type_name = (u.get("typeName") or "").strip()238 unit_type = normalize_unit_type(type_name)239 bed = u.get("bedMin", u.get("bed"))240 bath = u.get("bathMin", u.get("bath"))241 if bed is not None and (not unit_type or unit_type == type_name):242 unit_type = ("Studio" if int(bed) == 0243 else normalize_unit_type(f"{int(bed)} chambres"))244245 # loyer : plancher du groupe (masqué si hideRateWebsites=1)246 rates: list[float] = []247 for r in rows:248 if r.get("hideRateWebsites"):249 continue250 try:251 lo = float(r.get("rateMin") or r.get("rate") or 0)252 hi = float(r.get("rateMax") or lo)253 except (TypeError, ValueError):254 continue255 if lo > 0:256 rates += [lo, max(hi, lo)]257 price = min(rates) if rates else None258 price_label = ""259 if price is not None:260 price_label = (f"À partir de {price:.0f} $" if max(rates) > price261 else f"{price:.0f} $ /mois")262263 # superficie plancher plausible (le flux publie parfois 0)264 area = None265 for r in rows:266 try:267 v = float(r.get("sqFtMin") or r.get("sqFt") or 0)268 except (TypeError, ValueError):269 continue270 if 80 <= v <= 20000 and (area is None or v < area):271 area = v272273 dates = sorted(str(r.get("availabilityDate") or "").strip()[:10]274 for r in rows275 if _ISO_DATE_RE.match(str(r.get("availabilityDate")276 or "")))277 avail_date = dates[0] if dates else None278 availability = f"Disponible le {avail_date}" if avail_date \279 else "Disponible"280 if len(rows) > 1:281 availability += f" — {len(rows)} unités"282283 details = dict(b["details"])284 if (u.get("leaseTerm") or "").strip():285 details["Bail"] = u["leaseTerm"].strip()286287 slug = re.sub(r"[^a-z0-9]+", "-",288 strip_accents(type_name.lower())).strip("-")289 ext = f"{bid}-{slug or 'u'}"290 if bed is not None or bath is not None:291 ext += f"-{bed}-{bath}"292293 return Listing(294 source=self.source_id,295 external_id=ext,296 url=b["url"],297 title=f"{b['name']} — {type_name}" if type_name else b["name"],298 address=b["address"],299 sector=b["sector"],300 city=b["city"],301 province="ON",302 unit_type=unit_type,303 bedrooms=float(bed) if bed is not None else None,304 bathrooms=float(bath) if bath else None,305 price=price,306 price_label=price_label,307 availability=availability,308 availability_date=avail_date,309 area_sqft=area,310 pets=b["pets"],311 furnished=True if rows and all(r.get("furnished") == 1312 for r in rows) else None,313 description=b["desc"][:600],314 amenities=b["amenities"][:25],315 details=details,316 images=b["images"],317 lat=b["lat"],318 lng=b["lng"],319 )320