# ----------------------------------------------------------------------------- # Immo-Ka — Agrégateur de maisons à vendre (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/kw_prestige.py : Keller Williams Prestige (kwprestige.com) # Plateforme macleweb.com. La grille /fr/proprietes/ est servie par l'API # interne /ajax/getProps.php (JSON {html, nbPages}) : cartes complètes — # prix, adresse, ville (secteur), cac/sdb, mini-description, photo. Les # fiches détail sont rendues serveur : galerie complète (data-centris/ # large_photo), description, tableau Centris, GPS (iframe maps/embed), # courtiers inscripteurs. # ⚠️ Ne PAS revenir au sitemap.xml : il est figé (lastmod 2025-09) et ses # fiches expirées rendent un gabarit vide (prix « 0 », aucune adresse). # ⚠️ Le site exige un User-Agent navigateur (curl nu → 403) ; l'UA de # BaseConnector passe. # ----------------------------------------------------------------------------- from __future__ import annotations import html as _html import os import re from .base import BaseConnector from . import _detailutil as du from ..normalize import parse_price from ..schema import PropertyListing BASE = "https://www.kwprestige.com" AJAX = BASE + "/ajax/getProps.php?json=1&default_lang=fr&page={page}&qty=100" DETAIL_LIMIT = int(os.environ.get("IMMOKA_KW_DETAIL_LIMIT", "400")) # carte de la grille : … _CARD_RE = re.compile( r"(.*?)", re.S) _PRICE_RE = re.compile(r"class='titre2'>(.*?)

", re.S) _ADDR_RE = re.compile(r"class='titresm'>(.*?)\s*

(.*?)

", re.S) _ICON_RE = re.compile(r"\s*(\d+)\s*") _MINIDESC_RE = re.compile(r"class='minidescription'>(.*?)

", re.S) _THUMB_RE = re.compile(r"src='(https://www\.kwprestige\.com/data-centris/photo/[^']+)'") # fiche détail _LARGE_RE = re.compile( r'https://www\.kwprestige\.com/data-centris/large_photo/[^"\'\\ ]+' r'\.(?:jpg|jpeg|png|webp)', re.I) _DPRICE_RE = re.compile(r'

(.*?)

', re.S) _DDESC_RE = re.compile(r'fichecdescription">.*?

(.*?)

', re.S) _GPS_RE = re.compile(r'!2d(-?\d{2,3}\.\d+)!3d(-?\d{2}\.\d+)') _BROKER_RE = re.compile(r'courtierfiche">.*?

(.*?)

