Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
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