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/louis14.py : connecteur Quartier Louis Quatorze (louis14.ca)5# 424 condos locatifs (immeubles A-B-C-D), Lebourgneuf, Québec (Groupe Damco).6# Le site WordPress n'affiche aucun prix statiquement : la page /plans/7# embarque le sélecteur de plans Livya (app.livya.com, ex-RealVuu). La page8# Next.js du sélecteur est rendue serveur : son flux RSC (`self.__next_f9# .push`) contient les 423 unités du projet avec prix de location réel,10# disponibilité, superficie, étage, orientation, balcon, photos du type et11# plan d'étage — on le parse directement, sans exécuter de JavaScript.12# (403 sans UA navigateur ; le UA de base.py passe, Cloudflare accepte.)13# Seules les unités AVAILABLE deviennent des annonces (prix réels).14# -----------------------------------------------------------------------------15from __future__ import annotations1617import json18import re1920from ..schema import Listing, normalize_unit_type21from .base import BaseConnector2223BASE = "https://louis14.ca"24PLANS_PAGE = f"{BASE}/plans/"25LIVYA = "https://app.livya.com"2627# Valeurs observées sur /plans/ — repli si l'extraction dynamique casse28DEFAULT_CLIENT = "damco"29DEFAULT_PROJECT = "louis-14"30DEFAULT_ENTITY = "e5e643c4-89da-4f82-89e5-8895b67c9b3e" # sélecteur de plan3132# conteneur du module Livya sur /plans/ (l'ordre des attributs peut varier)33_CONTAINER_RE = re.compile(34 r'<div[^>]*class="livya-module-container-plans"[^>]*>', re.I)35_ATTR_RE = re.compile(r'data-(project|entity|lang)="([^"]*)"')36_CLIENT_RE = re.compile(r'<script[^>]*data-client="([^"]+)"[^>]*>')37# fragments RSC de Next.js : self.__next_f.push([1,"…"])38_FLIGHT_RE = re.compile(r'self\.__next_f\.push\(\[1,"((?:[^"\\]|\\.)*)"\]\)')394041class Louis14Connector(BaseConnector):42 source_id = "louis14"43 request_delay = 0.744 max_units = 500 # garde-fou (423 unités au projet)4546 # -- paramètres du module Livya (page /plans/, avec replis) ----------------47 def _module_params(self) -> tuple[str, str, str, str]:48 """(client, project, entity, lang) lus sur /plans/, replis constants."""49 client, project, entity, lang = (DEFAULT_CLIENT, DEFAULT_PROJECT,50 DEFAULT_ENTITY, "fr")51 try:52 page = self.get(PLANS_PAGE).text53 mc = _CLIENT_RE.search(page)54 if mc:55 client = mc.group(1).strip() or client56 md = _CONTAINER_RE.search(page)57 if md:58 attrs = dict(_ATTR_RE.findall(md.group(0)))59 project = attrs.get("project") or project60 entity = attrs.get("entity") or entity61 lang = attrs.get("lang") or lang62 except Exception:63 pass # replis : les constantes observées64 return client, project, entity, lang6566 def fetch(self) -> list[Listing]:67 client, project, entity, lang = self._module_params()68 html = self.get(69 f"{LIVYA}/{lang}/{client}/projects/{project}/plans/{entity}").text7071 # flux RSC : les fragments sont des corps de chaînes JSON — les72 # recoller puis décoder donne le JSON des unités en clair73 blob = "".join(json.loads(f'"{c}"') for c in _FLIGHT_RE.findall(html))7475 buildings = self._buildings(blob)76 units = self._units(blob)7778 listings: list[Listing] = []79 for u in units[: self.max_units]:80 if (u.get("availability") or "").upper() != "AVAILABLE":81 continue # 405 unités NOT_AVAILABLE82 if (u.get("stateCode") or "").upper() != "QC":83 continue # garde-fou province84 listings.append(self._listing(u, buildings))85 return listings8687 # -- extraction du flux RSC -------------------------------------------------88 @staticmethod89 def _units(blob: str) -> list[dict]:90 """Objets unité complets ({"unitId": …}) du flux, dédoublonnés."""91 dec = json.JSONDecoder()92 seen: set[str] = set()93 units: list[dict] = []94 for m in re.finditer(r'\{"unitId"', blob):95 try:96 obj, _ = dec.raw_decode(blob[m.start():m.start() + 30000])97 except Exception:98 continue99 uid = obj.get("unitId")100 if uid and uid not in seen and obj.get("number"):101 seen.add(uid)102 units.append(obj)103 return units104105 @staticmethod106 def _buildings(blob: str) -> dict[str, str]:107 """buildingId -> nom d'immeuble (« QUARTIER A-B », « QUARTIER C-D »)."""108 out: dict[str, str] = {}109 for m in re.finditer(110 r'\{"buildingId":"([0-9a-f-]+)","projectId":"[0-9a-f-]+",'111 r'"phaseId":[^,]*,"name":"([^"]*)"', blob):112 out.setdefault(m.group(1), m.group(2))113 return out114115 # -- une annonce par unité disponible ----------------------------------------116 def _listing(self, u: dict, buildings: dict[str, str]) -> Listing:117 num = str(u.get("number") or "").strip()118 bname = (buildings.get(u.get("buildingId") or "") or "").strip().title()119 title = f"Louis 14 — unité {num}"120 if bname:121 title = f"Louis 14 ({bname}) — unité {num}"122123 # adresse civique portée par chaque unité dans le flux124 street = (u.get("address") or "").strip()125 city = (u.get("city") or "").strip()126 postal = (u.get("postalCode") or "").strip()127 full_addr = ", ".join(x for x in (street, city, postal) if x)128129 price = float(u["rentalPrice"]) if u.get("rentalPrice") else None130131 # disponibilité : AVAILABLE = libre ; futureAvailability.startsOn132 # (ISO) = date structurée de libération133 availability = "Disponible"134 avail_date = None135 fut = u.get("futureAvailability") or {}136 starts = str(fut.get("startsOn") or "")[:10]137 if re.fullmatch(r"20\d{2}-\d{2}-\d{2}", starts):138 avail_date = starts139 availability = f"Disponible à partir du {starts}"140141 # superficie structurée (measurementSystem IMPERIAL -> déjà en pi²)142 area = None143 if (u.get("measurementSystem") or "").upper() == "IMPERIAL":144 size = u.get("unitSize")145 if isinstance(size, (int, float)) and size > 0:146 area = float(size)147148 # étage / orientation / balcon : champs structurés du flux149 details: dict = {}150 floor_disp = (u.get("floorDisplayName") or "").strip()151 try:152 details["floor"] = int(u.get("floorNumber"))153 except (TypeError, ValueError):154 pass155 orientation = (u.get("orientation") or "").strip()156 if orientation:157 details["orientation"] = orientation158 balcony = u.get("balconySize")159 amenities: list[str] = []160 if isinstance(balcony, (int, float)) and balcony > 0:161 details["balcony"] = True162 details["balcony_sqft"] = float(balcony)163 amenities.append(f"Balcon de {balcony:g} pi²")164 if u.get("roomsBed"):165 details["bedrooms"] = int(u["roomsBed"])166 if u.get("roomsBath"):167 details["bathrooms"] = int(u["roomsBath"])168 if floor_disp:169 amenities.append(floor_disp)170 if orientation:171 amenities.append(f"Orientation {orientation}")172173 # photos du type d'unité + plan d'étage (URLs absolues CloudFront)174 images: list[str] = []175 for img in u.get("typeImages") or []:176 url = (img or {}).get("fullUrl")177 if url and url not in images:178 images.append(url)179 plan = u.get("floorPlanImageUrl")180 if plan and plan not in images:181 images.append(plan)182183 try:184 lat = float(u["latitude"]) if u.get("latitude") else None185 lng = float(u["longitude"]) if u.get("longitude") else None186 except (TypeError, ValueError):187 lat = lng = None188189 return Listing(190 source=self.source_id,191 external_id=num, # « 816-C » : stable et unique192 url=PLANS_PAGE, # les unités n'ont pas de page propre193 title=title,194 address=full_addr,195 sector="", # quartier non exposé par le flux196 city=city or "Québec",197 unit_type=normalize_unit_type(str(u.get("rooms") or "")),198 price=price,199 price_label=f"{price:.0f} $ /mois" if price else "",200 availability=availability,201 availability_date=avail_date,202 area_sqft=area,203 description=(u.get("description") or "").strip()[:600],204 amenities=amenities,205 details=details,206 images=images[:15],207 lat=lat,208 lng=lng,209 )210