SPB Git

spb/lou-ka Public

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

HTML 99.7%
7.6 KB · 188 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/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