SPB Git

spb/lou-ka Public

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

HTML 99.7%
10.5 KB · 250 lines python
Raw Blame History
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/thrace.py : connecteur Gestions Thrace (gestionsthrace.com)5#   Plus gros gestionnaire de la Mauricie (~2 500 logements — Trois-Rivières,6#   Cap-de-la-Madeleine, Bécancour). Site WordPress (Bricks) ; la page7#   /fr/residential embarque le module « inventory » Livya (app.livya.com,8#   client « thrace »). Contrairement au module « plans » (somex, louis14,9#   quartier_sila), le module inventory charge ses unités côté client via10#   api.realvuu.com — dont le robots.txt interdit tout accès : on ne l'utilise11#   PAS. À la place, les pages « plans » des trois projets Livya du client12#   (thrace, radisson, carree-radisson — découverts via le sitemap public13#   d'app.livya.com, robots permissif) sont rendues côté serveur : leur flux14#   RSC (self.__next_f.push) contient l'inventaire JSON complet — unités15#   ({"unitId": …} : statut, loyer, pièces, inclusions, adresse civique, GPS,16#   photos) et immeubles ({"buildingId": …} : nom, slug). 5 requêtes par sync.17#   Unités AVAILABLE, résidentielles et locatives seulement (prix réels).18# -----------------------------------------------------------------------------19from __future__ import annotations2021import json22import re2324from ..schema import Listing25from .base import BaseConnector2627BASE = "https://www.gestionsthrace.com"28LIST_URL = f"{BASE}/fr/residential"29LIVYA = "https://app.livya.com"30SITEMAP = f"{LIVYA}/server-sitemap.xml"3132# Valeurs observées au 2026-08 — replis si la découverte dynamique casse33DEFAULT_CLIENT = "thrace"34DEFAULT_ENTITY = "fb25970c-6c5f-4253-a396-7669992522b0"     # module inventory35DEFAULT_PROJECTS = ["thrace", "radisson", "carree-radisson"]3637# fragments RSC de Next.js : self.__next_f.push([1,"…"])38_FLIGHT_RE = re.compile(r'self\.__next_f\.push\(\[1,"((?:[^"\\]|\\.)*)"\]\)')39# immeuble du flux : buildingId, name et slug (ordre des champs stable)40_BUILDING_RE = re.compile(41    r'\{"buildingId":"([0-9a-f-]+)","projectId":"[0-9a-f-]+",'42    r'"phaseId":[^,]*,"name":"([^"]*)","shortName":"[^"]*","slug":"([^"]*)"')434445def _flight_blob(html: str) -> str:46    """Concatène et décode les fragments RSC d'une page Livya (JSON en clair)."""47    return "".join(json.loads(f'"{c}"') for c in _FLIGHT_RE.findall(html))484950def _unit_type(rooms) -> str:51    """3.5 -> « 3½ » ; < 1 -> « Studio » (finalize gère 6½+)."""52    try:53        r = float(rooms or 0)54    except (TypeError, ValueError):55        return ""56    if not r:57        return ""58    if r < 1:59        return "Studio"60    return f"{int(r)}½"616263class ThraceConnector(BaseConnector):64    source_id = "thrace"65    request_delay = 0.866    max_units = 1500            # garde-fou (~430 unités publiées par projet)6768    # -- découverte des paramètres Livya ----------------------------------------69    def _module_params(self) -> tuple[str, str]:70        """(client, entity) lus sur /fr/residential, replis constants."""71        client, entity = DEFAULT_CLIENT, DEFAULT_ENTITY72        try:73            page = self.get(LIST_URL).text74            mc = re.search(r'<script[^>]*data-client="([^"]+)"', page)75            if mc:76                client = mc.group(1).strip() or client77            md = re.search(78                r'<[^>]*livya-module-container-inventory[^>]*'79                r'data-entity="([0-9a-f-]+)"', page)80            if md:81                entity = md.group(1)82        except Exception:83            pass    # replis : les constantes observées84        return client, entity8586    def _project_slugs(self, client: str) -> list[str]:87        """Slugs des projets du client dans le sitemap public d'app.livya.com."""88        try:89            xml = self.get(SITEMAP).text90        except Exception:91            return list(DEFAULT_PROJECTS)92        slugs = list(dict.fromkeys(re.findall(93            rf"{LIVYA}/fr/{re.escape(client)}/proj(?:ets|ects)/([a-z0-9-]+)<",94            xml)))95        return slugs or list(DEFAULT_PROJECTS)9697    # -- extraction du flux RSC ---------------------------------------------------98    @staticmethod99    def _units(blob: str) -> list[dict]:100        """Objets unité complets ({"unitId": …}) du flux, dédoublonnés."""101        dec = json.JSONDecoder()102        seen: set[str] = set()103        units: list[dict] = []104        for m in re.finditer(r'\{"unitId"', blob):105            try:106                obj, _ = dec.raw_decode(blob[m.start():m.start() + 60000])107            except Exception:108                continue109            uid = obj.get("unitId")110            if uid and uid not in seen and obj.get("number"):111                seen.add(uid)112                units.append(obj)113        return units114115    def fetch(self) -> list[Listing]:116        client, entity = self._module_params()117        projects = self._project_slugs(client)118119        units: dict[str, dict] = {}120        buildings: dict[str, tuple[str, str]] = {}   # id -> (nom, slug)121        for slug in projects:122            try:123                html = self.get(124                    f"{LIVYA}/fr/{client}/projects/{slug}/plans/{entity}",125                    params={"noLayout": "1"}).text126            except Exception:127                continue                     # un projet cassé ne bloque pas128            blob = _flight_blob(html)129            for u in self._units(blob):130                units.setdefault(u["unitId"], u)131            for bid, name, bslug in _BUILDING_RE.findall(blob):132                buildings.setdefault(bid, (name, bslug))133134        listings: list[Listing] = []135        for u in list(units.values())[: self.max_units]:136            if (u.get("availability") or "").upper() != "AVAILABLE":137                continue138            if not u.get("rental", True):139                continue                     # unités en vente140            if (u.get("segment") or "").upper() != "RESIDENTIAL":141                continue                     # locaux commerciaux exclus142            if (u.get("stateCode") or "QC").upper() != "QC":143                continue                     # garde-fou province144            listings.append(self._listing(u, buildings))145        return listings146147    # -- une annonce par unité disponible ----------------------------------------148    def _listing(self, u: dict, buildings: dict[str, tuple[str, str]]) -> Listing:149        num = str(u.get("number") or "").strip()150        bname, bslug = buildings.get(u.get("buildingId") or "", ("", ""))151152        # le portail WordPress accepte ?building=<slug> (module inventory)153        url = f"{LIST_URL}?building={bslug}" if bslug else LIST_URL154        title = (f"Gestions Thrace ({bname}) — unité {num}" if bname155                 else f"Gestions Thrace — unité {num}")156157        address = ", ".join(x for x in (158            (u.get("address") or "").strip(), (u.get("city") or "").strip(),159            (u.get("postalCode") or "").strip()) if x)160161        price = u.get("rentalPrice")162        price = float(price) if isinstance(price, (int, float)) and price > 0 else None163164        # disponibilité : date de libération future publiée, sinon « Disponible »165        availability = "Disponible"166        avail_date = None167        fut = u.get("futureAvailability") or {}168        starts = str(fut.get("startsOn") or "")[:10]169        if re.fullmatch(r"20\d{2}-\d{2}-\d{2}", starts):170            avail_date = starts171            availability = f"Disponible à partir du {starts}"172173        # superficie structurée (measurementSystem IMPERIAL -> déjà en pi²)174        area = None175        if (u.get("measurementSystem") or "IMPERIAL").upper() == "IMPERIAL":176            size = u.get("unitSize")177            if isinstance(size, (int, float)) and size > 0:178                area = float(size)179180        # inclusions et caractéristiques publiées par unité (« Eau chaude »,181        # « Animaux interdits », « Stationnement: 1 extérieur »…)182        amenities = [a["name"] for a in (u.get("attributes") or [])183                     if isinstance(a, dict) and a.get("name")]184185        # description : étage, modèle, pièces, balcon, précision de prix, plan186        desc: list[str] = []187        floor_disp = str(u.get("floorDisplayName") or "").strip()188        if floor_disp:189            desc.append(floor_disp)190        if u.get("typeName"):191            desc.append(f"Modèle {u['typeName']}")192        if u.get("roomsBed"):193            desc.append(f"{u['roomsBed']} chambre(s)")194        if u.get("roomsBath"):195            desc.append(f"{u['roomsBath']} salle(s) de bain")196        if u.get("balconySize"):197            desc.append(f"Balcon de {u['balconySize']} pi²")198        precision = str(u.get("pricePrecision") or "").strip()199        if precision:200            desc.append(precision)201        libre = str(u.get("description") or "").strip()202        if libre:203            desc.append(libre)204        if u.get("floorPlanUrl"):205            desc.append(f"Plan : {u['floorPlanUrl']}")206207        details: dict = {}208        if bname:209            details["building"] = bname210        floor_no = str(u.get("floorNumber") or "")211        if floor_no.isdigit():212            details["floor"] = int(floor_no)213        if u.get("roomsBed"):214            details["bedrooms"] = int(u["roomsBed"])215        if u.get("roomsBath"):216            details["bathrooms"] = int(u["roomsBath"])217218        images = [img.get("fullUrl") for img in (u.get("typeImages") or [])219                  if isinstance(img, dict) and img.get("fullUrl")]220        if u.get("floorPlanImageUrl"):221            images.append(u["floorPlanImageUrl"])222223        try:224            lat = float(u["latitude"]) if u.get("latitude") else None225            lng = float(u["longitude"]) if u.get("longitude") else None226        except (TypeError, ValueError):227            lat = lng = None228229        return Listing(230            source=self.source_id,231            external_id=str(u["unitId"]),          # UUID Livya : stable232            url=url,233            title=title,234            address=address,235            sector="",                             # non exposé par le flux236            city=(u.get("city") or "").strip() or "Trois-Rivières",237            unit_type=_unit_type(u.get("rooms")),238            price=price,239            price_label=f"{price:.0f} $ /mois" if price else "",240            availability=availability,241            availability_date=avail_date,242            area_sqft=area,243            description=" | ".join(desc),244            amenities=amenities,245            details=details,246            images=images[:12],247            lat=lat,248            lng=lng,249        )250