# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/trudel.py : connecteur Trudel — Fleur de Lys condos locatifs # (le21mars.ca / 18juillet.trudel.ca — 550, boul. Wilfrid-Hamel, Québec, # secteur Vanier). Le sélecteur de plans du Vingt-et-un Mars est un embed # RealVuu (client=trudel, project=fdl) dont la page contient toutes les # unités en JSON (numéro, pièces, prix, disponibilité, plans/images). # Le Dix-Huit Juillet n'affiche aucune unité (landing de contact seulement). # ----------------------------------------------------------------------------- from __future__ import annotations import json from ..schema import Listing, infer_city from .base import BaseConnector RV_URL = "https://app.realvuu.com/fr/external/trudel/fdl/plans" SITE_URL = "https://www.le21mars.ca/plans/" SECTOR = "Vanier (Fleur de Lys)" def _extract_json_array(html: str, key: str) -> list: """Extrait le plus grand tableau JSON `"key":[...]` de la page RealVuu. La clé peut apparaître plusieurs fois (souvent vide ailleurs) : on garde l'occurrence contenant le plus d'éléments. """ dec = json.JSONDecoder() needle = f'"{key}":[' best: list = [] start = 0 while True: i = html.find(needle, start) if i == -1: break try: arr, _ = dec.raw_decode(html[i + len(needle) - 1:]) if isinstance(arr, list) and len(arr) > len(best): best = arr except Exception: pass start = i + len(needle) return best def _unit_type_from_rooms(rooms: float) -> str: if not rooms: return "" if rooms <= 1: return "Studio" return f"{int(rooms)}½" class TrudelConnector(BaseConnector): source_id = "trudel" request_delay = 0.6 def fetch(self) -> list[Listing]: html = self.get(RV_URL).text units = _extract_json_array(html, "units") buildings = {b.get("buildingId"): b for b in _extract_json_array(html, "buildings")} # Tableau "floors" : floorId -> numéro d'étage (structuré à la source) floors = {f.get("floorId"): f for f in _extract_json_array(html, "floors")} avail = [u for u in units if u.get("availability") == "AVAILABLE" and u.get("rental") and u.get("typeType") == "APARTMENT" and u.get("segment") in ("RESIDENTIAL", "", None) and u.get("isMarketable", True)] # Garde-fou prix placeholder : prix unique pour des types différents prices = {u.get("rentalPrice") for u in avail} rooms_set = {u.get("rooms") for u in avail} placeholder = len(prices) == 1 and len(rooms_set) > 1 listings: list[Listing] = [] for u in avail: try: b = buildings.get(u.get("buildingId"), {}) bname = (b.get("name") or "").strip() if "ensemble" in bname.lower(): continue # entrée technique « plan d'ensemble » address = (u.get("address") or b.get("address") or "").strip() # Ville + code postal structurés à la source ucity = (u.get("city") or "").strip() postal = (u.get("postalCode") or "").strip() if address and ucity and ucity.lower() not in address.lower(): address = f"{address}, {ucity}" if address and postal and postal not in address: address = f"{address} {postal}" number = str(u.get("number") or "").strip() unit_type = _unit_type_from_rooms(u.get("rooms") or 0) price = None price_label = "" rp = u.get("rentalPrice") or 0 if rp and not placeholder and 100 <= rp <= 20000: price = float(rp) price_label = f"{rp} $/mois" availability = "Disponible" if u.get("futureAvailability"): availability = f"Disponible : {u['futureAvailability']}" images: list[str] = [] for ti in (u.get("typeImages") or []): if ti.get("fullUrl"): images.append(ti["fullUrl"]) for im in (u.get("images") or []): if isinstance(im, dict) and im.get("fullUrl"): images.append(im["fullUrl"]) if u.get("floorPlanImageUrl"): images.append(u["floorPlanImageUrl"]) images = list(dict.fromkeys(images)) building_label = bname or "Le Vingt-et-un Mars" desc = [] if u.get("unitSize"): desc.append(f"{u['unitSize']} pi²") 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(f"{u['roomsOffice']} bureau(x)") if u.get("typeName"): desc.append(f"Modèle {u['typeName']}") if u.get("orientation"): desc.append(f"Orientation {u['orientation']}") desc.append(f"Immeuble {building_label} — Fleur de Lys") # Superficie structurée (mesures IMPERIAL = pi² chez RealVuu) area_sqft = None usize = u.get("unitSize") or 0 if usize and (u.get("measurementSystem") or "IMPERIAL") == "IMPERIAL": area_sqft = float(usize) # Étage via le tableau "floors" (numéro structuré) details: dict = {} fl = floors.get(u.get("floorId")) or {} if isinstance(fl.get("number"), int) and fl["number"] > 0: details["floor"] = fl["number"] listings.append(Listing( source=self.source_id, external_id=u.get("unitId") or f"{bname}-{number}", url=SITE_URL, title=f"Fleur de Lys ({building_label}) — Unité {number}" f"{' (' + unit_type + ')' if unit_type else ''}", address=address or "550, boul. Wilfrid-Hamel, Québec", sector=SECTOR, city=infer_city(SECTOR, default="Québec"), unit_type=unit_type, price=price, price_label=price_label, availability=availability, area_sqft=area_sqft, description=" | ".join(desc), amenities=["Eau chaude incluse", "Air climatisé"], details=details, images=images, lat=u.get("latitude") or None, lng=u.get("longitude") or None, )) except Exception: continue return listings