SPB Git forge

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
13.4 KB · 308 lines python
Raw Blame History
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