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/rivero.py : connecteur Le Rivero (lerivero.ca — Québec,5# rue Bourdages, aux abords de la rivière Saint-Charles).6# Le sélecteur de plans est un embed RealVuu (app.realvuu.com) dont la page7# contient toutes les unités en JSON (numéro, pièces, prix, disponibilité,8# plans/images). Phase 1 louée; phase 2 (livraison été 2028) en prélocation.9# Garde-fou : si toutes les unités « disponibles » affichent un prix10# identique (placeholder), le prix n'est pas retenu.11# -----------------------------------------------------------------------------12from __future__ import annotations1314import json15import re1617from ..schema import Listing, infer_city18from .base import BaseConnector1920RV_URL = "https://app.realvuu.com/fr/external/immeubles-simard/rivero/plans"21SITE_URL = "https://www.lerivero.ca/plans/"22SECTOR = "Vanier (rivière Saint-Charles)"232425def _extract_json_array(html: str, key: str) -> list:26 """Extrait le plus grand tableau JSON `"key":[...]` de la page RealVuu.2728 La clé peut apparaître plusieurs fois (souvent vide ailleurs) : on garde29 l'occurrence contenant le plus d'éléments.30 """31 dec = json.JSONDecoder()32 needle = f'"{key}":['33 best: list = []34 start = 035 while True:36 i = html.find(needle, start)37 if i == -1:38 break39 try:40 arr, _ = dec.raw_decode(html[i + len(needle) - 1:])41 if isinstance(arr, list) and len(arr) > len(best):42 best = arr43 except Exception:44 pass45 start = i + len(needle)46 return best474849def _unit_type_from_rooms(rooms: float, rooms_bed: int = 0) -> str:50 if not rooms:51 # repli structuré : n chambres -> (n+2)½ (convention Lou-Ka)52 if rooms_bed:53 n = int(rooms_bed) + 254 return "6½+" if n >= 6 else f"{n}½"55 return ""56 if rooms <= 1:57 return "Studio"58 return f"{int(rooms)}½"596061class RiveroConnector(BaseConnector):62 source_id = "rivero"63 request_delay = 0.66465 def fetch(self) -> list[Listing]:66 html = self.get(RV_URL).text6768 units = _extract_json_array(html, "units")69 buildings = {b.get("buildingId"): b70 for b in _extract_json_array(html, "buildings")}71 # étages : floorId -> numéro (structuré, pour details.floor)72 floors = {f.get("floorId"): f.get("number")73 for f in _extract_json_array(html, "floors")74 if f.get("floorId")}7576 avail = [u for u in units77 if u.get("availability") == "AVAILABLE"78 and u.get("rental")79 and u.get("typeType") == "APARTMENT"80 and u.get("segment") in ("RESIDENTIAL", "", None)81 and u.get("isMarketable", True)]82 if not avail:83 return []8485 # Garde-fou prix placeholder : prix unique pour des types différents86 prices = {u.get("rentalPrice") for u in avail}87 rooms_set = {u.get("rooms") for u in avail}88 placeholder = len(prices) == 1 and len(rooms_set) > 18990 listings: list[Listing] = []91 for u in avail:92 try:93 b = buildings.get(u.get("buildingId"), {})94 bname = (b.get("name") or "").strip()95 address = (u.get("address") or b.get("address") or "").strip()96 # compléter l'adresse avec ville + code postal (structurés)97 city_src = (u.get("city") or b.get("city") or "").strip()98 postal = (u.get("postalCode") or "").strip()99 if address and city_src:100 address = f"{address}, {city_src}" \101 + (f", QC {postal}" if postal else "")102 number = str(u.get("number") or "").strip()103 unit_type = _unit_type_from_rooms(u.get("rooms") or 0,104 u.get("roomsBed") or 0)105106 price = None107 price_label = ""108 rp = u.get("rentalPrice") or 0109 if rp and not placeholder and 100 <= rp <= 20000:110 price = float(rp)111 price_label = f"{rp} $/mois"112113 availability = "Disponible"114 if u.get("futureAvailability"):115 availability = f"Disponible : {u['futureAvailability']}"116 if "phase 2" in bname.lower():117 availability = "Phase 2 — prélocation (livraison été 2028)"118119 images: list[str] = []120 for ti in (u.get("typeImages") or []):121 if ti.get("fullUrl"):122 images.append(ti["fullUrl"])123 for im in (u.get("images") or []):124 if isinstance(im, dict) and im.get("fullUrl"):125 images.append(im["fullUrl"])126 if u.get("floorPlanImageUrl"):127 images.append(u["floorPlanImageUrl"])128 images = list(dict.fromkeys(images))129130 desc = []131 if u.get("unitSize"):132 desc.append(f"{u['unitSize']} pi²")133 if u.get("roomsBed"):134 desc.append(f"{u['roomsBed']} chambre(s)")135 if u.get("roomsOffice"):136 desc.append(f"{u['roomsOffice']} espace(s) bureau")137 if u.get("roomsBath"):138 desc.append(f"{u['roomsBath']} salle(s) de bain")139 if u.get("balconySize"):140 desc.append(f"Balcon/loggia {u['balconySize']} pi²")141 if u.get("orientation"):142 desc.append(f"Orientation {u['orientation']}")143 if bname:144 desc.append(bname)145146 # superficie structurée (mesures IMPERIAL -> déjà en pi²)147 area = None148 us = u.get("unitSize") or 0149 if 80 <= us <= 20000:150 area = float(us)151152 # champs structurés : étage, coordonnées153 details: dict = {}154 floor_no = floors.get(u.get("floorId"))155 if isinstance(floor_no, int) and 0 < floor_no <= 60:156 details["floor"] = floor_no157 lat = u.get("latitude")158 lng = u.get("longitude")159 if not (lat and lng):160 lat, lng = b.get("latitude"), b.get("longitude")161162 listings.append(Listing(163 source=self.source_id,164 external_id=u.get("unitId") or f"{bname}-{number}",165 url=SITE_URL,166 title=f"Le Rivero — Unité {number}"167 f"{' (' + unit_type + ')' if unit_type else ''}",168 address=address,169 sector=SECTOR,170 city=infer_city(SECTOR, default="Québec"),171 unit_type=unit_type,172 price=price,173 price_label=price_label,174 availability=availability,175 area_sqft=area,176 description=" | ".join(desc),177 amenities=["Eau chaude incluse", "Électricité et chauffage",178 "Air climatisé", "Internet inclus"],179 details=details,180 images=images,181 lat=float(lat) if lat else None,182 lng=float(lng) if lng else None,183 ))184 except Exception:185 continue186187 return listings188