Python 68.8%
TypeScript 18.6%
CSS 8.7%
JavaScript 3.3%
HTML 0.6%
1# -----------------------------------------------------------------------------2# Rent-Ka — Agrégateur de logements à louer (province de Québec + Ontario)3# Author: Simon-Pierre Boucher — contact@spboucher.ai4# connectors/skyline.py : connecteur Skyline Living (skylineliving.ca)5# Gestionnaire clé des villes secondaires ontariennes (~22 000 unités,6# ~38 villes : Guelph HQ, Est ontarien, Niagara, Sud-Ouest, Nord, Centre).7# Le site est une SPA Rentsync « nouvelle génération » : ses bundles8# (cdn.rentsync.com/site/skyline) appellent la passerelle JSON publique9# `https://website-gateway-cdn.rentsync.com/v1/skyline/…` (aucun jeton,10# le UA de base passe). Endpoints utilisés :11# - /properties?limit=1000 — tous les immeubles actifs (~193) : adresse,12# lat/lng, cityId, description, caractéristiques, animaux, contact ;13# - /cities?where=id~in:…&relations=province:p — nom de ville + province14# (filtre ON strict : Skyline a aussi quelques immeubles hors Ontario) ;15# - /units?where=status:enabled,available~in:1 — types d'unités avec16# vacance (loyer, cc/sdb, pi², date), paginés (meta.totalPages) ;17# - /photos?relations=buildingsHasPhotos:bhp&where=bhp.buildingId~in:… —18# galeries par lots d'immeubles (images S3 lws_lift, taille « full »).19# Une annonce par type d'unité disponible (comme capreit). NB : dans les20# tours neuves en location (ex. Sky Towers à Barrie), le flux publie UNE21# rangée PAR APPARTEMENT (champ `number`) du même plan — on regroupe donc22# par (immeuble, typeName, cc, sdb) : loyer plancher « à partir de »,23# date la plus proche, identifiant stable immeuble+plan. Tout vient de24# l'API par lots (~15 requêtes au total) : aucune page détail à visiter,25# donc pas besoin du cache BD self.detail().26# connecteur est `disabled` (exclu du registre), prod QC inchangée.27# NB : le parc Thunder Bay a été vendu (2025-2026) — absent du flux, normal.28# -----------------------------------------------------------------------------29from __future__ import annotations3031import html as htmllib32import os33import re3435from bs4 import BeautifulSoup3637from ..schema import Listing, normalize_unit_type, strip_accents3839from .base import BaseConnector4041BASE = "https://www.skylineliving.ca"42GATEWAY = "https://website-gateway-cdn.rentsync.com/v1/skyline"43IMG_BASE = "https://s3.amazonaws.com/lws_lift/skyline/images/gallery/full"4445_ONTARIO = True # Rent-Ka: always on (ROC scope)4647# secteur : le champ `neighbourhood` du flux contient souvent le comté48# (« Grey County », « Niagara Region ») — pas un quartier, on l'écarte49_COUNTY_RE = re.compile(r"\b(county|region|district|municipality)\b", re.I)50_ISO_DATE_RE = re.compile(r"^20\d{2}-\d{2}-\d{2}")515253class SkylineConnector(BaseConnector):54 source_id = "skyline"55 request_delay = 0.856 disabled = False57 max_properties = 250 # garde-fou immeubles (193 au 2026-08)58 max_listings = 1500 # garde-fou global (≈480 groupes au 2026-08)59 max_images = 1560 page_limit = 750 # taille de page maximale de la passerelle61 photo_chunk = 40 # immeubles par requête /photos6263 # -- accès passerelle -------------------------------------------------------64 def _get_json(self, path: str, params: dict) -> dict:65 resp = self.get(GATEWAY + path, params=params, headers={66 "Accept": "application/json",67 "Origin": BASE,68 "Referer": BASE + "/",69 "rs-lang": "en",70 })71 return resp.json()7273 def _paged(self, path: str, params: dict, max_pages: int = 10) -> list[dict]:74 """Concatène toutes les pages d'un endpoint (meta.totalPages)."""75 out: list[dict] = []76 page = 177 while page <= max_pages:78 data = self._get_json(path, {**params, "page": page})79 out.extend(data.get("data") or [])80 meta = data.get("meta") or {}81 if page >= int(meta.get("totalPages") or 1):82 break83 page += 184 return out8586 # -- villes : cityId -> (nom, code province) --------------------------------87 def _cities(self, city_ids: set[int]) -> dict[int, tuple[str, str]]:88 ids = "|".join(str(i) for i in sorted(city_ids))89 rows = self._paged("/cities", {90 "where": f"id~in:{ids}",91 "relations": "province:p",92 "limit": self.page_limit,93 })94 out: dict[int, tuple[str, str]] = {}95 for c in rows:96 prov = c.get("province") or {}97 out[int(c["id"])] = ((c.get("cityName") or "").strip(),98 (prov.get("provinceCode") or "").strip().upper())99 return out100101 # -- galeries photo par lots d'immeubles ------------------------------------102 def _photos(self, building_ids: list[int]) -> dict[int, list[str]]:103 out: dict[int, list[str]] = {}104 for i in range(0, len(building_ids), self.photo_chunk):105 chunk = building_ids[i:i + self.photo_chunk]106 ids = "|".join(str(b) for b in chunk)107 try:108 rows = self._paged("/photos", {109 "relations": "buildingsHasPhotos:bhp",110 "where": f"bhp.buildingId~in:{ids}",111 "orderBy": "bhp.orderBy~asc",112 "limit": self.page_limit,113 })114 except Exception:115 continue # galerie manquante : annonces sans photo plutôt que rien116 for ph in rows:117 bid = ph.get("buildingId")118 img = (ph.get("image") or "").strip()119 if not bid or not img:120 continue121 urls = out.setdefault(int(bid), [])122 if len(urls) < self.max_images:123 u = f"{IMG_BASE}/{img}"124 if u not in urls:125 urls.append(u)126 return out127128 # -- collecte ---------------------------------------------------------------129 def fetch(self) -> list[Listing]:130 props = self._paged("/properties", {"limit": 1000})[: self.max_properties]131132 # villes -> filtre Ontario strict (le flux a quelques immeubles hors ON)133 cities = self._cities({int(p["cityId"]) for p in props if p.get("cityId")})134 on_props: dict[int, dict] = {}135 for p in props:136 name, prov = cities.get(int(p.get("cityId") or 0), ("", ""))137 if prov != "ON" or not name:138 continue139 p["_city"] = name140 on_props[int(p["id"])] = p141142 # types d'unités avec vacance : dans les tours en location, le flux143 # publie une rangée PAR APPARTEMENT du même plan — regrouper par144 # (immeuble, plan, cc, sdb) pour une annonce par type d'unité145 units = self._paged("/units", {146 "where": "status:enabled,available~in:1",147 "limit": self.page_limit,148 })149 by_building: dict[int, dict[tuple, list[dict]]] = {}150 for u in units:151 bid = int(u.get("buildingId") or 0)152 if bid not in on_props:153 continue154 key = ((u.get("typeName") or "").strip().lower(),155 u.get("bedMin", u.get("bed")),156 u.get("bathMin", u.get("bath")))157 by_building.setdefault(bid, {}).setdefault(key, []).append(u)158159 # galeries des seuls immeubles qui produisent des annonces160 photos = self._photos(sorted(by_building))161162 listings: list[Listing] = []163 for bid, groups in by_building.items():164 if len(listings) >= self.max_listings:165 break166 p = on_props[bid]167 try:168 base = self._building_ctx(p, photos.get(bid) or [])169 except Exception:170 continue171 for rows in groups.values():172 if len(listings) >= self.max_listings:173 break174 try:175 listings.append(self._listing(bid, rows, base))176 except Exception:177 continue178 return listings179180 # -- contexte immeuble (partagé entre ses annonces) --------------------------181 def _building_ctx(self, p: dict, images: list[str]) -> dict:182 city = p["_city"]183 street = " ".join(x for x in (184 (p.get("streetNumber") or "").strip(),185 (p.get("streetName") or "").strip()) if x)186 postal = (p.get("postal") or "").strip()187 address = ", ".join(x for x in (street, city) if x)188 if address:189 address += f", ON {postal}".rstrip()190191 # secteur : quartier seulement (écarter comtés/« regions » du flux)192 sector = (p.get("neighbourhood") or "").strip()193 if _COUNTY_RE.search(sector):194 sector = ""195196 try:197 lat = float(p["latitude"]) if p.get("latitude") else None198 lng = float(p["longitude"]) if p.get("longitude") else None199 except (TypeError, ValueError):200 lat = lng = None201202 # caractéristiques de l'immeuble : HTML doublement échappé dans le flux203 amenities: list[str] = []204 feats = htmllib.unescape(p.get("buildingFeatures") or "")205 for li in BeautifulSoup(feats, "html.parser").find_all("li"):206 t = li.get_text(" ", strip=True)207 if t and t not in amenities:208 amenities.append(t)209210 # description : aperçu de l'immeuble (+ détails des suites si présents)211 desc_html = (p.get("buildingOverview") or "") + " " + \212 (p.get("suiteDetails") or "")213 desc = BeautifulSoup(htmllib.unescape(desc_html),214 "html.parser").get_text(" ", strip=True)215216 # animaux : drapeaux structurés du flux217 pets = None218 if p.get("petsNotAllowed") == 1:219 pets = "non"220 elif p.get("petFriendly") == 1:221 pets = "oui"222223 details: dict = {}224 contact: dict = {}225 if (p.get("phone") or "").strip():226 contact["phone"] = p["phone"].strip()227 email = (p.get("email") or "").split(",")[0].strip()228 if email:229 contact["email"] = email230 if contact:231 details["contact"] = contact232 try:233 if int(p.get("yearBuilt") or 0) > 1800:234 details["Année de construction"] = int(p["yearBuilt"])235 if int(p.get("floorCount") or 0) > 0:236 details["Étages"] = int(p["floorCount"])237 except (TypeError, ValueError):238 pass239240 perma = (p.get("fullPermaLink") or "").strip().strip("/")241 url = f"{BASE}/en/{perma}" if perma else BASE242243 return {"city": city, "address": address, "sector": sector,244 "lat": lat, "lng": lng, "amenities": amenities, "desc": desc,245 "pets": pets, "details": details, "url": url,246 "images": images[: self.max_images],247 "name": (p.get("buildingName") or "").strip()}248249 # -- une annonce par type d'unité disponible (rangées regroupées) ------------250 def _listing(self, bid: int, rows: list[dict], b: dict) -> Listing:251 u = rows[0]252 type_name = (u.get("typeName") or "").strip()253 unit_type = normalize_unit_type(type_name)254 bed = u.get("bedMin", u.get("bed"))255 bath = u.get("bathMin", u.get("bath"))256 if bed is not None and (not unit_type or unit_type == type_name):257 # plans nommés (« Pinnacle », « Tempo »…) : dériver des chambres258 unit_type = ("Studio" if int(bed) == 0259 else normalize_unit_type(f"{int(bed)} chambres"))260261 # loyer : plancher du groupe (masqué sur le site si hideRateWebsites=1)262 rates: list[float] = []263 for r in rows:264 if r.get("hideRateWebsites"):265 continue266 try:267 lo = float(r.get("rateMin") or r.get("rate") or 0)268 hi = float(r.get("rateMax") or lo)269 except (TypeError, ValueError):270 continue271 if lo > 0:272 rates += [lo, max(hi, lo)]273 price = min(rates) if rates else None274 price_label = ""275 if price is not None:276 price_label = (f"À partir de {price:.0f} $" if max(rates) > price277 else f"{price:.0f} $ /mois")278279 # superficie plausible seulement (le flux publie parfois 0)280 area = None281 for r in rows:282 try:283 v = float(r.get("sqFtMin") or r.get("sqFt") or 0)284 except (TypeError, ValueError):285 continue286 if 80 <= v <= 20000 and (area is None or v < area):287 area = v288289 # disponibilité : date structurée la plus proche du groupe290 dates = sorted(str(r.get("availabilityDate") or "").strip()[:10]291 for r in rows292 if _ISO_DATE_RE.match(str(r.get("availabilityDate")293 or "")))294 avail_date = dates[0] if dates else None295 availability = f"Disponible le {avail_date}" if avail_date \296 else "Disponible"297 if len(rows) > 1:298 availability += f" — {len(rows)} unités"299300 details = dict(b["details"])301 if (u.get("leaseTerm") or "").strip():302 details["Bail"] = u["leaseTerm"].strip()303304 # identifiant stable : immeuble + plan (les ids d'appartements roulent)305 slug = re.sub(r"[^a-z0-9]+", "-",306 strip_accents(type_name.lower())).strip("-")307 ext = f"{bid}-{slug or 'u'}"308 if bed is not None or bath is not None:309 ext += f"-{bed}-{bath}"310311 return Listing(312 source=self.source_id,313 external_id=ext,314 url=b["url"],315 title=f"{b['name']} — {type_name}" if type_name else b["name"],316 address=b["address"],317 sector=b["sector"],318 city=b["city"],319 province="ON",320 unit_type=unit_type,321 bedrooms=float(bed) if bed is not None else None,322 bathrooms=float(bath) if bath else None,323 price=price,324 price_label=price_label,325 availability=availability,326 availability_date=avail_date,327 area_sqft=area,328 pets=b["pets"],329 furnished=True if all(r.get("furnished") == 1330 for r in rows) else None,331 description=b["desc"][:600],332 amenities=b["amenities"][:25],333 details=details,334 images=b["images"],335 lat=b["lat"],336 lng=b["lng"],337 )338