SPB Git forge

spb/lou-ka

Public

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

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%
5.2 KB · 124 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/ubee.py : Ubee (ubee.com) — plateforme immobilière québécoise5#   API publique anonyme : POST api.ubee.ca/api/anonymous/Search/SearchProperties6#   (pageIndex=N, 24 résultats/page, JSON riche : adresse, GPS, loyer, pièces,7#   superficies m², année, galerie Cloudinary complète). Volet LOCATION :8#   listingType=Renter + isResidential (~250 annonces) ; `askPrice` est le9#   loyer MENSUEL ($) pour ce volet (vérifié en live : 300–5 300 $/mois).10#   Les fiches location vivent sous /a-louer/{ville}/{slug} (canonical du11#   site). Les terrains et le volet commercial sont écartés — pas des12#   logements. Adapté du connecteur « à vendre » d'Immo-Ka13#   (agent-courtage/immoka).14# -----------------------------------------------------------------------------15from __future__ import annotations1617from ..schema import Listing18from .base import BaseConnector1920API = "https://api.ubee.ca/api/anonymous/Search/SearchProperties"21SITE = "https://ubee.com"22M2_TO_SQFT = 10.76392324# type d'inscription -> vocabulaire brut (finalize() normalise) ;25# Terrain/Commercial ne sont pas des logements -> exclus au filtrage26_TYPES = {27    "Unifamiliale": "Maison", "Condo": "Condo",28    "Plex": "Logement", "Chalet": "Chalet", "Fermette": "Fermette",29}30_EXCLUDE_TYPES = {"Terrain", "Commercial"}313233class UbeeConnector(BaseConnector):34    source_id = "ubee"35    request_delay = 0.53637    def _search(self, body: dict) -> list[dict]:38        out, page = [], 039        while True:40            r = self.post(f"{API}?pageIndex={page}", json=body).json()41            results = r.get("results") or []42            out.extend(results)43            if len(out) >= (r.get("totalCount") or 0) or not results:44                break45            page += 146            if page > 200:   # garde-fou47                break48        return out4950    def _to_listing(self, it: dict) -> Listing | None:51        lid = str(it.get("id") or "")52        slug = it.get("slugFr") or it.get("slugEn") or ""53        if not lid or not slug:54            return None55        itype = (it.get("inscriptionType") or "").strip()56        if itype in _EXCLUDE_TYPES:      # terrain/local : pas un logement57            return None58        city = (it.get("city") or "").strip()59        url = f"{SITE}/a-louer/{it.get('citySlug') or ''}/{slug}"60        images = [im["publicUrls"]["default_size"]61                  for im in it.get("images") or []62                  if (im.get("publicUrls") or {}).get("default_size")]63        living = it.get("livingSurfaceInMeters")64        ptype = _TYPES.get(itype, itype or "Logement")6566        # askPrice = loyer mensuel sur le volet Renter (vérifié en live)67        price = it.get("askPrice")68        if not isinstance(price, (int, float)) or price <= 0:69            price = None7071        # type d'unité : nb de chambres (normalisé n+2 ½ par finalize())72        beds = it.get("nbBedrooms")73        unit_type = ""74        if isinstance(beds, int):75            unit_type = "Studio" if beds == 0 else f"{beds} chambres"7677        details: dict = {"Courtier": "Ubee", "Type": ptype}78        if isinstance(it.get("nbBathrooms"), int) and it["nbBathrooms"] > 0:79            details["Salles de bain"] = str(it["nbBathrooms"])80        if isinstance(it.get("nbHalfBaths"), int) and it["nbHalfBaths"] > 0:81            details["Salles d'eau"] = str(it["nbHalfBaths"])82        for k, lab in (("propertyType", "Sous-type"),83                       ("buildingType", "Type de bâtiment"),84                       ("yearBuilt", "Année de construction")):85            if it.get(k):86                details[lab] = str(it[k])8788        lst = Listing(89            source=self.source_id,90            external_id=lid,91            url=url,92            title=f"{ptype} à louer — {city}" if city else f"{ptype} à louer",93            address=it.get("address") or "",94            city=city,95            unit_type=unit_type,96            price=price,97            price_label=(f"{price:,.0f} $/mois".replace(",", " ")98                         if price else ""),99            area_sqft=round(living * M2_TO_SQFT) if living else None,100            amenities=[f for f in (101                "Neuf / à construire" if it.get("toBuild") else "",102            ) if f],103            details=details,104            images=images,105            lat=it.get("latitude"),106            lng=it.get("longitude"),107        )108        return lst109110    def fetch(self) -> list[Listing]:111        # une seule passe : le volet isCommercial=Renter ne renvoie que des112        # locaux commerciaux (vérifié en live) — pas des logements113        out: dict[str, Listing] = {}114        body = {"sortBy": "DateDescending", "listingType": "Renter",115                "isResidential": True}116        for it in self._search(body):117            # Québec seulement (l'API est QC par nature, on double-vérifie)118            if (it.get("province") or "QC") != "QC":119                continue120            lst = self._to_listing(it)121            if lst is not None:122                out.setdefault(lst.uid, lst)123        return list(out.values())124