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