# -----------------------------------------------------------------------------
# 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 "", ""