spb/lou-ka Public
Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 99.7%
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