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/vendre_ca.py : Vendre.ca (portail hybride courtiers + vendeurs privés)5#6# Portail québécois (~9 500 fiches) mêlant inscriptions de courtiers (avec7# n° MLS/Centris) et vendeurs privés. Énumération EXHAUSTIVE via les 48# sitemaps de catégorie (res/mpx/com/lnd) : l'URL FR encode type, ville,9# adresse et le VID (identifiant Vendre.ca). La fiche détail (JSON-LD10# Place + RealEstateListing + Offer) fournit adresse exacte, géoloc, prix,11# année, courtier, description et le n° MLS ; galerie cloud.vendre.ca (_lrg).12#13# source_id « vendre_ag_ca » : l'infixe _ag_ active la déduplication Centris14# (db.refresh_dedup). Quand le n° MLS est connu, il devient l'external_id —15# les fiches déjà portées par la bannière du courtier (RE/MAX, Royal LePage…)16# sont masquées ; les fiches uniques (vendeurs privés = VID, courtiers non17# couverts) restent visibles. Aucun double-comptage.18# -----------------------------------------------------------------------------19from __future__ import annotations2021import html as _html22import json23import os24import re2526import requests2728from .base import BaseConnector29from . import _detailutil as du30from ..normalize import normalize_property_type31from ..schema import PropertyListing3233SITE = "https://www.vendre.ca"34SITEMAPS = [f"{SITE}/res.xml", f"{SITE}/mpx.xml", f"{SITE}/com.xml", f"{SITE}/lnd.xml"]35DETAIL_LIMIT = int(os.environ.get("IMMOKA_VENDRE_DETAIL_LIMIT", "400"))36AGENCY = "Vendre.ca"3738_LOC_RE = re.compile(r"<loc>([^<]+)</loc>")39# /fr/<type>-a-vendre-<ville>/<adresse>-<vid>/ (le type peut contenir des tirets)40_URL_RE = re.compile(41 r"https://www\.vendre\.ca/fr/([a-z0-9-]+?)-a-vendre-([a-z0-9-]+)/"42 r"([a-z0-9-]+?)-([a-z0-9]+)/?$", re.I)43_PHOTO_RE = re.compile(r"https://cloud\.vendre\.ca/[^\"'\\ ]+?_lrg\.(?:webp|jpg)", re.I)4445# slugs d'URL -> vocabulaire brut (finalize() normalise)46_TYPES = {47 "maisons": "Maison", "maisons-neuves": "Maison", "chalets": "Chalet",48 "condos": "Condo", "condos-neufs": "Condo", "lofts": "Loft",49 "fermettes": "Fermette", "duplex": "Duplex", "triplex": "Triplex",50 "4plex": "Quadruplex", "5plex": "Quintuplex", "multiplex": "Multiplex",51 "propriete-commerciale": "Commercial", "condo-commercial": "Commercial",52 "edifice-bureaux": "Commercial", "batiment-industriel": "Commercial",53 "ferme": "Fermette/Agricole", "terrain": "Terrain",54}555657class VendreCaConnector(BaseConnector):58 source_id = "vendre_ag_ca"59 request_delay = 0.46061 def fetch(self) -> list[PropertyListing]:62 by_id: dict[str, PropertyListing] = {}63 for sm in SITEMAPS:64 # depuis 2026-09 le serveur renvoie HTTP 404 sur les sitemaps tout65 # en servant le XML complet — on garde le corps s'il est un urlset66 try:67 xml = self.get(sm).text68 except requests.HTTPError as e:69 xml = e.response.text if e.response is not None else ""70 except Exception:71 continue72 if "<urlset" not in xml:73 continue74 for url in _LOC_RE.findall(xml):75 lst = self._to_listing(url)76 if lst and lst.external_id not in by_id:77 by_id[lst.external_id] = lst78 listings = list(by_id.values())79 du.enrich(self, listings, DETAIL_LIMIT, parse_vendre_detail, key="v1")80 # n° MLS connu -> external_id (clé de dédup Centris contre les bannières) ;81 # les fiches sans MLS (vendeurs privés) gardent le VID, jamais masquées.82 out: dict[str, PropertyListing] = {}83 for lst in listings:84 mls = str(lst.details.pop("_mls", "") or "")85 if mls:86 lst.mls = mls87 lst.external_id = mls88 region = lst.details.pop("_region", "")89 if region and not lst.region:90 lst.region = region91 # la fiche détail bat les valeurs dérivées du slug d'URL92 addr = lst.details.pop("_addr", "")93 if addr:94 lst.address = addr95 lst.title = addr96 city = lst.details.pop("_city", "")97 if city:98 lst.city = city99 out.setdefault(lst.external_id, lst)100 return list(out.values())101102 def _to_listing(self, url: str) -> PropertyListing | None:103 m = _URL_RE.match(url.strip())104 if not m:105 return None106 type_s, city_s, addr_s, vid = m.groups()107 # le n° civique ouvre le VID (« 160bq » -> 160 Chemin Le Nordais)108 mc = re.match(r"(\d+)", vid)109 addr = ((mc.group(1) + " ") if mc else "") + _deslug(addr_s)110 return PropertyListing(111 source=self.source_id, external_id=vid, url=url,112 title=addr,113 address=addr,114 city=_deslug(city_s),115 property_type=normalize_property_type(_TYPES.get(type_s, type_s)),116 agency=AGENCY,117 )118119120# ---------------------------------------------------------------------------121def parse_vendre_detail(html: str) -> dict:122 """JSON-LD Place (adresse/géo) + RealEstateListing (MLS, année, description)123 + Offer (prix, courtier) ; galerie cloud.vendre.ca pleine résolution."""124 out: dict = {}125 details: dict = {}126 for node in du.ld_nodes(html):127 t = node.get("@type")128 if t == "Place":129 addr = node.get("address") or {}130 if addr.get("streetAddress"):131 details["_addr"] = str(addr["streetAddress"])132 if addr.get("addressLocality"):133 details["_city"] = str(addr["addressLocality"])134 if addr.get("postalCode"):135 details["postal_code"] = str(addr["postalCode"])136 geo = node.get("geo") or {}137 try:138 out["lat"], out["lng"] = float(geo["latitude"]), float(geo["longitude"])139 except (KeyError, TypeError, ValueError):140 pass141 elif t == "RealEstateListing":142 if node.get("description"):143 out["description"] = _html.unescape(str(node["description"])).strip()[:4000]144 ident = node.get("identifier") or {}145 if isinstance(ident, dict) and ident.get("value"):146 details["_mls"] = str(ident["value"])147 y = node.get("yearBuilt")148 if y and str(y).isdigit():149 out["year_built"] = int(y)150 geo = node.get("geo") or {}151 if "lat" not in out:152 try:153 out["lat"], out["lng"] = float(geo["latitude"]), float(geo["longitude"])154 except (KeyError, TypeError, ValueError):155 pass156 elif t == "Offer":157 p = node.get("price")158 try:159 out["price"] = float(p)160 out["price_label"] = f"{float(p):,.0f} $".replace(",", " ")161 except (TypeError, ValueError):162 pass163 seller = node.get("seller") or {}164 if isinstance(seller, dict) and seller.get("name"):165 out["broker_name"] = str(seller["name"])166 elif t == "BreadcrumbList":167 for it in node.get("itemListElement") or []:168 name = (it or {}).get("name", "")169 mr = re.match(r"Région de\s+(.+)", str(name))170 if mr:171 details["_region"] = mr.group(1).strip()172173 # galerie pleine résolution (ordre du HTML, dédoublonnée)174 seen, imgs = set(), []175 for u in _PHOTO_RE.findall(html):176 if u not in seen:177 seen.add(u)178 imgs.append(u)179 if imgs:180 out["images"] = imgs181182 # chambres / salles de bains (l'entête de la fiche, ex. « 5 chambres à183 # coucher, 2 bains ») — finalize() fait le reste depuis la description.184 text = re.sub(r"\s+", " ", _html.unescape(re.sub(r"<[^>]+>", " ", html)))185 mb = re.search(r"(\d{1,2})\s*chambres?\s*à\s*coucher", text, re.I)186 if mb:187 out["bedrooms"] = int(mb.group(1))188 ms = re.search(r"(\d{1,2})\s*bains?\b", text, re.I)189 if ms:190 out["bathrooms"] = int(ms.group(1))191192 if details:193 out["details"] = details194 return out195196197def _deslug(s: str) -> str:198 return re.sub(r"\s+", " ", s.replace("-", " ")).strip().title()199