# ----------------------------------------------------------------------------- # Rent-Ka — Agrégateur de logements à louer (Québec + expansion Ontario) # Author: Simon-Pierre Boucher — contact@spboucher.ai # connectors/greenrock.py : connecteur Greenrock (Davisville Village, Toronto) # IMPASSE VÉRIFIÉE 2026-08-27 sur les sites Greenrock eux-mêmes : # - greenrock.ca : NXDOMAIN (n'existe plus) ; # - greenrockrsca.com (ancien site locatif Rentsync, vivant encore le # 2026-06-13 d'après la Wayback Machine) : 522 Cloudflare persistant, # origine décommissionnée ; sa clé de passerelle Rentsync `greenrock` # répond encore mais avec 0 propriété (portefeuille vidé) ; # - Village Green (40/50 Alexander, 55 Maitland…) : VENDU — désormais # commercialisé par Brookfield Properties (hors périmètre de ce fichier). # Ce qui RESTE à Greenrock (greenrockreal.ca, « Greenrock Portfolio ») : # Davisville Village à Toronto — 45 Balliol Street, 225 Davisville Avenue, # Balliol & Davisville Townhomes (226-228 Balliol n'a pas de page locative). # Ces immeubles sont maintenant GÉRÉS/AFFICHÉS PAR STERLING KARAMAR : on # les lit sur la passerelle JSON publique Rentsync nouvelle génération # `website-gateway-cdn.rentsync.com/v1/sterlingkaramar/…` (zéro auth, zéro # anti-bot — même mécanique que skyline.py), STRICTEMENT limités au # portefeuille Greenrock par une liste blanche d'importId Yardi stables # (yardi:ball0045, yardi:davi0225, yardi:davi0207). Une annonce par type # d'unité disponible (rangées /units regroupées par plan, loyer plancher). # ⚠️ Si un connecteur Sterling Karamar complet est écrit un jour (216+ # immeubles sur la même passerelle, suite notée dans project_ontario.md), # retirer ce fichier ou y exclure ces trois importId pour éviter le doublon. # ----------------------------------------------------------------------------- from __future__ import annotations import html as htmllib import os import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type, strip_accents from .base import BaseConnector SITE = "https://www.sterlingkaramar.com" GATEWAY = "https://website-gateway-cdn.rentsync.com/v1/sterlingkaramar" IMG_BASE = ("https://s3.amazonaws.com/lws_lift/sterlingkaramar/images/" "gallery/full") # Rent-Ka: connector always active. _ONTARIO = True # Rent-Ka: always on (ROC scope) # Portefeuille Greenrock (greenrockreal.ca) sur la passerelle Sterling # Karamar : identifiants d'import Yardi (stables, contrairement aux ids) _GREENROCK_IMPORT_IDS = { "yardi:ball0045", # 45 Balliol Street "yardi:davi0225", # 225 Davisville Avenue "yardi:davi0207", # Balliol & Davisville Townhomes } _ISO_DATE_RE = re.compile(r"^20\d{2}-\d{2}-\d{2}") _COUNTY_RE = re.compile(r"\b(county|region|district|municipality)\b", re.I) class GreenrockConnector(BaseConnector): source_id = "greenrock" request_delay = 1.0 disabled = False max_properties = 10 # garde-fou (3 immeubles au 2026-08) max_listings = 100 # garde-fou global (20 rangées au 2026-08) max_images = 15 page_limit = 200 # -- accès passerelle --------------------------------------------------------- def _get_json(self, path: str, params: dict) -> dict: resp = self.get(GATEWAY + 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 = 5) -> list[dict]: 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 # -- collecte ----------------------------------------------------------------- def fetch(self) -> list[Listing]: # immeubles du portefeuille Greenrock seulement (liste blanche) props = [p for p in self._paged("/properties", {"limit": self.page_limit}) if (p.get("importId") or "").strip() in _GREENROCK_IMPORT_IDS][: self.max_properties] if not props: return [] by_id = {int(p["id"]): p for p in props} ids = "|".join(str(i) for i in sorted(by_id)) # villes -> nom + garde-fou provincial (Toronto ON attendu) cities: dict[int, tuple[str, str]] = {} for c in self._paged("/cities", { "where": "id~in:" + "|".join( sorted({str(p.get("cityId")) for p in props if p.get("cityId")})), "relations": "province:p", "limit": self.page_limit, }): prov = c.get("province") or {} cities[int(c["id"])] = ( (c.get("cityName") or "").strip(), (prov.get("provinceCode") or "").strip().upper()) # rangées d'unités disponibles des seuls immeubles Greenrock, # regroupées par (immeuble, plan, cc, sdb) = une annonce par type units = self._paged("/units", { "where": f"status:enabled,available~in:1,buildingId~in:{ids}", "limit": self.page_limit, }) by_building: dict[int, dict[tuple, list[dict]]] = {} for u in units: bid = int(u.get("buildingId") or 0) if bid not in by_id: 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 photo par immeuble photos: dict[int, list[str]] = {} try: for ph in self._paged("/photos", { "relations": "buildingsHasPhotos:bhp", "where": f"bhp.buildingId~in:{ids}", "orderBy": "bhp.orderBy~asc", "limit": self.page_limit, }): bid, img = ph.get("buildingId"), (ph.get("image") or "").strip() if not bid or not img: continue urls = photos.setdefault(int(bid), []) u = f"{IMG_BASE}/{img}" if len(urls) < self.max_images and u not in urls: urls.append(u) except Exception: pass # galerie manquante : annonces sans photo plutôt que rien listings: list[Listing] = [] for bid, groups in by_building.items(): p = by_id[bid] city, prov = cities.get(int(p.get("cityId") or 0), ("", "")) if prov != "ON" or not city: continue try: base = self._building_ctx(p, city, 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, city: str, images: list[str]) -> dict: 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() sector = (p.get("neighbourhood") or "").strip() if not sector or _COUNTY_RE.search(sector): sector = "Davisville Village" # quartier réel du portefeuille 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 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) desc_html = (p.get("buildingOverview") or "") + " " + \ (p.get("suiteDetails") or "") desc = BeautifulSoup(htmllib.unescape(desc_html), "html.parser").get_text(" ", strip=True) pets = None if p.get("petsNotAllowed") == 1: pets = "non" elif p.get("petFriendly") == 1: pets = "oui" details: dict = {"Gestion": "Sterling Karamar (portefeuille Greenrock)"} contact: dict = {} if (p.get("phone") or "").strip(): contact["phone"] = p["phone"].strip() email = (p.get("email") or "").split(",")[0].strip() if email: 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 perma = (p.get("fullPermaLink") or "").strip().strip("/") url = f"{SITE}/{perma}" if perma else 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], "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): unit_type = ("Studio" if int(bed) == 0 else normalize_unit_type(f"{int(bed)} chambres")) # loyer : plancher du groupe (masqué 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 plancher plausible (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 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() 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"], )