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%
13.6 KB · 321 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (Québec + expansion Ontario)3# Auteur : 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#   Expansion Ontario — gaté LOUKA_ONTARIO=1 : sans la variable, disabled.27# -----------------------------------------------------------------------------28from __future__ import annotations2930import html as htmllib31import os32import re3334from bs4 import BeautifulSoup3536from ..schema import Listing, normalize_unit_type, strip_accents37from .base import BaseConnector3839SITE = "https://www.sterlingkaramar.com"40GATEWAY = "https://website-gateway-cdn.rentsync.com/v1/sterlingkaramar"41IMG_BASE = ("https://s3.amazonaws.com/lws_lift/sterlingkaramar/images/"42            "gallery/full")4344# Gate expansion Ontario : hors registre tant que LOUKA_ONTARIO=1 absent45_ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1"4647# Portefeuille Greenrock (greenrockreal.ca) sur la passerelle Sterling48# Karamar : identifiants d'import Yardi (stables, contrairement aux ids)49_GREENROCK_IMPORT_IDS = {50    "yardi:ball0045",   # 45 Balliol Street51    "yardi:davi0225",   # 225 Davisville Avenue52    "yardi:davi0207",   # Balliol & Davisville Townhomes53}5455_ISO_DATE_RE = re.compile(r"^20\d{2}-\d{2}-\d{2}")56_COUNTY_RE = re.compile(r"\b(county|region|district|municipality)\b", re.I)575859class GreenrockConnector(BaseConnector):60    source_id = "greenrock"61    request_delay = 1.062    disabled = not _ONTARIO          # gate Ontario (voir en-tête)63    max_properties = 10              # garde-fou (3 immeubles au 2026-08)64    max_listings = 100               # garde-fou global (20 rangées au 2026-08)65    max_images = 1566    page_limit = 2006768    # -- accès passerelle ---------------------------------------------------------69    def _get_json(self, path: str, params: dict) -> dict:70        resp = self.get(GATEWAY + path, params=params, headers={71            "Accept": "application/json",72            "Origin": SITE,73            "Referer": SITE + "/",74            "rs-lang": "en",75        })76        return resp.json()7778    def _paged(self, path: str, params: dict, max_pages: int = 5) -> list[dict]:79        out: list[dict] = []80        page = 181        while page <= max_pages:82            data = self._get_json(path, {**params, "page": page})83            out.extend(data.get("data") or [])84            meta = data.get("meta") or {}85            if page >= int(meta.get("totalPages") or 1):86                break87            page += 188        return out8990    # -- collecte -----------------------------------------------------------------91    def fetch(self) -> list[Listing]:92        # immeubles du portefeuille Greenrock seulement (liste blanche)93        props = [p for p in self._paged("/properties",94                                        {"limit": self.page_limit})95                 if (p.get("importId") or "").strip()96                 in _GREENROCK_IMPORT_IDS][: self.max_properties]97        if not props:98            return []99        by_id = {int(p["id"]): p for p in props}100        ids = "|".join(str(i) for i in sorted(by_id))101102        # villes -> nom + garde-fou provincial (Toronto ON attendu)103        cities: dict[int, tuple[str, str]] = {}104        for c in self._paged("/cities", {105            "where": "id~in:" + "|".join(106                sorted({str(p.get("cityId")) for p in props107                        if p.get("cityId")})),108            "relations": "province:p",109            "limit": self.page_limit,110        }):111            prov = c.get("province") or {}112            cities[int(c["id"])] = (113                (c.get("cityName") or "").strip(),114                (prov.get("provinceCode") or "").strip().upper())115116        # rangées d'unités disponibles des seuls immeubles Greenrock,117        # regroupées par (immeuble, plan, cc, sdb) = une annonce par type118        units = self._paged("/units", {119            "where": f"status:enabled,available~in:1,buildingId~in:{ids}",120            "limit": self.page_limit,121        })122        by_building: dict[int, dict[tuple, list[dict]]] = {}123        for u in units:124            bid = int(u.get("buildingId") or 0)125            if bid not in by_id:126                continue127            key = ((u.get("typeName") or "").strip().lower(),128                   u.get("bedMin", u.get("bed")),129                   u.get("bathMin", u.get("bath")))130            by_building.setdefault(bid, {}).setdefault(key, []).append(u)131132        # galeries photo par immeuble133        photos: dict[int, list[str]] = {}134        try:135            for ph in self._paged("/photos", {136                "relations": "buildingsHasPhotos:bhp",137                "where": f"bhp.buildingId~in:{ids}",138                "orderBy": "bhp.orderBy~asc",139                "limit": self.page_limit,140            }):141                bid, img = ph.get("buildingId"), (ph.get("image") or "").strip()142                if not bid or not img:143                    continue144                urls = photos.setdefault(int(bid), [])145                u = f"{IMG_BASE}/{img}"146                if len(urls) < self.max_images and u not in urls:147                    urls.append(u)148        except Exception:149            pass    # galerie manquante : annonces sans photo plutôt que rien150151        listings: list[Listing] = []152        for bid, groups in by_building.items():153            p = by_id[bid]154            city, prov = cities.get(int(p.get("cityId") or 0), ("", ""))155            if prov != "ON" or not city:156                continue157            try:158                base = self._building_ctx(p, city, photos.get(bid) or [])159            except Exception:160                continue161            for rows in groups.values():162                if len(listings) >= self.max_listings:163                    break164                try:165                    listings.append(self._listing(bid, rows, base))166                except Exception:167                    continue168        return listings169170    # -- contexte immeuble (partagé entre ses annonces) -----------------------------171    def _building_ctx(self, p: dict, city: str, images: list[str]) -> dict:172        street = " ".join(x for x in (173            (p.get("streetNumber") or "").strip(),174            (p.get("streetName") or "").strip()) if x)175        postal = (p.get("postal") or "").strip()176        address = ", ".join(x for x in (street, city) if x)177        if address:178            address += f", ON {postal}".rstrip()179180        sector = (p.get("neighbourhood") or "").strip()181        if not sector or _COUNTY_RE.search(sector):182            sector = "Davisville Village"   # quartier réel du portefeuille183184        try:185            lat = float(p["latitude"]) if p.get("latitude") else None186            lng = float(p["longitude"]) if p.get("longitude") else None187        except (TypeError, ValueError):188            lat = lng = None189190        amenities: list[str] = []191        feats = htmllib.unescape(p.get("buildingFeatures") or "")192        for li in BeautifulSoup(feats, "html.parser").find_all("li"):193            t = li.get_text(" ", strip=True)194            if t and t not in amenities:195                amenities.append(t)196197        desc_html = (p.get("buildingOverview") or "") + " " + \198                    (p.get("suiteDetails") or "")199        desc = BeautifulSoup(htmllib.unescape(desc_html),200                             "html.parser").get_text(" ", strip=True)201202        pets = None203        if p.get("petsNotAllowed") == 1:204            pets = "non"205        elif p.get("petFriendly") == 1:206            pets = "oui"207208        details: dict = {"Gestion": "Sterling Karamar (portefeuille Greenrock)"}209        contact: dict = {}210        if (p.get("phone") or "").strip():211            contact["phone"] = p["phone"].strip()212        email = (p.get("email") or "").split(",")[0].strip()213        if email:214            contact["email"] = email215        if contact:216            details["contact"] = contact217        try:218            if int(p.get("yearBuilt") or 0) > 1800:219                details["Année de construction"] = int(p["yearBuilt"])220            if int(p.get("floorCount") or 0) > 0:221                details["Étages"] = int(p["floorCount"])222        except (TypeError, ValueError):223            pass224225        perma = (p.get("fullPermaLink") or "").strip().strip("/")226        url = f"{SITE}/{perma}" if perma else SITE227228        return {"city": city, "address": address, "sector": sector,229                "lat": lat, "lng": lng, "amenities": amenities, "desc": desc,230                "pets": pets, "details": details, "url": url,231                "images": images[: self.max_images],232                "name": htmllib.unescape((p.get("buildingName") or "")233                                         .strip())}234235    # -- une annonce par type d'unité disponible (rangées regroupées) ---------------236    def _listing(self, bid: int, rows: list[dict], b: dict) -> Listing:237        u = rows[0]238        type_name = (u.get("typeName") or "").strip()239        unit_type = normalize_unit_type(type_name)240        bed = u.get("bedMin", u.get("bed"))241        bath = u.get("bathMin", u.get("bath"))242        if bed is not None and (not unit_type or unit_type == type_name):243            unit_type = ("Studio" if int(bed) == 0244                         else normalize_unit_type(f"{int(bed)} chambres"))245246        # loyer : plancher du groupe (masqué si hideRateWebsites=1)247        rates: list[float] = []248        for r in rows:249            if r.get("hideRateWebsites"):250                continue251            try:252                lo = float(r.get("rateMin") or r.get("rate") or 0)253                hi = float(r.get("rateMax") or lo)254            except (TypeError, ValueError):255                continue256            if lo > 0:257                rates += [lo, max(hi, lo)]258        price = min(rates) if rates else None259        price_label = ""260        if price is not None:261            price_label = (f"À partir de {price:.0f} $" if max(rates) > price262                           else f"{price:.0f} $ /mois")263264        # superficie plancher plausible (le flux publie parfois 0)265        area = None266        for r in rows:267            try:268                v = float(r.get("sqFtMin") or r.get("sqFt") or 0)269            except (TypeError, ValueError):270                continue271            if 80 <= v <= 20000 and (area is None or v < area):272                area = v273274        dates = sorted(str(r.get("availabilityDate") or "").strip()[:10]275                       for r in rows276                       if _ISO_DATE_RE.match(str(r.get("availabilityDate")277                                                  or "")))278        avail_date = dates[0] if dates else None279        availability = f"Disponible le {avail_date}" if avail_date \280            else "Disponible"281        if len(rows) > 1:282            availability += f" — {len(rows)} unités"283284        details = dict(b["details"])285        if (u.get("leaseTerm") or "").strip():286            details["Bail"] = u["leaseTerm"].strip()287288        slug = re.sub(r"[^a-z0-9]+", "-",289                      strip_accents(type_name.lower())).strip("-")290        ext = f"{bid}-{slug or 'u'}"291        if bed is not None or bath is not None:292            ext += f"-{bed}-{bath}"293294        return Listing(295            source=self.source_id,296            external_id=ext,297            url=b["url"],298            title=f"{b['name']} — {type_name}" if type_name else b["name"],299            address=b["address"],300            sector=b["sector"],301            city=b["city"],302            province="ON",303            unit_type=unit_type,304            bedrooms=float(bed) if bed is not None else None,305            bathrooms=float(bath) if bath else None,306            price=price,307            price_label=price_label,308            availability=availability,309            availability_date=avail_date,310            area_sqft=area,311            pets=b["pets"],312            furnished=True if rows and all(r.get("furnished") == 1313                                           for r in rows) else None,314            description=b["desc"][:600],315            amenities=b["amenities"][:25],316            details=details,317            images=b["images"],318            lat=b["lat"],319            lng=b["lng"],320        )321