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