SPB Git

spb/lou-ka Public

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

HTML 99.7%
8.9 KB · 210 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/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