SPB Git

spb/lou-ka Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

HTML 99.7%
7.2 KB · 174 lines python
Raw Blame History
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