# -----------------------------------------------------------------------------
# Sorti-Ka — Agrégateur de sorties & événements (province de Québec)
# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
# connectors/saguenaysorties.py : Sorties Saguenay — portail officiel de la
# Ville de Saguenay (Phase 3). Remplace le CSV Données Québec mort (2022).
# Source : https://sorties.saguenay.ca — portail événementiel municipal
# (motorisé par les données La Vitrine culturelle, médias
# lvc-medias.lavitrine.com ; recoupement géré par dedup_key).
# Extraction: POST /ajax/plus (offset par pas de 36, ~300-400 séances
# horodatées, vérifié 2026-08-19) — chaque = UNE séance :
# discipline, date FR, heure « 08 h 30 », titre, « Ville, Salle »,
# image, data-detail-id. Détails incrémentaux POST /ajax/details
# (cache data/saguenaysorties_cache.json, SAG_DETAIL_MAX/sync,
# re-visite 7 j) : description + adresse civique.
# Accès : portail public municipal, pas de robots.txt publié (page de
# maintenance) — volumes modestes, POST throttlés 0,8 s, UA
# honnête, lien vers la fiche officielle /festivals?id=.
# -----------------------------------------------------------------------------
from __future__ import annotations
import os
import re
import time
from ..normalize import clean_text, parse_date_fr, parse_time
from ..schema import Event
from .base import BaseConnector
BASE = "https://sorties.saguenay.ca"
AJAX_LIST = BASE + "/ajax/plus"
AJAX_DETAIL = BASE + "/ajax/details"
BATCH = 36
BATCH_MAX = int(os.environ.get("SAG_BATCH_MAX", "15")) # ≤ 540 séances
DETAIL_MAX = int(os.environ.get("SAG_DETAIL_MAX", "40"))
REFRESH_AFTER = 7 * 86400
_DISC_RE = re.compile(r'discipline_name">\s*([^<]+?)\s*', re.S)
_IMG_RE = re.compile(r'
]*>\s*([^<]+?)\s*
', re.S)
_TITLE_RE = re.compile(r'data-detail-id="(\d+)"[^>]*>\s*([^<]+?)\s*', re.S)
_PLACE_RE = re.compile(r'\s*\s*(.*?)\s*
', re.S)
_ADDR_RE = re.compile(r'
\s*([^<]+?)\s*', re.S)
class SaguenaySortiesConnector(BaseConnector):
source_id = "saguenaysorties"
request_delay = 0.8
def _post(self, url: str, data: dict) -> str:
self._throttle()
resp = self.session.post(url, data=data, timeout=self.timeout)
self._last_request = time.time()
resp.raise_for_status()
return resp.text
def _parse_batch(self, html: str) -> list[dict]:
rows = []
for art in html.split(" 1 else None
m_place = _PLACE_RE.search(art)
city = venue = ""
if m_place:
place = clean_text(m_place.group(1))
if "," in place:
city, venue = (p.strip() for p in place.split(",", 1))
else:
city = place
# événements multi-lieux : la carte liste TOUS les sites séparés
# par des virgules — un « lieu » de 100+ caractères n'est pas une
# salle, on ne garde que la ville (jamais un faux GPS de salle)
if len(venue) > 100:
venue = ""
if len(city) > 60:
city = ""
disc = _DISC_RE.search(art)
img = _IMG_RE.search(art)
rows.append({
"detail_id": m_title.group(1),
"title": clean_text(m_title.group(2)),
"date": date_iso, "time": heure,
"city": city, "venue": venue,
"discipline": clean_text(disc.group(1)) if disc else "",
"image": img.group(1) if img else "",
})
return rows
def _details(self, ids: list[str]) -> dict:
"""description + adresse par detail_id — incrémental, plafonné."""
cache = self.load_cache()
now = time.time()
to_fetch = sorted(
(i for i in ids
if i not in cache or now - cache[i].get("ts", 0) > REFRESH_AFTER),
key=lambda i: cache.get(i, {}).get("ts", 0))[:DETAIL_MAX]
for did in to_fetch:
try:
html = self._post(AJAX_DETAIL, {"id": did})
m = re.search(r"\s*(.*?)\s* list[Event]:
rows: list[dict] = []
for i in range(BATCH_MAX):
html = self._post(AJAX_LIST, {"offset": str(i * BATCH), "page": "1"})
batch = self._parse_batch(html)
rows.extend(batch)
if len(batch) < BATCH // 2: # dernière page (lots partiels vus)
break
details = self._details(sorted({r["detail_id"] for r in rows}))
events: list[Event] = []
seen: set[str] = set()
for r in rows:
ext = f"{r['detail_id']}-{r['date'] or 'nd'}-{r['time'] or 'nd'}"
if ext in seen or not r["title"]:
continue
seen.add(ext)
det = details.get(r["detail_id"]) or {}
events.append(Event(
source=self.source_id,
external_id=ext,
url=f"{BASE}/festivals?id={r['detail_id']}",
title=r["title"],
description=det.get("desc", ""),
raw_categories=[r["discipline"]] if r["discipline"] else [],
venue=r["venue"],
address=det.get("addr", ""),
city=r["city"],
tourist_region="Saguenay–Lac-Saint-Jean",
start_date=r["date"],
start_time=r["time"],
image=r["image"],
))
return events