SPB Git forge

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
14.6 KB · 340 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec + Ontario)3# Auteur : 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#   Expansion Ontario 2026-08 — gaté LOUKA_ONTARIO : sans la variable, le27#   connecteur est `disabled` (exclu du registre), prod QC inchangée.28#   NB : le parc Thunder Bay a été vendu (2025-2026) — absent du flux, normal.29# -----------------------------------------------------------------------------30from __future__ import annotations3132import html as htmllib33import os34import re3536from bs4 import BeautifulSoup3738from ..schema import Listing, normalize_unit_type, strip_accents3940from .base import BaseConnector4142BASE = "https://www.skylineliving.ca"43GATEWAY = "https://website-gateway-cdn.rentsync.com/v1/skyline"44IMG_BASE = "https://s3.amazonaws.com/lws_lift/skyline/images/gallery/full"4546# Expansion Ontario 2026-08 — gate : sans LOUKA_ONTARIO=1, connecteur inactif47_ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1"4849# secteur : le champ `neighbourhood` du flux contient souvent le comté50# (« Grey County », « Niagara Region ») — pas un quartier, on l'écarte51_COUNTY_RE = re.compile(r"\b(county|region|district|municipality)\b", re.I)52_ISO_DATE_RE = re.compile(r"^20\d{2}-\d{2}-\d{2}")535455class SkylineConnector(BaseConnector):56    source_id = "skyline"57    request_delay = 0.858    disabled = not _ONTARIO          # gate Ontario (voir en-tête)59    max_properties = 250             # garde-fou immeubles (193 au 2026-08)60    max_listings = 1500              # garde-fou global (≈480 groupes au 2026-08)61    max_images = 1562    page_limit = 750                 # taille de page maximale de la passerelle63    photo_chunk = 40                 # immeubles par requête /photos6465    # -- accès passerelle -------------------------------------------------------66    def _get_json(self, path: str, params: dict) -> dict:67        resp = self.get(GATEWAY + path, params=params, headers={68            "Accept": "application/json",69            "Origin": BASE,70            "Referer": BASE + "/",71            "rs-lang": "en",72        })73        return resp.json()7475    def _paged(self, path: str, params: dict, max_pages: int = 10) -> list[dict]:76        """Concatène toutes les pages d'un endpoint (meta.totalPages)."""77        out: list[dict] = []78        page = 179        while page <= max_pages:80            data = self._get_json(path, {**params, "page": page})81            out.extend(data.get("data") or [])82            meta = data.get("meta") or {}83            if page >= int(meta.get("totalPages") or 1):84                break85            page += 186        return out8788    # -- villes : cityId -> (nom, code province) --------------------------------89    def _cities(self, city_ids: set[int]) -> dict[int, tuple[str, str]]:90        ids = "|".join(str(i) for i in sorted(city_ids))91        rows = self._paged("/cities", {92            "where": f"id~in:{ids}",93            "relations": "province:p",94            "limit": self.page_limit,95        })96        out: dict[int, tuple[str, str]] = {}97        for c in rows:98            prov = c.get("province") or {}99            out[int(c["id"])] = ((c.get("cityName") or "").strip(),100                                 (prov.get("provinceCode") or "").strip().upper())101        return out102103    # -- galeries photo par lots d'immeubles ------------------------------------104    def _photos(self, building_ids: list[int]) -> dict[int, list[str]]:105        out: dict[int, list[str]] = {}106        for i in range(0, len(building_ids), self.photo_chunk):107            chunk = building_ids[i:i + self.photo_chunk]108            ids = "|".join(str(b) for b in chunk)109            try:110                rows = self._paged("/photos", {111                    "relations": "buildingsHasPhotos:bhp",112                    "where": f"bhp.buildingId~in:{ids}",113                    "orderBy": "bhp.orderBy~asc",114                    "limit": self.page_limit,115                })116            except Exception:117                continue    # galerie manquante : annonces sans photo plutôt que rien118            for ph in rows:119                bid = ph.get("buildingId")120                img = (ph.get("image") or "").strip()121                if not bid or not img:122                    continue123                urls = out.setdefault(int(bid), [])124                if len(urls) < self.max_images:125                    u = f"{IMG_BASE}/{img}"126                    if u not in urls:127                        urls.append(u)128        return out129130    # -- collecte ---------------------------------------------------------------131    def fetch(self) -> list[Listing]:132        props = self._paged("/properties", {"limit": 1000})[: self.max_properties]133134        # villes -> filtre Ontario strict (le flux a quelques immeubles hors ON)135        cities = self._cities({int(p["cityId"]) for p in props if p.get("cityId")})136        on_props: dict[int, dict] = {}137        for p in props:138            name, prov = cities.get(int(p.get("cityId") or 0), ("", ""))139            if prov != "ON" or not name:140                continue141            p["_city"] = name142            on_props[int(p["id"])] = p143144        # types d'unités avec vacance : dans les tours en location, le flux145        # publie une rangée PAR APPARTEMENT du même plan — regrouper par146        # (immeuble, plan, cc, sdb) pour une annonce par type d'unité147        units = self._paged("/units", {148            "where": "status:enabled,available~in:1",149            "limit": self.page_limit,150        })151        by_building: dict[int, dict[tuple, list[dict]]] = {}152        for u in units:153            bid = int(u.get("buildingId") or 0)154            if bid not in on_props:155                continue156            key = ((u.get("typeName") or "").strip().lower(),157                   u.get("bedMin", u.get("bed")),158                   u.get("bathMin", u.get("bath")))159            by_building.setdefault(bid, {}).setdefault(key, []).append(u)160161        # galeries des seuls immeubles qui produisent des annonces162        photos = self._photos(sorted(by_building))163164        listings: list[Listing] = []165        for bid, groups in by_building.items():166            if len(listings) >= self.max_listings:167                break168            p = on_props[bid]169            try:170                base = self._building_ctx(p, photos.get(bid) or [])171            except Exception:172                continue173            for rows in groups.values():174                if len(listings) >= self.max_listings:175                    break176                try:177                    listings.append(self._listing(bid, rows, base))178                except Exception:179                    continue180        return listings181182    # -- contexte immeuble (partagé entre ses annonces) --------------------------183    def _building_ctx(self, p: dict, images: list[str]) -> dict:184        city = p["_city"]185        street = " ".join(x for x in (186            (p.get("streetNumber") or "").strip(),187            (p.get("streetName") or "").strip()) if x)188        postal = (p.get("postal") or "").strip()189        address = ", ".join(x for x in (street, city) if x)190        if address:191            address += f", ON {postal}".rstrip()192193        # secteur : quartier seulement (écarter comtés/« regions » du flux)194        sector = (p.get("neighbourhood") or "").strip()195        if _COUNTY_RE.search(sector):196            sector = ""197198        try:199            lat = float(p["latitude"]) if p.get("latitude") else None200            lng = float(p["longitude"]) if p.get("longitude") else None201        except (TypeError, ValueError):202            lat = lng = None203204        # caractéristiques de l'immeuble : HTML doublement échappé dans le flux205        amenities: list[str] = []206        feats = htmllib.unescape(p.get("buildingFeatures") or "")207        for li in BeautifulSoup(feats, "html.parser").find_all("li"):208            t = li.get_text(" ", strip=True)209            if t and t not in amenities:210                amenities.append(t)211212        # description : aperçu de l'immeuble (+ détails des suites si présents)213        desc_html = (p.get("buildingOverview") or "") + " " + \214                    (p.get("suiteDetails") or "")215        desc = BeautifulSoup(htmllib.unescape(desc_html),216                             "html.parser").get_text(" ", strip=True)217218        # animaux : drapeaux structurés du flux219        pets = None220        if p.get("petsNotAllowed") == 1:221            pets = "non"222        elif p.get("petFriendly") == 1:223            pets = "oui"224225        details: dict = {}226        contact: dict = {}227        if (p.get("phone") or "").strip():228            contact["phone"] = p["phone"].strip()229        email = (p.get("email") or "").split(",")[0].strip()230        if email:231            contact["email"] = email232        if contact:233            details["contact"] = contact234        try:235            if int(p.get("yearBuilt") or 0) > 1800:236                details["Année de construction"] = int(p["yearBuilt"])237            if int(p.get("floorCount") or 0) > 0:238                details["Étages"] = int(p["floorCount"])239        except (TypeError, ValueError):240            pass241242        perma = (p.get("fullPermaLink") or "").strip().strip("/")243        url = f"{BASE}/en/{perma}" if perma else BASE244245        return {"city": city, "address": address, "sector": sector,246                "lat": lat, "lng": lng, "amenities": amenities, "desc": desc,247                "pets": pets, "details": details, "url": url,248                "images": images[: self.max_images],249                "name": (p.get("buildingName") or "").strip()}250251    # -- une annonce par type d'unité disponible (rangées regroupées) ------------252    def _listing(self, bid: int, rows: list[dict], b: dict) -> Listing:253        u = rows[0]254        type_name = (u.get("typeName") or "").strip()255        unit_type = normalize_unit_type(type_name)256        bed = u.get("bedMin", u.get("bed"))257        bath = u.get("bathMin", u.get("bath"))258        if bed is not None and (not unit_type or unit_type == type_name):259            # plans nommés (« Pinnacle », « Tempo »…) : dériver des chambres260            unit_type = ("Studio" if int(bed) == 0261                         else normalize_unit_type(f"{int(bed)} chambres"))262263        # loyer : plancher du groupe (masqué sur le site si hideRateWebsites=1)264        rates: list[float] = []265        for r in rows:266            if r.get("hideRateWebsites"):267                continue268            try:269                lo = float(r.get("rateMin") or r.get("rate") or 0)270                hi = float(r.get("rateMax") or lo)271            except (TypeError, ValueError):272                continue273            if lo > 0:274                rates += [lo, max(hi, lo)]275        price = min(rates) if rates else None276        price_label = ""277        if price is not None:278            price_label = (f"À partir de {price:.0f} $" if max(rates) > price279                           else f"{price:.0f} $ /mois")280281        # superficie plausible seulement (le flux publie parfois 0)282        area = None283        for r in rows:284            try:285                v = float(r.get("sqFtMin") or r.get("sqFt") or 0)286            except (TypeError, ValueError):287                continue288            if 80 <= v <= 20000 and (area is None or v < area):289                area = v290291        # disponibilité : date structurée la plus proche du groupe292        dates = sorted(str(r.get("availabilityDate") or "").strip()[:10]293                       for r in rows294                       if _ISO_DATE_RE.match(str(r.get("availabilityDate")295                                                  or "")))296        avail_date = dates[0] if dates else None297        availability = f"Disponible le {avail_date}" if avail_date \298            else "Disponible"299        if len(rows) > 1:300            availability += f" — {len(rows)} unités"301302        details = dict(b["details"])303        if (u.get("leaseTerm") or "").strip():304            details["Bail"] = u["leaseTerm"].strip()305306        # identifiant stable : immeuble + plan (les ids d'appartements roulent)307        slug = re.sub(r"[^a-z0-9]+", "-",308                      strip_accents(type_name.lower())).strip("-")309        ext = f"{bid}-{slug or 'u'}"310        if bed is not None or bath is not None:311            ext += f"-{bed}-{bath}"312313        return Listing(314            source=self.source_id,315            external_id=ext,316            url=b["url"],317            title=f"{b['name']} — {type_name}" if type_name else b["name"],318            address=b["address"],319            sector=b["sector"],320            city=b["city"],321            province="ON",322            unit_type=unit_type,323            bedrooms=float(bed) if bed is not None else None,324            bathrooms=float(bath) if bath else None,325            price=price,326            price_label=price_label,327            availability=availability,328            availability_date=avail_date,329            area_sqft=area,330            pets=b["pets"],331            furnished=True if all(r.get("furnished") == 1332                                  for r in rows) else None,333            description=b["desc"][:600],334            amenities=b["amenities"][:25],335            details=details,336            images=b["images"],337            lat=b["lat"],338            lng=b["lng"],339        )340