# ----------------------------------------------------------------------------- # Immo-Ka — Agrégateur de maisons à vendre (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/realtor_ca.py : Realtor.ca (couverture MLS pan-bannières, QC) # API interne https://api2.realtor.ca/Listing.svc/PropertySearch_Post (POST # form). Protégée (DataDome/Incapsula) : l'accès direct renvoie 403, mais # l'API se REJOUE via Scrapfly ASP SANS render_js (1 crédit-appel ASP, pas de # navigateur) → JSON complet : n° MLS, GPS, prix, CAC/SDB, superficie, photos, # courtier + agence. Vérifié 2026-08-18 : 200 fiches/page, plafond serveur # 600 fiches (MaxRecords) par recherche → SHARDING par boîtes lat/lng # couvrant le Québec, tri « plus récentes d'abord » (Sort=6-D). # # BUDGET BORNÉ : IMMOKA_REALTOR_MAX fiches max par sync (défaut 2 000, soit # ~10 appels Scrapfly), réparties en balayant la page 1 de chaque shard puis # les pages 2-3 si le budget le permet. Les fiches plus anciennes sortent de # la fenêtre au fil des syncs (miss_count → active=0) : le connecteur vaut # surtout pour les inscriptions récentes des bannières non couvertes. # Mettre IMMOKA_REALTOR_MAX=0 pour désactiver le connecteur. # # source_id « realtor_ag_ca » : l'infixe _ag_ + external_id = n° MLS Centris # activent la dédup existante — toute fiche déjà couverte par un connecteur # direct (RE/MAX, Royal LePage, Sutton…) est masquée, seules les fiches # UNIQUES (petites bannières sans connecteur) restent visibles. # # ENRICHISSEMENT DÉTAIL (2026-08-18) : la réponse PropertySearch_Post ne # contient qu'UNE photo et un PublicRemarks VIDE (vérifié) — la galerie # complète, la description et les caractéristiques Building/Land viennent de # l'API Listing.svc/PropertyDetails (GET, mêmes protections → Scrapfly ASP # sans render_js, 1 crédit-appel), rejouée avec CultureId=2 (français) via # ReferenceNumber= + PropertyID=. Cache BD detail_cache # (_detailutil.enrich) : chaque fiche n'est détaillée qu'UNE fois ; budget # IMMOKA_REALTOR_DETAIL_LIMIT appels/cycle (défaut 100, override ponctuel # IMMOKA_DETAIL_LIMIT) → rattrapage progressif du stock, nouveautés ensuite. # ----------------------------------------------------------------------------- from __future__ import annotations import json import os import time import requests from . import _detailutil as du from .base import BaseConnector from ..schema import PropertyListing SEARCH_URL = "https://api2.realtor.ca/Listing.svc/PropertySearch_Post" DETAIL_URL = "https://api2.realtor.ca/Listing.svc/PropertyDetails" SITE = "https://www.realtor.ca" MAX_RECORDS = int(os.environ.get("IMMOKA_REALTOR_MAX", "2000")) DETAIL_LIMIT = int(os.environ.get("IMMOKA_DETAIL_LIMIT") or os.environ.get("IMMOKA_REALTOR_DETAIL_LIMIT", "100")) PER_PAGE = 200 # maximum accepté par l'API MAX_PAGES_PER_SHARD = 3 # plafond serveur : MaxRecords=600 par recherche # Boîtes (sud, ouest, nord, est) couvrant le Québec habité, par densité # décroissante — le budget est d'abord dépensé sur les marchés actifs. SHARDS: list[tuple[str, float, float, float, float]] = [ ("montreal-laval", 45.35, -74.05, 45.75, -73.30), ("monteregie", 45.00, -74.40, 45.35, -72.80), ("rive-sud-est", 45.35, -73.30, 45.75, -72.80), ("laurentides-lanaudiere", 45.75, -74.80, 46.40, -73.20), ("quebec-metro", 46.55, -71.65, 47.10, -70.90), ("chaudiere-appalaches", 46.00, -71.80, 46.85, -70.00), ("estrie", 45.00, -72.80, 45.90, -71.50), ("mauricie-cdq", 45.90, -73.20, 46.90, -71.80), ("outaouais", 45.30, -77.60, 46.50, -74.80), ("saguenay-lac-st-jean", 48.00, -72.60, 48.80, -70.70), ("bas-st-laurent-gaspesie", 47.20, -70.60, 49.40, -64.00), ("charlevoix-cote-nord", 47.00, -71.00, 50.40, -65.90), ("abitibi-temiscamingue", 47.20, -79.60, 48.90, -77.40), ("hautes-laurentides", 46.40, -76.00, 47.20, -74.20), ] class RealtorCaConnector(BaseConnector): source_id = "realtor_ag_ca" request_delay = 0.2 # Scrapfly gère la politesse côté cible use_detail_cache = True # galerie/description via PropertyDetails, cachées def __init__(self) -> None: super().__init__() # external_id -> Id interne Realtor (PropertyID requis par l'API détail) self._prop_ids: dict[str, str] = {} def scrapfly(self, url: str, **kw) -> dict: # Coupures réseau transitoires vers api.scrapfly.io (ConnectTimeout # 180 s observé 2×, 2026-09-13) : sans retentative, un seul raté tue # les ~11 appels du sync → 0 fiche. On retente avec pause avant # de laisser l'exception remonter. last: Exception | None = None for attempt in range(3): try: return super().scrapfly(url, **kw) except (requests.ConnectionError, requests.Timeout) as exc: last = exc if attempt < 2: time.sleep(20 * (attempt + 1)) raise last # type: ignore[misc] def _search(self, shard: tuple, page: int) -> dict: _, south, west, north, east = shard body = "&".join(f"{k}={v}" for k, v in { "ZoomLevel": 10, "LatitudeMax": north, "LongitudeMax": east, "LatitudeMin": south, "LongitudeMin": west, "Sort": "6-D", # inscription la plus récente d'abord "PropertyTypeGroupID": 1, # résidentiel "TransactionTypeId": 2, # à vendre "PropertySearchTypeId": 0, "Currency": "CAD", "RecordsPerPage": PER_PAGE, "CurrentPage": page, "CultureId": 1, "ApplicationId": 1, "PropertyStatusId": 1, "Version": "7.0", }.items()) result = self.scrapfly( SEARCH_URL, render_js=False, asp=True, method="POST", body=body, headers={ "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", "Referer": "https://www.realtor.ca/", "Origin": "https://www.realtor.ca", }) try: return json.loads(result.get("content") or "") except ValueError: return {} def fetch(self) -> list[PropertyListing]: if MAX_RECORDS <= 0: return [] by_id: dict[str, PropertyListing] = {} exhausted: set[str] = set() for page in range(1, MAX_PAGES_PER_SHARD + 1): for shard in SHARDS: name = shard[0] if name in exhausted or len(by_id) >= MAX_RECORDS: continue data = self._search(shard, page) results = data.get("Results") or [] if len(results) < PER_PAGE: exhausted.add(name) if not results: continue for item in results: lst = self._to_listing(item) if lst and lst.external_id not in by_id: by_id[lst.external_id] = lst if len(by_id) >= MAX_RECORDS: break listings = list(by_id.values()) self._enrich(listings) return listings # -- détail : galerie complète + description + caractéristiques ----------- def _fetch_detail(self, mls: str, pid: str) -> str: """JSON brut de l'API PropertyDetails (CultureId=2 → contenu français).""" result = self.scrapfly( f"{DETAIL_URL}?ReferenceNumber={mls}&PropertyID={pid}" "&ApplicationId=1&CultureId=2&PreferedMeasurementUnit=1", render_js=False, asp=True, headers={"Referer": "https://www.realtor.ca/", "Origin": "https://www.realtor.ca"}) if result.get("status_code") != 200: raise RuntimeError(f"PropertyDetails {mls}: " f"HTTP {result.get('status_code')}") return result.get("content") or "" def _enrich(self, listings: list[PropertyListing]) -> None: """Complète chaque fiche via PropertyDetails, avec cache BD + budget. Contrairement à _detailutil.enrich, un échec (challenge ASP, JSON invalide) n'est JAMAIS mis en cache : la fiche est retentée au prochain cycle. Une fiche détaillée avec succès ne coûte plus rien. """ if DETAIL_LIMIT <= 0: return from .. import db con = db.connect() budget = DETAIL_LIMIT try: for lst in listings: payload = db.get_cached_detail(con, self.source_id, lst.external_id, "v1") if payload is None: pid = self._prop_ids.get(lst.external_id, "") mls = lst.mls or lst.external_id if budget <= 0 or not pid or not mls: continue budget -= 1 try: payload = _parse_detail(self._fetch_detail(mls, pid)) except Exception: payload = {} if not payload: continue # échec transitoire : nouvel essai plus tard db.put_cached_detail(con, self.source_id, lst.external_id, "v1", payload) du.apply_detail(lst, payload) finally: con.close() def _to_listing(self, item: dict) -> PropertyListing | None: prop = item.get("Property") or {} addr = prop.get("Address") or {} text = addr.get("AddressText") or "" # « 680 Rue De Courcelle|#613|Montréal (Le Sud-Ouest), Quebec H4C0B8 » if ", Quebec" not in text and ", Québec" not in text: return None # les shards frontaliers débordent (Ottawa, N.-B.) parts = [p.strip() for p in text.split("|")] locality = parts[-1] if parts else "" street = ", ".join(parts[:-1]) if len(parts) > 1 else "" city = sector = "" loc = locality.split(", Quebec")[0].split(", Québec")[0] if "(" in loc: city, _, rest = loc.partition("(") city, sector = city.strip(), rest.rstrip(")").strip() else: city = loc.strip() mls = str(item.get("MlsNumber") or "").strip() rid = str(item.get("Id") or "").strip() if not (mls or rid) or not street: return None try: price = float(prop.get("PriceUnformattedValue")) except (TypeError, ValueError): price = None building = item.get("Building") or {} lst = PropertyListing( source=self.source_id, external_id=mls or rid, url=f"{SITE}{item.get('RelativeDetailsURL') or ''}", title=f"{building.get('Type') or prop.get('Type') or ''} — {city}".strip(" —"), address=street, sector=sector, city=city, property_type=building.get("Type") or prop.get("Type") or "", price=price, price_label=prop.get("Price") or "", mls=mls, description=(item.get("PublicRemarks") or "").strip(), ) try: lst.lat = float(addr.get("Latitude")) lst.lng = float(addr.get("Longitude")) except (TypeError, ValueError): pass beds = str(building.get("Bedrooms") or "") if beds: try: # « 3 + 1 » → 4 lst.bedrooms = sum(int(x) for x in beds.replace(" ", "").split("+") if x) except ValueError: pass try: lst.bathrooms = int(building.get("BathroomTotal")) except (TypeError, ValueError): pass try: lst.powder_rooms = int(building.get("HalfBathTotal")) except (TypeError, ValueError): pass size = building.get("SizeInterior") or "" if size: lst.details["Superficie habitable"] = size land = (item.get("Land") or {}).get("SizeTotal") or "" if land: lst.details["Superficie du terrain"] = land alt = (item.get("AlternateURL") or {}).get("DetailsLink") or "" if alt: lst.details["Annonce originale"] = alt lst.images = [p.get("HighResPath") or p.get("MedResPath") for p in (prop.get("Photo") or []) if isinstance(p, dict)] lst.images = [u for u in lst.images if u] ind = (item.get("Individual") or [{}])[0] lst.broker_name = ind.get("Name") or "" org = ind.get("Organization") or {} lst.agency = org.get("Name") or "" phones = ind.get("Phones") or [] if phones: p0 = phones[0] lst.broker_phone = f"{p0.get('AreaCode', '')} {p0.get('PhoneNumber', '')}".strip() if rid: # Id interne Realtor, requis par l'API PropertyDetails self._prop_ids[lst.external_id] = rid return lst # libellés français des caractéristiques structurées de PropertyDetails _DETAIL_LABELS = [ # (section, clé API, libellé Immo-Ka) ("Building", "ConstructedDate", "Année de construction"), ("Building", "SizeInterior", "Superficie habitable"), ("Building", "StoriesTotal", "Nombre d'étages"), ("Building", "ConstructionStyleAttachment", "Type de bâtiment"), ("Building", "HeatingType", "Système de chauffage"), ("Building", "HeatingFuel", "Énergie pour le chauffage"), ("Building", "CoolingType", "Climatisation"), ("Building", "ExteriorFinish", "Revêtement"), ("Building", "RoofMaterial", "Toiture"), ("Building", "FoundationType", "Fondations"), ("Building", "BasementType", "Sous-sol"), ("Building", "FireplaceTotal", "Foyers"), ("Building", "Water", "Approvisionnement en eau"), ("Land", "SizeTotal", "Superficie du terrain"), ("Land", "Sewer", "Système d'égouts"), ("Land", "LandscapeFeatures", "Aménagement paysager"), ("Property", "PoolType", "Piscine"), ("Property", "ZoningType", "Zonage"), ("Property", "ParkingSpaceTotal", "Stationnement (total)"), ("Property", "OwnershipType", "Type de copropriété"), ("Property", "TaxTotal", "Taxes annuelles"), ] def _parse_detail(content: str) -> dict: """Réponse PropertyDetails (JSON) → payload compatible du.apply_detail.""" try: d = json.loads(content or "") except ValueError: return {} if not isinstance(d, dict) or not d.get("Id"): return {} prop = d.get("Property") or {} sections = {"Property": prop, "Building": d.get("Building") or {}, "Land": d.get("Land") or {}} out: dict = {} images = [p.get("HighResPath") or p.get("MedResPath") for p in (prop.get("Photo") or []) if isinstance(p, dict)] images = [u for u in images if u] if images: out["images"] = images desc = (d.get("PublicRemarks") or "").strip() if desc: out["description"] = desc details: dict = {} for section, key, label in _DETAIL_LABELS: val = sections[section].get(key) if val not in (None, "", 0, "0"): details[label] = str(val) parking = [p.get("Name") for p in (prop.get("Parking") or []) if isinstance(p, dict) and p.get("Name")] if parking: details["Stationnement"] = ", ".join(dict.fromkeys(parking)) rooms = (sections["Building"].get("Room") or []) if rooms: details["Nombre de pièces"] = str(len(rooms)) alt = (d.get("AlternateURL") or {}).get("DetailsLink") or "" if alt: details["Annonce originale"] = alt if details: out["details"] = details feats: list[str] = [] for blob in (prop.get("Features"), prop.get("AmmenitiesNearBy")): for f in (blob or "").split(","): if f.strip(): feats.append(f.strip()) if feats: out["features"] = feats b = sections["Building"] for src, dst in (("ConstructedDate", "year_built"), ("HalfBathTotal", "powder_rooms")): v = du._int(b.get(src)) if v: out[dst] = v ind = (d.get("Individual") or [{}])[0] if ind.get("Name"): out["broker_name"] = ind["Name"] for p in ind.get("Phones") or []: if p.get("PhoneNumber"): out["broker_phone"] = (f"{p.get('AreaCode', '')} " f"{p['PhoneNumber']}").strip() break return out