SPB Git

spb/immo-ka Public

Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)

Python 66.4% TypeScript 19.9% CSS 13.2% HTML 0.5%
10.1 KB · 223 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/via_capitale.py : Via Capitale (viacapitalevendu.com)5#   Bannière 100 % québécoise (Bridgemarq). Le site est rendu serveur (ASP.NET)6#   mais protégé par Cloudflare : on passe donc par Firecrawl (get_rendered) qui7#   franchit le challenge. Les résultats résidentiels sont paginés (34/page,8#   ~5 300 propriétés). On parse les cartes HTML et on dédoublonne par code9#   d'inscription. Photos : images.viacapitale.info/images/inscriptions/{id}/…10# -----------------------------------------------------------------------------11from __future__ import annotations1213import html as _htmlmod214import os15import re1617from .base import BaseConnector18from . import _detailutil as du19from ..normalize import parse_price20from ..schema import PropertyListing2122# Fiche descriptive accessible en requête simple (galerie ~60 photos + description).23DETAIL_LIMIT = int(os.environ.get("IMMOKA_VC_DETAIL_LIMIT", "300"))24_FICHE_IMG_RE = re.compile(r'images\.viacapitale\.info/images/inscriptions/(\d+)/([^"\'?)\s]+)', re.I)2526SITE = "https://www.viacapitalevendu.com"27SEARCH = f"{SITE}/recherche/residentiel/"28MAX_PAGES = 200                       # garde-fou (~155 pages attendues)29# La pagination du portail n'est active QUE si le paramètre `criteresJson`30# (blob base64 des critères de recherche) est présent — sinon `?page=N` retombe31# toujours sur la 1re page. Ce blob = critères vides + « AVendre:true » ; il est32# statique et réutilisable pour parcourir les ~155 pages (~5 300 propriétés).33CRITERES_JSON = (34    "eyJDYXJhY3RlcmlzdGlxdWVzIjpbXSwiQXV0cmVzQ3JpdGVyZSI6W10sIlR5cGVEZVByb3ByaWV0ZSI6"35    "W10sIlR5cGVEZUJhdGltZW50IjpudWxsLCJQZXJpb2Rlc0FmZmljaGFnZSI6bnVsbCwiQW5uZWVEZUNv"36    "bnN0cnVjdGlvbiI6bnVsbCwiUmVnaW9uIjpudWxsLCJOb21SZWdpb24iOm51bGwsIk5vbWJyZURlQ2hh"37    "bWJyZSI6MCwiTm9tYnJlRGVCYWluIjowLCJQcml4TWluaW11bSI6IjAiLCJQcml4TWF4aW11bSI6IjAi"38    "LCJQcml4TG9jYXRpb25NaW5pbXVtIjoiMCIsIlByaXhMb2NhdGlvbk1heGltdW0iOiIwIiwiT3JkZXJC"39    "eSI6bnVsbCwiVHlwZSI6MCwiQVZlbmRyZSI6dHJ1ZSwiRnJvbUFmZmljaGVyVG91dGVzUHJvcHJpZXRl"40    "IjpmYWxzZSwiU3VwZXJmaWNpZU1pbmltdW0iOiIiLCJTdXBlcmZpY2llTWF4aW11bSI6IiIsIlVuaXRl"41    "TWVzdXJlIjoiUEMiLCJNb3RzQ2xlcyI6bnVsbCwiWm9uYWdlIjpudWxsLCJOb21icmVVbml0ZXMiOjEs"42    "IlN1Y2N1cnNhbGVDb2RlIjpudWxsLCJBZ2VuY2VDb2RlIjpudWxsLCJNZW1icmVDb2RlIjpudWxsLCJF"43    "cXVpcGVJZCI6MCwiU3VjY3Vyc2FsZU5hbWUiOm51bGwsIkFnZW5jZU5hbWUiOm51bGwsIk1lbWJyZU5h"44    "bWUiOm51bGwsIkVxdWlwZU5hbWUiOm51bGwsImlucHV0UmVnaW9ucyI6bnVsbCwiUmV0dXJuVXJsIjpu"45    "dWxsLCJMYXRpdHVkZSI6bnVsbCwiTG9uZ2l0dWRlIjpudWxsLCJOb0luc2NyaXB0aW9uTm9uRGlzcG8i"46    "Om51bGwsIkVuUmVjaGVyY2hlIjpmYWxzZSwiUGx1c0RlQ3JpdGVyZXMiOiJmYWxzZSIsIlNwZWNpYWxp"47    "dGUiOm51bGwsIk5vbVBsYW5FYXUiOm51bGwsIk5vbVBsYW5FYXVNb2JpbGUiOm51bGwsIklkUGxhbkVh"48    "dSI6bnVsbCwiSWRQbGFuRWF1TW9iaWxlIjowLCJHZW5yZVByb3ByaWV0ZUZyVXJsIjpudWxsLCJHZW5y"49    "ZVByb3ByaWV0ZUVuVXJsIjpudWxsLCJzZWxlY1BBTW9iaWxlIjpudWxsLCJzZWxlY0NhcmFjTW9iaWxl"50    "IjpbbnVsbCxudWxsLG51bGwsbnVsbCxudWxsLG51bGwsbnVsbCxudWxsLG51bGwsbnVsbF0sInNlbGVj"51    "QUNNb2JpbGUiOltudWxsLG51bGwsbnVsbCxudWxsLG51bGwsbnVsbCxudWxsLG51bGwsbnVsbCxudWxs"52    "XSwic2VsZWNUQk1vYmlsZSI6W251bGwsbnVsbCxudWxsLG51bGwsbnVsbCxudWxsLG51bGwsbnVsbCxu"53    "dWxsLG51bGxdfQ=="54)55IMG_HOST = "https://images.viacapitale.info"5657# Un bloc-carte commence à un lien vers une fiche d'inscription horodatée58CARD_SPLIT = re.compile(r'<a href="(https://www\.viacapitalevendu\.com/[^"]*?-(\d{6,}))"')59PRICE_RE = re.compile(r'class="price">\s*([\d   ]+\$)', re.I)60ADDR_RE = re.compile(r'addressListe[^>]*>\s*<a[^>]*title="([^"]+)"', re.I)61IMG_RE = re.compile(r'images\.viacapitale\.info/images/inscriptions/(\d+)/([^"?)\s]+)', re.I)62TYPE_RE = re.compile(63    r'(Maison[\w\s\-àâéèêëîïôûùç]*|Condo[\w\s]*|Duplex|Triplex|Quadruplex|Quintuplex|'64    r'Plex|Terrain|Chalet|Fermette|Ferme|Loft|Terre|Maison de ville|Jumelé)',65    re.I)666768class ViaCapitaleConnector(BaseConnector):69    source_id = "via_capitale"7071    def fetch(self) -> list[PropertyListing]:72        by_id: dict[str, PropertyListing] = {}73        empty_streak = 074        for page in range(1, MAX_PAGES + 1):75            url = f"{SEARCH}?page={page}&criteresJson={CRITERES_JSON}"76            # Cloudflare renvoie parfois un challenge/vide par intermittence :77            # on retente la page une fois avant de la considérer vraiment vide.78            cards = []79            for attempt in range(2):80                try:81                    # Firecrawl (proxy stealth) : c'est LUI qui rend les cartes de82                    # Via Capitale. Testé : Scrapfly ASP franchit Cloudflare mais83                    # ne déclenche pas le rendu des inscriptions (0 carte) ; on84                    # garde donc Firecrawl, qui ramène bien les ~5 300 propriétés.85                    html = self.get_rendered(url, wait_for=6000, proxy="stealth")86                except Exception:87                    html = ""88                cards = self._parse_cards(html)89                if cards:90                    break91            new = 092            for lst in cards:93                if lst.uid not in by_id:94                    by_id[lst.uid] = lst95                    new += 196            if not cards or new == 0:97                empty_streak += 198                # tolérance élevée : ~155 pages attendues, on ne s'arrête qu'après99                # plusieurs pages consécutives réellement sans nouveauté100                if empty_streak >= 5:101                    break102            else:103                empty_streak = 0104        listings = list(by_id.values())105        # fiche détail (requête simple) : galerie complète + description106        du.enrich(self, listings, DETAIL_LIMIT, parse_vc_detail, key="v2")107        return listings108109    def _parse_cards(self, html: str) -> list[PropertyListing]:110        # découpe la page en segments, un par carte (lien fiche + id)111        matches = list(CARD_SPLIT.finditer(html))112        out = []113        for i, m in enumerate(matches):114            url, code = m.group(1), m.group(2)115            seg = html[m.start(): matches[i + 1].start() if i + 1 < len(matches) else m.start() + 2500]116            out.append(self._to_listing(url, code, seg))117        return [x for x in out if x]118119    def _to_listing(self, url: str, code: str, seg: str) -> PropertyListing | None:120        price_m = PRICE_RE.search(seg)121        price_label = (price_m.group(1).strip() if price_m else "").replace(" ", " ")122        addr_m = ADDR_RE.search(seg)123        address_full = _unescape(addr_m.group(1).strip()) if addr_m else ""124        type_m = TYPE_RE.search(re.sub(r"<[^>]+>", " ", seg))125        # ne garder que le libellé de type (avant l'adresse recopiée dans le bloc)126        prop_type = re.split(r"\s{2,}|\n", type_m.group(1))[0].strip() if type_m else ""127128        address, city, sector = _split_addr(address_full)129130        images = []131        for im in IMG_RE.finditer(seg):132            u = f"{IMG_HOST}/images/inscriptions/{im.group(1)}/{im.group(2)}"133            if u not in images:134                images.append(u)135136        region, _city_slug = _from_slug(url)137        return PropertyListing(138            source=self.source_id,139            external_id=code,140            url=f"{SITE}/inscription/fichedescriptive/?code={code}",141            title=f"{prop_type}{address}".strip(" —") or address_full,142            address=address,143            city=city or _city_slug,144            sector=sector,145            region=region,146            property_type=prop_type,147            price=parse_price(price_label),148            price_label=price_label,149            images=images,150            broker_name="Via Capitale",151        )152153154# slug ex. « saguenay-lac-saint-jean-sainte-hedwidge-ch-de-la-lievre-maison-... »155_REGIONS = (156    "bas-saint-laurent", "saguenay-lac-saint-jean", "capitale-nationale",157    "mauricie", "estrie", "montreal", "outaouais", "abitibi-temiscamingue",158    "cote-nord", "nord-du-quebec", "gaspesie-iles-de-la-madeleine",159    "chaudiere-appalaches", "laval", "lanaudiere", "laurentides",160    "monteregie", "centre-du-quebec",161)162163164import html as _htmlmod165166_PAREN = re.compile(r"\(([^)]*)\)")167168169def _unescape(s: str) -> str:170    return _htmlmod.unescape(s).replace("\xa0", " ").replace("&nbsp;", " ").strip()171172173def _split_addr(full: str) -> tuple[str, str, str]:174    """« 1455 Rue des Cèdres, Lévis (Les Chutes-de-la-Chaudière-Ouest) »175    -> (adresse, ville, secteur)."""176    if not full:177        return "", "", ""178    parts = [p.strip() for p in full.split(",", 1)]179    address = parts[0]180    city = sector = ""181    if len(parts) > 1:182        muni = parts[1]183        parens = _PAREN.findall(muni)184        city = _PAREN.sub("", muni).strip()185        if parens:186            sector = parens[-1].strip()187    return address, city, sector188189190def parse_vc_detail(html: str) -> dict:191    """Fiche Via Capitale : galerie complète + description (plus long bloc)."""192    out: dict = {}193    seen, imgs = set(), []194    for m in _FICHE_IMG_RE.finditer(html):195        u = f"https://images.viacapitale.info/images/inscriptions/{m.group(1)}/{m.group(2)}"196        if u not in seen:197            seen.add(u)198            imgs.append(u)199    if imgs:200        out["images"] = imgs201    # description = plus long bloc de texte visible (marketing de la propriété)202    best = ""203    for b in re.findall(r"<div[^>]*>(.*?)</div>", html, re.S):204        txt = re.sub(r"\s+", " ", _htmlmod2.unescape(re.sub(r"<[^>]+>", " ", b))).strip()205        if len(txt) > len(best) and "window" not in txt and "function" not in txt and "{" not in txt:206            best = txt207    if len(best) > 120:208        out["description"] = best[:4000]209    _det = du.centris_details(du.flatten(html))210    if _det:211        out.setdefault("details", {}).update(_det)212    return out213214215def _from_slug(url: str) -> tuple[str, str]:216    tail = url.rstrip("/").rsplit("/", 1)[-1]217    for reg in _REGIONS:218        if tail.startswith(reg):219            rest = tail[len(reg) + 1:]220            city = rest.split("-")[0].replace("-", " ").title() if rest else ""221            return reg.replace("-", " ").title(), city222    return "", ""223