# ----------------------------------------------------------------------------- # Immo-Ka — Agrégateur de maisons à vendre (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/via_capitale.py : Via Capitale (viacapitalevendu.com) # Bannière 100 % québécoise (Bridgemarq). Le site est rendu serveur (ASP.NET) # mais protégé par Cloudflare : on passe donc par Firecrawl (get_rendered) qui # franchit le challenge. Les résultats résidentiels sont paginés (34/page, # ~5 300 propriétés). On parse les cartes HTML et on dédoublonne par code # d'inscription. Photos : images.viacapitale.info/images/inscriptions/{id}/… # ----------------------------------------------------------------------------- from __future__ import annotations import html as _htmlmod2 import os import re from .base import BaseConnector from . import _detailutil as du from ..normalize import parse_price from ..schema import PropertyListing # Fiche descriptive accessible en requête simple (galerie ~60 photos + description). DETAIL_LIMIT = int(os.environ.get("IMMOKA_VC_DETAIL_LIMIT", "300")) _FICHE_IMG_RE = re.compile(r'images\.viacapitale\.info/images/inscriptions/(\d+)/([^"\'?)\s]+)', re.I) SITE = "https://www.viacapitalevendu.com" SEARCH = f"{SITE}/recherche/residentiel/" MAX_PAGES = 200 # garde-fou (~155 pages attendues) # La pagination du portail n'est active QUE si le paramètre `criteresJson` # (blob base64 des critères de recherche) est présent — sinon `?page=N` retombe # toujours sur la 1re page. Ce blob = critères vides + « AVendre:true » ; il est # statique et réutilisable pour parcourir les ~155 pages (~5 300 propriétés). CRITERES_JSON = ( "eyJDYXJhY3RlcmlzdGlxdWVzIjpbXSwiQXV0cmVzQ3JpdGVyZSI6W10sIlR5cGVEZVByb3ByaWV0ZSI6" "W10sIlR5cGVEZUJhdGltZW50IjpudWxsLCJQZXJpb2Rlc0FmZmljaGFnZSI6bnVsbCwiQW5uZWVEZUNv" "bnN0cnVjdGlvbiI6bnVsbCwiUmVnaW9uIjpudWxsLCJOb21SZWdpb24iOm51bGwsIk5vbWJyZURlQ2hh" "bWJyZSI6MCwiTm9tYnJlRGVCYWluIjowLCJQcml4TWluaW11bSI6IjAiLCJQcml4TWF4aW11bSI6IjAi" "LCJQcml4TG9jYXRpb25NaW5pbXVtIjoiMCIsIlByaXhMb2NhdGlvbk1heGltdW0iOiIwIiwiT3JkZXJC" "eSI6bnVsbCwiVHlwZSI6MCwiQVZlbmRyZSI6dHJ1ZSwiRnJvbUFmZmljaGVyVG91dGVzUHJvcHJpZXRl" "IjpmYWxzZSwiU3VwZXJmaWNpZU1pbmltdW0iOiIiLCJTdXBlcmZpY2llTWF4aW11bSI6IiIsIlVuaXRl" "TWVzdXJlIjoiUEMiLCJNb3RzQ2xlcyI6bnVsbCwiWm9uYWdlIjpudWxsLCJOb21icmVVbml0ZXMiOjEs" "IlN1Y2N1cnNhbGVDb2RlIjpudWxsLCJBZ2VuY2VDb2RlIjpudWxsLCJNZW1icmVDb2RlIjpudWxsLCJF" "cXVpcGVJZCI6MCwiU3VjY3Vyc2FsZU5hbWUiOm51bGwsIkFnZW5jZU5hbWUiOm51bGwsIk1lbWJyZU5h" "bWUiOm51bGwsIkVxdWlwZU5hbWUiOm51bGwsImlucHV0UmVnaW9ucyI6bnVsbCwiUmV0dXJuVXJsIjpu" "dWxsLCJMYXRpdHVkZSI6bnVsbCwiTG9uZ2l0dWRlIjpudWxsLCJOb0luc2NyaXB0aW9uTm9uRGlzcG8i" "Om51bGwsIkVuUmVjaGVyY2hlIjpmYWxzZSwiUGx1c0RlQ3JpdGVyZXMiOiJmYWxzZSIsIlNwZWNpYWxp" "dGUiOm51bGwsIk5vbVBsYW5FYXUiOm51bGwsIk5vbVBsYW5FYXVNb2JpbGUiOm51bGwsIklkUGxhbkVh" "dSI6bnVsbCwiSWRQbGFuRWF1TW9iaWxlIjowLCJHZW5yZVByb3ByaWV0ZUZyVXJsIjpudWxsLCJHZW5y" "ZVByb3ByaWV0ZUVuVXJsIjpudWxsLCJzZWxlY1BBTW9iaWxlIjpudWxsLCJzZWxlY0NhcmFjTW9iaWxl" "IjpbbnVsbCxudWxsLG51bGwsbnVsbCxudWxsLG51bGwsbnVsbCxudWxsLG51bGwsbnVsbF0sInNlbGVj" "QUNNb2JpbGUiOltudWxsLG51bGwsbnVsbCxudWxsLG51bGwsbnVsbCxudWxsLG51bGwsbnVsbCxudWxs" "XSwic2VsZWNUQk1vYmlsZSI6W251bGwsbnVsbCxudWxsLG51bGwsbnVsbCxudWxsLG51bGwsbnVsbCxu" "dWxsLG51bGxdfQ==" ) IMG_HOST = "https://images.viacapitale.info" # Un bloc-carte commence à un lien vers une fiche d'inscription horodatée CARD_SPLIT = re.compile(r'\s*([\d  ]+\$)', re.I) ADDR_RE = re.compile(r'addressListe[^>]*>\s*]*title="([^"]+)"', re.I) IMG_RE = re.compile(r'images\.viacapitale\.info/images/inscriptions/(\d+)/([^"?)\s]+)', re.I) TYPE_RE = re.compile( r'(Maison[\w\s\-àâéèêëîïôûùç]*|Condo[\w\s]*|Duplex|Triplex|Quadruplex|Quintuplex|' r'Plex|Terrain|Chalet|Fermette|Ferme|Loft|Terre|Maison de ville|Jumelé)', re.I) class ViaCapitaleConnector(BaseConnector): source_id = "via_capitale" def fetch(self) -> list[PropertyListing]: by_id: dict[str, PropertyListing] = {} empty_streak = 0 for page in range(1, MAX_PAGES + 1): url = f"{SEARCH}?page={page}&criteresJson={CRITERES_JSON}" # Cloudflare renvoie parfois un challenge/vide par intermittence : # on retente la page une fois avant de la considérer vraiment vide. cards = [] for attempt in range(2): try: # Firecrawl (proxy stealth) : c'est LUI qui rend les cartes de # Via Capitale. Testé : Scrapfly ASP franchit Cloudflare mais # ne déclenche pas le rendu des inscriptions (0 carte) ; on # garde donc Firecrawl, qui ramène bien les ~5 300 propriétés. html = self.get_rendered(url, wait_for=6000, proxy="stealth") except Exception: html = "" cards = self._parse_cards(html) if cards: break new = 0 for lst in cards: if lst.uid not in by_id: by_id[lst.uid] = lst new += 1 if not cards or new == 0: empty_streak += 1 # tolérance élevée : ~155 pages attendues, on ne s'arrête qu'après # plusieurs pages consécutives réellement sans nouveauté if empty_streak >= 5: break else: empty_streak = 0 listings = list(by_id.values()) # fiche détail (requête simple) : galerie complète + description du.enrich(self, listings, DETAIL_LIMIT, parse_vc_detail, key="v2") return listings def _parse_cards(self, html: str) -> list[PropertyListing]: # découpe la page en segments, un par carte (lien fiche + id) matches = list(CARD_SPLIT.finditer(html)) out = [] for i, m in enumerate(matches): url, code = m.group(1), m.group(2) seg = html[m.start(): matches[i + 1].start() if i + 1 < len(matches) else m.start() + 2500] out.append(self._to_listing(url, code, seg)) return [x for x in out if x] def _to_listing(self, url: str, code: str, seg: str) -> PropertyListing | None: price_m = PRICE_RE.search(seg) price_label = (price_m.group(1).strip() if price_m else "").replace(" ", " ") addr_m = ADDR_RE.search(seg) address_full = _unescape(addr_m.group(1).strip()) if addr_m else "" type_m = TYPE_RE.search(re.sub(r"<[^>]+>", " ", seg)) # ne garder que le libellé de type (avant l'adresse recopiée dans le bloc) prop_type = re.split(r"\s{2,}|\n", type_m.group(1))[0].strip() if type_m else "" address, city, sector = _split_addr(address_full) images = [] for im in IMG_RE.finditer(seg): u = f"{IMG_HOST}/images/inscriptions/{im.group(1)}/{im.group(2)}" if u not in images: images.append(u) region, _city_slug = _from_slug(url) return PropertyListing( source=self.source_id, external_id=code, url=f"{SITE}/inscription/fichedescriptive/?code={code}", title=f"{prop_type} — {address}".strip(" —") or address_full, address=address, city=city or _city_slug, sector=sector, region=region, property_type=prop_type, price=parse_price(price_label), price_label=price_label, images=images, broker_name="Via Capitale", ) # slug ex. « saguenay-lac-saint-jean-sainte-hedwidge-ch-de-la-lievre-maison-... » _REGIONS = ( "bas-saint-laurent", "saguenay-lac-saint-jean", "capitale-nationale", "mauricie", "estrie", "montreal", "outaouais", "abitibi-temiscamingue", "cote-nord", "nord-du-quebec", "gaspesie-iles-de-la-madeleine", "chaudiere-appalaches", "laval", "lanaudiere", "laurentides", "monteregie", "centre-du-quebec", ) import html as _htmlmod _PAREN = re.compile(r"\(([^)]*)\)") def _unescape(s: str) -> str: return _htmlmod.unescape(s).replace("\xa0", " ").replace(" ", " ").strip() def _split_addr(full: str) -> tuple[str, str, str]: """« 1455 Rue des Cèdres, Lévis (Les Chutes-de-la-Chaudière-Ouest) » -> (adresse, ville, secteur).""" if not full: return "", "", "" parts = [p.strip() for p in full.split(",", 1)] address = parts[0] city = sector = "" if len(parts) > 1: muni = parts[1] parens = _PAREN.findall(muni) city = _PAREN.sub("", muni).strip() if parens: sector = parens[-1].strip() return address, city, sector def parse_vc_detail(html: str) -> dict: """Fiche Via Capitale : galerie complète + description (plus long bloc).""" out: dict = {} seen, imgs = set(), [] for m in _FICHE_IMG_RE.finditer(html): u = f"https://images.viacapitale.info/images/inscriptions/{m.group(1)}/{m.group(2)}" if u not in seen: seen.add(u) imgs.append(u) if imgs: out["images"] = imgs # description = plus long bloc de texte visible (marketing de la propriété) best = "" for b in re.findall(r"]*>(.*?)", html, re.S): txt = re.sub(r"\s+", " ", _htmlmod2.unescape(re.sub(r"<[^>]+>", " ", b))).strip() if len(txt) > len(best) and "window" not in txt and "function" not in txt and "{" not in txt: best = txt if len(best) > 120: out["description"] = best[:4000] _det = du.centris_details(du.flatten(html)) if _det: out.setdefault("details", {}).update(_det) return out def _from_slug(url: str) -> tuple[str, str]: tail = url.rstrip("/").rsplit("/", 1)[-1] for reg in _REGIONS: if tail.startswith(reg): rest = tail[len(reg) + 1:] city = rest.split("-")[0].replace("-", " ").title() if rest else "" return reg.replace("-", " ").title(), city return "", ""