# -----------------------------------------------------------------------------
# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
# connectors/kw_urbain.py : Keller Williams Urbain (kwurbain.ca)
# Page /inscriptions rendue serveur : toutes les inscriptions de l'agence en
# cartes HTML (plateforme marketingwebsites.ca, commune aux bureaux KW Canada).
# -----------------------------------------------------------------------------
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.kwurbain.ca"
LISTINGS = f"{BASE}/inscriptions/"
IMG_TMPL = "https://realestate.marketingwebsites.ca/property-images/{id}/{id}-{n:02d}.jpg"
DETAIL_LIMIT = int(os.environ.get("IMMOKA_KW_DETAIL_LIMIT", "400"))
_KW_IMG_RE = re.compile(
r'https://realestate\.marketingwebsites\.ca/property-images/\d+/[^"\'\\ ]+\.(?:jpg|jpeg|png|webp)',
re.I)
_META_DESC_RE = re.compile(r'\s*\s*\s*|$)', re.S)
_H5_RE = re.compile(r'
(.*?)
', re.S)
_PRICE_RE = re.compile(r'(.*?)', re.S)
_SUB_RE = re.compile(r'card-subtitle[^>]*>(.*?)', re.S)
_BED_RE = re.compile(r'fa-bed">\s*(\d+)')
_BATH_RE = re.compile(r'fa-bath">\s*(\d+)')
_STATUS_RE = re.compile(r'card-status[^>]*>(.*?)', re.S)
def _txt(s: str) -> str:
return _html.unescape(re.sub(r"<[^>]+>", "", s or "")).strip()
class KwUrbainConnector(BaseConnector):
source_id = "kw_urbain"
request_delay = 0.6
def fetch(self) -> list[PropertyListing]:
html = self.get(LISTINGS).text
listings: list[PropertyListing] = []
for pid, block in _CARD_RE.findall(html):
lst = self._to_listing(pid, block)
if lst is not None:
listings.append(lst)
# fiche détail : galerie complète + description
du.enrich(self, listings, DETAIL_LIMIT, parse_kw_detail, key="v2")
return listings
def _to_listing(self, pid: str, block: str) -> PropertyListing | None:
addr = _txt((_H5_RE.search(block) or [None, ""])[1] if _H5_RE.search(block) else "")
m_addr = _H5_RE.search(block)
addr = _txt(m_addr.group(1)) if m_addr else ""
m_price = _PRICE_RE.search(block)
price_label = _txt(m_price.group(1)) if m_price else ""
m_sub = _SUB_RE.search(block)
sub = _txt(m_sub.group(1)) if m_sub else "" # "Blainville, Laurentides J7B1M1"
city = region = ""
if sub:
parts = [p.strip() for p in sub.split(",")]
city = parts[0]
if len(parts) > 1:
# « Laurentides J7B1M1 » -> retirer le code postal
region = re.sub(r"\s*[A-Z]\d[A-Z]\s?\d[A-Z]\d\s*$", "", parts[1]).strip()
beds = _BED_RE.search(block)
baths = _BATH_RE.search(block)
return PropertyListing(
source=self.source_id,
external_id=pid,
url=f"{BASE}/inscriptions/inscription/{pid}",
title=addr,
address=addr,
city=city,
region=region,
price=parse_price(price_label),
price_label=price_label,
bedrooms=int(beds.group(1)) if beds else None,
bathrooms=int(baths.group(1)) if baths else None,
images=[IMG_TMPL.format(id=pid, n=1)],
broker_name="Keller Williams Urbain",
)
def parse_kw_detail(html: str) -> dict:
"""Galerie complète + description (balise meta) de la fiche KW."""
out: dict = {}
seen, imgs = set(), []
for u in _KW_IMG_RE.findall(html):
if u not in seen:
seen.add(u)
imgs.append(u)
if imgs:
out["images"] = imgs
m = _META_DESC_RE.search(html)
if m:
d = _html.unescape(m.group(1)).strip()
if d and not d.lower().startswith(("keller williams", "trouvez")):
out["description"] = d
_det = du.centris_details(du.flatten(html))
if _det:
out.setdefault("details", {}).update(_det)
return out