# ----------------------------------------------------------------------------- # Lou-Ka — Agrégateur de logements à louer (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/ubee.py : Ubee (ubee.com) — plateforme immobilière québécoise # API publique anonyme : POST api.ubee.ca/api/anonymous/Search/SearchProperties # (pageIndex=N, 24 résultats/page, JSON riche : adresse, GPS, loyer, pièces, # superficies m², année, galerie Cloudinary complète). Volet LOCATION : # listingType=Renter + isResidential (~250 annonces) ; `askPrice` est le # loyer MENSUEL ($) pour ce volet (vérifié en live : 300–5 300 $/mois). # Les fiches location vivent sous /a-louer/{ville}/{slug} (canonical du # site). Les terrains et le volet commercial sont écartés — pas des # logements. Adapté du connecteur « à vendre » d'Immo-Ka # (agent-courtage/immoka). # ----------------------------------------------------------------------------- from __future__ import annotations from ..schema import Listing from .base import BaseConnector API = "https://api.ubee.ca/api/anonymous/Search/SearchProperties" SITE = "https://ubee.com" M2_TO_SQFT = 10.7639 # type d'inscription -> vocabulaire brut (finalize() normalise) ; # Terrain/Commercial ne sont pas des logements -> exclus au filtrage _TYPES = { "Unifamiliale": "Maison", "Condo": "Condo", "Plex": "Logement", "Chalet": "Chalet", "Fermette": "Fermette", } _EXCLUDE_TYPES = {"Terrain", "Commercial"} class UbeeConnector(BaseConnector): source_id = "ubee" request_delay = 0.5 def _search(self, body: dict) -> list[dict]: out, page = [], 0 while True: r = self.post(f"{API}?pageIndex={page}", json=body).json() results = r.get("results") or [] out.extend(results) if len(out) >= (r.get("totalCount") or 0) or not results: break page += 1 if page > 200: # garde-fou break return out def _to_listing(self, it: dict) -> Listing | None: lid = str(it.get("id") or "") slug = it.get("slugFr") or it.get("slugEn") or "" if not lid or not slug: return None itype = (it.get("inscriptionType") or "").strip() if itype in _EXCLUDE_TYPES: # terrain/local : pas un logement return None city = (it.get("city") or "").strip() url = f"{SITE}/a-louer/{it.get('citySlug') or ''}/{slug}" images = [im["publicUrls"]["default_size"] for im in it.get("images") or [] if (im.get("publicUrls") or {}).get("default_size")] living = it.get("livingSurfaceInMeters") ptype = _TYPES.get(itype, itype or "Logement") # askPrice = loyer mensuel sur le volet Renter (vérifié en live) price = it.get("askPrice") if not isinstance(price, (int, float)) or price <= 0: price = None # type d'unité : nb de chambres (normalisé n+2 ½ par finalize()) beds = it.get("nbBedrooms") unit_type = "" if isinstance(beds, int): unit_type = "Studio" if beds == 0 else f"{beds} chambres" details: dict = {"Courtier": "Ubee", "Type": ptype} if isinstance(it.get("nbBathrooms"), int) and it["nbBathrooms"] > 0: details["Salles de bain"] = str(it["nbBathrooms"]) if isinstance(it.get("nbHalfBaths"), int) and it["nbHalfBaths"] > 0: details["Salles d'eau"] = str(it["nbHalfBaths"]) for k, lab in (("propertyType", "Sous-type"), ("buildingType", "Type de bâtiment"), ("yearBuilt", "Année de construction")): if it.get(k): details[lab] = str(it[k]) lst = Listing( source=self.source_id, external_id=lid, url=url, title=f"{ptype} à louer — {city}" if city else f"{ptype} à louer", address=it.get("address") or "", city=city, unit_type=unit_type, price=price, price_label=(f"{price:,.0f} $/mois".replace(",", " ") if price else ""), area_sqft=round(living * M2_TO_SQFT) if living else None, amenities=[f for f in ( "Neuf / à construire" if it.get("toBuild") else "", ) if f], details=details, images=images, lat=it.get("latitude"), lng=it.get("longitude"), ) return lst def fetch(self) -> list[Listing]: # une seule passe : le volet isCommercial=Renter ne renvoie que des # locaux commerciaux (vérifié en live) — pas des logements out: dict[str, Listing] = {} body = {"sortBy": "DateDescending", "listingType": "Renter", "isResidential": True} for it in self._search(body): # Québec seulement (l'API est QC par nature, on double-vérifie) if (it.get("province") or "QC") != "QC": continue lst = self._to_listing(it) if lst is not None: out.setdefault(lst.uid, lst) return list(out.values())