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/ovation.py : Réseau Ovation — billetterie 100 % québécoise5# (Phase 4). ~26 organisations (salles, diffuseurs municipaux, clubs6# sportifs) : Salle Albert-Rousseau, Petit Champlain, Diffusion Saguenay,7# Gatineau, Terrebonne, Granby, Magog, La Tuque… (sondé 2026-08-21).8# Source : https://m.ovation.ca (sous-site mobile : HTML rendu serveur9# propre, AUCUN anti-bot — le desktop reseau.ovation.ca exige10# des cookies de session). Organisations configurées dans11# data/ovation_orgs.json (ajouter une org = 1 bloc, 0 code ;12# codes validés par balayage base36).13# Extraction: par org, /fr/Search/AllEvents paginé (?pageNumber=N, 4014# cartes/page) → eventIds ; chaque eventId = UNE représentation15# datée. Fiches fragment /fr/Event/EventDescription?eventId=…16# (≈ 5 Ko) incrémentales (cache data/ovation_cache.json,17# OVA_FICHE_MAX/sync, re-visite 7 j) : date pleine FR18# (« mercredi 26 août 2026, 20h00 »), catégories, artistes19# (EventsByPerformer), description, salle + adresse + ville +20# code postal.21# Prix : NON PUBLIÉS par la source sur ses surfaces crawlables —22# re-sondé live 2026-08-25 : la page Event complète mobile23# (77 Ko) et la page desktop www.ovation.ca (ForceWebsite-24# Version=Desktop, 72 Ko) ne contiennent AUCUN montant ni25# JSON-LD offers (les seules mentions « prix » = politique de26# cookies) ; aucun endpoint JSON public. Les tarifs27# n'apparaissent qu'au choix des sièges (/event/tickets),28# chemin Disallow par robots.txt (mobile ET desktop, avec29# /api/) → jamais requêté, prix jamais inventé.30# Accès : billetterie publique, HTML public sans auth. robots.txt31# (lu 2026-08-21) : User-agent * → Crawl-delay: 30 +32# Disallow /event/tickets (jamais requêté) → GET espacés de33# 30,5 s sur m.ovation.ca (hôte unique). Pour rester poli, le34# PASSAGE RÉSEAU complet (listes + fiches) n'a lieu qu'à35# l'expiration d'un TTL (OVA_TTL_HOURS, 20 h — ≈ 45 min de36# moisson/jour) ; entre deux passages, le connecteur réémet son37# cache. Lien d'achat = la fiche officielle.38# -----------------------------------------------------------------------------39from __future__ import annotations4041import json42import os43import re44import time45from pathlib import Path4647from ..normalize import clean_text, parse_date_fr, parse_time48from ..schema import Event49from .base import BaseConnector5051BASE = "https://m.ovation.ca"52CONFIG_PATH = Path(__file__).resolve().parents[2] / "data" / "ovation_orgs.json"53PAGE_MAX = int(os.environ.get("OVA_PAGE_MAX", "6"))54FICHE_MAX = int(os.environ.get("OVA_FICHE_MAX", "50"))55REFRESH_AFTER = 7 * 8640056TTL = float(os.environ.get("OVA_TTL_HOURS", "20")) * 36005758_CARD_RE = re.compile(r'data-event-id="([0-9A-Z]+)"')59_TITLE_RE = re.compile(60 r'<div class="title">\s*([^<]+?)\s*'61 r'(?:<span class="event-additional-name">\s*\|?\s*([^<]+?)\s*</span>)?\s*</div>',62 re.S)63_DATE_RE = re.compile(r'class="info-date">\s*([^<]+)')64_CAT_RE = re.compile(r'EventsInCategory/[^"]*">\s*([^<]+?)\s*</a>')65_PERF_RE = re.compile(r'EventsByPerformer/[^"]*">\s*([^<]+?)\s*</a>')66_VENUE_RE = re.compile(r'EventsByVenue/[^"]*">\s*([^<]+?)\s*</a>')67_ADDR_RE = re.compile(68 r"EventsByVenue/[^>]*>[^<]+</a>\s*</div>\s*"69 r"([^<]{3,90}?),\s*<a href=[^>]*EventsByCity[^>]*>([^<]+)</a>"70 r"[^<]*?([A-Z]\d[A-Z]\s?\d[A-Z]\d)?\s*,\s*Canada", re.S)71_IMG_RE = re.compile(r'<img src="(/product-catalog-images/[^"]+)"')72_DESC_RE = re.compile(r'event-presentation.*?<p>(.*?)</p>', re.S)737475class OvationConnector(BaseConnector):76 source_id = "ovation"77 request_delay = 30.5 # robots.txt : Crawl-delay: 30 (hôte unique)7879 def _load_orgs(self) -> list[dict]:80 try:81 return json.loads(82 CONFIG_PATH.read_text(encoding="utf-8"))["organisations"]83 except Exception:84 return []8586 def _get_utf8(self, url: str) -> str:87 """GET décodé UTF-8 (les fragments mêlent entités HTML et UTF-8 brut)."""88 return self.get(url).content.decode("utf-8", errors="replace")8990 def _event_ids(self, code: str) -> list[str]:91 """Tous les eventIds publiés par une organisation (pages AllEvents)."""92 ids: list[str] = []93 for page in range(1, PAGE_MAX + 1):94 url = f"{BASE}/{code}/fr/Search/AllEvents"95 if page > 1:96 url += f"?pageNumber={page}" # ?page= est ignoré (vérifié)97 try:98 html = self._get_utf8(url)99 except Exception:100 break101 batch = [i for i in _CARD_RE.findall(html) if i not in ids]102 if not batch:103 break104 ids.extend(batch)105 return ids106107 def _parse_fiche(self, org: dict, event_id: str, html: str) -> dict | None:108 title = _TITLE_RE.search(html)109 date_raw = _DATE_RE.search(html)110 if not title or not date_raw:111 return None112 date_iso = parse_date_fr(date_raw.group(1))113 if not date_iso:114 return None115 name = clean_text(title.group(1))116 if title.group(2):117 name = f"{name} — {clean_text(title.group(2))}"118 addr = _ADDR_RE.search(html)119 venue = _VENUE_RE.search(html)120 img = _IMG_RE.search(html)121 desc = _DESC_RE.search(html)122 return {123 "external_id": f"{org['code']}-{event_id}",124 "url": f"{BASE}/{org['code']}/fr/Event/?eventId={event_id}",125 "title": name,126 "description": clean_text(desc.group(1)) if desc else "",127 "raw_categories": [clean_text(c) for c in _CAT_RE.findall(html)],128 "artists": [clean_text(a) for a in _PERF_RE.findall(html)],129 "venue": clean_text(venue.group(1)) if venue else "",130 "address": clean_text(addr.group(1)) if addr else "",131 "city": clean_text(addr.group(2)) if addr else org.get("ville", ""),132 "postal_code": clean_text(addr.group(3) or "") if addr else "",133 "start_date": date_iso,134 "start_time": parse_time(date_raw.group(1)),135 "organizer": org.get("nom", ""),136 "image": BASE + img.group(1) if img else "",137 }138139 def fetch(self) -> list[Event]:140 cache = self.load_cache()141 now = time.time()142 meta = cache.get("_meta") or {}143 if now - meta.get("ts", 0) <= TTL:144 return self._emit(cache) # cache frais : aucun réseau145 budget = FICHE_MAX146 current: set[str] = {"_meta"}147 for org in self._load_orgs():148 code = org["code"]149 ids = self._event_ids(code)150 keys = {f"{code}-{i}": i for i in ids}151 current |= set(keys)152 to_fetch = sorted(153 (k for k in keys154 if k not in cache or now - cache[k].get("ts", 0) > REFRESH_AFTER),155 key=lambda k: cache.get(k, {}).get("ts", 0))[:max(0, budget)]156 budget -= len(to_fetch)157 for k in to_fetch:158 url = (f"{BASE}/{code}/fr/Event/EventDescription"159 f"?eventId={keys[k]}")160 try:161 row = self._parse_fiche(org, keys[k], self._get_utf8(url))162 except Exception:163 row = None164 if row is None:165 print(f"[sorti-ka] ovation : fiche ignorée {url}")166 cache[k] = {"ts": now, "row": row}167 cache = {k: v for k, v in cache.items() if k in current}168 cache["_meta"] = {"ts": now}169 self.save_cache(cache)170 return self._emit(cache)171172 def _emit(self, cache: dict) -> list[Event]:173 events: list[Event] = []174 for key, entry in cache.items():175 if key == "_meta":176 continue177 r = entry.get("row")178 if not r:179 continue180 events.append(Event(181 source=self.source_id,182 external_id=r["external_id"],183 url=r["url"],184 title=r["title"],185 description=r["description"],186 raw_categories=r["raw_categories"] or [r["title"]],187 artists=r["artists"],188 venue=r["venue"],189 address=r["address"],190 city=r["city"],191 postal_code=r["postal_code"],192 start_date=r["start_date"],193 start_time=r["start_time"],194 organizer=r["organizer"],195 image=r["image"],196 ))197 return events198