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/realtor_ca.py : Realtor.ca (couverture MLS pan-bannières, QC)5# API interne https://api2.realtor.ca/Listing.svc/PropertySearch_Post (POST6# form). Protégée (DataDome/Incapsula) : l'accès direct renvoie 403, mais7# l'API se REJOUE via Scrapfly ASP SANS render_js (1 crédit-appel ASP, pas de8# navigateur) → JSON complet : n° MLS, GPS, prix, CAC/SDB, superficie, photos,9# courtier + agence. Vérifié 2026-08-18 : 200 fiches/page, plafond serveur10# 600 fiches (MaxRecords) par recherche → SHARDING par boîtes lat/lng11# couvrant le Québec, tri « plus récentes d'abord » (Sort=6-D).12#13# BUDGET BORNÉ : IMMOKA_REALTOR_MAX fiches max par sync (défaut 2 000, soit14# ~10 appels Scrapfly), réparties en balayant la page 1 de chaque shard puis15# les pages 2-3 si le budget le permet. Les fiches plus anciennes sortent de16# la fenêtre au fil des syncs (miss_count → active=0) : le connecteur vaut17# surtout pour les inscriptions récentes des bannières non couvertes.18# Mettre IMMOKA_REALTOR_MAX=0 pour désactiver le connecteur.19#20# source_id « realtor_ag_ca » : l'infixe _ag_ + external_id = n° MLS Centris21# activent la dédup existante — toute fiche déjà couverte par un connecteur22# direct (RE/MAX, Royal LePage, Sutton…) est masquée, seules les fiches23# UNIQUES (petites bannières sans connecteur) restent visibles.24#25# ENRICHISSEMENT DÉTAIL (2026-08-18) : la réponse PropertySearch_Post ne26# contient qu'UNE photo et un PublicRemarks VIDE (vérifié) — la galerie27# complète, la description et les caractéristiques Building/Land viennent de28# l'API Listing.svc/PropertyDetails (GET, mêmes protections → Scrapfly ASP29# sans render_js, 1 crédit-appel), rejouée avec CultureId=2 (français) via30# ReferenceNumber=<MLS> + PropertyID=<Id interne>. Cache BD detail_cache31# (_detailutil.enrich) : chaque fiche n'est détaillée qu'UNE fois ; budget32# IMMOKA_REALTOR_DETAIL_LIMIT appels/cycle (défaut 100, override ponctuel33# IMMOKA_DETAIL_LIMIT) → rattrapage progressif du stock, nouveautés ensuite.34# -----------------------------------------------------------------------------35from __future__ import annotations3637import json38import os39import time4041import requests4243from . import _detailutil as du44from .base import BaseConnector45from ..schema import PropertyListing4647SEARCH_URL = "https://api2.realtor.ca/Listing.svc/PropertySearch_Post"48DETAIL_URL = "https://api2.realtor.ca/Listing.svc/PropertyDetails"49SITE = "https://www.realtor.ca"5051MAX_RECORDS = int(os.environ.get("IMMOKA_REALTOR_MAX", "2000"))52DETAIL_LIMIT = int(os.environ.get("IMMOKA_DETAIL_LIMIT")53 or os.environ.get("IMMOKA_REALTOR_DETAIL_LIMIT", "100"))54PER_PAGE = 200 # maximum accepté par l'API55MAX_PAGES_PER_SHARD = 3 # plafond serveur : MaxRecords=600 par recherche5657# Boîtes (sud, ouest, nord, est) couvrant le Québec habité, par densité58# décroissante — le budget est d'abord dépensé sur les marchés actifs.59SHARDS: list[tuple[str, float, float, float, float]] = [60 ("montreal-laval", 45.35, -74.05, 45.75, -73.30),61 ("monteregie", 45.00, -74.40, 45.35, -72.80),62 ("rive-sud-est", 45.35, -73.30, 45.75, -72.80),63 ("laurentides-lanaudiere", 45.75, -74.80, 46.40, -73.20),64 ("quebec-metro", 46.55, -71.65, 47.10, -70.90),65 ("chaudiere-appalaches", 46.00, -71.80, 46.85, -70.00),66 ("estrie", 45.00, -72.80, 45.90, -71.50),67 ("mauricie-cdq", 45.90, -73.20, 46.90, -71.80),68 ("outaouais", 45.30, -77.60, 46.50, -74.80),69 ("saguenay-lac-st-jean", 48.00, -72.60, 48.80, -70.70),70 ("bas-st-laurent-gaspesie", 47.20, -70.60, 49.40, -64.00),71 ("charlevoix-cote-nord", 47.00, -71.00, 50.40, -65.90),72 ("abitibi-temiscamingue", 47.20, -79.60, 48.90, -77.40),73 ("hautes-laurentides", 46.40, -76.00, 47.20, -74.20),74]757677class RealtorCaConnector(BaseConnector):78 source_id = "realtor_ag_ca"79 request_delay = 0.2 # Scrapfly gère la politesse côté cible80 use_detail_cache = True # galerie/description via PropertyDetails, cachées8182 def __init__(self) -> None:83 super().__init__()84 # external_id -> Id interne Realtor (PropertyID requis par l'API détail)85 self._prop_ids: dict[str, str] = {}8687 def scrapfly(self, url: str, **kw) -> dict:88 # Coupures réseau transitoires vers api.scrapfly.io (ConnectTimeout89 # 180 s observé 2×, 2026-09-13) : sans retentative, un seul raté tue90 # les ~11 appels du sync → 0 fiche. On retente avec pause avant91 # de laisser l'exception remonter.92 last: Exception | None = None93 for attempt in range(3):94 try:95 return super().scrapfly(url, **kw)96 except (requests.ConnectionError, requests.Timeout) as exc:97 last = exc98 if attempt < 2:99 time.sleep(20 * (attempt + 1))100 raise last # type: ignore[misc]101102 def _search(self, shard: tuple, page: int) -> dict:103 _, south, west, north, east = shard104 body = "&".join(f"{k}={v}" for k, v in {105 "ZoomLevel": 10,106 "LatitudeMax": north, "LongitudeMax": east,107 "LatitudeMin": south, "LongitudeMin": west,108 "Sort": "6-D", # inscription la plus récente d'abord109 "PropertyTypeGroupID": 1, # résidentiel110 "TransactionTypeId": 2, # à vendre111 "PropertySearchTypeId": 0,112 "Currency": "CAD",113 "RecordsPerPage": PER_PAGE,114 "CurrentPage": page,115 "CultureId": 1,116 "ApplicationId": 1,117 "PropertyStatusId": 1,118 "Version": "7.0",119 }.items())120 result = self.scrapfly(121 SEARCH_URL, render_js=False, asp=True, method="POST", body=body,122 headers={123 "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",124 "Referer": "https://www.realtor.ca/",125 "Origin": "https://www.realtor.ca",126 })127 try:128 return json.loads(result.get("content") or "")129 except ValueError:130 return {}131132 def fetch(self) -> list[PropertyListing]:133 if MAX_RECORDS <= 0:134 return []135 by_id: dict[str, PropertyListing] = {}136 exhausted: set[str] = set()137 for page in range(1, MAX_PAGES_PER_SHARD + 1):138 for shard in SHARDS:139 name = shard[0]140 if name in exhausted or len(by_id) >= MAX_RECORDS:141 continue142 data = self._search(shard, page)143 results = data.get("Results") or []144 if len(results) < PER_PAGE:145 exhausted.add(name)146 if not results:147 continue148 for item in results:149 lst = self._to_listing(item)150 if lst and lst.external_id not in by_id:151 by_id[lst.external_id] = lst152 if len(by_id) >= MAX_RECORDS:153 break154 listings = list(by_id.values())155 self._enrich(listings)156 return listings157158 # -- détail : galerie complète + description + caractéristiques -----------159 def _fetch_detail(self, mls: str, pid: str) -> str:160 """JSON brut de l'API PropertyDetails (CultureId=2 → contenu français)."""161 result = self.scrapfly(162 f"{DETAIL_URL}?ReferenceNumber={mls}&PropertyID={pid}"163 "&ApplicationId=1&CultureId=2&PreferedMeasurementUnit=1",164 render_js=False, asp=True,165 headers={"Referer": "https://www.realtor.ca/",166 "Origin": "https://www.realtor.ca"})167 if result.get("status_code") != 200:168 raise RuntimeError(f"PropertyDetails {mls}: "169 f"HTTP {result.get('status_code')}")170 return result.get("content") or ""171172 def _enrich(self, listings: list[PropertyListing]) -> None:173 """Complète chaque fiche via PropertyDetails, avec cache BD + budget.174175 Contrairement à _detailutil.enrich, un échec (challenge ASP, JSON176 invalide) n'est JAMAIS mis en cache : la fiche est retentée au177 prochain cycle. Une fiche détaillée avec succès ne coûte plus rien.178 """179 if DETAIL_LIMIT <= 0:180 return181 from .. import db182 con = db.connect()183 budget = DETAIL_LIMIT184 try:185 for lst in listings:186 payload = db.get_cached_detail(con, self.source_id,187 lst.external_id, "v1")188 if payload is None:189 pid = self._prop_ids.get(lst.external_id, "")190 mls = lst.mls or lst.external_id191 if budget <= 0 or not pid or not mls:192 continue193 budget -= 1194 try:195 payload = _parse_detail(self._fetch_detail(mls, pid))196 except Exception:197 payload = {}198 if not payload:199 continue # échec transitoire : nouvel essai plus tard200 db.put_cached_detail(con, self.source_id,201 lst.external_id, "v1", payload)202 du.apply_detail(lst, payload)203 finally:204 con.close()205206 def _to_listing(self, item: dict) -> PropertyListing | None:207 prop = item.get("Property") or {}208 addr = prop.get("Address") or {}209 text = addr.get("AddressText") or ""210 # « 680 Rue De Courcelle|#613|Montréal (Le Sud-Ouest), Quebec H4C0B8 »211 if ", Quebec" not in text and ", Québec" not in text:212 return None # les shards frontaliers débordent (Ottawa, N.-B.)213 parts = [p.strip() for p in text.split("|")]214 locality = parts[-1] if parts else ""215 street = ", ".join(parts[:-1]) if len(parts) > 1 else ""216 city = sector = ""217 loc = locality.split(", Quebec")[0].split(", Québec")[0]218 if "(" in loc:219 city, _, rest = loc.partition("(")220 city, sector = city.strip(), rest.rstrip(")").strip()221 else:222 city = loc.strip()223 mls = str(item.get("MlsNumber") or "").strip()224 rid = str(item.get("Id") or "").strip()225 if not (mls or rid) or not street:226 return None227 try:228 price = float(prop.get("PriceUnformattedValue"))229 except (TypeError, ValueError):230 price = None231 building = item.get("Building") or {}232 lst = PropertyListing(233 source=self.source_id,234 external_id=mls or rid,235 url=f"{SITE}{item.get('RelativeDetailsURL') or ''}",236 title=f"{building.get('Type') or prop.get('Type') or ''} — {city}".strip(" —"),237 address=street,238 sector=sector,239 city=city,240 property_type=building.get("Type") or prop.get("Type") or "",241 price=price,242 price_label=prop.get("Price") or "",243 mls=mls,244 description=(item.get("PublicRemarks") or "").strip(),245 )246 try:247 lst.lat = float(addr.get("Latitude"))248 lst.lng = float(addr.get("Longitude"))249 except (TypeError, ValueError):250 pass251 beds = str(building.get("Bedrooms") or "")252 if beds:253 try: # « 3 + 1 » → 4254 lst.bedrooms = sum(int(x) for x in beds.replace(" ", "").split("+") if x)255 except ValueError:256 pass257 try:258 lst.bathrooms = int(building.get("BathroomTotal"))259 except (TypeError, ValueError):260 pass261 try:262 lst.powder_rooms = int(building.get("HalfBathTotal"))263 except (TypeError, ValueError):264 pass265 size = building.get("SizeInterior") or ""266 if size:267 lst.details["Superficie habitable"] = size268 land = (item.get("Land") or {}).get("SizeTotal") or ""269 if land:270 lst.details["Superficie du terrain"] = land271 alt = (item.get("AlternateURL") or {}).get("DetailsLink") or ""272 if alt:273 lst.details["Annonce originale"] = alt274 lst.images = [p.get("HighResPath") or p.get("MedResPath")275 for p in (prop.get("Photo") or []) if isinstance(p, dict)]276 lst.images = [u for u in lst.images if u]277 ind = (item.get("Individual") or [{}])[0]278 lst.broker_name = ind.get("Name") or ""279 org = ind.get("Organization") or {}280 lst.agency = org.get("Name") or ""281 phones = ind.get("Phones") or []282 if phones:283 p0 = phones[0]284 lst.broker_phone = f"{p0.get('AreaCode', '')} {p0.get('PhoneNumber', '')}".strip()285 if rid: # Id interne Realtor, requis par l'API PropertyDetails286 self._prop_ids[lst.external_id] = rid287 return lst288289290# libellés français des caractéristiques structurées de PropertyDetails291_DETAIL_LABELS = [292 # (section, clé API, libellé Immo-Ka)293 ("Building", "ConstructedDate", "Année de construction"),294 ("Building", "SizeInterior", "Superficie habitable"),295 ("Building", "StoriesTotal", "Nombre d'étages"),296 ("Building", "ConstructionStyleAttachment", "Type de bâtiment"),297 ("Building", "HeatingType", "Système de chauffage"),298 ("Building", "HeatingFuel", "Énergie pour le chauffage"),299 ("Building", "CoolingType", "Climatisation"),300 ("Building", "ExteriorFinish", "Revêtement"),301 ("Building", "RoofMaterial", "Toiture"),302 ("Building", "FoundationType", "Fondations"),303 ("Building", "BasementType", "Sous-sol"),304 ("Building", "FireplaceTotal", "Foyers"),305 ("Building", "Water", "Approvisionnement en eau"),306 ("Land", "SizeTotal", "Superficie du terrain"),307 ("Land", "Sewer", "Système d'égouts"),308 ("Land", "LandscapeFeatures", "Aménagement paysager"),309 ("Property", "PoolType", "Piscine"),310 ("Property", "ZoningType", "Zonage"),311 ("Property", "ParkingSpaceTotal", "Stationnement (total)"),312 ("Property", "OwnershipType", "Type de copropriété"),313 ("Property", "TaxTotal", "Taxes annuelles"),314]315316317def _parse_detail(content: str) -> dict:318 """Réponse PropertyDetails (JSON) → payload compatible du.apply_detail."""319 try:320 d = json.loads(content or "")321 except ValueError:322 return {}323 if not isinstance(d, dict) or not d.get("Id"):324 return {}325 prop = d.get("Property") or {}326 sections = {"Property": prop, "Building": d.get("Building") or {},327 "Land": d.get("Land") or {}}328 out: dict = {}329 images = [p.get("HighResPath") or p.get("MedResPath")330 for p in (prop.get("Photo") or []) if isinstance(p, dict)]331 images = [u for u in images if u]332 if images:333 out["images"] = images334 desc = (d.get("PublicRemarks") or "").strip()335 if desc:336 out["description"] = desc337 details: dict = {}338 for section, key, label in _DETAIL_LABELS:339 val = sections[section].get(key)340 if val not in (None, "", 0, "0"):341 details[label] = str(val)342 parking = [p.get("Name") for p in (prop.get("Parking") or [])343 if isinstance(p, dict) and p.get("Name")]344 if parking:345 details["Stationnement"] = ", ".join(dict.fromkeys(parking))346 rooms = (sections["Building"].get("Room") or [])347 if rooms:348 details["Nombre de pièces"] = str(len(rooms))349 alt = (d.get("AlternateURL") or {}).get("DetailsLink") or ""350 if alt:351 details["Annonce originale"] = alt352 if details:353 out["details"] = details354 feats: list[str] = []355 for blob in (prop.get("Features"), prop.get("AmmenitiesNearBy")):356 for f in (blob or "").split(","):357 if f.strip():358 feats.append(f.strip())359 if feats:360 out["features"] = feats361 b = sections["Building"]362 for src, dst in (("ConstructedDate", "year_built"),363 ("HalfBathTotal", "powder_rooms")):364 v = du._int(b.get(src))365 if v:366 out[dst] = v367 ind = (d.get("Individual") or [{}])[0]368 if ind.get("Name"):369 out["broker_name"] = ind["Name"]370 for p in ind.get("Phones") or []:371 if p.get("PhoneNumber"):372 out["broker_phone"] = (f"{p.get('AreaCode', '')} "373 f"{p['PhoneNumber']}").strip()374 break375 return out376