# -----------------------------------------------------------------------------
# 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