Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1# -----------------------------------------------------------------------------2# Lou-Ka — Agrégateur de logements à louer (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/le_fjord.py : connecteur Le Fjord (le-fjord.com) — condos5# locatifs tout inclus au 11, rue Fortier à Lévis (occupation printemps6# 2027). Le site WordPress/Elementor embarque le widget Livya (Graph7# Synergie) : la page plans app.livya.com/fr/fjord/projects/le-fjord/8# plans/<entity> (Next.js) sérialise dans son flux RSC9# (self.__next_f.push) un tableau "units" complet — unitId, no, étage,10# pièces (5.5), chambres, sdb, PRIX mensuel, pi², adresse, lat/lng,11# availability AVAILABLE / RESERVED / NOT_AVAILABLE.12# Granularité : unité, avec prix.13# -----------------------------------------------------------------------------14from __future__ import annotations1516import json17import re1819from ..schema import Listing, normalize_unit_type20from .base import BaseConnector2122PAGE_URL = "https://www.le-fjord.com/unites/"23# widget Livya : data-client / data-project / data-entity de /unites/24LIVYA_URL = ("https://app.livya.com/fr/fjord/projects/le-fjord/plans/"25 "0f1e45d7-a5e2-4efd-9f23-6c31fd915001?noLayout=1")2627CHUNK_RE = re.compile(r'self\.__next_f\.push\(\[1,"(.*?)"\]\)', re.S)28UNITS_KEY = '"units":['2930ADDRESS = "11, rue Fortier, Lévis"31AMENITIES = ["Tout inclus", "5 électroménagers", "Internet haute vitesse",32 "Chauffage et climatisation", "Eau chaude"]33CONTACT = {"phone": "581-318-3628"}343536def _rsc_payload(html: str) -> str:37 """Concatène et décode les chunks RSC Next.js (JS string escapes)."""38 payload = "".join(c.encode().decode("unicode_escape")39 for c in CHUNK_RE.findall(html))40 return payload.encode("latin-1", "ignore").decode("utf-8", "ignore")414243def _balanced_array(s: str, start: int) -> str | None:44 """Extrait le tableau JSON équilibré commençant à s[start] == '['."""45 depth = 046 for i in range(start, len(s)):47 if s[i] == "[":48 depth += 149 elif s[i] == "]":50 depth -= 151 if depth == 0:52 return s[start:i + 1]53 return None545556class LeFjordConnector(BaseConnector):57 source_id = "le_fjord"58 request_delay = 0.65960 def fetch(self) -> list[Listing]:61 # échec HONNÊTE : une erreur réseau ou un rendu Next.js partiel (20062 # sans tableau "units" dans le flux RSC) doit lever (ok=0 dans63 # sync_log) et non retourner [] — un « 0 trouvé ok » dépublierait les64 # 68 unités (dérive vue le 2026-09-06 19:15)65 listings: list[Listing] = []66 html = self.get(LIVYA_URL).text67 payload = _rsc_payload(html)68 i = payload.find(UNITS_KEY)69 if i < 0:70 raise RuntimeError(71 "app.livya.com (le-fjord) : flux RSC sans tableau \"units\" "72 "(rendu partiel ou page restructurée)")73 arr = _balanced_array(payload, i + len(UNITS_KEY) - 1)74 if not arr:75 raise RuntimeError(76 "app.livya.com (le-fjord) : tableau \"units\" tronqué dans "77 "le flux RSC")78 try:79 units = json.loads(arr)80 except ValueError as e:81 raise RuntimeError(82 "app.livya.com (le-fjord) : tableau \"units\" illisible "83 f"({e})") from e8485 seen: set[str] = set()86 for u in units:87 try:88 if u.get("availability") != "AVAILABLE" \89 or not u.get("rental") \90 or u.get("isMarketable") is False \91 or u.get("segment") not in (None, "", "RESIDENTIAL"):92 continue93 uid = u.get("unitId") or ""94 if not uid or uid in seen:95 continue96 seen.add(uid)9798 number = str(u.get("number") or "").strip()99 rooms = u.get("rooms")100 unit_type = normalize_unit_type(str(rooms)) \101 if isinstance(rooms, (int, float)) and rooms > 0 else ""102 price = u.get("rentalPrice")103 price = float(price) \104 if isinstance(price, (int, float)) and price > 0 else None105 area = u.get("unitSize")106 area = float(area) \107 if isinstance(area, (int, float)) and area > 0 else None108 beds = u.get("roomsBed")109 baths = u.get("roomsBath")110 floor = (u.get("floorDisplayName")111 or str(u.get("floorNumber") or "")).strip()112 lat, lng = u.get("latitude"), u.get("longitude")113114 details: dict = {"contact": dict(CONTACT)}115 if floor:116 details["floor"] = floor117 if u.get("typeName"):118 details["model"] = u["typeName"]119 if u.get("orientation"):120 details["orientation"] = u["orientation"]121122 listings.append(Listing(123 source=self.source_id,124 external_id=uid, # GUID Livya de l'unité (stable)125 url=PAGE_URL,126 title=f"Unité {number} — Le Fjord",127 address=ADDRESS,128 city="Lévis",129 unit_type=unit_type,130 bedrooms=float(beds)131 if isinstance(beds, (int, float)) and beds > 0 else None,132 bathrooms=float(baths)133 if isinstance(baths, (int, float)) and baths > 0 else None,134 price=price,135 price_label=f"{price:.0f} $ /mois" if price else "",136 availability="Disponible — occupation printemps 2027",137 area_sqft=area,138 description=f"Unité {number} ({unit_type}"139 f"{', modèle ' + u['typeName'] if u.get('typeName') else ''}), "140 f"{floor or 'Le Fjord'} — condos locatifs "141 "tout inclus avec vue sur le fleuve à Lévis "142 "(occupation printemps 2027).",143 amenities=list(AMENITIES),144 details=details,145 lat=lat if (lat and lng) else None,146 lng=lng if (lat and lng) else None,147 ))148 except Exception:149 continue150 return listings151