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%
10.5 KB · 252 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/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#   Expansion Ontario — GATÉE par LOUKA_ONTARIO=1 : sans la variable, le29#   connecteur est `disabled` et exclu du registre (zéro impact prod QC).30# -----------------------------------------------------------------------------31from __future__ import annotations3233import html34import os35import re3637from bs4 import BeautifulSoup3839from ..schema import Listing, normalize_unit_type40from .base import BaseConnector4142SITE = "https://www.myrental.ca"43GATEWAY = "https://website-gateway-cdn.rentsync.com/v1/kg_rebuild"44# galerie S3 du client Rentsync (préfixe du compte : « kggroup »)45IMG_BASE = "https://s3.amazonaws.com/lws_lift/kggroup/images/gallery/full"4647# Gate expansion Ontario : le connecteur reste hors registre tant que la48# variable d'environnement LOUKA_ONTARIO=1 n'est pas posée.49_ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1"505152class KgGroupConnector(BaseConnector):53    source_id = "kggroup"54    request_delay = 1.055    disabled = not _ONTARIO   # gate expansion Ontario (LOUKA_ONTARIO=1)56    max_properties = 15       # garde-fou (7 immeubles aujourd'hui)57    max_images = 205859    def _api(self, path: str):60        return self.get(f"{GATEWAY}/{path}",61                        headers={"Accept": "application/json",62                                 "Referer": SITE + "/"}).json().get("data")6364    def fetch(self) -> list[Listing]:65        props = self._api("properties") or []6667        listings: list[Listing] = []68        count = 069        for p in props:70            try:71                if (p.get("status") or "").lower() != "enabled":72                    continue73                if count >= self.max_properties:74                    break75                count += 176                listings.extend(self._property_listings(p))77            except Exception:78                continue79        return listings8081    # -- annonces d'un immeuble (une par type d'unité disponible) ---------------82    def _property_listings(self, p: dict) -> list[Listing]:83        pid = str(p.get("id"))84        perma = (p.get("permaLink") or "").strip()85        url = f"{SITE}/apartments-for-rent/{perma}" if perma else SITE86        name = (p.get("buildingName") or "").strip()8788        # adresse complète : rue + Toronto + « ON <postal> » — le parc KG est89        # entièrement torontois (North York/Midtown = quartiers, pas villes)90        street = " ".join(x for x in ((p.get("streetNumber") or "").strip(),91                                      (p.get("streetName") or "").strip()) if x)92        postal = (p.get("postal") or "").strip()93        city = "Toronto"94        sector = (p.get("neighbourhood") or "").strip()95        full_addr = ", ".join(x for x in (street, city) if x) + ", ON"96        if postal:97            full_addr += f" {postal}"9899        # coordonnées GPS du flux (finalize() valide la bbox Ontario)100        try:101            lat = float(p.get("latitude")) if p.get("latitude") else None102            lng = float(p.get("longitude")) if p.get("longitude") else None103        except (TypeError, ValueError):104            lat = lng = None105106        # politique animaux : champs structurés du flux — rien d'inventé107        pets = None108        if p.get("petsNotAllowed"):109            pets = "non"110        elif p.get("petFriendly"):111            pets = "oui"112        elif p.get("petsCats") or p.get("petsSmallDogs") \113                or p.get("petsLargeDogs"):114            pets = "conditions"115116        # description : overview HTML doublement échappé du flux117        desc = BeautifulSoup(html.unescape(p.get("buildingOverview") or ""),118                             "html.parser").get_text(" ", strip=True)[:600]119120        details: dict = {}121        phone = (p.get("phone") or "").strip()122        if phone:123            details["contact"] = {"phone": phone}124125        # photos + commodités via le cache BD : revisitées seulement quand126        # l'immeuble est modifié côté Rentsync127        feed_key = str(p.get("modified") or "")128        d = self.detail(pid, feed_key, lambda: self._fetch_media(pid))129        images = (d.get("images") or [])[: self.max_images]130        amenities = (d.get("amenities") or [])[:25]131132        common = dict(133            source=self.source_id, url=url, address=full_addr, sector=sector,134            city=city, province="ON", pets=pets, description=desc,135            amenities=amenities, images=images, lat=lat, lng=lng,136            details=details,137        )138139        # unités disponibles EN DIRECT (unit-summary) — la donnée vivante140        units = []141        try:142            summary = self._api(f"properties/{pid}/unit-summary") or {}143            units = (((summary.get("availableSummary") or {})144                      .get("available") or {}).get("units")) or []145        except Exception:146            units = []147148        # une annonce par TYPE d'unité disponible (groupé sur typeName)149        groups: dict[str, dict] = {}150        for u in units:151            if u.get("available") != 1 or u.get("hideSuiteTypeWebsite"):152                continue153            key = str(u.get("typeName") or154                      f"{u.get('bed')}-{u.get('bath')}-{u.get('sqFt')}")155            g = groups.setdefault(key, {"units": [], "bed": u.get("bed"),156                                        "bath": u.get("bath"),157                                        "sqft": u.get("sqFt")})158            g["units"].append(u)159160        out: list[Listing] = []161        for key, g in groups.items():162            rates = []163            for u in g["units"]:164                if u.get("hideRateWebsites"):165                    continue166                try:167                    r = float(u.get("rate") or 0)168                except (TypeError, ValueError):169                    r = 0.0170                if r > 0:171                    rates.append(r)172            price = min(rates) if rates else None173            beds = g["bed"]174            try:175                beds = float(beds) if beds is not None else None176            except (TypeError, ValueError):177                beds = None178            baths = g["bath"]179            try:180                baths = float(baths) if baths else None181            except (TypeError, ValueError):182                baths = None183            area = None184            try:185                v = float(g.get("sqft") or 0)186                if 80 <= v <= 20000:187                    area = v188            except (TypeError, ValueError):189                pass190            n = len(g["units"])191            slug = re.sub(r"[^a-z0-9]+", "-", key.lower()).strip("-")192            out.append(Listing(193                external_id=f"{pid}-{slug or 'u'}",194                title=f"{name} — Suite {key}" if name else f"Suite {key}",195                unit_type=("Studio" if beds == 0 else normalize_unit_type(196                    f"{int(beds)} chambres") if beds is not None else ""),197                bedrooms=beds,198                bathrooms=baths,199                price=price,200                price_label=(f"À partir de {price:.0f} $ /mois"201                             if price is not None and n > 1202                             else f"{price:.0f} $ /mois"203                             if price is not None else ""),204                availability=(f"{n} unités disponibles" if n > 1205                              else "Unité disponible"),206                area_sqft=area,207                **common,208            ))209        if out:210            return out211212        # repli : une annonce par immeuble — aucun prix inventé213        return [Listing(214            external_id=pid,215            title=name,216            unit_type="",217            availability="Aucune unité disponible",218            **common,219        )]220221    # -- médias : galerie S3 + commodités (endpoints secondaires) ---------------222    def _fetch_media(self, pid: str) -> dict:223        out: dict = {"images": [], "amenities": []}224        try:225            photos = self._api(f"properties/{pid}/photos") or []226        except Exception:227            photos = []228        images: list[str] = []229        for ph in sorted(photos, key=lambda x: (x or {}).get("orderBy") or 0):230            if not ph.get("active"):231                continue232            f = (ph.get("image") or "").strip()233            if not f:234                continue235            u = f"{IMG_BASE}/{f}"236            if u not in images:237                images.append(u)238        out["images"] = images[: self.max_images]239240        try:241            ams = self._api(f"properties/{pid}/amenities") or []242        except Exception:243            ams = []244        amenities: list[str] = []245        for a in ams:246            t = (a.get("name") or "").strip() if isinstance(a, dict) else ""247            if t and (a.get("status") or "enabled") == "enabled" \248                    and t not in amenities:249                amenities.append(t)250        out["amenities"] = amenities[:25]251        return out252