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/liftsystem.py : connecteur GÉNÉRIQUE Rentsync/LiftSystem5# Des dizaines de gestionnaires (surtout en Ontario) publient leur parc via6# la même plateforme Rentsync « classique », dont le flux JSON officiel est7# `https://api.theliftsystem.com/v2/search?client_id=<N>&auth_token=<token>`.8# Le token est universel (embarqué dans le /scripts/main.js de chaque site) ;9# seul le client_id change. Plutôt que N modules copiés-collés, ce module lit10# le registre data/liftsystem_clients.json ({id, name, site, client_id…},11# découverts et validés en live le 2026-08-26) et GÉNÈRE une sous-classe de12# BaseConnector par client (source_id = "lift_<id>", ex. "lift_greenwin"),13# déposée dans les globals du module pour que le registre auto-découvrant14# (connectors/__init__.py) les enregistre toutes. Mapping identique au15# connecteur canonique centurion.py (même plateforme, client_id 21) : adresse16# complète, geocode, prix min « À partir de », galerie assets.rentsync.com17# via le cache BD self.detail() (revisitée quand la ligne du flux change).18# Différence : les flux sont pancanadiens — on ne garde que ON et QC, et le19# champ Listing.province est renseigné selon la propriété.20#21# Expansion Ontario — activer via LOUKA_ONTARIO=122# (voir gestion-immobiliere-ontario.md) : sans la variable, toutes les23# classes générées sont `disabled` et donc exclues du registre / de la prod.24# -----------------------------------------------------------------------------25from __future__ import annotations2627import hashlib28import json29import os30import re31from pathlib import Path3233from bs4 import BeautifulSoup3435from ..schema import Listing, infer_city, normalize_unit_type36from .base import BaseConnector3738# Gate expansion Ontario : rien ne s'active en prod sans LOUKA_ONTARIO=139_ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1"4041REGISTRY_PATH = Path(__file__).resolve().parents[2] / "data" / \42 "liftsystem_clients.json"43LIFT_API = "https://api.theliftsystem.com/v2/search"4445# Jeton universel Rentsync/LiftSystem — le même pour tous les sites clients46# (vérifié dans le main.js de cpliving, parkproperty, williamsandmcdaniel,47# yorkproperty… et validé sur les 13 client_id du registre le 2026-08-26).48# Une entrée du registre peut le surcharger via sa clé "auth_token".49DEFAULT_AUTH_TOKEN = "sswpREkUtyeYjeoahA2i"5051# Provinces couvertes par Lou-Ka — plusieurs clients sont pancanadiens52# (Shelter Canadian : MB/AB/SK…) : tout le reste est ignoré au fetch.53_PROVINCES = {"ON", "QC"}5455# Types de propriété NON résidentiels du flux (property_type, chaîne libre) —56# plusieurs clients mélangent bureaux/commerces (tarifs au pi², ex. « 18 $ »)57# et chantiers sans unités : hors sujet pour Lou-Ka (logements au mois).58_NON_RESIDENTIAL = {59 "office", "retail", "warehouse", "industrial", "commercial", "land",60 "construction", "motel", "hotel", "parking", "storage",61}6263# galerie de la fiche propriété (img + backgrounds CSS), comme centurion.py64_IMG_RE = re.compile(65 r"https://assets\.rentsync\.com/[^\"'\\)\s]+\.(?:jpg|jpeg|png|webp)", re.I)66_SKIP_IMG = re.compile(r"logo|icon|favicon|badge|/thumb", re.I)676869def _load_registry() -> list[dict]:70 """Entrées validées du registre (client_id présent, status != a_verifier)."""71 try:72 data = json.loads(REGISTRY_PATH.read_text("utf-8"))73 except (OSError, ValueError):74 return []75 return [c for c in data.get("clients") or []76 if c.get("client_id") and c.get("status") == "valide"]777879class LiftSystemConnector(BaseConnector):80 """Base commune des connecteurs LiftSystem générés — non enregistrée81 elle-même (source_id vide) ; chaque sous-classe reçoit son entrée de82 registre dans l'attribut de classe `client`."""8384 source_id = "" # les sous-classes générées le définissent85 client: dict = {} # entrée du registre (name, site, client_id…)86 request_delay = 0.7 # politesse — même rythme que centurion.py87 max_properties = 400 # garde-fou (plus gros client : ~204 propriétés)88 max_images = 208990 def fetch(self) -> list[Listing]:91 props = self.get(LIFT_API, params={92 "client_id": str(self.client["client_id"]),93 "auth_token": self.client.get("auth_token") or DEFAULT_AUTH_TOKEN,94 "show_all_properties": "true",95 "show_custom_fields": "true",96 "show_amenities": "true",97 "show_promotions": "true",98 "limit": "1000",99 }, headers={"Accept": "application/json",100 "Referer": (self.client.get("site") or "") + "/"}).json()101102 listings: list[Listing] = []103 for p in props:104 if len(listings) >= self.max_properties: # garde-fou APRÈS filtres105 break106 try:107 addr = p.get("address") or {}108 if (addr.get("province_code") or "").upper() not in _PROVINCES:109 continue # flux pancanadiens : ON et QC seulement110 ptype = str(p.get("property_type") or "").strip().lower()111 if ptype in _NON_RESIDENTIAL:112 continue # bureaux/commerces/chantiers : hors sujet113 listings.append(self._listing(p))114 except Exception:115 continue116 return listings117118 # -- une annonce par propriété (mapping aligné sur centurion.py) -----------119 def _listing(self, p: dict) -> Listing:120 pid = str(p.get("id"))121 addr = p.get("address") or {}122 url = p.get("permalink") or self.client.get("listing_url") or \123 self.client.get("site") or ""124 # certains clients publient des permaliens cassés (ex. Shelter :125 # « /images/<slug> » → 404, la vraie fiche est « /residential-rental/ »)126 # — correctif déclaré au registre : "permalink_sub": [avant, après]127 sub = self.client.get("permalink_sub") or []128 if len(sub) == 2 and sub[0] in url:129 url = url.replace(sub[0], sub[1], 1)130 name = (p.get("name") or "").strip()131 prov = (addr.get("province_code") or "").upper()132133 # adresse complète : rue + ville + « , ON/QC <postal> » selon le flux134 city = re.sub(r"\s+(?:ON|QC)$", "",135 (addr.get("city") or "").strip(), flags=re.I)136 street = (addr.get("address") or "").strip()137 postal = (addr.get("postal_code") or "").strip()138 full_addr = ", ".join(x for x in (street, city) if x)139 if full_addr:140 full_addr += f", {prov} {postal}".rstrip()141 sector = (addr.get("neighbourhood") or "").strip()142143 # coordonnées GPS structurées du flux144 geo = p.get("geocode") or {}145 try:146 lat = float(geo["latitude"]) if geo.get("latitude") else None147 lng = float(geo["longitude"]) if geo.get("longitude") else None148 except (TypeError, ValueError):149 lat = lng = None150151 # sommaire des unités disponibles (rempli seulement s'il y a vacance)152 stats = ((p.get("statistics") or {}).get("suites") or {})153 rates = stats.get("rates") or {}154 beds = stats.get("bedrooms") or {}155 baths = stats.get("bathrooms") or {}156 sqft = stats.get("square_feet") or {}157 price = float(rates["min"]) if rates.get("min") else None158 price_label = ""159 if price is not None:160 price_label = (f"À partir de {price:.0f} $"161 if rates.get("max") and rates["max"] != rates["min"]162 else f"{price:.0f} $ /mois")163 # type d'unité : seulement si la gamme est sans ambiguïté164 unit_type = ""165 if beds.get("min") is not None and beds.get("min") == beds.get("max"):166 n = int(beds["min"])167 unit_type = "Studio" if n == 0 else normalize_unit_type(168 f"{n} chambres")169 # superficie : le flux publie parfois « 0.0 » — ignorer170 area = None171 try:172 v = float(sqft.get("min") or 0)173 if 80 <= v <= 20000:174 area = v175 except (TypeError, ValueError):176 pass177178 # disponibilité : libellé du flux (« No Vacancy », « X Vacancies »…)179 availability = (p.get("availability_status_label") or "").strip()180 avail_date = None181 mad = str(p.get("min_availability_date") or "").strip()182 if re.fullmatch(r"20\d{2}-\d{2}-\d{2}", mad[:10]):183 avail_date = mad[:10]184185 # description : aperçu HTML du flux (rendu texte)186 details_src = p.get("details") or {}187 desc = BeautifulSoup(details_src.get("overview") or "",188 "html.parser").get_text(" ", strip=True)189 promo = p.get("promotion") or {}190 promo_txt = (promo.get("title") or promo.get("name") or "").strip() \191 if isinstance(promo, dict) else ""192 if promo_txt:193 desc = f"Promotion : {promo_txt}. {desc}".strip()194195 # commodités : liste du flux + champ personnalisé Rentsync (CSV)196 amenities: list[str] = []197 for a in p.get("amenities") or []:198 t = (a.get("name") if isinstance(a, dict) else str(a) or "").strip()199 if t and t not in amenities:200 amenities.append(t)201 cf = p.get("custom_fields") or {}202 for t in (cf.get("amenities") or "").split(","):203 t = t.strip()204 if t and t not in amenities:205 amenities.append(t)206207 # champs structurés du flux208 details: dict = {}209 contact = p.get("contact") or {}210 if contact.get("phone"):211 details["contact"] = {"phone": contact["phone"]}212 if contact.get("email"):213 details.setdefault("contact", {})["email"] = contact["email"]214 # pet_friendly=false ne distingue pas « interdit » de « non renseigné »215 pets = "oui" if p.get("pet_friendly") is True else None216217 # galerie photo de la fiche propriété — via le cache BD : revisitée218 # seulement quand la ligne du flux change219 feed_key = hashlib.sha1("|".join(str(x) for x in (220 p.get("availability_count"), p.get("availability_status"),221 rates.get("min"), rates.get("max"), mad, p.get("photo"),222 )).encode("utf-8")).hexdigest()223 d = self.detail(pid, feed_key, lambda: self._fetch_gallery(url))224 images = list(d.get("images") or [])225 photo = (p.get("photo_path") or "").strip()226 if photo and photo not in images:227 images.insert(0, photo)228229 # infer_city (secteur -> Lévis…) n'a de sens que côté Québec230 final_city = infer_city(sector, default=city) if prov == "QC" else city231232 return Listing(233 source=self.source_id,234 external_id=pid,235 url=url,236 title=name,237 address=full_addr,238 sector=sector,239 city=final_city,240 province=prov,241 unit_type=unit_type,242 price=price,243 price_label=price_label,244 availability=availability,245 availability_date=avail_date,246 area_sqft=area,247 pets=pets,248 description=desc[:600] + (249 f" Salles de bain : {baths['min']:g}+."250 if baths.get("min") else ""),251 amenities=amenities[:25],252 details=details,253 images=images[: self.max_images],254 lat=lat,255 lng=lng,256 )257258 def _fetch_gallery(self, url: str) -> dict:259 """Scrape la galerie photo (assets.rentsync.com) de la fiche propriété."""260 out: dict = {"images": []}261 if not url:262 return out263 try:264 page = self.get(url).text265 except Exception:266 return out267 images: list[str] = []268 for u in _IMG_RE.findall(page):269 if _SKIP_IMG.search(u):270 continue271 # variante pleine résolution de la galerie (…/gallery/full/…)272 u = re.sub(r"/gallery/\d{3,4}/", "/gallery/full/", u)273 if u not in images:274 images.append(u)275 out["images"] = images[: self.max_images]276 return out277278279# -----------------------------------------------------------------------------280# Génération : une sous-classe par client du registre, déposée dans les globals281# du module — connectors/__init__.py (scan de vars(module)) les découvre alors282# comme n'importe quel connecteur écrit à la main.283# -----------------------------------------------------------------------------284def _make_connector(entry: dict) -> type[LiftSystemConnector]:285 cls = type(286 f"Lift{re.sub(r'[^A-Za-z0-9]', '', entry['id']).capitalize()}Connector",287 (LiftSystemConnector,),288 {289 "source_id": f"lift_{entry['id']}",290 "client": entry,291 # Expansion Ontario — activer via LOUKA_ONTARIO=1292 # (voir gestion-immobiliere-ontario.md)293 "disabled": not _ONTARIO,294 "__doc__": f"Connecteur LiftSystem généré — {entry.get('name')} "295 f"(client_id {entry.get('client_id')}).",296 },297 )298 return cls299300301def _register_all() -> None:302 for entry in _load_registry():303 cls = _make_connector(entry)304 globals()[cls.__name__] = cls305306307_register_all()308