Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
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