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%
8.3 KB · 199 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/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