# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (Québec + expansion Ontario) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/liftsystem.py : connecteur GÉNÉRIQUE Rentsync/LiftSystem # Des dizaines de gestionnaires (surtout en Ontario) publient leur parc via # la même plateforme Rentsync « classique », dont le flux JSON officiel est # `https://api.theliftsystem.com/v2/search?client_id=&auth_token=`. # Le token est universel (embarqué dans le /scripts/main.js de chaque site) ; # seul le client_id change. Plutôt que N modules copiés-collés, ce module lit # le registre data/liftsystem_clients.json ({id, name, site, client_id…}, # découverts et validés en live le 2026-08-26) et GÉNÈRE une sous-classe de # BaseConnector par client (source_id = "lift_", ex. "lift_greenwin"), # déposée dans les globals du module pour que le registre auto-découvrant # (connectors/__init__.py) les enregistre toutes. Mapping identique au # connecteur canonique centurion.py (même plateforme, client_id 21) : adresse # complète, geocode, prix min « À partir de », galerie assets.rentsync.com # via le cache BD self.detail() (revisitée quand la ligne du flux change). # Différence : les flux sont pancanadiens — on ne garde que ON et QC, et le # champ Listing.province est renseigné selon la propriété. # # Expansion Ontario — activer via LOUKA_ONTARIO=1 # (voir gestion-immobiliere-ontario.md) : sans la variable, toutes les # classes générées sont `disabled` et donc exclues du registre / de la prod. # ----------------------------------------------------------------------------- from __future__ import annotations import hashlib import json import os import re from pathlib import Path from bs4 import BeautifulSoup from ..schema import Listing, infer_city, normalize_unit_type from .base import BaseConnector # Gate expansion Ontario : rien ne s'active en prod sans LOUKA_ONTARIO=1 _ONTARIO = os.environ.get("LOUKA_ONTARIO") == "1" REGISTRY_PATH = Path(__file__).resolve().parents[2] / "data" / \ "liftsystem_clients.json" LIFT_API = "https://api.theliftsystem.com/v2/search" # Jeton universel Rentsync/LiftSystem — le même pour tous les sites clients # (vérifié dans le main.js de cpliving, parkproperty, williamsandmcdaniel, # yorkproperty… et validé sur les 13 client_id du registre le 2026-08-26). # Une entrée du registre peut le surcharger via sa clé "auth_token". DEFAULT_AUTH_TOKEN = "sswpREkUtyeYjeoahA2i" # Provinces couvertes par Lou-Ka — plusieurs clients sont pancanadiens # (Shelter Canadian : MB/AB/SK…) : tout le reste est ignoré au fetch. _PROVINCES = {"ON", "QC"} # Types de propriété NON résidentiels du flux (property_type, chaîne libre) — # plusieurs clients mélangent bureaux/commerces (tarifs au pi², ex. « 18 $ ») # et chantiers sans unités : hors sujet pour Lou-Ka (logements au mois). _NON_RESIDENTIAL = { "office", "retail", "warehouse", "industrial", "commercial", "land", "construction", "motel", "hotel", "parking", "storage", } # galerie de la fiche propriété (img + backgrounds CSS), comme centurion.py _IMG_RE = re.compile( r"https://assets\.rentsync\.com/[^\"'\\)\s]+\.(?:jpg|jpeg|png|webp)", re.I) _SKIP_IMG = re.compile(r"logo|icon|favicon|badge|/thumb", re.I) def _load_registry() -> list[dict]: """Entrées validées du registre (client_id présent, status != a_verifier).""" try: data = json.loads(REGISTRY_PATH.read_text("utf-8")) except (OSError, ValueError): return [] return [c for c in data.get("clients") or [] if c.get("client_id") and c.get("status") == "valide"] class LiftSystemConnector(BaseConnector): """Base commune des connecteurs LiftSystem générés — non enregistrée elle-même (source_id vide) ; chaque sous-classe reçoit son entrée de registre dans l'attribut de classe `client`.""" source_id = "" # les sous-classes générées le définissent client: dict = {} # entrée du registre (name, site, client_id…) request_delay = 0.7 # politesse — même rythme que centurion.py max_properties = 400 # garde-fou (plus gros client : ~204 propriétés) max_images = 20 def fetch(self) -> list[Listing]: props = self.get(LIFT_API, params={ "client_id": str(self.client["client_id"]), "auth_token": self.client.get("auth_token") or DEFAULT_AUTH_TOKEN, "show_all_properties": "true", "show_custom_fields": "true", "show_amenities": "true", "show_promotions": "true", "limit": "1000", }, headers={"Accept": "application/json", "Referer": (self.client.get("site") or "") + "/"}).json() listings: list[Listing] = [] for p in props: if len(listings) >= self.max_properties: # garde-fou APRÈS filtres break try: addr = p.get("address") or {} if (addr.get("province_code") or "").upper() not in _PROVINCES: continue # flux pancanadiens : ON et QC seulement ptype = str(p.get("property_type") or "").strip().lower() if ptype in _NON_RESIDENTIAL: continue # bureaux/commerces/chantiers : hors sujet listings.append(self._listing(p)) except Exception: continue return listings # -- une annonce par propriété (mapping aligné sur centurion.py) ----------- def _listing(self, p: dict) -> Listing: pid = str(p.get("id")) addr = p.get("address") or {} url = p.get("permalink") or self.client.get("listing_url") or \ self.client.get("site") or "" # certains clients publient des permaliens cassés (ex. Shelter : # « /images/ » → 404, la vraie fiche est « /residential-rental/ ») # — correctif déclaré au registre : "permalink_sub": [avant, après] sub = self.client.get("permalink_sub") or [] if len(sub) == 2 and sub[0] in url: url = url.replace(sub[0], sub[1], 1) name = (p.get("name") or "").strip() prov = (addr.get("province_code") or "").upper() # adresse complète : rue + ville + « , ON/QC » selon le flux city = re.sub(r"\s+(?:ON|QC)$", "", (addr.get("city") or "").strip(), flags=re.I) street = (addr.get("address") or "").strip() postal = (addr.get("postal_code") or "").strip() full_addr = ", ".join(x for x in (street, city) if x) if full_addr: full_addr += f", {prov} {postal}".rstrip() sector = (addr.get("neighbourhood") or "").strip() # coordonnées GPS structurées du flux geo = p.get("geocode") or {} try: lat = float(geo["latitude"]) if geo.get("latitude") else None lng = float(geo["longitude"]) if geo.get("longitude") else None except (TypeError, ValueError): lat = lng = None # sommaire des unités disponibles (rempli seulement s'il y a vacance) stats = ((p.get("statistics") or {}).get("suites") or {}) rates = stats.get("rates") or {} beds = stats.get("bedrooms") or {} baths = stats.get("bathrooms") or {} sqft = stats.get("square_feet") or {} price = float(rates["min"]) if rates.get("min") else None price_label = "" if price is not None: price_label = (f"À partir de {price:.0f} $" if rates.get("max") and rates["max"] != rates["min"] else f"{price:.0f} $ /mois") # type d'unité : seulement si la gamme est sans ambiguïté unit_type = "" if beds.get("min") is not None and beds.get("min") == beds.get("max"): n = int(beds["min"]) unit_type = "Studio" if n == 0 else normalize_unit_type( f"{n} chambres") # superficie : le flux publie parfois « 0.0 » — ignorer area = None try: v = float(sqft.get("min") or 0) if 80 <= v <= 20000: area = v except (TypeError, ValueError): pass # disponibilité : libellé du flux (« No Vacancy », « X Vacancies »…) availability = (p.get("availability_status_label") or "").strip() avail_date = None mad = str(p.get("min_availability_date") or "").strip() if re.fullmatch(r"20\d{2}-\d{2}-\d{2}", mad[:10]): avail_date = mad[:10] # description : aperçu HTML du flux (rendu texte) details_src = p.get("details") or {} desc = BeautifulSoup(details_src.get("overview") or "", "html.parser").get_text(" ", strip=True) promo = p.get("promotion") or {} promo_txt = (promo.get("title") or promo.get("name") or "").strip() \ if isinstance(promo, dict) else "" if promo_txt: desc = f"Promotion : {promo_txt}. {desc}".strip() # commodités : liste du flux + champ personnalisé Rentsync (CSV) amenities: list[str] = [] for a in p.get("amenities") or []: t = (a.get("name") if isinstance(a, dict) else str(a) or "").strip() if t and t not in amenities: amenities.append(t) cf = p.get("custom_fields") or {} for t in (cf.get("amenities") or "").split(","): t = t.strip() if t and t not in amenities: amenities.append(t) # champs structurés du flux details: dict = {} contact = p.get("contact") or {} if contact.get("phone"): details["contact"] = {"phone": contact["phone"]} if contact.get("email"): details.setdefault("contact", {})["email"] = contact["email"] # pet_friendly=false ne distingue pas « interdit » de « non renseigné » pets = "oui" if p.get("pet_friendly") is True else None # galerie photo de la fiche propriété — via le cache BD : revisitée # seulement quand la ligne du flux change feed_key = hashlib.sha1("|".join(str(x) for x in ( p.get("availability_count"), p.get("availability_status"), rates.get("min"), rates.get("max"), mad, p.get("photo"), )).encode("utf-8")).hexdigest() d = self.detail(pid, feed_key, lambda: self._fetch_gallery(url)) images = list(d.get("images") or []) photo = (p.get("photo_path") or "").strip() if photo and photo not in images: images.insert(0, photo) # infer_city (secteur -> Lévis…) n'a de sens que côté Québec final_city = infer_city(sector, default=city) if prov == "QC" else city return Listing( source=self.source_id, external_id=pid, url=url, title=name, address=full_addr, sector=sector, city=final_city, province=prov, unit_type=unit_type, price=price, price_label=price_label, availability=availability, availability_date=avail_date, area_sqft=area, pets=pets, description=desc[:600] + ( f" Salles de bain : {baths['min']:g}+." if baths.get("min") else ""), amenities=amenities[:25], details=details, images=images[: self.max_images], lat=lat, lng=lng, ) def _fetch_gallery(self, url: str) -> dict: """Scrape la galerie photo (assets.rentsync.com) de la fiche propriété.""" out: dict = {"images": []} if not url: return out try: page = self.get(url).text except Exception: return out images: list[str] = [] for u in _IMG_RE.findall(page): if _SKIP_IMG.search(u): continue # variante pleine résolution de la galerie (…/gallery/full/…) u = re.sub(r"/gallery/\d{3,4}/", "/gallery/full/", u) if u not in images: images.append(u) out["images"] = images[: self.max_images] return out # ----------------------------------------------------------------------------- # Génération : une sous-classe par client du registre, déposée dans les globals # du module — connectors/__init__.py (scan de vars(module)) les découvre alors # comme n'importe quel connecteur écrit à la main. # ----------------------------------------------------------------------------- def _make_connector(entry: dict) -> type[LiftSystemConnector]: cls = type( f"Lift{re.sub(r'[^A-Za-z0-9]', '', entry['id']).capitalize()}Connector", (LiftSystemConnector,), { "source_id": f"lift_{entry['id']}", "client": entry, # Expansion Ontario — activer via LOUKA_ONTARIO=1 # (voir gestion-immobiliere-ontario.md) "disabled": not _ONTARIO, "__doc__": f"Connecteur LiftSystem généré — {entry.get('name')} " f"(client_id {entry.get('client_id')}).", }, ) return cls def _register_all() -> None: for entry in _load_registry(): cls = _make_connector(entry) globals()[cls.__name__] = cls _register_all()