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%
19.1 KB · 432 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/rentsyncgw.py : connecteur GÉNÉRIQUE passerelle Rentsync5#   « nouvelle génération » (website-gateway.rentsync.com)6#   Plusieurs gros gestionnaires ontariens ont migré leur site vers la SPA7#   Rentsync nouvelle génération (bundles cdn.rentsync.com/site/<siteKey>) qui8#   parle à la passerelle JSON PUBLIQUE — aucune auth, aucun anti-bot :9#     https://website-gateway.rentsync.com/v1/<siteKey>/10#       properties?limit=500      → immeubles (adresse, GPS, cityId, permaLink,11#           buildingTypeId, animaux, description, modified) ; paginé meta.totalPages12#       cities?where=id~in:X|Y&relations=province:p → nom de ville + province13#           (filtre ON strict ; séparateur multi-valeurs « | »)14#       units?where=status:enabled,available~in:1&limit=750&page=N15#           → types d'unités AVEC vacance (bed/bath/pi²/loyer/date), paginés16#       photos?relations=buildingsHasPhotos:bhp&where=bhp.buildingId~in:…17#           → galeries par lots d'immeubles (images S3 lws_lift, taille « full »)18#   Images : https://s3.amazonaws.com/lws_lift/<img_key>/images/gallery/full/…19#   — la clé S3 peut différer du siteKey (drewlo_rebuild → drewlo,20#   panoramic_properties → panoramic ; validé en live HEAD 200 vs 403), d'où21#   la clé `img_key` du registre.22#   Plutôt que N modules copiés-collés (homestead.py et skyline.py sont les23#   connecteurs canoniques de cette passerelle), ce module lit le registre24#   data/rentsyncgw_clients.json ({id, name, site, site_key, img_key…},25#   validés en live le 2026-08-27) et GÉNÈRE une sous-classe par client26#   (source_id = "rsgw_<id>", ex. "rsgw_drewlo"), déposée dans les globals du27#   module pour le registre auto-découvrant (connectors/__init__.py).28#   Extraction modelée sur skyline.py : UNE annonce par type d'unité disponible,29#   regroupée par (immeuble, typeName, cc, sdb) — certains flux publient une30#   rangée PAR APPARTEMENT du même plan (champ `number`) : loyer plancher31#   « à partir de », date la plus proche, identifiant stable immeuble+plan.32#   Filtre province ON strict via /cities, filtre types non résidentiels33#   (office/retail/industrial/construction/subdivision — Sterling Karamar et34#   Drewlo mélangent commercial et chantiers dans le même flux). Tout vient de35#   l'API par lots : aucune page détail à visiter, pas de self.detail().36#   Clients au 2026-08-27 : Sterling Karamar (~15 000 unités, 217 propriétés),37#   Drewlo Holdings (~10 000, 108), Panoramic Properties (Sudbury, 49).38#   les classes générées sont `disabled` (exclues du registre), prod QC intacte.39# -----------------------------------------------------------------------------40from __future__ import annotations4142import html as htmllib43import json44import os45import re46from pathlib import Path4748from bs4 import BeautifulSoup4950from ..schema import Listing, normalize_unit_type, strip_accents51from .base import BaseConnector5253# Rent-Ka: connectors always active.54_ONTARIO = True  # Rent-Ka: always on (ROC scope)5556REGISTRY_PATH = Path(__file__).resolve().parents[2] / "data" / \57    "rentsyncgw_clients.json"58GATEWAY = "https://website-gateway.rentsync.com/v1"59IMG_BASE = "https://s3.amazonaws.com/lws_lift"6061# Types d'immeubles NON résidentiels du flux (buildingTypeId) — Sterling62# Karamar publie 46 locaux commerciaux, Drewlo des chantiers (« coming-soon »)63# et des subdivisions de maisons neuves À VENDRE : hors sujet pour Rent-Ka.64_NON_RESIDENTIAL = {65    "office", "retail", "warehouse", "industrial", "commercial", "land",66    "construction", "subdivision", "motel", "hotel", "parking", "storage",67}6869# secteur : le champ `neighbourhood` contient parfois le comté (« Grey70# County », « Niagara Region ») — pas un quartier, on l'écarte (cf. skyline)71_COUNTY_RE = re.compile(r"\b(county|region|district|municipality)\b", re.I)72_ISO_DATE_RE = re.compile(r"^20\d{2}-\d{2}-\d{2}")737475def _load_registry() -> list[dict]:76    """Entrées validées du registre (site_key présent, status = valide)."""77    try:78        data = json.loads(REGISTRY_PATH.read_text("utf-8"))79    except (OSError, ValueError):80        return []81    return [c for c in data.get("clients") or []82            if c.get("site_key") and c.get("status") == "valide"]838485class RentsyncGatewayConnector(BaseConnector):86    """Base commune des connecteurs passerelle Rentsync générés — non87    enregistrée elle-même (source_id vide) ; chaque sous-classe reçoit son88    entrée de registre dans l'attribut de classe `client`."""8990    source_id = ""            # les sous-classes générées le définissent91    client: dict = {}         # entrée du registre (name, site, site_key…)92    province = "ON"           # overridden per client ("province" in registry)93    request_delay = 0.8       # politesse — même rythme que skyline/homestead94    max_properties = 300      # garde-fou immeubles (plus gros : 217 au 2026-08)95    max_listings = 1500       # garde-fou global d'annonces96    max_images = 1597    page_limit = 750          # taille de page maximale de la passerelle98    photo_chunk = 40          # immeubles par requête /photos99100    # -- accès passerelle -------------------------------------------------------101    def _get_json(self, path: str, params: dict) -> dict:102        site = (self.client.get("site") or "").rstrip("/")103        resp = self.get(f"{GATEWAY}/{self.client['site_key']}{path}",104                        params=params, headers={105                            "Accept": "application/json",106                            "Origin": site,107                            "Referer": site + "/",108                            "rs-lang": "en",109                        })110        return resp.json()111112    def _paged(self, path: str, params: dict, max_pages: int = 20) -> list[dict]:113        """Concatène toutes les pages d'un endpoint (meta.totalPages)."""114        out: list[dict] = []115        page = 1116        while page <= max_pages:117            data = self._get_json(path, {**params, "page": page})118            out.extend(data.get("data") or [])119            meta = data.get("meta") or {}120            if page >= int(meta.get("totalPages") or 1):121                break122            page += 1123        return out124125    # -- villes : cityId -> (nom, code province) --------------------------------126    def _cities(self, city_ids: set[int]) -> dict[int, tuple[str, str]]:127        if not city_ids:128            return {}129        ids = "|".join(str(i) for i in sorted(city_ids))130        rows = self._paged("/cities", {131            "where": f"id~in:{ids}",132            "relations": "province:p",133            "limit": self.page_limit,134        })135        out: dict[int, tuple[str, str]] = {}136        for c in rows:137            prov = c.get("province") or {}138            out[int(c["id"])] = ((c.get("cityName") or "").strip(),139                                 (prov.get("provinceCode") or "").strip().upper())140        return out141142    # -- galeries photo par lots d'immeubles ------------------------------------143    def _photos(self, building_ids: list[int]) -> dict[int, list[str]]:144        img_base = f"{IMG_BASE}/{self.client.get('img_key') or self.client['site_key']}" \145                   "/images/gallery/full"146        out: dict[int, list[str]] = {}147        for i in range(0, len(building_ids), self.photo_chunk):148            chunk = building_ids[i:i + self.photo_chunk]149            ids = "|".join(str(b) for b in chunk)150            try:151                rows = self._paged("/photos", {152                    "relations": "buildingsHasPhotos:bhp",153                    "where": f"bhp.buildingId~in:{ids}",154                    "orderBy": "bhp.orderBy~asc",155                    "limit": self.page_limit,156                })157            except Exception:158                continue    # galerie manquante : annonces sans photo plutôt que rien159            for ph in rows:160                bid = ph.get("buildingId")161                img = (ph.get("image") or "").strip()162                if not bid or not img:163                    continue164                urls = out.setdefault(int(bid), [])165                if len(urls) < self.max_images:166                    u = f"{img_base}/{img}"167                    if u not in urls:168                        urls.append(u)169        return out170171    # -- collecte ---------------------------------------------------------------172    def fetch(self) -> list[Listing]:173        props = self._paged("/properties",174                            {"limit": 500})[: self.max_properties]175176        # écarter commerces/chantiers/subdivisions du flux (buildingTypeId)177        props = [p for p in props178                 if str(p.get("buildingTypeId") or "").strip().lower()179                 not in _NON_RESIDENTIAL]180181        # immeubles gérés pour un tiers déjà couvert par un connecteur dédié182        # (ex. Greenrock via sterlingkaramar) — exclus par importId183        excl = {s.strip() for s in (self.client.get("exclude_import_ids") or [])}184        if excl:185            props = [p for p in props186                     if (p.get("importId") or "").strip() not in excl]187188        # villes -> filtre Ontario strict (certains flux débordent de l'ON)189        cities = self._cities({int(p["cityId"]) for p in props190                               if p.get("cityId")})191        on_props: dict[int, dict] = {}192        for p in props:193            name, prov = cities.get(int(p.get("cityId") or 0), ("", ""))194            if prov != "ON" or not name:195                continue196            p["_city"] = name197            on_props[int(p["id"])] = p198199        # types d'unités avec vacance : certains flux publient une rangée PAR200        # APPARTEMENT (champ `number`) du même plan — regrouper par201        # (immeuble, plan, cc, sdb) pour une annonce par type d'unité202        units = self._paged("/units", {203            "where": "status:enabled,available~in:1",204            "limit": self.page_limit,205        })206        by_building: dict[int, dict[tuple, list[dict]]] = {}207        for u in units:208            if u.get("hideSuiteTypeWebsite"):209                continue210            bid = int(u.get("buildingId") or 0)211            if bid not in on_props:212                continue213            key = ((u.get("typeName") or "").strip().lower(),214                   u.get("bedMin", u.get("bed")),215                   u.get("bathMin", u.get("bath")))216            by_building.setdefault(bid, {}).setdefault(key, []).append(u)217218        # galeries des seuls immeubles qui produisent des annonces219        photos = self._photos(sorted(by_building))220221        listings: list[Listing] = []222        for bid, groups in by_building.items():223            if len(listings) >= self.max_listings:224                break225            p = on_props[bid]226            try:227                base = self._building_ctx(p, photos.get(bid) or [])228            except Exception:229                continue230            for rows in groups.values():231                if len(listings) >= self.max_listings:232                    break233                try:234                    listings.append(self._listing(bid, rows, base))235                except Exception:236                    continue237        return listings238239    # -- contexte immeuble (partagé entre ses annonces) --------------------------240    def _building_ctx(self, p: dict, images: list[str]) -> dict:241        city = p["_city"]242        street = " ".join(x for x in (243            (p.get("streetNumber") or "").strip(),244            (p.get("streetName") or "").strip()) if x)245        postal = (p.get("postal") or "").strip()246        address = ", ".join(x for x in (street, city) if x)247        if address:248            address += f", ON {postal}".rstrip()249250        # secteur : quartier seulement (écarter comtés/« regions » du flux)251        sector = (p.get("neighbourhood") or "").strip()252        if _COUNTY_RE.search(sector):253            sector = ""254255        try:256            lat = float(p["latitude"]) if p.get("latitude") else None257            lng = float(p["longitude"]) if p.get("longitude") else None258        except (TypeError, ValueError):259            lat = lng = None260261        # caractéristiques de l'immeuble : HTML doublement échappé dans le flux262        amenities: list[str] = []263        feats = htmllib.unescape(p.get("buildingFeatures") or "")264        for li in BeautifulSoup(feats, "html.parser").find_all("li"):265            t = li.get_text(" ", strip=True)266            if t and t not in amenities:267                amenities.append(t)268269        # description : aperçu de l'immeuble (+ détails des suites si présents)270        desc_html = (p.get("buildingOverview") or "") + " " + \271                    (p.get("suiteDetails") or "")272        desc = BeautifulSoup(htmllib.unescape(desc_html),273                             "html.parser").get_text(" ", strip=True)274275        # animaux : drapeaux structurés du flux276        pets = None277        if p.get("petsNotAllowed") == 1:278            pets = "non"279        elif p.get("petFriendly") == 1:280            pets = "oui"281282        details: dict = {}283        contact: dict = {}284        if (p.get("phone") or "").strip():285            contact["phone"] = p["phone"].strip()286        email = (p.get("email") or "").split(",")[0].strip()287        # adresses de tracking (leadmanaging…) : pas un vrai contact public288        if email and "leadmanaging" not in email.lower():289            contact["email"] = email290        if contact:291            details["contact"] = contact292        try:293            if int(p.get("yearBuilt") or 0) > 1800:294                details["Année de construction"] = int(p["yearBuilt"])295            if int(p.get("floorCount") or 0) > 0:296                details["Étages"] = int(p["floorCount"])297        except (TypeError, ValueError):298            pass299300        # fiche : site du client + fullPermaLink (validé 200 sur les 3 clients)301        site = (self.client.get("site") or "").rstrip("/")302        perma = (p.get("fullPermaLink") or "").strip().strip("/")303        url = f"{site}/{perma}" if perma else \304            (self.client.get("listing_url") or site)305306        return {"city": city, "address": address, "sector": sector,307                "lat": lat, "lng": lng, "amenities": amenities, "desc": desc,308                "pets": pets, "details": details, "url": url,309                "images": images[: self.max_images],310                # nom : entités HTML dans certains flux (« &amp; » chez Sterling)311                "name": htmllib.unescape(p.get("buildingName") or "").strip()}312313    # -- une annonce par type d'unité disponible (rangées regroupées) ------------314    def _listing(self, bid: int, rows: list[dict], b: dict) -> Listing:315        u = rows[0]316        type_name = (u.get("typeName") or "").strip()317        unit_type = normalize_unit_type(type_name)318        bed = u.get("bedMin", u.get("bed"))319        bath = u.get("bathMin", u.get("bath"))320        if bed is not None and (not unit_type or unit_type == type_name):321            # plans nommés (« Elgin », « Tempo »…) : dériver des chambres322            unit_type = ("Studio" if int(bed) == 0323                         else normalize_unit_type(f"{int(bed)} chambres"))324325        # loyer : plancher du groupe (masqué sur le site si hideRateWebsites=1)326        rates: list[float] = []327        for r in rows:328            if r.get("hideRateWebsites"):329                continue330            try:331                lo = float(r.get("rateMin") or r.get("rate") or 0)332                hi = float(r.get("rateMax") or lo)333            except (TypeError, ValueError):334                continue335            if lo > 0:336                rates += [lo, max(hi, lo)]337        price = min(rates) if rates else None338        price_label = ""339        if price is not None:340            price_label = (f"À partir de {price:.0f} $" if max(rates) > price341                           else f"{price:.0f} $ /mois")342343        # superficie plausible seulement (le flux publie parfois 0)344        area = None345        for r in rows:346            try:347                v = float(r.get("sqFtMin") or r.get("sqFt") or 0)348            except (TypeError, ValueError):349                continue350            if 80 <= v <= 20000 and (area is None or v < area):351                area = v352353        # disponibilité : date structurée la plus proche du groupe354        dates = sorted(str(r.get("availabilityDate") or "").strip()[:10]355                       for r in rows356                       if _ISO_DATE_RE.match(str(r.get("availabilityDate")357                                                  or "")))358        avail_date = dates[0] if dates else None359        availability = f"Disponible le {avail_date}" if avail_date \360            else "Disponible"361        if len(rows) > 1:362            availability += f" — {len(rows)} unités"363364        details = dict(b["details"])365        if (u.get("leaseTerm") or "").strip():366            details["Bail"] = u["leaseTerm"].strip()367368        # identifiant stable : immeuble + plan (les ids d'appartements roulent)369        slug = re.sub(r"[^a-z0-9]+", "-",370                      strip_accents(type_name.lower())).strip("-")371        ext = f"{bid}-{slug or 'u'}"372        if bed is not None or bath is not None:373            ext += f"-{bed}-{bath}"374375        return Listing(376            source=self.source_id,377            external_id=ext,378            url=b["url"],379            title=f"{b['name']} — {type_name}" if type_name else b["name"],380            address=b["address"],381            sector=b["sector"],382            city=b["city"],383            province=self.province,384            unit_type=unit_type,385            bedrooms=float(bed) if bed is not None else None,386            bathrooms=float(bath) if bath else None,387            price=price,388            price_label=price_label,389            availability=availability,390            availability_date=avail_date,391            area_sqft=area,392            pets=b["pets"],393            furnished=True if rows and all(r.get("furnished") == 1394                                           for r in rows) else None,395            description=b["desc"][:600],396            amenities=b["amenities"][:25],397            details=details,398            images=b["images"],399            lat=b["lat"],400            lng=b["lng"],401        )402403404# -----------------------------------------------------------------------------405# Génération : une sous-classe par client du registre, déposée dans les globals406# du module — connectors/__init__.py (scan de vars(module)) les découvre alors407# comme n'importe quel connecteur écrit à la main.408# -----------------------------------------------------------------------------409def _make_connector(entry: dict) -> type[RentsyncGatewayConnector]:410    cls = type(411        f"Rsgw{re.sub(r'[^A-Za-z0-9]', '', entry['id']).capitalize()}Connector",412        (RentsyncGatewayConnector,),413        {414            "source_id": f"rsgw_{entry['id']}",415            "client": entry,416            "province": (entry.get("province") or "ON").upper(),417            "disabled": False,418            "__doc__": f"Connecteur passerelle Rentsync généré — "419                       f"{entry.get('name')} (siteKey {entry.get('site_key')}).",420        },421    )422    return cls423424425def _register_all() -> None:426    for entry in _load_registry():427        cls = _make_connector(entry)428        globals()[cls.__name__] = cls429430431_register_all()432