SPB Git

spb/lou-ka Public

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

HTML 99.7%
8.0 KB · 198 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/somex_saintnicolas.py : connecteur Somex Saint-Nicolas5#   (somexsaintnicolas.com, Groupe Mahlex / OÏKOS Construction) — 407 unités à6#   terme au 710, Route des Rivières, Saint-Nicolas (Lévis).7#   Site WordPress/Elementor ; la page « Plans » embarque le module de plans8#   interactifs Livya (app.livya.com, client « hulix », projet « somex »). La9#   page Next.js du module est rendue côté serveur : son flux RSC10#   (self.__next_f.push) contient l'inventaire JSON complet — numéro d'unité,11#   étage, statut, loyer, pièces, superficie, balcon, adresse, GPS et plans.12#   2 requêtes par sync : la page WordPress (découverte de l'id d'entité13#   Livya) + la page du module. Aucun rendu JavaScript nécessaire.14# -----------------------------------------------------------------------------15from __future__ import annotations1617import codecs18import json19import re2021from ..schema import Listing, infer_city22from .base import BaseConnector2324BASE = "https://www.somexsaintnicolas.com"25PLANS_URL = f"{BASE}/plans/"26LIVYA = "https://app.livya.com"27SECTOR = "Saint-Nicolas"2829# fragments RSC de Next.js : self.__next_f.push([1,"...payload échappé..."])30_NEXT_F_RE = re.compile(r'self\.__next_f\.push\(\[1,"((?:[^"\\]|\\.)*)"\]\)')313233def _flight_blob(html: str) -> str:34    """Concatène et désérialise les fragments RSC d'une page Livya.3536    `unicode_escape` interprète les octets en latin-1 : on ré-encode pour37    retrouver l'UTF-8 d'origine (sinon « Étage » devient « Ãtage »).38    """39    blob = "".join(codecs.decode(c, "unicode_escape")40                   for c in _NEXT_F_RE.findall(html))41    return blob.encode("latin-1", "ignore").decode("utf-8", "ignore")424344def _json_arrays(blob: str, key: str) -> list[list]:45    """Toutes les valeurs de `"key":[...]` du flux (équilibrage de crochets)."""46    out: list[list] = []47    for m in re.finditer(re.escape(f'"{key}":['), blob):48        j = m.end() - 149        depth, in_str, esc = 0, False, False50        for k in range(j, len(blob)):51            c = blob[k]52            if in_str:53                if esc:54                    esc = False55                elif c == "\\":56                    esc = True57                elif c == '"':58                    in_str = False59            elif c == '"':60                in_str = True61            elif c == "[":62                depth += 163            elif c == "]":64                depth -= 165                if depth == 0:66                    try:67                        out.append(json.loads(blob[j:k + 1]))68                    except ValueError:69                        pass70                    break71    return out727374def _livya_units(html: str) -> list[dict]:75    """Unités (dicts avec unitId) trouvées dans une page de module Livya."""76    units, seen = [], set()77    for arr in _json_arrays(_flight_blob(html), "units"):78        for u in arr:79            if isinstance(u, dict) and u.get("unitId") and u["unitId"] not in seen:80                seen.add(u["unitId"])81                units.append(u)82    return units838485def _unit_type(rooms: float | None) -> str:86    """3.5 -> « 3½ » ; 0.5 -> « Studio » (finalize gère 6½+)."""87    if not rooms:88        return ""89    if rooms < 1:90        return "Studio"91    return f"{int(rooms)}½"929394class SomexSaintNicolasConnector(BaseConnector):95    source_id = "somex_saintnicolas"96    request_delay = 0.89798    def fetch(self) -> list[Listing]:99        # 1) Page « Plans » : id d'entité du module Livya (dans le HTML statique)100        wp = self.get(PLANS_URL).text101        m = re.search(r'<[^>]*livya-module-container-plans[^>]*>', wp)102        if not m:103            raise RuntimeError("module Livya introuvable sur /plans/")104        tag = m.group(0)105        client_m = re.search(r'data-client="([^"]+)"', wp)106        project = re.search(r'data-project="([^"]+)"', tag)107        entity = re.search(r'data-entity="([^"]+)"', tag)108        if not (client_m and project and entity):109            raise RuntimeError("attributs data-client/project/entity manquants")110111        # contact du bureau de location (lien tel: du pied de page WordPress)112        contact: dict = {}113        tel = re.search(r'href="tel:(\d{10})"', wp)114        if tel:115            d = tel.group(1)116            contact["phone"] = f"{d[:3]}-{d[3:6]}-{d[6:]}"117118        # 2) Page du module Livya (rendue serveur) -> inventaire JSON complet119        livya_url = (f"{LIVYA}/fr/{client_m.group(1)}/projects/"120                     f"{project.group(1)}/plans/{entity.group(1)}?noLayout=1")121        units = _livya_units(self.get(livya_url).text)122123        listings: list[Listing] = []124        for u in units:125            if u.get("availability") != "AVAILABLE" or not u.get("rental", True):126                continue127            num = str(u.get("number") or "").strip()128            price = u.get("rentalPrice")129            price = float(price) if isinstance(price, (int, float)) and price > 0 else None130            area = u.get("unitSize")131            area = float(area) if isinstance(area, (int, float)) and area > 0 else None132133            # description : étage, modèle, pièces, balcon, plan PDF134            desc: list[str] = []135            floor = str(u.get("floorNumber") or "").strip()136            if floor:137                desc.append(f"Étage {floor}")138            if u.get("typeName"):139                desc.append(f"Modèle {u['typeName']}")140            if u.get("roomsBed"):141                desc.append(f"{u['roomsBed']} chambre(s)")142            if u.get("roomsBath"):143                desc.append(f"{u['roomsBath']} salle(s) de bain")144            if u.get("roomsOffice"):145                desc.append("Espace bureau")146            if u.get("balconySize"):147                desc.append(f"Balcon de {u['balconySize']} pi²")148            if u.get("floorPlanUrl"):149                desc.append(f"Plan : {u['floorPlanUrl']}")150151            address = ", ".join(x for x in (152                u.get("address") or "", u.get("city") or "",153                u.get("postalCode") or "") if x)154155            images = [img.get("fullUrl") for img in (u.get("typeImages") or [])156                      if isinstance(img, dict) and img.get("fullUrl")]157            if u.get("floorPlanImageUrl"):158                images.append(u["floorPlanImageUrl"])159160            # date de disponibilité future si publiée, sinon statut du plan161            future = u.get("futureAvailability")162            availability = str(future) if future else "Disponible"163164            details: dict = {}165            if contact:166                details["contact"] = dict(contact)167            if floor.isdigit():168                details["floor"] = int(floor)169170            lat, lng = u.get("latitude"), u.get("longitude")171            listings.append(Listing(172                source=self.source_id,173                external_id=str(u["unitId"]),174                url=PLANS_URL,175                title=f"Somex Saint-Nicolas — unité {num}"176                      f" ({_unit_type(u.get('rooms'))})",177                address=address,178                sector=SECTOR,179                city=infer_city(SECTOR),180                unit_type=_unit_type(u.get("rooms")),181                price=price,182                price_label=f"{price:.0f} $ /mois" if price else "",183                availability=availability,184                area_sqft=area,185                description=" | ".join(desc),186                # Inclusions affichées sur la page « Appartements » du site187                # (valables pour toutes les unités) : eau chaude, électros en188                # inox, éclairage, accès internet, air climatisé.189                amenities=["Eau chaude incluse", "Électroménagers en inox",190                           "Éclairage inclus", "Accès internet inclus",191                           "Air climatisé"],192                details=details,193                images=images[:12],194                lat=float(lat) if lat else None,195                lng=float(lng) if lng else None,196            ))197        return listings198