# -----------------------------------------------------------------------------
# Sorti-Ka — Agrégateur de sorties & événements (province de Québec)
# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
# connectors/ovation.py : Réseau Ovation — billetterie 100 % québécoise
# (Phase 4). ~26 organisations (salles, diffuseurs municipaux, clubs
# sportifs) : Salle Albert-Rousseau, Petit Champlain, Diffusion Saguenay,
# Gatineau, Terrebonne, Granby, Magog, La Tuque… (sondé 2026-08-21).
# Source : https://m.ovation.ca (sous-site mobile : HTML rendu serveur
# propre, AUCUN anti-bot — le desktop reseau.ovation.ca exige
# des cookies de session). Organisations configurées dans
# data/ovation_orgs.json (ajouter une org = 1 bloc, 0 code ;
# codes validés par balayage base36).
# Extraction: par org, /fr/Search/AllEvents paginé (?pageNumber=N, 40
# cartes/page) → eventIds ; chaque eventId = UNE représentation
# datée. Fiches fragment /fr/Event/EventDescription?eventId=…
# (≈ 5 Ko) incrémentales (cache data/ovation_cache.json,
# OVA_FICHE_MAX/sync, re-visite 7 j) : date pleine FR
# (« mercredi 26 août 2026, 20h00 »), catégories, artistes
# (EventsByPerformer), description, salle + adresse + ville +
# code postal.
# Prix : NON PUBLIÉS par la source sur ses surfaces crawlables —
# re-sondé live 2026-08-25 : la page Event complète mobile
# (77 Ko) et la page desktop www.ovation.ca (ForceWebsite-
# Version=Desktop, 72 Ko) ne contiennent AUCUN montant ni
# JSON-LD offers (les seules mentions « prix » = politique de
# cookies) ; aucun endpoint JSON public. Les tarifs
# n'apparaissent qu'au choix des sièges (/event/tickets),
# chemin Disallow par robots.txt (mobile ET desktop, avec
# /api/) → jamais requêté, prix jamais inventé.
# Accès : billetterie publique, HTML public sans auth. robots.txt
# (lu 2026-08-21) : User-agent * → Crawl-delay: 30 +
# Disallow /event/tickets (jamais requêté) → GET espacés de
# 30,5 s sur m.ovation.ca (hôte unique). Pour rester poli, le
# PASSAGE RÉSEAU complet (listes + fiches) n'a lieu qu'à
# l'expiration d'un TTL (OVA_TTL_HOURS, 20 h — ≈ 45 min de
# moisson/jour) ; entre deux passages, le connecteur réémet son
# cache. Lien d'achat = la fiche officielle.
# -----------------------------------------------------------------------------
from __future__ import annotations
import json
import os
import re
import time
from pathlib import Path
from ..normalize import clean_text, parse_date_fr, parse_time
from ..schema import Event
from .base import BaseConnector
BASE = "https://m.ovation.ca"
CONFIG_PATH = Path(__file__).resolve().parents[2] / "data" / "ovation_orgs.json"
PAGE_MAX = int(os.environ.get("OVA_PAGE_MAX", "6"))
FICHE_MAX = int(os.environ.get("OVA_FICHE_MAX", "50"))
REFRESH_AFTER = 7 * 86400
TTL = float(os.environ.get("OVA_TTL_HOURS", "20")) * 3600
_CARD_RE = re.compile(r'data-event-id="([0-9A-Z]+)"')
_TITLE_RE = re.compile(
r'
\s*([^<]+?)\s*'
r'(?:\s*\|?\s*([^<]+?)\s*)?\s*
',
re.S)
_DATE_RE = re.compile(r'class="info-date">\s*([^<]+)')
_CAT_RE = re.compile(r'EventsInCategory/[^"]*">\s*([^<]+?)\s*')
_PERF_RE = re.compile(r'EventsByPerformer/[^"]*">\s*([^<]+?)\s*')
_VENUE_RE = re.compile(r'EventsByVenue/[^"]*">\s*([^<]+?)\s*')
_ADDR_RE = re.compile(
r"EventsByVenue/[^>]*>[^<]+\s*\s*"
r"([^<]{3,90}?),\s*]*EventsByCity[^>]*>([^<]+)"
r"[^<]*?([A-Z]\d[A-Z]\s?\d[A-Z]\d)?\s*,\s*Canada", re.S)
_IMG_RE = re.compile(r'
(.*?)', re.S)
class OvationConnector(BaseConnector):
source_id = "ovation"
request_delay = 30.5 # robots.txt : Crawl-delay: 30 (hôte unique)
def _load_orgs(self) -> list[dict]:
try:
return json.loads(
CONFIG_PATH.read_text(encoding="utf-8"))["organisations"]
except Exception:
return []
def _get_utf8(self, url: str) -> str:
"""GET décodé UTF-8 (les fragments mêlent entités HTML et UTF-8 brut)."""
return self.get(url).content.decode("utf-8", errors="replace")
def _event_ids(self, code: str) -> list[str]:
"""Tous les eventIds publiés par une organisation (pages AllEvents)."""
ids: list[str] = []
for page in range(1, PAGE_MAX + 1):
url = f"{BASE}/{code}/fr/Search/AllEvents"
if page > 1:
url += f"?pageNumber={page}" # ?page= est ignoré (vérifié)
try:
html = self._get_utf8(url)
except Exception:
break
batch = [i for i in _CARD_RE.findall(html) if i not in ids]
if not batch:
break
ids.extend(batch)
return ids
def _parse_fiche(self, org: dict, event_id: str, html: str) -> dict | None:
title = _TITLE_RE.search(html)
date_raw = _DATE_RE.search(html)
if not title or not date_raw:
return None
date_iso = parse_date_fr(date_raw.group(1))
if not date_iso:
return None
name = clean_text(title.group(1))
if title.group(2):
name = f"{name} — {clean_text(title.group(2))}"
addr = _ADDR_RE.search(html)
venue = _VENUE_RE.search(html)
img = _IMG_RE.search(html)
desc = _DESC_RE.search(html)
return {
"external_id": f"{org['code']}-{event_id}",
"url": f"{BASE}/{org['code']}/fr/Event/?eventId={event_id}",
"title": name,
"description": clean_text(desc.group(1)) if desc else "",
"raw_categories": [clean_text(c) for c in _CAT_RE.findall(html)],
"artists": [clean_text(a) for a in _PERF_RE.findall(html)],
"venue": clean_text(venue.group(1)) if venue else "",
"address": clean_text(addr.group(1)) if addr else "",
"city": clean_text(addr.group(2)) if addr else org.get("ville", ""),
"postal_code": clean_text(addr.group(3) or "") if addr else "",
"start_date": date_iso,
"start_time": parse_time(date_raw.group(1)),
"organizer": org.get("nom", ""),
"image": BASE + img.group(1) if img else "",
}
def fetch(self) -> list[Event]:
cache = self.load_cache()
now = time.time()
meta = cache.get("_meta") or {}
if now - meta.get("ts", 0) <= TTL:
return self._emit(cache) # cache frais : aucun réseau
budget = FICHE_MAX
current: set[str] = {"_meta"}
for org in self._load_orgs():
code = org["code"]
ids = self._event_ids(code)
keys = {f"{code}-{i}": i for i in ids}
current |= set(keys)
to_fetch = sorted(
(k for k in keys
if k not in cache or now - cache[k].get("ts", 0) > REFRESH_AFTER),
key=lambda k: cache.get(k, {}).get("ts", 0))[:max(0, budget)]
budget -= len(to_fetch)
for k in to_fetch:
url = (f"{BASE}/{code}/fr/Event/EventDescription"
f"?eventId={keys[k]}")
try:
row = self._parse_fiche(org, keys[k], self._get_utf8(url))
except Exception:
row = None
if row is None:
print(f"[sorti-ka] ovation : fiche ignorée {url}")
cache[k] = {"ts": now, "row": row}
cache = {k: v for k, v in cache.items() if k in current}
cache["_meta"] = {"ts": now}
self.save_cache(cache)
return self._emit(cache)
def _emit(self, cache: dict) -> list[Event]:
events: list[Event] = []
for key, entry in cache.items():
if key == "_meta":
continue
r = entry.get("row")
if not r:
continue
events.append(Event(
source=self.source_id,
external_id=r["external_id"],
url=r["url"],
title=r["title"],
description=r["description"],
raw_categories=r["raw_categories"] or [r["title"]],
artists=r["artists"],
venue=r["venue"],
address=r["address"],
city=r["city"],
postal_code=r["postal_code"],
start_date=r["start_date"],
start_time=r["start_time"],
organizer=r["organizer"],
image=r["image"],
))
return events