# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (Québec + expansion Ontario) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/kggroup.py : connecteur KG Group (myrental.ca) # Gestionnaire familial torontois (~4 700 suites — 7 tours à Midtown, # North York, Yonge & Sheppard). Le site est un Rentsync « nouvelle # génération » (SPA Vue servie par cdn.rentsync.com/site/kg_rebuild) : PAS # le flux LiftSystem classique (api.theliftsystem.com, cf. liftsystem.py) # mais l'API interne « website-gateway » découverte dans les bundles JS : # https://website-gateway-cdn.rentsync.com/v1/kg_rebuild/ # properties -> 7 immeubles (adresse, GPS, quartier, # overview HTML, politique animaux…) # properties//unit-summary -> unités DISPONIBLES en direct # (typeName, bed, bath, sqFt, rate) # properties//photos -> galerie (fichiers S3 lws_lift, servis # en https://s3.amazonaws.com/lws_lift/ # kggroup/images/gallery/full/) # properties//amenities -> commodités nommées # Aucun anti-bot, pas de clé : l'API répond à un simple GET JSON. # Une annonce PAR TYPE D'UNITÉ DISPONIBLE (groupé sur typeName, ex. « 1A »), # prix plancher réel du groupe ; repli « une annonce par immeuble » SANS # prix quand aucune unité n'est affichée (rien d'inventé). unit-summary est # interrogé en direct à chaque synchronisation (c'est la donnée vivante) ; # photos + commodités passent par le cache BD self.detail() (clé = champ # `modified` de l'immeuble). Toutes les adresses sont à Toronto (North York, # Midtown… = quartiers) : city = « Toronto », sector = neighbourhood du flux. # # Expansion Ontario — GATÉE par LOUKA_ONTARIO=1 : sans la variable, le # connecteur est `disabled` et exclu du registre (zéro impact prod QC). # ----------------------------------------------------------------------------- from __future__ import annotations import html import os import re from bs4 import BeautifulSoup from ..schema import Listing, normalize_unit_type from .base import BaseConnector SITE = "https://www.myrental.ca" GATEWAY = "https://website-gateway-cdn.rentsync.com/v1/kg_rebuild" # galerie S3 du client Rentsync (préfixe du compte : « kggroup ») IMG_BASE = "https://s3.amazonaws.com/lws_lift/kggroup/images/gallery/full" # Gate expansion Ontario : le connecteur reste hors registre tant que la # variable d'environnement LOUKA_ONTARIO=1 n'est pas posée. _ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1" class KgGroupConnector(BaseConnector): source_id = "kggroup" request_delay = 1.0 disabled = not _ONTARIO # gate expansion Ontario (LOUKA_ONTARIO=1) max_properties = 15 # garde-fou (7 immeubles aujourd'hui) max_images = 20 def _api(self, path: str): return self.get(f"{GATEWAY}/{path}", headers={"Accept": "application/json", "Referer": SITE + "/"}).json().get("data") def fetch(self) -> list[Listing]: props = self._api("properties") or [] listings: list[Listing] = [] count = 0 for p in props: try: if (p.get("status") or "").lower() != "enabled": continue if count >= self.max_properties: break count += 1 listings.extend(self._property_listings(p)) except Exception: continue return listings # -- annonces d'un immeuble (une par type d'unité disponible) --------------- def _property_listings(self, p: dict) -> list[Listing]: pid = str(p.get("id")) perma = (p.get("permaLink") or "").strip() url = f"{SITE}/apartments-for-rent/{perma}" if perma else SITE name = (p.get("buildingName") or "").strip() # adresse complète : rue + Toronto + « ON » — le parc KG est # entièrement torontois (North York/Midtown = quartiers, pas villes) street = " ".join(x for x in ((p.get("streetNumber") or "").strip(), (p.get("streetName") or "").strip()) if x) postal = (p.get("postal") or "").strip() city = "Toronto" sector = (p.get("neighbourhood") or "").strip() full_addr = ", ".join(x for x in (street, city) if x) + ", ON" if postal: full_addr += f" {postal}" # coordonnées GPS du flux (finalize() valide la bbox Ontario) try: lat = float(p.get("latitude")) if p.get("latitude") else None lng = float(p.get("longitude")) if p.get("longitude") else None except (TypeError, ValueError): lat = lng = None # politique animaux : champs structurés du flux — rien d'inventé pets = None if p.get("petsNotAllowed"): pets = "non" elif p.get("petFriendly"): pets = "oui" elif p.get("petsCats") or p.get("petsSmallDogs") \ or p.get("petsLargeDogs"): pets = "conditions" # description : overview HTML doublement échappé du flux desc = BeautifulSoup(html.unescape(p.get("buildingOverview") or ""), "html.parser").get_text(" ", strip=True)[:600] details: dict = {} phone = (p.get("phone") or "").strip() if phone: details["contact"] = {"phone": phone} # photos + commodités via le cache BD : revisitées seulement quand # l'immeuble est modifié côté Rentsync feed_key = str(p.get("modified") or "") d = self.detail(pid, feed_key, lambda: self._fetch_media(pid)) images = (d.get("images") or [])[: self.max_images] amenities = (d.get("amenities") or [])[:25] common = dict( source=self.source_id, url=url, address=full_addr, sector=sector, city=city, province="ON", pets=pets, description=desc, amenities=amenities, images=images, lat=lat, lng=lng, details=details, ) # unités disponibles EN DIRECT (unit-summary) — la donnée vivante units = [] try: summary = self._api(f"properties/{pid}/unit-summary") or {} units = (((summary.get("availableSummary") or {}) .get("available") or {}).get("units")) or [] except Exception: units = [] # une annonce par TYPE d'unité disponible (groupé sur typeName) groups: dict[str, dict] = {} for u in units: if u.get("available") != 1 or u.get("hideSuiteTypeWebsite"): continue key = str(u.get("typeName") or f"{u.get('bed')}-{u.get('bath')}-{u.get('sqFt')}") g = groups.setdefault(key, {"units": [], "bed": u.get("bed"), "bath": u.get("bath"), "sqft": u.get("sqFt")}) g["units"].append(u) out: list[Listing] = [] for key, g in groups.items(): rates = [] for u in g["units"]: if u.get("hideRateWebsites"): continue try: r = float(u.get("rate") or 0) except (TypeError, ValueError): r = 0.0 if r > 0: rates.append(r) price = min(rates) if rates else None beds = g["bed"] try: beds = float(beds) if beds is not None else None except (TypeError, ValueError): beds = None baths = g["bath"] try: baths = float(baths) if baths else None except (TypeError, ValueError): baths = None area = None try: v = float(g.get("sqft") or 0) if 80 <= v <= 20000: area = v except (TypeError, ValueError): pass n = len(g["units"]) slug = re.sub(r"[^a-z0-9]+", "-", key.lower()).strip("-") out.append(Listing( external_id=f"{pid}-{slug or 'u'}", title=f"{name} — Suite {key}" if name else f"Suite {key}", unit_type=("Studio" if beds == 0 else normalize_unit_type( f"{int(beds)} chambres") if beds is not None else ""), bedrooms=beds, bathrooms=baths, price=price, price_label=(f"À partir de {price:.0f} $ /mois" if price is not None and n > 1 else f"{price:.0f} $ /mois" if price is not None else ""), availability=(f"{n} unités disponibles" if n > 1 else "Unité disponible"), area_sqft=area, **common, )) if out: return out # repli : une annonce par immeuble — aucun prix inventé return [Listing( external_id=pid, title=name, unit_type="", availability="Aucune unité disponible", **common, )] # -- médias : galerie S3 + commodités (endpoints secondaires) --------------- def _fetch_media(self, pid: str) -> dict: out: dict = {"images": [], "amenities": []} try: photos = self._api(f"properties/{pid}/photos") or [] except Exception: photos = [] images: list[str] = [] for ph in sorted(photos, key=lambda x: (x or {}).get("orderBy") or 0): if not ph.get("active"): continue f = (ph.get("image") or "").strip() if not f: continue u = f"{IMG_BASE}/{f}" if u not in images: images.append(u) out["images"] = images[: self.max_images] try: ams = self._api(f"properties/{pid}/amenities") or [] except Exception: ams = [] amenities: list[str] = [] for a in ams: t = (a.get("name") or "").strip() if isinstance(a, dict) else "" if t and (a.get("status") or "enabled") == "enabled" \ and t not in amenities: amenities.append(t) out["amenities"] = amenities[:25] return out