# -----------------------------------------------------------------------------
# Lou-Ka — Agrégateur de logements à louer (province de Québec)
# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
# connectors/via_capitale.py : Via Capitale (viacapitalevendu.com) — LOCATIONS
# Bannière 100 % québécoise (Bridgemarq). Le site est rendu serveur (ASP.NET)
# mais protégé par Cloudflare : on passe par le rendu Scrapfly (get_rendered, ex-Firecrawl) qui
# franchit le challenge — testé : Scrapfly ASP passe Cloudflare mais ne rend
# pas les cartes. La recherche accepte un blob base64 `criteresJson` ;
# « AVendre »: false y bascule le même moteur en mode LOCATION (prix
# « X $ / mois », ~34 cartes/page). Adapté du connecteur « à vendre »
# d'Immo-Ka (agent-courtage/immoka).
# -----------------------------------------------------------------------------
from __future__ import annotations
import base64
import html as _htmlmod
import json
import os
import re
from .base import BaseConnector
from . import _detailutil as du
from ..schema import Listing, parse_price
DETAIL_LIMIT = int(os.environ.get("LOUKA_VC_DETAIL_LIMIT", "150"))
_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 = int(os.environ.get("LOUKA_VC_MAX_PAGES", "40"))
# La pagination du portail n'est active QUE si `criteresJson` est présent.
# Blob = critères vides + « AVendre »: false (= locations seulement) —
# construit au chargement pour rester lisible/modifiable.
_CRITERES = {
"Caracteristiques": [], "AutresCritere": [], "TypeDePropriete": [],
"TypeDeBatiment": None, "PeriodesAffichage": None, "AnneeDeConstruction": None,
"Region": None, "NomRegion": None, "NombreDeChambre": 0, "NombreDeBain": 0,
"PrixMinimum": "0", "PrixMaximum": "0",
"PrixLocationMinimum": "0", "PrixLocationMaximum": "0",
"OrderBy": None, "Type": 0, "AVendre": False,
"FromAfficherToutesPropriete": False,
"SuperficieMinimum": "", "SuperficieMaximum": "", "UniteMesure": "PC",
"MotsCles": None, "Zonage": None, "NombreUnites": 1,
"SuccursaleCode": None, "AgenceCode": None, "MembreCode": None,
"EquipeId": 0, "SuccursaleName": None, "AgenceName": None,
"MembreName": None, "EquipeName": None, "inputRegions": None,
"ReturnUrl": None, "Latitude": None, "Longitude": None,
"NoInscriptionNonDispo": None, "EnRecherche": False,
"PlusDeCriteres": "false", "Specialite": None,
"NomPlanEau": None, "NomPlanEauMobile": None,
"IdPlanEau": None, "IdPlanEauMobile": 0,
"GenrePropreteFrUrl": None, "GenreProprieteFrUrl": None,
"GenreProprieteEnUrl": None, "selecPAMobile": None,
"selecCaracMobile": [None] * 10, "selecACMobile": [None] * 10,
"selecTBMobile": [None] * 10,
}
CRITERES_JSON = base64.b64encode(
json.dumps(_CRITERES, separators=(",", ":")).encode()).decode()
IMG_HOST = "https://images.viacapitale.info"
# Un bloc-carte commence à un lien vers une fiche d'inscription horodatée
# le rendu Scrapfly sérialise les hrefs en RELATIF (Firecrawl donnait
# l'absolu) : on accepte les deux formes, groupe 1 = chemin relatif
CARD_SPLIT = re.compile(
r'\s*([\d ]+\$\s*/\s*mois)', 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'(Appartement|Condo[\w\s]*|Maison[\w\s\-àâéèêëîïôûùç]*|Duplex|Triplex|'
r'Quadruplex|Loft|Studio|Maison de ville|Jumelé|Chalet)', re.I)
_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
# slug ex. « saguenay-lac-saint-jean-sainte-hedwidge-ch-de-la-lievre-… »
_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",
)
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 "", ""
class ViaCapitaleConnector(BaseConnector):
source_id = "via_capitale"
def fetch(self) -> list[Listing]:
by_id: dict[str, Listing] = {}
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:
html = self.get_rendered(url)
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
if empty_streak >= 3:
break
else:
empty_streak = 0
listings = list(by_id.values())
# fiche détail (requête simple, hors Cloudflare) : galerie + description
du.enrich(self, listings, DETAIL_LIMIT, parse_vc_detail, key="v1")
return listings
def _parse_cards(self, html: str) -> list[Listing]:
# 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) -> Listing | None:
price_m = PRICE_RE.search(seg)
if not price_m:
return None # pas de « $ / mois » = pas une location rendue
price_label = _unescape(price_m.group(1))
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))
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)
details = {"Courtier": "Via Capitale"}
if region:
details["Région"] = region
if prop_type:
details["Type"] = prop_type
unit_type = prop_type if prop_type.lower() in ("studio", "loft") else ""
return Listing(
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,
unit_type=unit_type,
price=parse_price(price_label),
price_label=price_label,
details=details,
images=images,
)
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"{IMG_HOST}/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+", " ", _htmlmod.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["details"] = det
# « Nombre de pièces : 4 pièces » → unité normalisable (4½ etc.)
pieces = det.get("Nombre de pièces")
if pieces:
out["unit_type"] = pieces
return out