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%
13.4 KB · 320 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Rent-Ka — Agrégateur de logements à louer (Québec + expansion Ontario)3# Author: Simon-Pierre Boucher — contact@spboucher.ai4# connectors/greenrock.py : connecteur Greenrock (Davisville Village, Toronto)5#   IMPASSE VÉRIFIÉE 2026-08-27 sur les sites Greenrock eux-mêmes :6#     - greenrock.ca : NXDOMAIN (n'existe plus) ;7#     - greenrockrsca.com (ancien site locatif Rentsync, vivant encore le8#       2026-06-13 d'après la Wayback Machine) : 522 Cloudflare persistant,9#       origine décommissionnée ; sa clé de passerelle Rentsync `greenrock`10#       répond encore mais avec 0 propriété (portefeuille vidé) ;11#     - Village Green (40/50 Alexander, 55 Maitland…) : VENDU — désormais12#       commercialisé par Brookfield Properties (hors périmètre de ce fichier).13#   Ce qui RESTE à Greenrock (greenrockreal.ca, « Greenrock Portfolio ») :14#   Davisville Village à Toronto — 45 Balliol Street, 225 Davisville Avenue,15#   Balliol & Davisville Townhomes (226-228 Balliol n'a pas de page locative).16#   Ces immeubles sont maintenant GÉRÉS/AFFICHÉS PAR STERLING KARAMAR : on17#   les lit sur la passerelle JSON publique Rentsync nouvelle génération18#   `website-gateway-cdn.rentsync.com/v1/sterlingkaramar/…` (zéro auth, zéro19#   anti-bot — même mécanique que skyline.py), STRICTEMENT limités au20#   portefeuille Greenrock par une liste blanche d'importId Yardi stables21#   (yardi:ball0045, yardi:davi0225, yardi:davi0207). Une annonce par type22#   d'unité disponible (rangées /units regroupées par plan, loyer plancher).23#   ⚠️ Si un connecteur Sterling Karamar complet est écrit un jour (216+24#   immeubles sur la même passerelle, suite notée dans project_ontario.md),25#   retirer ce fichier ou y exclure ces trois importId pour éviter le doublon.26# -----------------------------------------------------------------------------27from __future__ import annotations2829import html as htmllib30import os31import re3233from bs4 import BeautifulSoup3435from ..schema import Listing, normalize_unit_type, strip_accents36from .base import BaseConnector3738SITE = "https://www.sterlingkaramar.com"39GATEWAY = "https://website-gateway-cdn.rentsync.com/v1/sterlingkaramar"40IMG_BASE = ("https://s3.amazonaws.com/lws_lift/sterlingkaramar/images/"41            "gallery/full")4243# Rent-Ka: connector always active.44_ONTARIO = True  # Rent-Ka: always on (ROC scope)4546# Portefeuille Greenrock (greenrockreal.ca) sur la passerelle Sterling47# Karamar : identifiants d'import Yardi (stables, contrairement aux ids)48_GREENROCK_IMPORT_IDS = {49    "yardi:ball0045",   # 45 Balliol Street50    "yardi:davi0225",   # 225 Davisville Avenue51    "yardi:davi0207",   # Balliol & Davisville Townhomes52}5354_ISO_DATE_RE = re.compile(r"^20\d{2}-\d{2}-\d{2}")55_COUNTY_RE = re.compile(r"\b(county|region|district|municipality)\b", re.I)565758class GreenrockConnector(BaseConnector):59    source_id = "greenrock"60    request_delay = 1.061    disabled = False62    max_properties = 10              # garde-fou (3 immeubles au 2026-08)63    max_listings = 100               # garde-fou global (20 rangées au 2026-08)64    max_images = 1565    page_limit = 2006667    # -- accès passerelle ---------------------------------------------------------68    def _get_json(self, path: str, params: dict) -> dict:69        resp = self.get(GATEWAY + path, params=params, headers={70            "Accept": "application/json",71            "Origin": SITE,72            "Referer": SITE + "/",73            "rs-lang": "en",74        })75        return resp.json()7677    def _paged(self, path: str, params: dict, max_pages: int = 5) -> list[dict]:78        out: list[dict] = []79        page = 180        while page <= max_pages:81            data = self._get_json(path, {**params, "page": page})82            out.extend(data.get("data") or [])83            meta = data.get("meta") or {}84            if page >= int(meta.get("totalPages") or 1):85                break86            page += 187        return out8889    # -- collecte -----------------------------------------------------------------90    def fetch(self) -> list[Listing]:91        # immeubles du portefeuille Greenrock seulement (liste blanche)92        props = [p for p in self._paged("/properties",93                                        {"limit": self.page_limit})94                 if (p.get("importId") or "").strip()95                 in _GREENROCK_IMPORT_IDS][: self.max_properties]96        if not props:97            return []98        by_id = {int(p["id"]): p for p in props}99        ids = "|".join(str(i) for i in sorted(by_id))100101        # villes -> nom + garde-fou provincial (Toronto ON attendu)102        cities: dict[int, tuple[str, str]] = {}103        for c in self._paged("/cities", {104            "where": "id~in:" + "|".join(105                sorted({str(p.get("cityId")) for p in props106                        if p.get("cityId")})),107            "relations": "province:p",108            "limit": self.page_limit,109        }):110            prov = c.get("province") or {}111            cities[int(c["id"])] = (112                (c.get("cityName") or "").strip(),113                (prov.get("provinceCode") or "").strip().upper())114115        # rangées d'unités disponibles des seuls immeubles Greenrock,116        # regroupées par (immeuble, plan, cc, sdb) = une annonce par type117        units = self._paged("/units", {118            "where": f"status:enabled,available~in:1,buildingId~in:{ids}",119            "limit": self.page_limit,120        })121        by_building: dict[int, dict[tuple, list[dict]]] = {}122        for u in units:123            bid = int(u.get("buildingId") or 0)124            if bid not in by_id:125                continue126            key = ((u.get("typeName") or "").strip().lower(),127                   u.get("bedMin", u.get("bed")),128                   u.get("bathMin", u.get("bath")))129            by_building.setdefault(bid, {}).setdefault(key, []).append(u)130131        # galeries photo par immeuble132        photos: dict[int, list[str]] = {}133        try:134            for ph in self._paged("/photos", {135                "relations": "buildingsHasPhotos:bhp",136                "where": f"bhp.buildingId~in:{ids}",137                "orderBy": "bhp.orderBy~asc",138                "limit": self.page_limit,139            }):140                bid, img = ph.get("buildingId"), (ph.get("image") or "").strip()141                if not bid or not img:142                    continue143                urls = photos.setdefault(int(bid), [])144                u = f"{IMG_BASE}/{img}"145                if len(urls) < self.max_images and u not in urls:146                    urls.append(u)147        except Exception:148            pass    # galerie manquante : annonces sans photo plutôt que rien149150        listings: list[Listing] = []151        for bid, groups in by_building.items():152            p = by_id[bid]153            city, prov = cities.get(int(p.get("cityId") or 0), ("", ""))154            if prov != "ON" or not city:155                continue156            try:157                base = self._building_ctx(p, city, photos.get(bid) or [])158            except Exception:159                continue160            for rows in groups.values():161                if len(listings) >= self.max_listings:162                    break163                try:164                    listings.append(self._listing(bid, rows, base))165                except Exception:166                    continue167        return listings168169    # -- contexte immeuble (partagé entre ses annonces) -----------------------------170    def _building_ctx(self, p: dict, city: str, images: list[str]) -> dict:171        street = " ".join(x for x in (172            (p.get("streetNumber") or "").strip(),173            (p.get("streetName") or "").strip()) if x)174        postal = (p.get("postal") or "").strip()175        address = ", ".join(x for x in (street, city) if x)176        if address:177            address += f", ON {postal}".rstrip()178179        sector = (p.get("neighbourhood") or "").strip()180        if not sector or _COUNTY_RE.search(sector):181            sector = "Davisville Village"   # quartier réel du portefeuille182183        try:184            lat = float(p["latitude"]) if p.get("latitude") else None185            lng = float(p["longitude"]) if p.get("longitude") else None186        except (TypeError, ValueError):187            lat = lng = None188189        amenities: list[str] = []190        feats = htmllib.unescape(p.get("buildingFeatures") or "")191        for li in BeautifulSoup(feats, "html.parser").find_all("li"):192            t = li.get_text(" ", strip=True)193            if t and t not in amenities:194                amenities.append(t)195196        desc_html = (p.get("buildingOverview") or "") + " " + \197                    (p.get("suiteDetails") or "")198        desc = BeautifulSoup(htmllib.unescape(desc_html),199                             "html.parser").get_text(" ", strip=True)200201        pets = None202        if p.get("petsNotAllowed") == 1:203            pets = "non"204        elif p.get("petFriendly") == 1:205            pets = "oui"206207        details: dict = {"Gestion": "Sterling Karamar (portefeuille Greenrock)"}208        contact: dict = {}209        if (p.get("phone") or "").strip():210            contact["phone"] = p["phone"].strip()211        email = (p.get("email") or "").split(",")[0].strip()212        if email:213            contact["email"] = email214        if contact:215            details["contact"] = contact216        try:217            if int(p.get("yearBuilt") or 0) > 1800:218                details["Année de construction"] = int(p["yearBuilt"])219            if int(p.get("floorCount") or 0) > 0:220                details["Étages"] = int(p["floorCount"])221        except (TypeError, ValueError):222            pass223224        perma = (p.get("fullPermaLink") or "").strip().strip("/")225        url = f"{SITE}/{perma}" if perma else SITE226227        return {"city": city, "address": address, "sector": sector,228                "lat": lat, "lng": lng, "amenities": amenities, "desc": desc,229                "pets": pets, "details": details, "url": url,230                "images": images[: self.max_images],231                "name": htmllib.unescape((p.get("buildingName") or "")232                                         .strip())}233234    # -- une annonce par type d'unité disponible (rangées regroupées) ---------------235    def _listing(self, bid: int, rows: list[dict], b: dict) -> Listing:236        u = rows[0]237        type_name = (u.get("typeName") or "").strip()238        unit_type = normalize_unit_type(type_name)239        bed = u.get("bedMin", u.get("bed"))240        bath = u.get("bathMin", u.get("bath"))241        if bed is not None and (not unit_type or unit_type == type_name):242            unit_type = ("Studio" if int(bed) == 0243                         else normalize_unit_type(f"{int(bed)} chambres"))244245        # loyer : plancher du groupe (masqué si hideRateWebsites=1)246        rates: list[float] = []247        for r in rows:248            if r.get("hideRateWebsites"):249                continue250            try:251                lo = float(r.get("rateMin") or r.get("rate") or 0)252                hi = float(r.get("rateMax") or lo)253            except (TypeError, ValueError):254                continue255            if lo > 0:256                rates += [lo, max(hi, lo)]257        price = min(rates) if rates else None258        price_label = ""259        if price is not None:260            price_label = (f"À partir de {price:.0f} $" if max(rates) > price261                           else f"{price:.0f} $ /mois")262263        # superficie plancher plausible (le flux publie parfois 0)264        area = None265        for r in rows:266            try:267                v = float(r.get("sqFtMin") or r.get("sqFt") or 0)268            except (TypeError, ValueError):269                continue270            if 80 <= v <= 20000 and (area is None or v < area):271                area = v272273        dates = sorted(str(r.get("availabilityDate") or "").strip()[:10]274                       for r in rows275                       if _ISO_DATE_RE.match(str(r.get("availabilityDate")276                                                  or "")))277        avail_date = dates[0] if dates else None278        availability = f"Disponible le {avail_date}" if avail_date \279            else "Disponible"280        if len(rows) > 1:281            availability += f" — {len(rows)} unités"282283        details = dict(b["details"])284        if (u.get("leaseTerm") or "").strip():285            details["Bail"] = u["leaseTerm"].strip()286287        slug = re.sub(r"[^a-z0-9]+", "-",288                      strip_accents(type_name.lower())).strip("-")289        ext = f"{bid}-{slug or 'u'}"290        if bed is not None or bath is not None:291            ext += f"-{bed}-{bath}"292293        return Listing(294            source=self.source_id,295            external_id=ext,296            url=b["url"],297            title=f"{b['name']} — {type_name}" if type_name else b["name"],298            address=b["address"],299            sector=b["sector"],300            city=b["city"],301            province="ON",302            unit_type=unit_type,303            bedrooms=float(bed) if bed is not None else None,304            bathrooms=float(bath) if bath else None,305            price=price,306            price_label=price_label,307            availability=availability,308            availability_date=avail_date,309            area_sqft=area,310            pets=b["pets"],311            furnished=True if rows and all(r.get("furnished") == 1312                                           for r in rows) else None,313            description=b["desc"][:600],314            amenities=b["amenities"][:25],315            details=details,316            images=b["images"],317            lat=b["lat"],318            lng=b["lng"],319        )320