Toutes les sorties et tous les événements du Québec, un seul endroit — 7 connecteurs, fiches SSR, design Groupe KA.
HTML 82.9%
Python 15.2%
TypeScript 0.9%
JavaScript 0.7%
1# -----------------------------------------------------------------------------2# Sorti-Ka — Agrégateur de sorties & événements (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/saguenaysorties.py : Sorties Saguenay — portail officiel de la5# Ville de Saguenay (Phase 3). Remplace le CSV Données Québec mort (2022).6# Source : https://sorties.saguenay.ca — portail événementiel municipal7# (motorisé par les données La Vitrine culturelle, médias8# lvc-medias.lavitrine.com ; recoupement géré par dedup_key).9# Extraction: POST /ajax/plus (offset par pas de 36, ~300-400 séances10# horodatées, vérifié 2026-08-19) — chaque <article> = UNE séance :11# discipline, date FR, heure « 08 h 30 », titre, « Ville, Salle »,12# image, data-detail-id. Détails incrémentaux POST /ajax/details13# (cache data/saguenaysorties_cache.json, SAG_DETAIL_MAX/sync,14# re-visite 7 j) : description + adresse civique.15# Accès : portail public municipal, pas de robots.txt publié (page de16# maintenance) — volumes modestes, POST throttlés 0,8 s, UA17# honnête, lien vers la fiche officielle /festivals?id=<id>.18# -----------------------------------------------------------------------------19from __future__ import annotations2021import os22import re23import time2425from ..normalize import clean_text, parse_date_fr, parse_time26from ..schema import Event27from .base import BaseConnector2829BASE = "https://sorties.saguenay.ca"30AJAX_LIST = BASE + "/ajax/plus"31AJAX_DETAIL = BASE + "/ajax/details"32BATCH = 3633BATCH_MAX = int(os.environ.get("SAG_BATCH_MAX", "15")) # ≤ 540 séances34DETAIL_MAX = int(os.environ.get("SAG_DETAIL_MAX", "40"))35REFRESH_AFTER = 7 * 864003637_DISC_RE = re.compile(r'discipline_name">\s*([^<]+?)\s*</div>', re.S)38_IMG_RE = re.compile(r'<img src="([^"]+)"')39_DATE_RE = re.compile(r'article-date[^>]*>\s*([^<]+?)\s*</p>', re.S)40_TITLE_RE = re.compile(r'data-detail-id="(\d+)"[^>]*>\s*([^<]+?)\s*</a>', re.S)41_PLACE_RE = re.compile(r'</h3>\s*<p>\s*(.*?)\s*</p>', re.S)42_ADDR_RE = re.compile(r'<br\s*/?>\s*([^<]+?)\s*</p>', re.S)434445class SaguenaySortiesConnector(BaseConnector):46 source_id = "saguenaysorties"47 request_delay = 0.84849 def _post(self, url: str, data: dict) -> str:50 self._throttle()51 resp = self.session.post(url, data=data, timeout=self.timeout)52 self._last_request = time.time()53 resp.raise_for_status()54 return resp.text5556 def _parse_batch(self, html: str) -> list[dict]:57 rows = []58 for art in html.split("<article")[1:]:59 m_title = _TITLE_RE.search(art)60 if not m_title:61 continue62 dates = _DATE_RE.findall(art)63 date_iso = parse_date_fr(dates[0]) if dates else None64 heure = parse_time(dates[1]) if len(dates) > 1 else None65 m_place = _PLACE_RE.search(art)66 city = venue = ""67 if m_place:68 place = clean_text(m_place.group(1))69 if "," in place:70 city, venue = (p.strip() for p in place.split(",", 1))71 else:72 city = place73 # événements multi-lieux : la carte liste TOUS les sites séparés74 # par des virgules — un « lieu » de 100+ caractères n'est pas une75 # salle, on ne garde que la ville (jamais un faux GPS de salle)76 if len(venue) > 100:77 venue = ""78 if len(city) > 60:79 city = ""80 disc = _DISC_RE.search(art)81 img = _IMG_RE.search(art)82 rows.append({83 "detail_id": m_title.group(1),84 "title": clean_text(m_title.group(2)),85 "date": date_iso, "time": heure,86 "city": city, "venue": venue,87 "discipline": clean_text(disc.group(1)) if disc else "",88 "image": img.group(1) if img else "",89 })90 return rows9192 def _details(self, ids: list[str]) -> dict:93 """description + adresse par detail_id — incrémental, plafonné."""94 cache = self.load_cache()95 now = time.time()96 to_fetch = sorted(97 (i for i in ids98 if i not in cache or now - cache[i].get("ts", 0) > REFRESH_AFTER),99 key=lambda i: cache.get(i, {}).get("ts", 0))[:DETAIL_MAX]100 for did in to_fetch:101 try:102 html = self._post(AJAX_DETAIL, {"id": did})103 m = re.search(r"</h3>\s*(.*?)\s*<h4", html, re.S)104 desc = clean_text(m.group(1)) if m else ""105 m_addr = _ADDR_RE.search(html)106 addr = clean_text(m_addr.group(1)) if m_addr else ""107 cache[did] = {"ts": now, "desc": desc, "addr": addr}108 except Exception:109 cache[did] = {"ts": now, "desc": "", "addr": ""}110 self.save_cache(cache)111 return cache112113 def fetch(self) -> list[Event]:114 rows: list[dict] = []115 for i in range(BATCH_MAX):116 html = self._post(AJAX_LIST, {"offset": str(i * BATCH), "page": "1"})117 batch = self._parse_batch(html)118 rows.extend(batch)119 if len(batch) < BATCH // 2: # dernière page (lots partiels vus)120 break121 details = self._details(sorted({r["detail_id"] for r in rows}))122123 events: list[Event] = []124 seen: set[str] = set()125 for r in rows:126 ext = f"{r['detail_id']}-{r['date'] or 'nd'}-{r['time'] or 'nd'}"127 if ext in seen or not r["title"]:128 continue129 seen.add(ext)130 det = details.get(r["detail_id"]) or {}131 events.append(Event(132 source=self.source_id,133 external_id=ext,134 url=f"{BASE}/festivals?id={r['detail_id']}",135 title=r["title"],136 description=det.get("desc", ""),137 raw_categories=[r["discipline"]] if r["discipline"] else [],138 venue=r["venue"],139 address=det.get("addr", ""),140 city=r["city"],141 tourist_region="Saguenay–Lac-Saint-Jean",142 start_date=r["date"],143 start_time=r["time"],144 image=r["image"],145 ))146 return events147