SPB Git forge

spb/lou-ka

Public

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

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
6.4 KB · 151 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/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