spb/lou-ka Public
Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 99.7%
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