# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/somex_saintnicolas.py : connecteur Somex Saint-Nicolas # (somexsaintnicolas.com, Groupe Mahlex / OÏKOS Construction) — 407 unités à # terme au 710, Route des Rivières, Saint-Nicolas (Lévis). # Site WordPress/Elementor ; la page « Plans » embarque le module de plans # interactifs Livya (app.livya.com, client « hulix », projet « somex »). La # page Next.js du module est rendue côté serveur : son flux RSC # (self.__next_f.push) contient l'inventaire JSON complet — numéro d'unité, # étage, statut, loyer, pièces, superficie, balcon, adresse, GPS et plans. # 2 requêtes par sync : la page WordPress (découverte de l'id d'entité # Livya) + la page du module. Aucun rendu JavaScript nécessaire. # ----------------------------------------------------------------------------- from __future__ import annotations import codecs import json import re from ..schema import Listing, infer_city from .base import BaseConnector BASE = "https://www.somexsaintnicolas.com" PLANS_URL = f"{BASE}/plans/" LIVYA = "https://app.livya.com" SECTOR = "Saint-Nicolas" # fragments RSC de Next.js : self.__next_f.push([1,"...payload échappé..."]) _NEXT_F_RE = re.compile(r'self\.__next_f\.push\(\[1,"((?:[^"\\]|\\.)*)"\]\)') def _flight_blob(html: str) -> str: """Concatène et désérialise les fragments RSC d'une page Livya. `unicode_escape` interprète les octets en latin-1 : on ré-encode pour retrouver l'UTF-8 d'origine (sinon « Étage » devient « Ãtage »). """ blob = "".join(codecs.decode(c, "unicode_escape") for c in _NEXT_F_RE.findall(html)) return blob.encode("latin-1", "ignore").decode("utf-8", "ignore") def _json_arrays(blob: str, key: str) -> list[list]: """Toutes les valeurs de `"key":[...]` du flux (équilibrage de crochets).""" out: list[list] = [] for m in re.finditer(re.escape(f'"{key}":['), blob): j = m.end() - 1 depth, in_str, esc = 0, False, False for k in range(j, len(blob)): c = blob[k] if in_str: if esc: esc = False elif c == "\\": esc = True elif c == '"': in_str = False elif c == '"': in_str = True elif c == "[": depth += 1 elif c == "]": depth -= 1 if depth == 0: try: out.append(json.loads(blob[j:k + 1])) except ValueError: pass break return out def _livya_units(html: str) -> list[dict]: """Unités (dicts avec unitId) trouvées dans une page de module Livya.""" units, seen = [], set() for arr in _json_arrays(_flight_blob(html), "units"): for u in arr: if isinstance(u, dict) and u.get("unitId") and u["unitId"] not in seen: seen.add(u["unitId"]) units.append(u) return units def _unit_type(rooms: float | None) -> str: """3.5 -> « 3½ » ; 0.5 -> « Studio » (finalize gère 6½+).""" if not rooms: return "" if rooms < 1: return "Studio" return f"{int(rooms)}½" class SomexSaintNicolasConnector(BaseConnector): source_id = "somex_saintnicolas" request_delay = 0.8 def fetch(self) -> list[Listing]: # 1) Page « Plans » : id d'entité du module Livya (dans le HTML statique) wp = self.get(PLANS_URL).text m = re.search(r'<[^>]*livya-module-container-plans[^>]*>', wp) if not m: raise RuntimeError("module Livya introuvable sur /plans/") tag = m.group(0) client_m = re.search(r'data-client="([^"]+)"', wp) project = re.search(r'data-project="([^"]+)"', tag) entity = re.search(r'data-entity="([^"]+)"', tag) if not (client_m and project and entity): raise RuntimeError("attributs data-client/project/entity manquants") # contact du bureau de location (lien tel: du pied de page WordPress) contact: dict = {} tel = re.search(r'href="tel:(\d{10})"', wp) if tel: d = tel.group(1) contact["phone"] = f"{d[:3]}-{d[3:6]}-{d[6:]}" # 2) Page du module Livya (rendue serveur) -> inventaire JSON complet livya_url = (f"{LIVYA}/fr/{client_m.group(1)}/projects/" f"{project.group(1)}/plans/{entity.group(1)}?noLayout=1") units = _livya_units(self.get(livya_url).text) listings: list[Listing] = [] for u in units: if u.get("availability") != "AVAILABLE" or not u.get("rental", True): continue num = str(u.get("number") or "").strip() 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 # description : étage, modèle, pièces, balcon, plan PDF desc: list[str] = [] floor = str(u.get("floorNumber") or "").strip() if floor: desc.append(f"Étage {floor}") if u.get("typeName"): desc.append(f"Modèle {u['typeName']}") if u.get("roomsBed"): desc.append(f"{u['roomsBed']} chambre(s)") if u.get("roomsBath"): desc.append(f"{u['roomsBath']} salle(s) de bain") if u.get("roomsOffice"): desc.append("Espace bureau") if u.get("balconySize"): desc.append(f"Balcon de {u['balconySize']} pi²") if u.get("floorPlanUrl"): desc.append(f"Plan : {u['floorPlanUrl']}") address = ", ".join(x for x in ( u.get("address") or "", u.get("city") or "", u.get("postalCode") or "") if x) images = [img.get("fullUrl") for img in (u.get("typeImages") or []) if isinstance(img, dict) and img.get("fullUrl")] if u.get("floorPlanImageUrl"): images.append(u["floorPlanImageUrl"]) # date de disponibilité future si publiée, sinon statut du plan future = u.get("futureAvailability") availability = str(future) if future else "Disponible" details: dict = {} if contact: details["contact"] = dict(contact) if floor.isdigit(): details["floor"] = int(floor) lat, lng = u.get("latitude"), u.get("longitude") listings.append(Listing( source=self.source_id, external_id=str(u["unitId"]), url=PLANS_URL, title=f"Somex Saint-Nicolas — unité {num}" f" ({_unit_type(u.get('rooms'))})", address=address, sector=SECTOR, city=infer_city(SECTOR), unit_type=_unit_type(u.get("rooms")), price=price, price_label=f"{price:.0f} $ /mois" if price else "", availability=availability, area_sqft=area, description=" | ".join(desc), # Inclusions affichées sur la page « Appartements » du site # (valables pour toutes les unités) : eau chaude, électros en # inox, éclairage, accès internet, air climatisé. amenities=["Eau chaude incluse", "Électroménagers en inox", "Éclairage inclus", "Accès internet inclus", "Air climatisé"], details=details, images=images[:12], lat=float(lat) if lat else None, lng=float(lng) if lng else None, )) return listings