SPB Git forge

spb/immo-ka

Public

Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)

112commits 1branches 0releases
125.4 MBsize
maindefault branch
13 days agolast push
Python 47.5% HTML 27.9% TypeScript 15.5% CSS 7.2% JavaScript 2%
7.5 KB · 184 lines python
Raw Blame History
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