Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)
Python 47.5%
HTML 27.9%
TypeScript 15.5%
CSS 7.2%
JavaScript 2%
1# -----------------------------------------------------------------------------2# Immo-Ka — Agrégateur de maisons à vendre (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, prix, pièces,7# superficies m², année, galerie Cloudinary complète).8# Deux volets À VENDRE (listingType=Seller) : résidentiel + commercial.9#10# ENRICHISSEMENT DÉTAIL (fiche complète) :11# GET api.ubee.ca/api/anonymous/Listing/{id}12# retourne TOUT : description longue (FR/EN), n° Centris, ~30 listes de13# caractéristiques énumérées (chauffage, sous-sol, garage, piscine, vue,14# proximité…), taxes/frais (expenses), évaluation municipale, garantie15# légale, délai d'occupation, stationnements, courtier inscripteur16# (nom/agence/téléphone/courriel), visite virtuelle, lien Centris original.17# Mise en cache BD (detail_cache) — clé = prix + photo de couverture, seules18# les fiches nouvelles/modifiées sont re-lues (plafond IMMOKA_DETAIL_LIMIT).19# NB : l'endpoint détail refuse les User-Agent non navigateur (403) — le20# User-Agent de BaseConnector passe. Images Cloudinary : transformations21# nommées strictes, t_default_size (1260 px) est le maximum public.22# -----------------------------------------------------------------------------23from __future__ import annotations2425import os26import re2728from ..schema import PropertyListing29from ._detailutil import apply_detail30from .base import BaseConnector3132API = "https://api.ubee.ca/api/anonymous/Search/SearchProperties"33DETAIL_API = "https://api.ubee.ca/api/anonymous/Listing"34SITE = "https://ubee.com"35M2_TO_SQFT = 10.76393637_TYPES = {38 "Unifamiliale": "Maison", "Condo": "Condo", "Terrain": "Terrain",39 "Plex": "Immeuble à revenus", "Commercial": "Commercial",40 "Fermette": "Fermette", "Chalet": "Chalet",41}4243# --- vocabulaire énuméré de l'API détail -> libellés français ----------------44_ENUM_FR = {45 # sous-sol / fondation / structure46 "Aucun": "Aucun", "Autre": "Autre", "EntreeExterieure": "Entrée extérieure",47 "MoinsDeSixPieds": "Moins de 6 pieds", "SixPiedsOuPlus": "6 pieds ou plus",48 "NonAmenage": "Non aménagé", "PartiellementAmenage": "Partiellement aménagé",49 "TotalementAmenage": "Totalement aménagé", "VideSanitaire": "Vide sanitaire",50 "BetonCoule": "Béton coulé", "BlocDeBeton": "Bloc de béton",51 "DalleDeBetonAuSol": "Dalle de béton au sol",52 # chauffage / énergie53 "AirSouffle": "Air soufflé", "PlinthesAConvection": "Plinthes à convection",54 "PlinthesElectriques": "Plinthes électriques", "Radiant": "Radiant",55 "Electricite": "Électricité", "GazNaturel": "Gaz naturel",56 "Mazout": "Mazout", "Propane": "Propane", "Bois": "Bois",57 "Geothermie": "Géothermie", "Solaire": "Solaire",58 # foyer / poêle59 "FoyerAuBois": "Foyer au bois", "FoyerAuGaz": "Foyer au gaz",60 "FoyerAuMazout": "Foyer au mazout", "PoeleAuBois": "Poêle au bois",61 "PoeleAuxGranules": "Poêle aux granules",62 # garage / abri / allée / stationnement63 "Attache": "Attaché", "Detache": "Détaché", "Integre": "Intégré",64 "Chauffe": "Chauffé", "DoubleOuPlus": "Double ou plus",65 "SimpleLargeur": "Simple largeur", "DoubleLargeurOuPlus": "Double largeur ou plus",66 "Asphalte": "Asphalte", "NonPave": "Non pavé", "PaveUni": "Pavé uni",67 "AvecPriseExterieure": "Avec prise extérieure",68 # revêtements / toiture / fenêtres69 "Acier": "Acier", "Aluminium": "Aluminium", "Brique": "Brique",70 "Pierre": "Pierre", "Stucco": "Stucco", "Vinyle": "Vinyle", "PVC": "PVC",71 "BardeauxDAsphalte": "Bardeaux d'asphalte", "BardeauxDeCedre": "Bardeaux de cèdre",72 "BitumeEtGravier": "Bitume et gravier", "MembraneElastomere": "Membrane élastomère",73 "Tole": "Tôle", "Crank": "À manivelle", "PorteFenetre": "Porte-fenêtre",74 "Sliding": "Coulissante", "Melamine": "Mélamine", "Polyester": "Polyester",75 # commodités76 "AdoucisseurDEau": "Adoucisseur d'eau", "Ascenseur": "Ascenseur",77 "AspirateurCentral": "Aspirateur central", "Balcon": "Balcon",78 "BorneDeRecharge": "Borne de recharge", "ClimatiseurCentral": "Climatiseur central",79 "ClimatiseurMural": "Climatiseur mural", "EchangeurDAir": "Échangeur d'air",80 "EspaceDeRangement": "Espace de rangement", "Interphone": "Interphone",81 "Meuble": "Meublé", "SemiMeuble": "Semi-meublé", "Sauna": "Sauna",82 "PorteDeGarageElectrique": "Porte de garage électrique",83 "SystemeDAlarme": "Système d'alarme", "ThermopompeCentrale": "Thermopompe centrale",84 "ThermopompeMurale": "Thermopompe murale", "WaterHeater": "Chauffe-eau",85 # salle de bains86 "AttachedToMasterBedroom": "Attenante à la chambre principale",87 "BaignoireARemous": "Baignoire à remous", "SeparateShower": "Douche séparée",88 # piscine89 "Chauffee": "Chauffée", "Creusee": "Creusée", "HorsTerre": "Hors terre",90 "Interieure": "Intérieure",91 # eau / égouts92 "ChampDEpuration": "Champ d'épuration", "FosseSeptique": "Fosse septique",93 "Municipalite": "Municipalité", "SystemeBIONEST": "Système BIONEST",94 "EauDuLac": "Eau du lac", "PuitsArtesien": "Puits artésien",95 "PuitsDeSurface": "Puits de surface", "AccesALEau": "Accès à l'eau",96 "BordeParLEau": "Bordé par l'eau", "Navigable": "Navigable",97 # vue / terrain / zonage / particularités98 "Panoramique": "Panoramique", "SurLEau": "Sur l'eau",99 "SurLaMontagne": "Sur la montagne", "SurLaVille": "Sur la ville",100 "Other": "Autre", "Boise": "Boisé", "Cloture": "Clôturé",101 "Paysage": "Paysagé", "Agricultural": "Agricole", "Commercial": "Commercial",102 "Holiday": "Villégiature", "Multifamily": "Multifamilial",103 "Recreational": "Récréatif", "Residential": "Résidentiel",104 "CoinDeRue": "Coin de rue", "CornerUnit": "Unité de coin",105 "CulDeSac": "Cul-de-sac", "ForestMixed": "Forêt mixte",106 "MotorizedBoatPermitted": "Bateau à moteur permis",107 "MotorlessBoatOnly": "Bateau sans moteur seulement",108 "PrivateRoad": "Chemin privé", "SansVoisinArriere": "Sans voisin à l'arrière",109 # proximité110 "Autoroute": "Autoroute", "Cegep": "Cégep", "EcolePrimaire": "École primaire",111 "EcoleSecondaire": "École secondaire", "Garderie": "Garderie", "Golf": "Golf",112 "Hopital": "Hôpital", "MetropolitanExpressNetwork": "REM", "Parc": "Parc",113 "PisteCyclable": "Piste cyclable", "SentierDeMotoneige": "Sentier de motoneige",114 "SentierDeVTT": "Sentier de VTT", "SkiAlpin": "Ski alpin",115 "SkiDeFond": "Ski de fond", "TransportEnCommun": "Transport en commun",116 "Universite": "Université",117 # divers118 "AnimauxPermis": "Animaux permis", "RezDeChaussee": "Rez-de-chaussée",119 "SousSol": "Sous-sol", "Flat": "Plat", "Slope": "En pente",120}121122# champs-listes de la fiche détail -> groupe de caractéristiques (features)123_FEATURE_GROUPS = [124 ("heatingType", "Chauffage"),125 ("heatingEnergyType", "Énergie pour le chauffage"),126 ("basementType", "Sous-sol"),127 ("garageType", "Garage"),128 ("carportType", "Abri d'auto"),129 ("drivewayType", "Allée d'accès"),130 ("poolType", "Piscine"),131 ("fireplaceType", "Foyer/Poêle"),132 ("convenienceType", "Commodités"),133 ("cabinetType", "Armoires"),134 ("bathroomFeatureType", "Salle de bains"),135 ("exteriorCoveringType", "Revêtement extérieur"),136 ("roofingType", "Toiture"),137 ("foundationType", "Fondation"),138 ("fenestrationType", "Fenestration"),139 ("windowMechanismType", "Type de fenêtres"),140 ("sewerSystemType", "Système d'égouts"),141 ("waterSupplyType", "Approvisionnement en eau"),142 ("waterAccessType", "Accès à l'eau"),143 ("viewType", "Vue"),144 ("proximityType", "À proximité"),145 ("landType", "Terrain"),146 ("landZoningType", "Zonage"),147 ("particularityType", "Particularités"),148 ("reducedMobilityType", "Mobilité réduite"),149 ("animalPermissionType", "Animaux"),150 ("energyEfficiencyType", "Efficacité énergétique"),151 ("rentedApplianceType", "Équipement loué"),152 ("washerDryerLocationType", "Laveuse/sécheuse"),153 ("commercialListingUsage", "Usage commercial"),154 ("farmAnimalType", "Animaux de ferme"),155 ("farmBuildingType", "Bâtiments agricoles"),156]157158_EXPENSE_FR = {159 "Taxmun": "Taxes municipales", "Taxsco": "Taxes scolaires",160 "Fcop": "Frais de copropriété", "Taxeau": "Taxe d'eau",161 "Elec": "Électricité", "Chauffage": "Chauffage",162 "CommonExpenses": "Frais communs", "Assurance": "Assurances",163}164_FREQ_FR = {"Annual": "an", "Monthly": "mois", "Weekly": "sem.", "Daily": "jour"}165_WARRANTY_FR = {166 "WithWarranty": "Avec garantie légale", "WithExclusions": "Avec exclusions",167 "WithoutWarranty": "Sans garantie légale (aux risques et périls de l'acheteur)",168 "NoWarranty": "Sans garantie légale (aux risques et périls de l'acheteur)",169}170_PARKING_FR = {"Garage": "garage", "Driveway": "allée", "Carport": "abri d'auto",171 "Street": "rue", "Interior": "intérieur", "Exterior": "extérieur"}172_HALF_UNITS = [("oneAndHalfCount", "1½"), ("twoAndHalfCount", "2½"),173 ("threeAndHalfCount", "3½"), ("fourAndHalfCount", "4½"),174 ("fiveAndHalfCount", "5½"), ("sixAndHalfCount", "6½"),175 ("sevenAndHalfCount", "7½+")]176177_CAMEL_RE = re.compile(r"(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])")178179180def _fr(code) -> str:181 """Libellé français d'un code énuméré de l'API (repli : découpe CamelCase)."""182 code = str(code or "").strip()183 if not code:184 return ""185 return _ENUM_FR.get(code) or _CAMEL_RE.sub(" ", code)186187188def _money(amount) -> str:189 try:190 return f"{float(amount):,.0f} $".replace(",", " ")191 except (TypeError, ValueError):192 return ""193194195class UbeeConnector(BaseConnector):196 source_id = "ubee"197 request_delay = 0.5198 # fiches détail (re)lues au plus par synchronisation — le cache BD rend les199 # cycles suivants quasi gratuits. IMMOKA_DETAIL_LIMIT pour un rattrapage.200 detail_limit = 300201202 def _search(self, body: dict) -> list[dict]:203 out, page = [], 0204 while True:205 r = self.post(f"{API}?pageIndex={page}", json=body).json()206 results = r.get("results") or []207 out.extend(results)208 if len(out) >= (r.get("totalCount") or 0) or not results:209 break210 page += 1211 if page > 200: # garde-fou212 break213 return out214215 def _to_listing(self, it: dict) -> PropertyListing | None:216 lid = str(it.get("id") or "")217 slug = it.get("slugFr") or it.get("slugEn") or ""218 if not lid or not slug:219 return None220 city = (it.get("city") or "").strip()221 url = f"{SITE}/a-vendre/{it.get('citySlug') or ''}/{slug}"222 images = [im["publicUrls"]["default_size"]223 for im in it.get("images") or []224 if (im.get("publicUrls") or {}).get("default_size")]225 living = it.get("livingSurfaceInMeters")226 land = it.get("landSurfaceInMeters")227 ptype = _TYPES.get(it.get("inscriptionType") or "",228 it.get("inscriptionType") or "")229 return PropertyListing(230 source=self.source_id,231 external_id=lid,232 url=url,233 title=f"{ptype} à vendre — {city}" if city else f"{ptype} à vendre",234 address=it.get("address") or "",235 city=city,236 property_type=ptype,237 price=it.get("askPrice"),238 price_label=(f"{it['askPrice']:,.0f} $".replace(",", " ")239 if it.get("askPrice") else ""),240 bedrooms=it.get("nbBedrooms"),241 bathrooms=it.get("nbBathrooms"),242 powder_rooms=it.get("nbHalfBaths"),243 area_sqft=round(living * M2_TO_SQFT) if living else None,244 lot_sqft=round(land * M2_TO_SQFT) if land else None,245 year_built=it.get("yearBuilt"),246 details={k: it.get(k) for k in247 ("propertyType", "buildingType", "toBuild", "taxable",248 "openHouseDetail", "isOnlineSince") if it.get(k)},249 features=[f for f in (250 f"Type de bâtiment : {it['buildingType']}" if it.get("buildingType") else "",251 f"Sous-type : {it['propertyType']}" if it.get("propertyType") else "",252 "Neuf / à construire" if it.get("toBuild") else "",253 "Prix taxable (+tx)" if it.get("taxable") else "",254 ) if f],255 images=images,256 lat=it.get("latitude"),257 lng=it.get("longitude"),258 broker_name="Ubee",259 agency="Ubee Québec",260 )261262 def fetch(self) -> list[PropertyListing]:263 out: dict[str, PropertyListing] = {}264 for flags in ({"isResidential": True}, {"isCommercial": True}):265 body = {"sortBy": "DateDescending", "listingType": "Seller", **flags}266 for it in self._search(body):267 # Québec seulement (l'API est QC par nature, on double-vérifie)268 if (it.get("province") or "QC") != "QC":269 continue270 lst = self._to_listing(it)271 if lst is not None:272 out.setdefault(lst.uid, lst)273 listings = list(out.values())274 self._enrich_details(listings)275 return listings276277 # -- fiche détail (description, caractéristiques, taxes, courtier…) --------278 def _enrich_details(self, listings: list[PropertyListing]) -> None:279 budget = int(os.environ.get("IMMOKA_DETAIL_LIMIT", self.detail_limit))280 if budget <= 0:281 return282 from .. import db283 con = db.connect()284 try:285 for lst in listings:286 # clé de changement : prix + photo de couverture (l'URL Cloudinary287 # porte la version) — les fiches modifiées sont re-lues288 cover = lst.images[0] if lst.images else ""289 key = f"v1:{lst.price or ''}:{cover}"290 cached = db.get_cached_detail(con, self.source_id,291 lst.external_id, key)292 if cached is None:293 stale = db.get_stale_detail(con, self.source_id, lst.external_id)294 if budget <= 0:295 if stale:296 self._apply(lst, stale)297 continue298 try:299 raw = self.get(f"{DETAIL_API}/{lst.external_id}").json()300 cached = self._detail_payload(raw)301 except Exception:302 cached = {}303 if not cached and stale:304 # échec transitoire : on garde l'ancien payload305 self._apply(lst, stale)306 budget -= 1307 continue308 db.put_cached_detail(con, self.source_id, lst.external_id,309 key, cached)310 budget -= 1311 self._apply(lst, cached)312 finally:313 con.close()314315 def _apply(self, lst: PropertyListing, payload: dict) -> None:316 if not payload:317 return318 if payload.get("mls") and not lst.mls:319 lst.mls = payload["mls"]320 # agence réelle du courtier inscripteur (remplace « Ubee Québec »)321 if payload.get("agency"):322 lst.agency = payload["agency"]323 apply_detail(lst, payload)324325 def _detail_payload(self, d: dict) -> dict:326 """Payload compact (JSON-sérialisable, mis en cache) depuis la fiche API."""327 if not isinstance(d, dict) or not d.get("id"):328 return {}329 out: dict = {}330 details: dict = {}331 features: list[str] = []332333 # -- description longue (préférence au texte FR) -------------------------334 texts = d.get("descriptionTexts") or []335 desc = next((t.get("plainText") or t.get("text") or ""336 for t in texts if (t.get("language") or "").lower() == "fr"), "")337 desc = (desc or d.get("plainTextDescription") or d.get("description") or "").strip()338 if desc:339 out["description"] = desc340341 # -- galerie (même famille Cloudinary ; on garde la plus fournie) --------342 images = [im["publicUrls"]["default_size"]343 for im in d.get("images") or []344 if (im.get("publicUrls") or {}).get("default_size")]345 if images:346 out["images"] = images347348 # -- identifiants / champs simples ---------------------------------------349 if d.get("mlsNumber"):350 out["mls"] = str(d["mlsNumber"])351 details["Numéro Centris"] = str(d["mlsNumber"])352 if d.get("nbRooms"):353 details["Nombre de pièces"] = d["nbRooms"]354 if d.get("numberOfFloors"):355 details["Nombre d'étages"] = d["numberOfFloors"]356 if d.get("numberOfParkings"):357 details["Stationnement (total)"] = d["numberOfParkings"]358 parks = [f"{_PARKING_FR.get(p.get('parkingKind'), _fr(p.get('parkingKind')).lower())}"359 f" ({p['count']})"360 for p in d.get("parkings") or [] if p.get("count")]361 if parks:362 features.append("Stationnement : " + ", ".join(parks))363 land = d.get("landSurfaceInMeters")364 if land:365 out["lot_sqft"] = round(land * M2_TO_SQFT)366 if d.get("yearBuilt"):367 out["year_built"] = d["yearBuilt"]368369 # -- taxes / frais / évaluation municipale --------------------------------370 for e in d.get("expenses") or []:371 label = _EXPENSE_FR.get(e.get("expenseType") or "") or \372 _fr(e.get("expenseType"))373 money = _money(e.get("amount"))374 if not (label and money):375 continue376 per = _FREQ_FR.get(e.get("paymentFrequency") or "", "")377 details[label] = f"{money}/{per}" if per else money378 lot_a = d.get("lotMunicipalAssessment") or 0379 bld_a = d.get("buildingMunicipalAssessment") or 0380 if lot_a or bld_a:381 year = d.get("municipalAssessmentYear")382 details["Évaluation municipale"] = _money(lot_a + bld_a) + \383 (f" ({year})" if year else "")384 if lot_a:385 details["Évaluation municipale (terrain)"] = _money(lot_a)386 if bld_a:387 details["Évaluation municipale (bâtiment)"] = _money(bld_a)388389 # -- conditions de vente / occupation -------------------------------------390 if d.get("legalWarrantyType") in _WARRANTY_FR:391 details["Garantie légale"] = _WARRANTY_FR[d["legalWarrantyType"]]392 if d.get("coOwnershipKind") in ("Divise", "Indivise"):393 details["Copropriété"] = d["coOwnershipKind"]394 if d.get("coOwnershipPercentage"):395 details["Quote-part"] = f"{d['coOwnershipPercentage']} %"396 if d.get("moveInDelay"):397 details["Délai d'occupation"] = str(d["moveInDelay"]).strip()398 if d.get("moveInDate"):399 details["Date d'occupation"] = str(d["moveInDate"])[:10]400 if d.get("topographyType"):401 details["Topographie"] = _fr(d["topographyType"])402 if d.get("waterBodyName"):403 details["Plan d'eau"] = str(d["waterBodyName"])404 if d.get("constructionCharacteristic") == "New":405 features.append("Construction neuve")406 if d.get("shortTermRentalPolicy"):407 details["Location court terme"] = (408 "Non permise" if d["shortTermRentalPolicy"] == "NotAllowed"409 else "Permise")410411 # -- plex / commercial -----------------------------------------------------412 units = [f"{lab} × {d[k]}" for k, lab in _HALF_UNITS if d.get(k)]413 for k, lab in (("commercialCount", "commercial"),414 ("officeCount", "bureau"), ("industrialCount", "industriel")):415 if d.get(k):416 units.append(f"{lab} × {d[k]}")417 if units:418 details["Unités"] = ", ".join(units)419 if d.get("potentialCommercialRevenue"):420 details["Revenus potentiels"] = _money(d["potentialCommercialRevenue"])421422 # -- caractéristiques énumérées (~30 listes) ------------------------------423 for field, group in _FEATURE_GROUPS:424 vals = [_fr(v) for v in d.get(field) or []]425 vals = [v for v in vals if v]426 if vals:427 features.append(f"{group} : {', '.join(vals)}")428429 # -- liens : fiche Centris originale, visite virtuelle ---------------------430 links = d.get("additionalLinks") or {}431 if links.get("listingLink"):432 details["listing_origin_url"] = links["listingLink"]433 tour = links.get("virtualTourFrLink") or links.get("virtualTourEnLink")434 if tour:435 details["Visite virtuelle"] = tour436437 # -- courtier inscripteur (sinon la fiche reste « Ubee » = proprio direct) --438 pro = d.get("professional") or {}439 name = (pro.get("fullname") or "").strip()440 if name:441 out["broker_name"] = name442 phone = (pro.get("displayPhoneNumber") or "").strip()443 if phone:444 out["broker_phone"] = phone445 agence = (pro.get("agenceName") or pro.get("corporationName") or "").strip()446 if agence:447 out["agency"] = agence448 details["Courtier inscripteur"] = name + \449 (f" ({agence})" if agence else "") + (f" — {phone}" if phone else "")450 if pro.get("displayEmail"):451 details["Courriel du courtier"] = pro["displayEmail"]452 cols = [c.get("fullname") for c in d.get("coListingProfessionals") or []453 if c.get("fullname")]454 if cols:455 details["Courtier collaborateur"] = ", ".join(cols)456457 if features:458 out["features"] = features459 if details:460 out["details"] = details461 return out462