# -----------------------------------------------------------------------------
# Sorti-Ka — Agrégateur de sorties & événements (province de Québec)
# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
# connectors/victoriaville.py : connecteur Ville de Victoriaville — « Quoi
# faire à Victo » (calendrier municipal officiel).
# Source : https://victoriaville.ca/calendriers-cours-et-ateliers/
# quoi-faire-a-victo (Craft CMS, rendu serveur).
# Extraction: la page liste UNE SEMAINE de séances, jour par jour
# (sections id="day-YYYY-MM-DD", ~115 par semaine :
# titre, lieu, heures « De 9 h à 17 h », image CloudFront,
# lien fiche ?date=YYYY-MM-DD). Navigation par
# ?dateDebut=YYYY-MM-DD → on couvre VIC_WEEKS semaines
# (défaut 4) = 1 GET/semaine. Vérifié 2026-08-25.
# Accès : page municipale publique, robots.txt permissif (Craft :
# /cpresources/ et /vendor/ seulement). GET directs throttlés,
# identification honnête.
# Prix : non publié dans la liste → jamais inventé.
# -----------------------------------------------------------------------------
from __future__ import annotations
import os
import re
from datetime import date, timedelta
from ..normalize import clean_text, parse_time
from ..schema import Event
from .base import BaseConnector
LIST_URL = ("https://victoriaville.ca/calendriers-cours-et-ateliers/"
"quoi-faire-a-victo")
WEEKS = int(os.environ.get("VIC_WEEKS", "4"))
_DAY_RE = re.compile(r'id="day-(\d{4}-\d{2}-\d{2})"')
_ARTICLE_RE = re.compile(
r'(.*?)', re.S)
_LINK_RE = re.compile(
r'href="(https://victoriaville\.ca/[^"?]+)\?date=(\d{4}-\d{2}-\d{2})"')
_TITLE_RE = re.compile(
r'thumbnails-listing-item-title[^>]*>\s*(.*?)\s*', re.S)
_LIEU_RE = re.compile(
r'thumbnails-listing-item-lieu[^>]*>\s*(.*?)\s*
', re.S)
_HOURS_RE = re.compile(
r'thumbnails-listing-item-hours[^>]*>(.*?)', re.S)
_IMG_RE = re.compile(r'
]+src="([^"]+)"')
# « De 9 h à 17 h », « 19 h 30 » — début puis fin éventuelle
_RANGE_RE = re.compile(
r"(\d{1,2}\s*h(?:\s*\d{2})?)\s*(?:à|-|–)\s*(\d{1,2}\s*h(?:\s*\d{2})?)", re.I)
class VictoriavilleConnector(BaseConnector):
source_id = "victoriaville"
request_delay = 1.0
def _parse_week(self, html: str) -> list[dict]:
"""Découpe la page en sections-jour puis en cartes-séance."""
rows: list[dict] = []
marks = [(m.start(), m.group(1)) for m in _DAY_RE.finditer(html)]
for i, (pos, day) in enumerate(marks):
end = marks[i + 1][0] if i + 1 < len(marks) else len(html)
for block in _ARTICLE_RE.findall(html[pos:end]):
link = _LINK_RE.search(block)
title = _TITLE_RE.search(block)
if not link or not title:
continue
hours_txt = ""
m = _HOURS_RE.search(block)
if m:
hours_txt = clean_text(re.sub(r"<[^>]+>", " ", m.group(1)))
start_time = end_time = None
rng = _RANGE_RE.search(hours_txt)
if rng:
start_time = parse_time(rng.group(1))
end_time = parse_time(rng.group(2))
else:
start_time = parse_time(hours_txt)
# « 00h00 » = placeholder « toute la journée » (vérifié live
# 2026-08-25 : 28 cartes/semaine) → heure jamais inventée
if start_time == "00:00":
start_time = None
if end_time == "00:00":
end_time = None
lieu = _LIEU_RE.search(block)
img = _IMG_RE.search(block)
rows.append({
"url": link.group(1),
"date": day,
"title": clean_text(re.sub(r"<[^>]+>", "", title.group(1))),
"venue": clean_text(re.sub(
r"<[^>]+>", "", lieu.group(1))) if lieu else "",
"start_time": start_time,
"end_time": end_time,
"image": img.group(1) if img else "",
})
return rows
def fetch(self) -> list[Event]:
rows: list[dict] = []
today = date.today()
for week in range(WEEKS):
url = LIST_URL
params = None
if week:
params = {"dateDebut": (today + timedelta(days=7 * week))
.isoformat(), "recherche": "",
"programmationSpeciale": ""}
try:
rows += self._parse_week(self.get(url, params=params).text)
except Exception as exc: # une semaine cassée ≠ source cassée
if week == 0:
raise
print(f"[sorti-ka] victoriaville : semaine +{week} ignorée : {exc}")
events: list[Event] = []
seen: set[str] = set()
for r in rows:
slug = r["url"].rstrip("/").rsplit("/", 1)[-1]
ext = f"{slug}-{r['date']}"
if ext in seen or not r["title"]:
continue
seen.add(ext)
events.append(Event(
source=self.source_id,
external_id=ext,
url=f"{r['url']}?date={r['date']}",
title=r["title"],
raw_categories=[r["title"]],
venue=r["venue"],
city="Victoriaville",
region="Centre-du-Québec",
start_date=r["date"],
start_time=r["start_time"],
end_date=r["date"],
end_time=r["end_time"],
organizer="Ville de Victoriaville",
image=r["image"],
))
return events