SPB Git

spb/lou-ka Public

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

HTML 99.7%
9.8 KB · 243 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/urban_services.py : connecteur Urban Services (urbanservices.ca)5#   Gestionnaire de Gatineau (Urban Tenant Solutions) ; portail de location6#   dédié rent.urbanservices.ca sur la plateforme Building Stack — même7#   famille que le portail edifialocation.com du connecteur edifia.py :8#     - /Listing/Listings embarque `var units = [...]` (une entrée par9#       appartement annoncé : prix, pi², chambres, sdb, adresse complète,10#       GPS, contact de l'immeuble, photo) ;11#     - les pages immeuble /b/<id> listent TOUTES les unités disponibles12#       (onglets : numéro, prix, pi², chambres, sdb, date de disponibilité)13#       + les commodités de l'immeuble — le JSON de la liste est parfois14#       tronqué (immeubles à fort inventaire), la page /b/<id> fait donc foi15#       pour l'inventaire et les dates ; le JSON enrichit (GPS, contact…).16#   Une annonce Lou-Ka = une unité disponible. Le parc ontarien (Ottawa)17#   est exclu (province != QC). ~17 requêtes par sync (1 + 16 immeubles).18# -----------------------------------------------------------------------------19from __future__ import annotations2021import html as _html22import json23import re2425from bs4 import BeautifulSoup2627from ..schema import Listing, parse_price28from .base import BaseConnector2930BASE = "https://rent.urbanservices.ca"31LIST_URL = f"{BASE}/Listing/Listings"323334def _extract_json(html: str, marker: str):35    """Décode la structure JSON qui suit `marker` dans un script inline."""36    i = html.find(marker)37    if i < 0:38        return None39    try:40        data, _ = json.JSONDecoder().raw_decode(html[i + len(marker):])41    except Exception:42        return None43    return data444546def _sqft(raw) -> float | None:47    try:48        v = float(str(raw).strip().replace(" ", "").replace(" ", "")49                  .replace(",", "."))50    except (TypeError, ValueError):51        return None52    return v if 80 <= v <= 20000 else None535455class UrbanServicesConnector(BaseConnector):56    source_id = "urban_services"57    request_delay = 0.658    max_buildings = 40          # garde-fou pages immeuble (16 au 2026-08)59    max_images = 126061    def fetch(self) -> list[Listing]:62        html = self.get(LIST_URL).text63        bs_units = _extract_json(html, "var units = ") or []6465        # regrouper le JSON par immeuble (parc québécois seulement)66        by_pub: dict[str, list[dict]] = {}67        for u in bs_units:68            addr = u.get("Address") or {}69            prov = ((addr.get("Province") or {}).get("ProvinceCode") or "")70            if prov.upper() != "QC":71                continue                     # Ottawa (Ontario) : exclu72            pub = str((u.get("Building") or {})73                      .get("PublicListBuildingName") or "")74            if pub:75                by_pub.setdefault(pub, []).append(u)7677        listings: list[Listing] = []78        for pub, units in sorted(by_pub.items())[: self.max_buildings]:79            try:80                listings.extend(self._building(pub, units))81            except Exception:82                continue                     # un immeuble ne bloque pas le reste83        # dédup par external_id (sécurité)84        uniq: dict[str, Listing] = {}85        for lst in listings:86            uniq.setdefault(lst.external_id, lst)87        return list(uniq.values())8889    # -- page immeuble /b/<id> : inventaire complet + commodités -------------------90    def _building_page(self, pub: str) -> tuple[list[dict], list[str]]:91        """(lignes d'unités des onglets, commodités de l'immeuble)."""92        html = self.get(f"{BASE}/b/{pub}").text93        soup = BeautifulSoup(html, "html.parser")94        rows: list[dict] = []95        for a in soup.select("ul.wpb-tabs-menu a.apartment-view-potential"):96            spans = [s.get_text(" ", strip=True) for s in a.find_all("span")]97            if len(spans) >= 6 and spans[0]:98                rows.append({           # Unité, Prix, pi², chambres, sdb, dispo99                    "unit": spans[0], "price": spans[1], "sqft": spans[2],100                    "beds": spans[3], "baths": spans[4], "dispo": spans[5]})101        amenities: list[str] = []102        for li in soup.select(".facilities ul li label"):103            t = re.sub(r"\s+", " ", li.get_text(" ", strip=True))104            if 3 <= len(t) <= 90 and t not in amenities:105                amenities.append(t)106        return rows, amenities107108    def _building(self, pub: str, units: list[dict]) -> list[Listing]:109        rows, amenities = self._building_page(pub)110111        # infos communes de l'immeuble depuis le JSON de la liste112        ref = units[0]113        addr = ref.get("Address") or {}114        # le JSON Razor du portail encode les apostrophes (&#39;) : décoder115        bname = _html.unescape(str(ref.get("BuildingName") or "")).strip()116        try:117            lat = float(addr.get("Latitude"))118            lng = float(addr.get("Longitude"))119        except (TypeError, ValueError):120            lat = lng = None121        contact: dict = {}122        contacts = ((ref.get("Building") or {})123                    .get("ListingEmployeesContacts") or [])124        if contacts:125            c = contacts[0]126            contact = {k: v for k, v in [127                ("name", c.get("FullName")),128                ("phone", c.get("FormattedPhoneNumber")),129                ("email", c.get("Email"))] if v}130        b_img = str(ref.get("BuildingPreviewUrl") or "")131        by_name = {str((u.get("Apartment") or {}).get("UnitName") or ""): u132                   for u in units}133134        out: list[Listing] = []135        seen: set[str] = set()136        for row in rows:137            num = row["unit"].strip()138            if not num or num in seen:139                continue140            seen.add(num)141            js = by_name.get(num) or {}142            out.append(self._listing(pub, num, row, js, bname, addr,143                                     amenities, contact, b_img, lat, lng))144        # unités du JSON absentes des onglets (prudence : ne rien perdre)145        for num, js in by_name.items():146            if num and num not in seen:147                seen.add(num)148                out.append(self._listing(pub, num, None, js, bname, addr,149                                         amenities, contact, b_img, lat, lng))150        return out151152    # -- une annonce par unité -------------------------------------------------------153    def _listing(self, pub: str, num: str, row: dict | None, js: dict,154                 bname: str, addr: dict, amenities: list[str], contact: dict,155                 b_img: str, lat, lng) -> Listing:156        apt = js.get("Apartment") or {}157158        # chambres / sdb : onglet de la page immeuble, sinon JSON de la liste159        def _int(v) -> int | None:160            try:161                return int(str(v).strip())162            except (TypeError, ValueError):163                return None164165        beds = _int(row["beds"]) if row else None166        if beds is None:167            beds = _int(apt.get("NumberOfBedrooms"))168        baths = _int(row["baths"]) if row else None169        if baths is None:170            baths = _int(apt.get("NumberOfBathrooms"))171        # convention Building Stack (comme edifia) : 0 chambre = studio172        unit_type = "" if beds is None else ("Studio" if beds == 0173                                             else f"{beds + 2}½")174175        # prix : JSON structuré prioritaire, sinon texte de l'onglet176        price = float(apt.get("Price") or 0) or None177        price_label = str(apt.get("PriceFormatted") or "")178        if row and not price_label:179            price_label = row["price"]180        if price is None and row:181            price = parse_price(row["price"])182183        area = _sqft(apt.get("Area"))184        if area is None and row:185            area = _sqft(row["sqft"])186187        # disponibilité : texte de l'onglet (« Disponible dès maintenant! »,188        # « oct. 01, 2027 »…) — préfixé quand c'est une date nue189        availability = ""190        if row:191            dispo = row["dispo"].strip()192            if dispo and re.search(r"\d", dispo) and "maintenant" not in dispo.lower():193                availability = f"Libre {dispo}"194            else:195                availability = dispo196197        desc = " — ".join(x for x in [198            f"{area:g} pi²" if area else "",199            f"{beds} chambre(s)" if beds else "",200            f"{baths} salle(s) de bain" if baths else ""] if x)201202        details: dict = {}203        if bname:204            details["building"] = bname205        if contact:206            details["contact"] = dict(contact)207        if beds is not None:208            details["bedrooms"] = beds209        if baths is not None:210            details["bathrooms"] = baths211212        images: list[str] = []213        prev = str(js.get("PreviewUrl") or "")214        if prev.startswith("http"):215            images.append(prev)216        if b_img.startswith("http") and b_img not in images:217            images.append(b_img)218219        apt_id = js.get("ApartmentId")220        url = (f"{BASE}/b/{pub}/{apt_id}" if apt_id else f"{BASE}/b/{pub}")221        title = (f"{bname} — unité {num}" if bname222                 else f"Urban Services — unité {num}")223        return Listing(224            source=self.source_id,225            external_id=f"{pub}-{num}",226            url=url,227            title=title,228            address=_html.unescape(str(addr.get("Full") or "")),229            sector="",230            city=_html.unescape(str(addr.get("City") or "")),231            unit_type=unit_type,232            price=price,233            price_label=price_label,234            availability=availability,235            area_sqft=area,236            description=desc,237            amenities=list(amenities),238            details=details,239            images=images[: self.max_images],240            lat=lat,241            lng=lng,242        )243