Python 68.8%
TypeScript 18.6%
CSS 8.7%
JavaScript 3.3%
HTML 0.6%
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