', re.S) _PHONE_RE = re.compile(r'href="tel:([+\d.\-]{7,})"') _ROOM_RE = re.compile(r'class="nbr_(chambres|sdb|pieces)">\s*]*>\s*(\d+)') # type de propriété depuis le slug d'URL (« {type}-a-vendre-… ») _TYPE = [ ("location-d-espace", "Commercial"), ("location-de", "Commercial"), ("location", "Commercial"), ("maison-etages", "Maison"), ("maison-paliers", "Maison"), ("maison-plain-pied", "Maison"), ("maison-mobile", "Maison mobile"), ("maison", "Maison"), ("appartement", "Condo"), ("condo", "Condo"), ("loft", "Condo"), ("duplex", "Duplex"), ("triplex", "Triplex"), ("quadruplex", "Multiplex"), ("quintuplex", "Multiplex"), ("propriete-revenu", "Multiplex"), ("terrain", "Terrain"), ("chalet", "Chalet"), ("fermette", "Fermette/Agricole"), ("ferme", "Fermette/Agricole"), ("local-commercial", "Commercial"), ("espace-commercial", "Commercial"), ("espace-industriel", "Commercial"), ("espace-bureau", "Commercial"), ("commerce", "Commercial"), ("bureau", "Commercial"), ("batisse", "Commercial"), ("industriel", "Commercial"), ] def _txt(s: str) -> str: return _html.unescape(re.sub(r"<[^>]+>", " ", s or "")).replace("\xa0", " ").strip() def _city_sector(label: str) -> tuple[str, str]: """« Montréal (Ville-Marie) » -> (Montréal, Ville-Marie).""" m = re.match(r"(.+?)\s*\((.+?)\)\s*$", label or "") if m: return m.group(1).strip(), m.group(2).strip() return (label or "").strip(), "" class KwPrestigeConnector(BaseConnector): source_id = "kw_prestige" request_delay = 0.5 def fetch(self) -> list[PropertyListing]: by_id: dict[str, PropertyListing] = {} page = 1 while page <= 50: # garde-fou data = self.get(AJAX.format(page=page)).json() for path, cid, block in _CARD_RE.findall(data.get("html") or ""): if cid not in by_id: by_id[cid] = self._to_listing(path, cid, block) if page >= int(data.get("nbPages") or 1): break page += 1 listings = list(by_id.values()) du.enrich(self, listings, DETAIL_LIMIT, parse_kw_prestige_detail, key="v3") return listings def _to_listing(self, path: str, cid: str, block: str) -> PropertyListing: m_price = _PRICE_RE.search(block) price_label = _txt(m_price.group(1)) if m_price else "" m_addr = _ADDR_RE.search(block) addr = _txt(m_addr.group(1)) if m_addr else "" city, sector = _city_sector(_txt(m_addr.group(2)) if m_addr else "") nums = _ICON_RE.findall(block) m_desc = _MINIDESC_RE.search(block) m_img = _THUMB_RE.search(block) slug = path.lstrip("/") typ_slug = re.split(r"-a-(?:vendre|louer)-", slug)[0] ptype = "" for key, canon in _TYPE: if typ_slug.startswith(key): ptype = canon break low = price_label.lower() status = "vendu" if low.startswith(("vendu", "loué", "loue")) else "a-vendre" return PropertyListing( source=self.source_id, external_id=cid, url=BASE + path, title=addr or _txt(price_label), status=status, address=addr, city=city, sector=sector, property_type=ptype, price=parse_price(price_label), price_label=price_label, bedrooms=int(nums[0]) if nums else None, bathrooms=int(nums[1]) if len(nums) > 1 else None, description=_txt(m_desc.group(1)) if m_desc else "", images=[m_img.group(1)] if m_img else [], mls=cid, broker_name="Keller Williams Prestige", agency="Keller Williams Prestige", ) def parse_kw_prestige_detail(html: str) -> dict: """Fiche détail macleweb : galerie complète, prix, description, tableau Centris, GPS (iframe Google Maps embed), courtiers, cac/sdb/pièces.""" out: dict = {} seen, imgs = set(), [] for u in _LARGE_RE.findall(html): if u not in seen: seen.add(u) imgs.append(u) if imgs: out["images"] = imgs m = _DPRICE_RE.search(html) if m: label = _txt(m.group(1)) price = parse_price(label) if price: # « 0 » = gabarit vide/expiré out["price"] = price out["price_label"] = label m = _DDESC_RE.search(html) if m: d = _txt(m.group(1)) if len(d) > 40: out["description"] = d m = _GPS_RE.search(html) if m: lng, lat = float(m.group(1)), float(m.group(2)) if 44.5 <= lat <= 63.0 and -80.0 <= lng <= -56.0: out["lat"], out["lng"] = lat, lng m = _BROKER_RE.search(html) if m: out["broker_name"] = _txt(m.group(1)) m = _PHONE_RE.search(html) if m: out["broker_phone"] = m.group(1) for key, n in _ROOM_RE.findall(html): if key == "chambres": out["bedrooms"] = int(n) elif key == "sdb": out["bathrooms"] = int(n) det = du.centris_details(du.flatten(html)) if det: out.setdefault("details", {}).update(det) return out