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