SPB Git forge

spb/sorti-ka

Public

Toutes les sorties et tous les événements du Québec, un seul endroit — 7 connecteurs, fiches SSR, design Groupe KA.

58commits 1branches 0releases
13.7 MBsize
maindefault branch
17 days agolast push
HTML 82.9% Python 15.2% TypeScript 0.9% JavaScript 0.7%
8.9 KB · 198 lines python
Raw Blame History
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