SPB Git forge

spb/rent-ka

Public
8commits 1branches 0releases
7.4 MBsize
maindefault branch
19 days agolast push
Python 68.8% TypeScript 18.6% CSS 8.7% JavaScript 3.3% HTML 0.6%
13.4 KB · 310 lines python
Raw Blame History
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