# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec + Ontario) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/skyline.py : connecteur Skyline Living (skylineliving.ca) # Gestionnaire clé des villes secondaires ontariennes (~22 000 unités, # ~38 villes : Guelph HQ, Est ontarien, Niagara, Sud-Ouest, Nord, Centre). # Le site est une SPA Rentsync « nouvelle génération » : ses bundles # (cdn.rentsync.com/site/skyline) appellent la passerelle JSON publique # `https://website-gateway-cdn.rentsync.com/v1/skyline/…` (aucun jeton, # le UA de base passe). Endpoints utilisés : # - /properties?limit=1000 — tous les immeubles actifs (~193) : adresse, # lat/lng, cityId, description, caractéristiques, animaux, contact ; # - /cities?where=id~in:…&relations=province:p — nom de ville + province # (filtre ON strict : Skyline a aussi quelques immeubles hors Ontario) ; # - /units?where=status:enabled,available~in:1 — types d'unités avec # vacance (loyer, cc/sdb, pi², date), paginés (meta.totalPages) ; # - /photos?relations=buildingsHasPhotos:bhp&where=bhp.buildingId~in:… — # galeries par lots d'immeubles (images S3 lws_lift, taille « full »). # Une annonce par type d'unité disponible (comme capreit). NB : dans les # tours neuves en location (ex. Sky Towers à Barrie), le flux publie UNE # rangée PAR APPARTEMENT (champ `number`) du même plan — on regroupe donc # par (immeuble, typeName, cc, sdb) : loyer plancher « à partir de », # date la plus proche, identifiant stable immeuble+plan. Tout vient de # l'API par lots (~15 requêtes au total) : aucune page détail à visiter, # donc pas besoin du cache BD self.detail(). # Expansion Ontario 2026-08 — gaté LOUKA_ONTARIO : sans la variable, le # connecteur est `disabled` (exclu du registre), prod QC inchangée. # NB : le parc Thunder Bay a été vendu (2025-2026) — absent du flux, normal. # ----------------------------------------------------------------------------- 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 BASE = "https://www.skylineliving.ca" GATEWAY = "https://website-gateway-cdn.rentsync.com/v1/skyline" IMG_BASE = "https://s3.amazonaws.com/lws_lift/skyline/images/gallery/full" # Expansion Ontario 2026-08 — gate : sans LOUKA_ONTARIO=1, connecteur inactif _ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1" # secteur : le champ `neighbourhood` du flux contient souvent le comté # (« Grey County », « Niagara Region ») — pas un quartier, on l'écarte _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}") class SkylineConnector(BaseConnector): source_id = "skyline" request_delay = 0.8 disabled = not _ONTARIO # gate Ontario (voir en-tête) max_properties = 250 # garde-fou immeubles (193 au 2026-08) max_listings = 1500 # garde-fou global (≈480 groupes au 2026-08) 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: resp = self.get(GATEWAY + path, params=params, headers={ "Accept": "application/json", "Origin": BASE, "Referer": BASE + "/", "rs-lang": "en", }) return resp.json() def _paged(self, path: str, params: dict, max_pages: int = 10) -> 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]]: 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]]: 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": 1000})[: self.max_properties] # villes -> filtre Ontario strict (le flux a quelques immeubles hors 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 : dans les tours en location, le flux # publie une rangée PAR APPARTEMENT 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: 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() 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"{BASE}/en/{perma}" if perma else BASE 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": (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 (« Pinnacle », « 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 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"], )