# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/le_fjord.py : connecteur Le Fjord (le-fjord.com) — condos # locatifs tout inclus au 11, rue Fortier à Lévis (occupation printemps # 2027). Le site WordPress/Elementor embarque le widget Livya (Graph # Synergie) : la page plans app.livya.com/fr/fjord/projects/le-fjord/ # plans/ (Next.js) sérialise dans son flux RSC # (self.__next_f.push) un tableau "units" complet — unitId, no, étage, # pièces (5.5), chambres, sdb, PRIX mensuel, pi², adresse, lat/lng, # availability AVAILABLE / RESERVED / NOT_AVAILABLE. # Granularité : unité, avec prix. # ----------------------------------------------------------------------------- from __future__ import annotations import json import re from ..schema import Listing, normalize_unit_type from .base import BaseConnector PAGE_URL = "https://www.le-fjord.com/unites/" # widget Livya : data-client / data-project / data-entity de /unites/ LIVYA_URL = ("https://app.livya.com/fr/fjord/projects/le-fjord/plans/" "0f1e45d7-a5e2-4efd-9f23-6c31fd915001?noLayout=1") CHUNK_RE = re.compile(r'self\.__next_f\.push\(\[1,"(.*?)"\]\)', re.S) UNITS_KEY = '"units":[' ADDRESS = "11, rue Fortier, Lévis" AMENITIES = ["Tout inclus", "5 électroménagers", "Internet haute vitesse", "Chauffage et climatisation", "Eau chaude"] CONTACT = {"phone": "581-318-3628"} def _rsc_payload(html: str) -> str: """Concatène et décode les chunks RSC Next.js (JS string escapes).""" payload = "".join(c.encode().decode("unicode_escape") for c in CHUNK_RE.findall(html)) return payload.encode("latin-1", "ignore").decode("utf-8", "ignore") def _balanced_array(s: str, start: int) -> str | None: """Extrait le tableau JSON équilibré commençant à s[start] == '['.""" depth = 0 for i in range(start, len(s)): if s[i] == "[": depth += 1 elif s[i] == "]": depth -= 1 if depth == 0: return s[start:i + 1] return None class LeFjordConnector(BaseConnector): source_id = "le_fjord" request_delay = 0.6 def fetch(self) -> list[Listing]: # échec HONNÊTE : une erreur réseau ou un rendu Next.js partiel (200 # sans tableau "units" dans le flux RSC) doit lever (ok=0 dans # sync_log) et non retourner [] — un « 0 trouvé ok » dépublierait les # 68 unités (dérive vue le 2026-09-06 19:15) listings: list[Listing] = [] html = self.get(LIVYA_URL).text payload = _rsc_payload(html) i = payload.find(UNITS_KEY) if i < 0: raise RuntimeError( "app.livya.com (le-fjord) : flux RSC sans tableau \"units\" " "(rendu partiel ou page restructurée)") arr = _balanced_array(payload, i + len(UNITS_KEY) - 1) if not arr: raise RuntimeError( "app.livya.com (le-fjord) : tableau \"units\" tronqué dans " "le flux RSC") try: units = json.loads(arr) except ValueError as e: raise RuntimeError( "app.livya.com (le-fjord) : tableau \"units\" illisible " f"({e})") from e seen: set[str] = set() for u in units: try: if u.get("availability") != "AVAILABLE" \ or not u.get("rental") \ or u.get("isMarketable") is False \ or u.get("segment") not in (None, "", "RESIDENTIAL"): continue uid = u.get("unitId") or "" if not uid or uid in seen: continue seen.add(uid) number = str(u.get("number") or "").strip() rooms = u.get("rooms") unit_type = normalize_unit_type(str(rooms)) \ if isinstance(rooms, (int, float)) and rooms > 0 else "" price = u.get("rentalPrice") price = float(price) \ if isinstance(price, (int, float)) and price > 0 else None area = u.get("unitSize") area = float(area) \ if isinstance(area, (int, float)) and area > 0 else None beds = u.get("roomsBed") baths = u.get("roomsBath") floor = (u.get("floorDisplayName") or str(u.get("floorNumber") or "")).strip() lat, lng = u.get("latitude"), u.get("longitude") details: dict = {"contact": dict(CONTACT)} if floor: details["floor"] = floor if u.get("typeName"): details["model"] = u["typeName"] if u.get("orientation"): details["orientation"] = u["orientation"] listings.append(Listing( source=self.source_id, external_id=uid, # GUID Livya de l'unité (stable) url=PAGE_URL, title=f"Unité {number} — Le Fjord", address=ADDRESS, city="Lévis", unit_type=unit_type, bedrooms=float(beds) if isinstance(beds, (int, float)) and beds > 0 else None, bathrooms=float(baths) if isinstance(baths, (int, float)) and baths > 0 else None, price=price, price_label=f"{price:.0f} $ /mois" if price else "", availability="Disponible — occupation printemps 2027", area_sqft=area, description=f"Unité {number} ({unit_type}" f"{', modèle ' + u['typeName'] if u.get('typeName') else ''}), " f"{floor or 'Le Fjord'} — condos locatifs " "tout inclus avec vue sur le fleuve à Lévis " "(occupation printemps 2027).", amenities=list(AMENITIES), details=details, lat=lat if (lat and lng) else None, lng=lng if (lat and lng) else None, )) except Exception: continue return listings