SPB Git forge

spb/rent-ka

Public
8commits 1branches 0releases
7.4 MBsize
maindefault branch
19 days agolast push
Python 68.8% TypeScript 18.6% CSS 8.7% JavaScript 3.3% HTML 0.6%
14.4 KB · 338 lines python
Raw Blame History
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