SPB Git forge

spb/rent-ka

Public
8commits 1branches 0releases
7.4 MBsize
maindefault branch
20 days agolast push
Python 68.8% TypeScript 18.6% CSS 8.7% JavaScript 3.3% HTML 0.6%
10.4 KB · 250 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/kggroup.py : connecteur KG Group (myrental.ca)5#   Gestionnaire familial torontois (~4 700 suites — 7 tours à Midtown,6#   North York, Yonge & Sheppard). Le site est un Rentsync « nouvelle7#   génération » (SPA Vue servie par cdn.rentsync.com/site/kg_rebuild) : PAS8#   le flux LiftSystem classique (api.theliftsystem.com, cf. liftsystem.py)9#   mais l'API interne « website-gateway » découverte dans les bundles JS :10#     https://website-gateway-cdn.rentsync.com/v1/kg_rebuild/11#       properties                     -> 7 immeubles (adresse, GPS, quartier,12#                                         overview HTML, politique animaux…)13#       properties/<id>/unit-summary   -> unités DISPONIBLES en direct14#                                         (typeName, bed, bath, sqFt, rate)15#       properties/<id>/photos         -> galerie (fichiers S3 lws_lift, servis16#                                         en https://s3.amazonaws.com/lws_lift/17#                                         kggroup/images/gallery/full/<image>)18#       properties/<id>/amenities      -> commodités nommées19#   Aucun anti-bot, pas de clé : l'API répond à un simple GET JSON.20#   Une annonce PAR TYPE D'UNITÉ DISPONIBLE (groupé sur typeName, ex. « 1A »),21#   prix plancher réel du groupe ; repli « une annonce par immeuble » SANS22#   prix quand aucune unité n'est affichée (rien d'inventé). unit-summary est23#   interrogé en direct à chaque synchronisation (c'est la donnée vivante) ;24#   photos + commodités passent par le cache BD self.detail() (clé = champ25#   `modified` de l'immeuble). Toutes les adresses sont à Toronto (North York,26#   Midtown… = quartiers) : city = « Toronto », sector = neighbourhood du flux.27#28#   connecteur est `disabled` et exclu du registre (zéro impact prod QC).29# -----------------------------------------------------------------------------30from __future__ import annotations3132import html33import os34import re3536from bs4 import BeautifulSoup3738from ..schema import Listing, normalize_unit_type39from .base import BaseConnector4041SITE = "https://www.myrental.ca"42GATEWAY = "https://website-gateway-cdn.rentsync.com/v1/kg_rebuild"43# galerie S3 du client Rentsync (préfixe du compte : « kggroup »)44IMG_BASE = "https://s3.amazonaws.com/lws_lift/kggroup/images/gallery/full"4546# Gate expansion Ontario : le connecteur reste hors registre tant que la47_ONTARIO = True  # Rent-Ka: always on (ROC scope)484950class KgGroupConnector(BaseConnector):51    source_id = "kggroup"52    request_delay = 1.053    disabled = False54    max_properties = 15       # garde-fou (7 immeubles aujourd'hui)55    max_images = 205657    def _api(self, path: str):58        return self.get(f"{GATEWAY}/{path}",59                        headers={"Accept": "application/json",60                                 "Referer": SITE + "/"}).json().get("data")6162    def fetch(self) -> list[Listing]:63        props = self._api("properties") or []6465        listings: list[Listing] = []66        count = 067        for p in props:68            try:69                if (p.get("status") or "").lower() != "enabled":70                    continue71                if count >= self.max_properties:72                    break73                count += 174                listings.extend(self._property_listings(p))75            except Exception:76                continue77        return listings7879    # -- annonces d'un immeuble (une par type d'unité disponible) ---------------80    def _property_listings(self, p: dict) -> list[Listing]:81        pid = str(p.get("id"))82        perma = (p.get("permaLink") or "").strip()83        url = f"{SITE}/apartments-for-rent/{perma}" if perma else SITE84        name = (p.get("buildingName") or "").strip()8586        # adresse complète : rue + Toronto + « ON <postal> » — le parc KG est87        # entièrement torontois (North York/Midtown = quartiers, pas villes)88        street = " ".join(x for x in ((p.get("streetNumber") or "").strip(),89                                      (p.get("streetName") or "").strip()) if x)90        postal = (p.get("postal") or "").strip()91        city = "Toronto"92        sector = (p.get("neighbourhood") or "").strip()93        full_addr = ", ".join(x for x in (street, city) if x) + ", ON"94        if postal:95            full_addr += f" {postal}"9697        # coordonnées GPS du flux (finalize() valide la bbox Ontario)98        try:99            lat = float(p.get("latitude")) if p.get("latitude") else None100            lng = float(p.get("longitude")) if p.get("longitude") else None101        except (TypeError, ValueError):102            lat = lng = None103104        # politique animaux : champs structurés du flux — rien d'inventé105        pets = None106        if p.get("petsNotAllowed"):107            pets = "non"108        elif p.get("petFriendly"):109            pets = "oui"110        elif p.get("petsCats") or p.get("petsSmallDogs") \111                or p.get("petsLargeDogs"):112            pets = "conditions"113114        # description : overview HTML doublement échappé du flux115        desc = BeautifulSoup(html.unescape(p.get("buildingOverview") or ""),116                             "html.parser").get_text(" ", strip=True)[:600]117118        details: dict = {}119        phone = (p.get("phone") or "").strip()120        if phone:121            details["contact"] = {"phone": phone}122123        # photos + commodités via le cache BD : revisitées seulement quand124        # l'immeuble est modifié côté Rentsync125        feed_key = str(p.get("modified") or "")126        d = self.detail(pid, feed_key, lambda: self._fetch_media(pid))127        images = (d.get("images") or [])[: self.max_images]128        amenities = (d.get("amenities") or [])[:25]129130        common = dict(131            source=self.source_id, url=url, address=full_addr, sector=sector,132            city=city, province="ON", pets=pets, description=desc,133            amenities=amenities, images=images, lat=lat, lng=lng,134            details=details,135        )136137        # unités disponibles EN DIRECT (unit-summary) — la donnée vivante138        units = []139        try:140            summary = self._api(f"properties/{pid}/unit-summary") or {}141            units = (((summary.get("availableSummary") or {})142                      .get("available") or {}).get("units")) or []143        except Exception:144            units = []145146        # une annonce par TYPE d'unité disponible (groupé sur typeName)147        groups: dict[str, dict] = {}148        for u in units:149            if u.get("available") != 1 or u.get("hideSuiteTypeWebsite"):150                continue151            key = str(u.get("typeName") or152                      f"{u.get('bed')}-{u.get('bath')}-{u.get('sqFt')}")153            g = groups.setdefault(key, {"units": [], "bed": u.get("bed"),154                                        "bath": u.get("bath"),155                                        "sqft": u.get("sqFt")})156            g["units"].append(u)157158        out: list[Listing] = []159        for key, g in groups.items():160            rates = []161            for u in g["units"]:162                if u.get("hideRateWebsites"):163                    continue164                try:165                    r = float(u.get("rate") or 0)166                except (TypeError, ValueError):167                    r = 0.0168                if r > 0:169                    rates.append(r)170            price = min(rates) if rates else None171            beds = g["bed"]172            try:173                beds = float(beds) if beds is not None else None174            except (TypeError, ValueError):175                beds = None176            baths = g["bath"]177            try:178                baths = float(baths) if baths else None179            except (TypeError, ValueError):180                baths = None181            area = None182            try:183                v = float(g.get("sqft") or 0)184                if 80 <= v <= 20000:185                    area = v186            except (TypeError, ValueError):187                pass188            n = len(g["units"])189            slug = re.sub(r"[^a-z0-9]+", "-", key.lower()).strip("-")190            out.append(Listing(191                external_id=f"{pid}-{slug or 'u'}",192                title=f"{name} — Suite {key}" if name else f"Suite {key}",193                unit_type=("Studio" if beds == 0 else normalize_unit_type(194                    f"{int(beds)} chambres") if beds is not None else ""),195                bedrooms=beds,196                bathrooms=baths,197                price=price,198                price_label=(f"À partir de {price:.0f} $ /mois"199                             if price is not None and n > 1200                             else f"{price:.0f} $ /mois"201                             if price is not None else ""),202                availability=(f"{n} unités disponibles" if n > 1203                              else "Unité disponible"),204                area_sqft=area,205                **common,206            ))207        if out:208            return out209210        # repli : une annonce par immeuble — aucun prix inventé211        return [Listing(212            external_id=pid,213            title=name,214            unit_type="",215            availability="Aucune unité disponible",216            **common,217        )]218219    # -- médias : galerie S3 + commodités (endpoints secondaires) ---------------220    def _fetch_media(self, pid: str) -> dict:221        out: dict = {"images": [], "amenities": []}222        try:223            photos = self._api(f"properties/{pid}/photos") or []224        except Exception:225            photos = []226        images: list[str] = []227        for ph in sorted(photos, key=lambda x: (x or {}).get("orderBy") or 0):228            if not ph.get("active"):229                continue230            f = (ph.get("image") or "").strip()231            if not f:232                continue233            u = f"{IMG_BASE}/{f}"234            if u not in images:235                images.append(u)236        out["images"] = images[: self.max_images]237238        try:239            ams = self._api(f"properties/{pid}/amenities") or []240        except Exception:241            ams = []242        amenities: list[str] = []243        for a in ams:244            t = (a.get("name") or "").strip() if isinstance(a, dict) else ""245            if t and (a.get("status") or "enabled") == "enabled" \246                    and t not in amenities:247                amenities.append(t)248        out["amenities"] = amenities[:25]249        return out250