# ----------------------------------------------------------------------------- # Immo-Ka — Agrégateur de maisons à vendre (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/sothebys_quebec.py : Sotheby's International Realty Québec # Recherche rendue côté client : le grid public est plafonné (~40/page) et la # pagination passe par du JS (pas d'URL). On SHARD donc par région # administrative du Québec (chaque page « region-{slug}-real-estate » renvoie # son lot), on dédoublonne par id, puis on enrichit chaque fiche via son # JSON-LD `Product` (prix, galerie S3 complète, description, n° MLS = sku). # ----------------------------------------------------------------------------- from __future__ import annotations import html as _html import json import os import re from .base import BaseConnector from . import _detailutil as du from ..schema import PropertyListing SITE = "https://sothebysrealty.ca" DETAIL_LIMIT = int(os.environ.get("IMMOKA_SOTHEBYS_DETAIL_LIMIT", "400")) # Régions administratives du Québec (slugs de recherche Sotheby's). QC_REGIONS = [ "region-montreal", "region-laval", "region-monteregie", "region-laurentides", "region-lanaudiere", "region-eastern-townships", "region-outaouais", "region-capitale-nationale", "region-mauricie", "region-centre-du-quebec", "region-chaudiere-appalaches", "region-bas-saint-laurent", "region-saguenay-lac-saint-jean", "region-cote-nord", "region-charlevoix", "region-gaspesie", "region-abitibi-temiscamingue", ] _PROP_RE = re.compile( r'/(?:fr|en)/property/quebec/region-([a-z-]+)/([a-z0-9-]+?)-real-estate/(\d{5,})/([a-z0-9-]+)/', re.I) class SothebysQuebecConnector(BaseConnector): source_id = "sothebys_quebec" request_delay = 1.0 def _get_tolerant(self, url: str) -> str: """GET qui ignore le statut HTTP : les pages de résultats Sotheby's renvoient un 404 tout en servant le contenu complet.""" import time wait = self.request_delay - (time.time() - self._last_request) if wait > 0: time.sleep(wait) resp = self.session.get(url, timeout=self.timeout) self._last_request = time.time() return resp.text def fetch(self) -> list[PropertyListing]: by_id: dict[str, PropertyListing] = {} for region in QC_REGIONS: url = f"{SITE}/en/search-results/{region}-real-estate/" try: html = self._get_tolerant(url) except Exception: continue for m in _PROP_RE.finditer(html): lst = self._to_listing(m) if lst and lst.external_id not in by_id: by_id[lst.external_id] = lst listings = list(by_id.values()) # fiche détail : JSON-LD Product (prix, galerie complète, description, MLS) du.enrich(self, listings, DETAIL_LIMIT, parse_sothebys_detail, key="v1") return listings def _to_listing(self, m: re.Match) -> PropertyListing | None: region_slug, city_slug, pid, addr_slug = m.group(1), m.group(2), m.group(3), m.group(4) url = f"{SITE}/fr/property/quebec/region-{region_slug}/{city_slug}-real-estate/{pid}/{addr_slug}/" return PropertyListing( source=self.source_id, external_id=pid, url=url, title=_deslug(addr_slug), address=_deslug(addr_slug), city=_deslug(city_slug), region=_deslug(region_slug), broker_name="Sotheby's International Realty Québec", ) def parse_sothebys_detail(html: str) -> dict: """Extrait le JSON-LD Product : prix, galerie S3 complète, description, MLS.""" out: dict = {} for node in du.ld_nodes(html): if node.get("@type") != "Product": continue offers = node.get("offers") or {} if isinstance(offers, list): offers = offers[0] if offers else {} price = offers.get("price") try: out["price"] = float(price) if price is not None else None except (TypeError, ValueError): pass if node.get("description"): out["description"] = _html.unescape(str(node["description"])).strip() if node.get("sku"): out["mls"] = str(node["sku"]) cat = node.get("category") if cat: out["details"] = {"Type de propriété": str(cat)} break # galerie complète : photos S3 de CETTE fiche (pleine résolution 'r'), # dédoublonnées par identifiant de base — plus complètes que le JSON-LD. mid = re.search(r'/live/images/listings/(\d+)/', html) if mid: lid = mid.group(1) seen, gallery = set(), [] for u in re.findall( rf'https://sircmedia\.s3\.ca-central-1\.amazonaws\.com/live/images/listings/{lid}/' r'[^"\' ]+\.(?:jpg|jpeg|webp)', html): base = re.sub(r'(?:nr|m|r)?\.(?:jpg|jpeg|webp)$', '', u.rsplit("/", 1)[-1]) full = re.sub(r'(?:nr|m)?\.jpg$', 'r.jpg', u) if base not in seen: seen.add(base) gallery.append(full) if gallery: out["images"] = gallery # repli sur les images du JSON-LD si aucune photo S3 trouvée if not out.get("images"): for node in du.ld_nodes(html): if node.get("@type") == "Product" and node.get("image"): imgs = node["image"] out["images"] = imgs if isinstance(imgs, list) else [imgs] break # chambres / salles de bains (best-effort, valeurs plausibles seulement) text = re.sub(r"\s+", " ", _html.unescape(re.sub(r"<[^>]+>", " ", html))) mb = re.search(r"\b(\d{1,2})\s*(?:Bedroom|Chambre|bed\b|ch\.)", text, re.I) if mb and int(mb.group(1)) <= 20: out["bedrooms"] = int(mb.group(1)) ms = re.search(r"\b(\d{1,2})\s*(?:Bathroom|Salle de bain|bath\b)", text, re.I) if ms and int(ms.group(1)) <= 20: out["bathrooms"] = int(ms.group(1)) coords = du.gmaps_coords(html) if coords: out["lat"], out["lng"] = coords return out def _deslug(s: str) -> str: return re.sub(r"\s+", " ", s.replace("-", " ")).strip().title()