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/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# (voir gestion-immobiliere-ontario.md) : sans la variable, toutes les22# classes générées sont `disabled` et donc exclues du registre / de la prod.23# -----------------------------------------------------------------------------24from __future__ import annotations2526import hashlib27import json28import os29import re30from pathlib import Path3132from bs4 import BeautifulSoup3334from ..schema import Listing, normalize_unit_type35from .base import BaseConnector3637# Rent-Ka: connectors always active.38_ONTARIO = True # Rent-Ka: always on (ROC scope)3940REGISTRY_PATH = Path(__file__).resolve().parents[2] / "data" / \41 "liftsystem_clients.json"42LIFT_API = "https://api.theliftsystem.com/v2/search"4344# Jeton universel Rentsync/LiftSystem — le même pour tous les sites clients45# (vérifié dans le main.js de cpliving, parkproperty, williamsandmcdaniel,46# yorkproperty… et validé sur les 13 client_id du registre le 2026-08-26).47# Une entrée du registre peut le surcharger via sa clé "auth_token".48DEFAULT_AUTH_TOKEN = "sswpREkUtyeYjeoahA2i"4950# Rent-Ka scope = every Canadian province/territory EXCEPT Québec. Feeds are51# pan-Canadian; each listing's province comes from its address (province_code),52# the registry entry's "province" is only a documented fallback.53_PROVINCES = {"ON", "BC", "AB", "SK", "MB", "NB", "NS", "PE", "NL",54 "YT", "NT", "NU"}5556# Types de propriété NON résidentiels du flux (property_type, chaîne libre) —57# plusieurs clients mélangent bureaux/commerces (tarifs au pi², ex. « 18 $ »)58# et chantiers sans unités : hors sujet pour Rent-Ka (logements au mois).59_NON_RESIDENTIAL = {60 "office", "retail", "warehouse", "industrial", "commercial", "land",61 "construction", "motel", "hotel", "parking", "storage",62}6364# galerie de la fiche propriété (img + backgrounds CSS), comme centurion.py65_IMG_RE = re.compile(66 r"https://assets\.rentsync\.com/[^\"'\\)\s]+\.(?:jpg|jpeg|png|webp)", re.I)67_SKIP_IMG = re.compile(r"logo|icon|favicon|badge|/thumb", re.I)686970def _load_registry() -> list[dict]:71 """Entrées validées du registre (client_id présent, status != a_verifier)."""72 try:73 data = json.loads(REGISTRY_PATH.read_text("utf-8"))74 except (OSError, ValueError):75 return []76 return [c for c in data.get("clients") or []77 if c.get("client_id") and c.get("status") == "valide"]787980class LiftSystemConnector(BaseConnector):81 """Base commune des connecteurs LiftSystem générés — non enregistrée82 elle-même (source_id vide) ; chaque sous-classe reçoit son entrée de83 registre dans l'attribut de classe `client`."""8485 source_id = "" # les sous-classes générées le définissent86 client: dict = {} # entrée du registre (name, site, client_id…)87 request_delay = 0.7 # politesse — même rythme que centurion.py88 max_properties = 400 # garde-fou (plus gros client : ~204 propriétés)89 max_images = 209091 def fetch(self) -> list[Listing]:92 props = self.get(LIFT_API, params={93 "client_id": str(self.client["client_id"]),94 "auth_token": self.client.get("auth_token") or DEFAULT_AUTH_TOKEN,95 "show_all_properties": "true",96 "show_custom_fields": "true",97 "show_amenities": "true",98 "show_promotions": "true",99 "limit": "1000",100 }, headers={"Accept": "application/json",101 "Referer": (self.client.get("site") or "") + "/"}).json()102103 listings: list[Listing] = []104 for p in props:105 if len(listings) >= self.max_properties: # garde-fou APRÈS filtres106 break107 try:108 addr = p.get("address") or {}109 pc = (addr.get("province_code") or "").upper() or \110 (self.client.get("province") or "").upper()111 if pc not in _PROVINCES:112 continue # pan-Canadian feeds: Québec (and unknown) dropped113 ptype = str(p.get("property_type") or "").strip().lower()114 if ptype in _NON_RESIDENTIAL:115 continue # bureaux/commerces/chantiers : hors sujet116 listings.append(self._listing(p))117 except Exception:118 continue119 return listings120121 # -- une annonce par propriété (mapping aligné sur centurion.py) -----------122 def _listing(self, p: dict) -> Listing:123 pid = str(p.get("id"))124 addr = p.get("address") or {}125 url = p.get("permalink") or self.client.get("listing_url") or \126 self.client.get("site") or ""127 # certains clients publient des permaliens cassés (ex. Shelter :128 # « /images/<slug> » → 404, la vraie fiche est « /residential-rental/ »)129 # — correctif déclaré au registre : "permalink_sub": [avant, après]130 sub = self.client.get("permalink_sub") or []131 if len(sub) == 2 and sub[0] in url:132 url = url.replace(sub[0], sub[1], 1)133 name = (p.get("name") or "").strip()134 prov = (addr.get("province_code") or "").upper() or \135 (self.client.get("province") or "").upper()136137 # full address: street + city + ", <PROV> <postal>" per the feed138 city = re.sub(r"\s+(?:ON|QC|BC|AB|SK|MB|NB|NS|PE|NL|YT|NT|NU)$", "",139 (addr.get("city") or "").strip(), flags=re.I)140 street = (addr.get("address") or "").strip()141 postal = (addr.get("postal_code") or "").strip()142 full_addr = ", ".join(x for x in (street, city) if x)143 if full_addr:144 full_addr += f", {prov} {postal}".rstrip()145 sector = (addr.get("neighbourhood") or "").strip()146147 # coordonnées GPS structurées du flux148 geo = p.get("geocode") or {}149 try:150 lat = float(geo["latitude"]) if geo.get("latitude") else None151 lng = float(geo["longitude"]) if geo.get("longitude") else None152 except (TypeError, ValueError):153 lat = lng = None154155 # sommaire des unités disponibles (rempli seulement s'il y a vacance)156 stats = ((p.get("statistics") or {}).get("suites") or {})157 rates = stats.get("rates") or {}158 beds = stats.get("bedrooms") or {}159 baths = stats.get("bathrooms") or {}160 sqft = stats.get("square_feet") or {}161 price = float(rates["min"]) if rates.get("min") else None162 price_label = ""163 if price is not None:164 price_label = (f"From ${price:,.0f}"165 if rates.get("max") and rates["max"] != rates["min"]166 else f"${price:,.0f}/mo")167 # unit type: only when the range is unambiguous168 unit_type = ""169 if beds.get("min") is not None and beds.get("min") == beds.get("max"):170 n = int(beds["min"])171 unit_type = "Studio" if n == 0 else normalize_unit_type(172 f"{n} bedrooms")173 # superficie : le flux publie parfois « 0.0 » — ignorer174 area = None175 try:176 v = float(sqft.get("min") or 0)177 if 80 <= v <= 20000:178 area = v179 except (TypeError, ValueError):180 pass181182 # disponibilité : libellé du flux (« No Vacancy », « X Vacancies »…)183 availability = (p.get("availability_status_label") or "").strip()184 avail_date = None185 mad = str(p.get("min_availability_date") or "").strip()186 if re.fullmatch(r"20\d{2}-\d{2}-\d{2}", mad[:10]):187 avail_date = mad[:10]188189 # description : aperçu HTML du flux (rendu texte)190 details_src = p.get("details") or {}191 desc = BeautifulSoup(details_src.get("overview") or "",192 "html.parser").get_text(" ", strip=True)193 promo = p.get("promotion") or {}194 promo_txt = (promo.get("title") or promo.get("name") or "").strip() \195 if isinstance(promo, dict) else ""196 if promo_txt:197 desc = f"Promotion: {promo_txt}. {desc}".strip()198199 # commodités : liste du flux + champ personnalisé Rentsync (CSV)200 amenities: list[str] = []201 for a in p.get("amenities") or []:202 t = (a.get("name") if isinstance(a, dict) else str(a) or "").strip()203 if t and t not in amenities:204 amenities.append(t)205 cf = p.get("custom_fields") or {}206 for t in (cf.get("amenities") or "").split(","):207 t = t.strip()208 if t and t not in amenities:209 amenities.append(t)210211 # champs structurés du flux212 details: dict = {}213 contact = p.get("contact") or {}214 if contact.get("phone"):215 details["contact"] = {"phone": contact["phone"]}216 if contact.get("email"):217 details.setdefault("contact", {})["email"] = contact["email"]218 # pet_friendly=false doesn't distinguish "forbidden" from "unknown"219 pets = "yes" if p.get("pet_friendly") is True else None220221 # galerie photo de la fiche propriété — via le cache BD : revisitée222 # seulement quand la ligne du flux change223 feed_key = hashlib.sha1("|".join(str(x) for x in (224 p.get("availability_count"), p.get("availability_status"),225 rates.get("min"), rates.get("max"), mad, p.get("photo"),226 )).encode("utf-8")).hexdigest()227 d = self.detail(pid, feed_key, lambda: self._fetch_gallery(url))228 images = list(d.get("images") or [])229 photo = (p.get("photo_path") or "").strip()230 if photo and photo not in images:231 images.insert(0, photo)232233 final_city = city234235 return Listing(236 source=self.source_id,237 external_id=pid,238 url=url,239 title=name,240 address=full_addr,241 sector=sector,242 city=final_city,243 province=prov,244 unit_type=unit_type,245 price=price,246 price_label=price_label,247 availability=availability,248 availability_date=avail_date,249 area_sqft=area,250 pets=pets,251 description=desc[:600] + (252 f" Bathrooms: {baths['min']:g}+."253 if baths.get("min") else ""),254 amenities=amenities[:25],255 details=details,256 images=images[: self.max_images],257 lat=lat,258 lng=lng,259 )260261 def _fetch_gallery(self, url: str) -> dict:262 """Scrape la galerie photo (assets.rentsync.com) de la fiche propriété."""263 out: dict = {"images": []}264 if not url:265 return out266 try:267 page = self.get(url).text268 except Exception:269 return out270 images: list[str] = []271 for u in _IMG_RE.findall(page):272 if _SKIP_IMG.search(u):273 continue274 # variante pleine résolution de la galerie (…/gallery/full/…)275 u = re.sub(r"/gallery/\d{3,4}/", "/gallery/full/", u)276 if u not in images:277 images.append(u)278 out["images"] = images[: self.max_images]279 return out280281282# -----------------------------------------------------------------------------283# Génération : une sous-classe par client du registre, déposée dans les globals284# du module — connectors/__init__.py (scan de vars(module)) les découvre alors285# comme n'importe quel connecteur écrit à la main.286# -----------------------------------------------------------------------------287def _make_connector(entry: dict) -> type[LiftSystemConnector]:288 cls = type(289 f"Lift{re.sub(r'[^A-Za-z0-9]', '', entry['id']).capitalize()}Connector",290 (LiftSystemConnector,),291 {292 "source_id": f"lift_{entry['id']}",293 "client": entry,294 # (voir gestion-immobiliere-ontario.md)295 "disabled": False,296 "__doc__": f"Connecteur LiftSystem généré — {entry.get('name')} "297 f"(client_id {entry.get('client_id')}).",298 },299 )300 return cls301302303def _register_all() -> None:304 for entry in _load_registry():305 cls = _make_connector(entry)306 globals()[cls.__name__] = cls307308309_register_all()310