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/trudel.py : connecteur Trudel — Fleur de Lys condos locatifs5# (le21mars.ca / 18juillet.trudel.ca — 550, boul. Wilfrid-Hamel, Québec,6# secteur Vanier). Le sélecteur de plans du Vingt-et-un Mars est un embed7# RealVuu (client=trudel, project=fdl) dont la page contient toutes les8# unités en JSON (numéro, pièces, prix, disponibilité, plans/images).9# Le Dix-Huit Juillet n'affiche aucune unité (landing de contact seulement).10# -----------------------------------------------------------------------------11from __future__ import annotations1213import json1415from ..schema import Listing, infer_city16from .base import BaseConnector1718RV_URL = "https://app.realvuu.com/fr/external/trudel/fdl/plans"19SITE_URL = "https://www.le21mars.ca/plans/"20SECTOR = "Vanier (Fleur de Lys)"212223def _extract_json_array(html: str, key: str) -> list:24 """Extrait le plus grand tableau JSON `"key":[...]` de la page RealVuu.2526 La clé peut apparaître plusieurs fois (souvent vide ailleurs) : on garde27 l'occurrence contenant le plus d'éléments.28 """29 dec = json.JSONDecoder()30 needle = f'"{key}":['31 best: list = []32 start = 033 while True:34 i = html.find(needle, start)35 if i == -1:36 break37 try:38 arr, _ = dec.raw_decode(html[i + len(needle) - 1:])39 if isinstance(arr, list) and len(arr) > len(best):40 best = arr41 except Exception:42 pass43 start = i + len(needle)44 return best454647def _unit_type_from_rooms(rooms: float) -> str:48 if not rooms:49 return ""50 if rooms <= 1:51 return "Studio"52 return f"{int(rooms)}½"535455class TrudelConnector(BaseConnector):56 source_id = "trudel"57 request_delay = 0.65859 def fetch(self) -> list[Listing]:60 html = self.get(RV_URL).text6162 units = _extract_json_array(html, "units")63 buildings = {b.get("buildingId"): b64 for b in _extract_json_array(html, "buildings")}65 # Tableau "floors" : floorId -> numéro d'étage (structuré à la source)66 floors = {f.get("floorId"): f67 for f in _extract_json_array(html, "floors")}6869 avail = [u for u in units70 if u.get("availability") == "AVAILABLE"71 and u.get("rental")72 and u.get("typeType") == "APARTMENT"73 and u.get("segment") in ("RESIDENTIAL", "", None)74 and u.get("isMarketable", True)]7576 # Garde-fou prix placeholder : prix unique pour des types différents77 prices = {u.get("rentalPrice") for u in avail}78 rooms_set = {u.get("rooms") for u in avail}79 placeholder = len(prices) == 1 and len(rooms_set) > 18081 listings: list[Listing] = []82 for u in avail:83 try:84 b = buildings.get(u.get("buildingId"), {})85 bname = (b.get("name") or "").strip()86 if "ensemble" in bname.lower():87 continue # entrée technique « plan d'ensemble »88 address = (u.get("address") or b.get("address") or "").strip()89 # Ville + code postal structurés à la source90 ucity = (u.get("city") or "").strip()91 postal = (u.get("postalCode") or "").strip()92 if address and ucity and ucity.lower() not in address.lower():93 address = f"{address}, {ucity}"94 if address and postal and postal not in address:95 address = f"{address} {postal}"96 number = str(u.get("number") or "").strip()97 unit_type = _unit_type_from_rooms(u.get("rooms") or 0)9899 price = None100 price_label = ""101 rp = u.get("rentalPrice") or 0102 if rp and not placeholder and 100 <= rp <= 20000:103 price = float(rp)104 price_label = f"{rp} $/mois"105106 availability = "Disponible"107 if u.get("futureAvailability"):108 availability = f"Disponible : {u['futureAvailability']}"109110 images: list[str] = []111 for ti in (u.get("typeImages") or []):112 if ti.get("fullUrl"):113 images.append(ti["fullUrl"])114 for im in (u.get("images") or []):115 if isinstance(im, dict) and im.get("fullUrl"):116 images.append(im["fullUrl"])117 if u.get("floorPlanImageUrl"):118 images.append(u["floorPlanImageUrl"])119 images = list(dict.fromkeys(images))120121 building_label = bname or "Le Vingt-et-un Mars"122 desc = []123 if u.get("unitSize"):124 desc.append(f"{u['unitSize']} pi²")125 if u.get("roomsBed"):126 desc.append(f"{u['roomsBed']} chambre(s)")127 if u.get("roomsBath"):128 desc.append(f"{u['roomsBath']} salle(s) de bain")129 if u.get("roomsOffice"):130 desc.append(f"{u['roomsOffice']} bureau(x)")131 if u.get("typeName"):132 desc.append(f"Modèle {u['typeName']}")133 if u.get("orientation"):134 desc.append(f"Orientation {u['orientation']}")135 desc.append(f"Immeuble {building_label} — Fleur de Lys")136137 # Superficie structurée (mesures IMPERIAL = pi² chez RealVuu)138 area_sqft = None139 usize = u.get("unitSize") or 0140 if usize and (u.get("measurementSystem") or "IMPERIAL") == "IMPERIAL":141 area_sqft = float(usize)142143 # Étage via le tableau "floors" (numéro structuré)144 details: dict = {}145 fl = floors.get(u.get("floorId")) or {}146 if isinstance(fl.get("number"), int) and fl["number"] > 0:147 details["floor"] = fl["number"]148149 listings.append(Listing(150 source=self.source_id,151 external_id=u.get("unitId") or f"{bname}-{number}",152 url=SITE_URL,153 title=f"Fleur de Lys ({building_label}) — Unité {number}"154 f"{' (' + unit_type + ')' if unit_type else ''}",155 address=address or "550, boul. Wilfrid-Hamel, Québec",156 sector=SECTOR,157 city=infer_city(SECTOR, default="Québec"),158 unit_type=unit_type,159 price=price,160 price_label=price_label,161 availability=availability,162 area_sqft=area_sqft,163 description=" | ".join(desc),164 amenities=["Eau chaude incluse", "Air climatisé"],165 details=details,166 images=images,167 lat=u.get("latitude") or None,168 lng=u.get("longitude") or None,169 ))170 except Exception:171 continue172173 return listings174