# -----------------------------------------------------------------------------
# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)
# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
# connectors/vendre_ca.py : Vendre.ca (portail hybride courtiers + vendeurs privés)
#
# Portail québécois (~9 500 fiches) mêlant inscriptions de courtiers (avec
# n° MLS/Centris) et vendeurs privés. Énumération EXHAUSTIVE via les 4
# sitemaps de catégorie (res/mpx/com/lnd) : l'URL FR encode type, ville,
# adresse et le VID (identifiant Vendre.ca). La fiche détail (JSON-LD
# Place + RealEstateListing + Offer) fournit adresse exacte, géoloc, prix,
# année, courtier, description et le n° MLS ; galerie cloud.vendre.ca (_lrg).
#
# source_id « vendre_ag_ca » : l'infixe _ag_ active la déduplication Centris
# (db.refresh_dedup). Quand le n° MLS est connu, il devient l'external_id —
# les fiches déjà portées par la bannière du courtier (RE/MAX, Royal LePage…)
# sont masquées ; les fiches uniques (vendeurs privés = VID, courtiers non
# couverts) restent visibles. Aucun double-comptage.
# -----------------------------------------------------------------------------
from __future__ import annotations
import html as _html
import json
import os
import re
import requests
from .base import BaseConnector
from . import _detailutil as du
from ..normalize import normalize_property_type
from ..schema import PropertyListing
SITE = "https://www.vendre.ca"
SITEMAPS = [f"{SITE}/res.xml", f"{SITE}/mpx.xml", f"{SITE}/com.xml", f"{SITE}/lnd.xml"]
DETAIL_LIMIT = int(os.environ.get("IMMOKA_VENDRE_DETAIL_LIMIT", "400"))
AGENCY = "Vendre.ca"
_LOC_RE = re.compile(r"([^<]+)")
# /fr/-a-vendre-/-/ (le type peut contenir des tirets)
_URL_RE = re.compile(
r"https://www\.vendre\.ca/fr/([a-z0-9-]+?)-a-vendre-([a-z0-9-]+)/"
r"([a-z0-9-]+?)-([a-z0-9]+)/?$", re.I)
_PHOTO_RE = re.compile(r"https://cloud\.vendre\.ca/[^\"'\\ ]+?_lrg\.(?:webp|jpg)", re.I)
# slugs d'URL -> vocabulaire brut (finalize() normalise)
_TYPES = {
"maisons": "Maison", "maisons-neuves": "Maison", "chalets": "Chalet",
"condos": "Condo", "condos-neufs": "Condo", "lofts": "Loft",
"fermettes": "Fermette", "duplex": "Duplex", "triplex": "Triplex",
"4plex": "Quadruplex", "5plex": "Quintuplex", "multiplex": "Multiplex",
"propriete-commerciale": "Commercial", "condo-commercial": "Commercial",
"edifice-bureaux": "Commercial", "batiment-industriel": "Commercial",
"ferme": "Fermette/Agricole", "terrain": "Terrain",
}
class VendreCaConnector(BaseConnector):
source_id = "vendre_ag_ca"
request_delay = 0.4
def fetch(self) -> list[PropertyListing]:
by_id: dict[str, PropertyListing] = {}
for sm in SITEMAPS:
# depuis 2026-09 le serveur renvoie HTTP 404 sur les sitemaps tout
# en servant le XML complet — on garde le corps s'il est un urlset
try:
xml = self.get(sm).text
except requests.HTTPError as e:
xml = e.response.text if e.response is not None else ""
except Exception:
continue
if " external_id (clé de dédup Centris contre les bannières) ;
# les fiches sans MLS (vendeurs privés) gardent le VID, jamais masquées.
out: dict[str, PropertyListing] = {}
for lst in listings:
mls = str(lst.details.pop("_mls", "") or "")
if mls:
lst.mls = mls
lst.external_id = mls
region = lst.details.pop("_region", "")
if region and not lst.region:
lst.region = region
# la fiche détail bat les valeurs dérivées du slug d'URL
addr = lst.details.pop("_addr", "")
if addr:
lst.address = addr
lst.title = addr
city = lst.details.pop("_city", "")
if city:
lst.city = city
out.setdefault(lst.external_id, lst)
return list(out.values())
def _to_listing(self, url: str) -> PropertyListing | None:
m = _URL_RE.match(url.strip())
if not m:
return None
type_s, city_s, addr_s, vid = m.groups()
# le n° civique ouvre le VID (« 160bq » -> 160 Chemin Le Nordais)
mc = re.match(r"(\d+)", vid)
addr = ((mc.group(1) + " ") if mc else "") + _deslug(addr_s)
return PropertyListing(
source=self.source_id, external_id=vid, url=url,
title=addr,
address=addr,
city=_deslug(city_s),
property_type=normalize_property_type(_TYPES.get(type_s, type_s)),
agency=AGENCY,
)
# ---------------------------------------------------------------------------
def parse_vendre_detail(html: str) -> dict:
"""JSON-LD Place (adresse/géo) + RealEstateListing (MLS, année, description)
+ Offer (prix, courtier) ; galerie cloud.vendre.ca pleine résolution."""
out: dict = {}
details: dict = {}
for node in du.ld_nodes(html):
t = node.get("@type")
if t == "Place":
addr = node.get("address") or {}
if addr.get("streetAddress"):
details["_addr"] = str(addr["streetAddress"])
if addr.get("addressLocality"):
details["_city"] = str(addr["addressLocality"])
if addr.get("postalCode"):
details["postal_code"] = str(addr["postalCode"])
geo = node.get("geo") or {}
try:
out["lat"], out["lng"] = float(geo["latitude"]), float(geo["longitude"])
except (KeyError, TypeError, ValueError):
pass
elif t == "RealEstateListing":
if node.get("description"):
out["description"] = _html.unescape(str(node["description"])).strip()[:4000]
ident = node.get("identifier") or {}
if isinstance(ident, dict) and ident.get("value"):
details["_mls"] = str(ident["value"])
y = node.get("yearBuilt")
if y and str(y).isdigit():
out["year_built"] = int(y)
geo = node.get("geo") or {}
if "lat" not in out:
try:
out["lat"], out["lng"] = float(geo["latitude"]), float(geo["longitude"])
except (KeyError, TypeError, ValueError):
pass
elif t == "Offer":
p = node.get("price")
try:
out["price"] = float(p)
out["price_label"] = f"{float(p):,.0f} $".replace(",", " ")
except (TypeError, ValueError):
pass
seller = node.get("seller") or {}
if isinstance(seller, dict) and seller.get("name"):
out["broker_name"] = str(seller["name"])
elif t == "BreadcrumbList":
for it in node.get("itemListElement") or []:
name = (it or {}).get("name", "")
mr = re.match(r"Région de\s+(.+)", str(name))
if mr:
details["_region"] = mr.group(1).strip()
# galerie pleine résolution (ordre du HTML, dédoublonnée)
seen, imgs = set(), []
for u in _PHOTO_RE.findall(html):
if u not in seen:
seen.add(u)
imgs.append(u)
if imgs:
out["images"] = imgs
# chambres / salles de bains (l'entête de la fiche, ex. « 5 chambres à
# coucher, 2 bains ») — finalize() fait le reste depuis la description.
text = re.sub(r"\s+", " ", _html.unescape(re.sub(r"<[^>]+>", " ", html)))
mb = re.search(r"(\d{1,2})\s*chambres?\s*à\s*coucher", text, re.I)
if mb:
out["bedrooms"] = int(mb.group(1))
ms = re.search(r"(\d{1,2})\s*bains?\b", text, re.I)
if ms:
out["bathrooms"] = int(ms.group(1))
if details:
out["details"] = details
return out
def _deslug(s: str) -> str:
return re.sub(r"\s+", " ", s.replace("-", " ")).strip().title()