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/kw_prestige.py : Keller Williams Prestige (kwprestige.com)5# Plateforme macleweb.com. La grille /fr/proprietes/ est servie par l'API6# interne /ajax/getProps.php (JSON {html, nbPages}) : cartes complètes —7# prix, adresse, ville (secteur), cac/sdb, mini-description, photo. Les8# fiches détail sont rendues serveur : galerie complète (data-centris/9# large_photo), description, tableau Centris, GPS (iframe maps/embed),10# courtiers inscripteurs.11# ⚠️ Ne PAS revenir au sitemap.xml : il est figé (lastmod 2025-09) et ses12# fiches expirées rendent un gabarit vide (prix « 0 », aucune adresse).13# ⚠️ Le site exige un User-Agent navigateur (curl nu → 403) ; l'UA de14# BaseConnector passe.15# -----------------------------------------------------------------------------16from __future__ import annotations1718import html as _html19import os20import re2122from .base import BaseConnector23from . import _detailutil as du24from ..normalize import parse_price25from ..schema import PropertyListing2627BASE = "https://www.kwprestige.com"28AJAX = BASE + "/ajax/getProps.php?json=1&default_lang=fr&page={page}&qty=100"29DETAIL_LIMIT = int(os.environ.get("IMMOKA_KW_DETAIL_LIMIT", "400"))3031# carte de la grille : <a href='/{slug}-{id}-detail-Fr' class='proprieteitem'>…</a>32_CARD_RE = re.compile(33 r"<a href='(/[^']*?-(\d{6,9})-detail-Fr)' class='proprieteitem'>(.*?)</a>", re.S)34_PRICE_RE = re.compile(r"class='titre2'>(.*?)</p>", re.S)35_ADDR_RE = re.compile(r"class='titresm'>(.*?)</h3>\s*<p>(.*?)</p>", re.S)36_ICON_RE = re.compile(r"</svg>\s*(\d+)\s*</div>")37_MINIDESC_RE = re.compile(r"class='minidescription'>(.*?)</p>", re.S)38_THUMB_RE = re.compile(r"src='(https://www\.kwprestige\.com/data-centris/photo/[^']+)'")3940# fiche détail41_LARGE_RE = re.compile(42 r'https://www\.kwprestige\.com/data-centris/large_photo/[^"\'\\ ]+'43 r'\.(?:jpg|jpeg|png|webp)', re.I)44_DPRICE_RE = re.compile(r'<p class="prix">(.*?)</p>', re.S)45_DDESC_RE = re.compile(r'fichecdescription">.*?<p>(.*?)</p>', re.S)46_GPS_RE = re.compile(r'!2d(-?\d{2,3}\.\d+)!3d(-?\d{2}\.\d+)')47_BROKER_RE = re.compile(r'courtierfiche">.*?<h3 class="titresm">(.*?)</h3>', re.S)48_PHONE_RE = re.compile(r'href="tel:([+\d.\-]{7,})"')49_ROOM_RE = re.compile(r'class="nbr_(chambres|sdb|pieces)">\s*<img[^>]*>\s*(\d+)')5051# type de propriété depuis le slug d'URL (« {type}-a-vendre-… »)52_TYPE = [53 ("location-d-espace", "Commercial"), ("location-de", "Commercial"),54 ("location", "Commercial"),55 ("maison-etages", "Maison"), ("maison-paliers", "Maison"),56 ("maison-plain-pied", "Maison"), ("maison-mobile", "Maison mobile"),57 ("maison", "Maison"), ("appartement", "Condo"), ("condo", "Condo"),58 ("loft", "Condo"), ("duplex", "Duplex"), ("triplex", "Triplex"),59 ("quadruplex", "Multiplex"), ("quintuplex", "Multiplex"),60 ("propriete-revenu", "Multiplex"), ("terrain", "Terrain"),61 ("chalet", "Chalet"), ("fermette", "Fermette/Agricole"),62 ("ferme", "Fermette/Agricole"),63 ("local-commercial", "Commercial"), ("espace-commercial", "Commercial"),64 ("espace-industriel", "Commercial"), ("espace-bureau", "Commercial"),65 ("commerce", "Commercial"), ("bureau", "Commercial"),66 ("batisse", "Commercial"), ("industriel", "Commercial"),67]686970def _txt(s: str) -> str:71 return _html.unescape(re.sub(r"<[^>]+>", " ", s or "")).replace("\xa0", " ").strip()727374def _city_sector(label: str) -> tuple[str, str]:75 """« Montréal (Ville-Marie) » -> (Montréal, Ville-Marie)."""76 m = re.match(r"(.+?)\s*\((.+?)\)\s*$", label or "")77 if m:78 return m.group(1).strip(), m.group(2).strip()79 return (label or "").strip(), ""808182class KwPrestigeConnector(BaseConnector):83 source_id = "kw_prestige"84 request_delay = 0.58586 def fetch(self) -> list[PropertyListing]:87 by_id: dict[str, PropertyListing] = {}88 page = 189 while page <= 50: # garde-fou90 data = self.get(AJAX.format(page=page)).json()91 for path, cid, block in _CARD_RE.findall(data.get("html") or ""):92 if cid not in by_id:93 by_id[cid] = self._to_listing(path, cid, block)94 if page >= int(data.get("nbPages") or 1):95 break96 page += 197 listings = list(by_id.values())98 du.enrich(self, listings, DETAIL_LIMIT, parse_kw_prestige_detail, key="v3")99 return listings100101 def _to_listing(self, path: str, cid: str, block: str) -> PropertyListing:102 m_price = _PRICE_RE.search(block)103 price_label = _txt(m_price.group(1)) if m_price else ""104 m_addr = _ADDR_RE.search(block)105 addr = _txt(m_addr.group(1)) if m_addr else ""106 city, sector = _city_sector(_txt(m_addr.group(2)) if m_addr else "")107 nums = _ICON_RE.findall(block)108 m_desc = _MINIDESC_RE.search(block)109 m_img = _THUMB_RE.search(block)110 slug = path.lstrip("/")111 typ_slug = re.split(r"-a-(?:vendre|louer)-", slug)[0]112 ptype = ""113 for key, canon in _TYPE:114 if typ_slug.startswith(key):115 ptype = canon116 break117 low = price_label.lower()118 status = "vendu" if low.startswith(("vendu", "loué", "loue")) else "a-vendre"119 return PropertyListing(120 source=self.source_id,121 external_id=cid,122 url=BASE + path,123 title=addr or _txt(price_label),124 status=status,125 address=addr,126 city=city,127 sector=sector,128 property_type=ptype,129 price=parse_price(price_label),130 price_label=price_label,131 bedrooms=int(nums[0]) if nums else None,132 bathrooms=int(nums[1]) if len(nums) > 1 else None,133 description=_txt(m_desc.group(1)) if m_desc else "",134 images=[m_img.group(1)] if m_img else [],135 mls=cid,136 broker_name="Keller Williams Prestige",137 agency="Keller Williams Prestige",138 )139140141def parse_kw_prestige_detail(html: str) -> dict:142 """Fiche détail macleweb : galerie complète, prix, description, tableau143 Centris, GPS (iframe Google Maps embed), courtiers, cac/sdb/pièces."""144 out: dict = {}145 seen, imgs = set(), []146 for u in _LARGE_RE.findall(html):147 if u not in seen:148 seen.add(u)149 imgs.append(u)150 if imgs:151 out["images"] = imgs152 m = _DPRICE_RE.search(html)153 if m:154 label = _txt(m.group(1))155 price = parse_price(label)156 if price: # « 0 » = gabarit vide/expiré157 out["price"] = price158 out["price_label"] = label159 m = _DDESC_RE.search(html)160 if m:161 d = _txt(m.group(1))162 if len(d) > 40:163 out["description"] = d164 m = _GPS_RE.search(html)165 if m:166 lng, lat = float(m.group(1)), float(m.group(2))167 if 44.5 <= lat <= 63.0 and -80.0 <= lng <= -56.0:168 out["lat"], out["lng"] = lat, lng169 m = _BROKER_RE.search(html)170 if m:171 out["broker_name"] = _txt(m.group(1))172 m = _PHONE_RE.search(html)173 if m:174 out["broker_phone"] = m.group(1)175 for key, n in _ROOM_RE.findall(html):176 if key == "chambres":177 out["bedrooms"] = int(n)178 elif key == "sdb":179 out["bathrooms"] = int(n)180 det = du.centris_details(du.flatten(html))181 if det:182 out.setdefault("details", {}).update(det)183 return out184