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/ticketpro.py : Ticketpro — billetterie canadienne, tenants Québec5# (Phase 5). Grande billetterie indépendante (Théâtre St-Denis, Fun Nation,6# ExpoCité, Salle André-Gagnon, Palais des congrès, salles régionales…).7# Source : nouvelle plateforme SPA par tenant <org>.ticketpro.ca dont8# l'API JSON est PUBLIQUE et sans anti-bot (vérifié 2026-08-22,9# robots.txt : seuls /admin/ /gui/ /report/ interdits) :10# GET /api/v1/pages = 1 fiche par spectacle avec titre, artiste,11# sous-titre, description, image, lieu COMPLET (adresse, ville,12# province, code postal, lat/lng), TOUTES les dates horodatées13# des représentations, tags (catégories) et drapeaux14# cancelled/soldout. AUCUN rendu JS ni Scrapfly nécessaire.15# Extraction: 1 GET /api/v1/pages par tenant (6 tenants QC configurés dans16# data/ticketpro_tenants.json — tproqc agrège la province mais17# PAS tprotsd, vérifié) → 1 Event par (page, date à venir),18# dédup inter-tenants par id de page (ids globaux). Les lieux19# de restauration (soupers-spectacles) et le musée à créneaux20# sont exclus par config (exclure_lieux). Filtre province:21# provinceState == QC. startingPrice et prices[] sont toujours22# vides dans la LISTE, mais les tarifs réels sont publics en23# 2 GET par spectacle (vérifié 2026-08-25) :24# /api/v1/pages/<id> (détail → itemsDates.items[].dates[] =25# séances avec saleState + URL relative de l'offre) puis26# /api/v1<url de la 1re séance à venir> (?options=prices) →27# itemHours[].priceLevels[].price_min en CENTS (taxes et28# frais inclus : taxIncluded/serviceChargesIncluded=true).29# Prix plancher de la page appliqué à ses séances + statut30# PAR séance (saleState SOLDOUT/CANCELLED…). Enrichissement31# incrémental : cache data/ticketpro_cache.json,32# TP_PRICE_MAX pages/sync, re-visite 3 j, version de parse33# « v » (patron lavitrine).34# Accès : billetterie publique, API du site officiel, GET throttlés35# 1 s, identification honnête, lien d'achat vers la fiche36# officielle /fr/pages/<id> du tenant.37# -----------------------------------------------------------------------------38from __future__ import annotations3940import json41import os42import re43import time44from datetime import date45from pathlib import Path4647from ..normalize import clean_text, strip_accents48from ..schema import Event, normalize_status49from .base import BaseConnector5051CONFIG_PATH = Path(__file__).resolve().parents[2] / "data" / "ticketpro_tenants.json"52# enrichissement tarifs : 2 GET par page (détail + offre de la 1re séance à53# venir) — plafonné par sync, re-visité pour suivre soldout/changements de prix54PRICE_MAX = int(os.environ.get("TP_PRICE_MAX", "40"))55REFRESH_AFTER = 3 * 8640056PRICE_V = 1 # version du parseur d'offre (bump = re-visite)5758_TAG_RE = re.compile(r"<[^>]+>")59_BLOCK_RE = re.compile(r"<(style|script)[^>]*>.*?</\1>", re.S | re.I)60# CSS inline hors balise (les fiches en contiennent parfois du résiduel)61_CSS_RE = re.compile(r"(?:@media[^{]*|[\w\.\#\-]+\s*)\{[^}]*\}")62_DATE_RE = re.compile(r"^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2})")636465def _strip_html(raw: str) -> str:66 """HTML de description → texte plat (les fiches sont en HTML riche)."""67 txt = _BLOCK_RE.sub(" ", raw or "")68 txt = _CSS_RE.sub(" ", _TAG_RE.sub(" ", txt))69 return clean_text(txt)707172def _lieu_key(name: str) -> str:73 return strip_accents(clean_text(name or "")).lower()747576class TicketproConnector(BaseConnector):77 source_id = "ticketpro"78 request_delay = 1.07980 def _load_config(self) -> dict:81 try:82 return json.loads(CONFIG_PATH.read_text(encoding="utf-8"))83 except Exception:84 return {"tenants": [], "exclure_lieux": []}8586 # -- tarifs & statuts par séance (2 GET par page, incrémental) -------------87 def _fetch_prices(self, tenant: str, page_id) -> dict:88 """{price_min: float|None, states: {"AAAA-MM-JJTHH:MM": saleState}}.8990 Détail /api/v1/pages/<id> → itemsDates (séances + saleState) ; l'offre91 de la 1re séance à venir (?options=prices) donne les priceLevels en92 cents. Le plancher d'une page vaut pour ses séances (patron93 ticketacces : prix plancher de la fiche). Jamais inventé : pages sans94 itemsDates (cartes-cadeaux…) ou sans priceLevels → aucun prix.95 """96 detail = self.get_json(97 f"https://{tenant}.ticketpro.ca/api/v1/pages/{page_id}")98 seances = [d for item in (detail.get("itemsDates") or {}).get("items")99 or [] for d in item.get("dates") or []]100 states = {str(s.get("date"))[:16]: str(s.get("saleState") or "")101 for s in seances if s.get("date")}102 today = date.today().isoformat()103 upcoming = sorted((s for s in seances104 if s.get("url") and str(s.get("date"))[:10] >= today),105 key=lambda s: str(s.get("date")))106 # 1re séance encore en vente, sinon 1re à venir (offre = mêmes niveaux)107 best = next((s for s in upcoming if s.get("saleState") == "ONSALE"),108 upcoming[0] if upcoming else None)109 price_min = None110 if best is not None:111 offer = self.get_json(112 f"https://{tenant}.ticketpro.ca/api/v1{best['url']}")113 cents = [pl.get("price_min")114 for ih in offer.get("itemHours") or []115 for pl in ih.get("priceLevels") or []]116 cents = [c for c in cents if isinstance(c, (int, float)) and c > 0]117 if cents:118 price_min = round(min(cents) / 100, 2)119 return {"price_min": price_min, "states": states}120121 def _events_from_page(self, tenant: str, page: dict, excluded: list[str],122 enrich: dict | None = None) -> list[Event]:123 title = clean_text(page.get("title") or "")124 dates = page.get("dates") or []125 loc = (page.get("locations") or [{}])[0] or {}126 if not title or not dates:127 return []128 if (loc.get("provinceState") or "QC").upper() != "QC":129 return [] # tenant QC mais salle hors province130 venue_key = _lieu_key(loc.get("name") or "")131 if any(x in venue_key for x in excluded):132 return [] # restauration / musée à créneaux133134 page_id = page.get("id")135 url = f"https://{tenant}.ticketpro.ca/fr/pages/{page.get('slug') or page_id}"136 image = page.get("imageUrlBig") or page.get("imageUrlSmall") or ""137 if image and not image.startswith("http"):138 # « /v1/resources/… » → servi par l'API du tenant (vérifié 200)139 image = f"https://{tenant}.ticketpro.ca/api{image}"140 status = ("cancelled" if page.get("cancelled")141 else "soldout" if page.get("soldout") else "")142 artist = clean_text(page.get("artist") or "")143 raw_cats = [t.get("smallName") or t.get("name") or ""144 for t in page.get("tags") or []]145 description = _strip_html(page.get("description")146 or page.get("miniDescription") or "")147148 enrich = enrich or {}149 price_min = enrich.get("price_min")150 states = enrich.get("states") or {}151152 today = date.today().isoformat()153 events: list[Event] = []154 for d in dates:155 m = _DATE_RE.match(str(d))156 if not m:157 continue158 day, hh, mm = m.group(1), m.group(2), m.group(3)159 if day < today: # l'API conserve les dates passées160 continue161 # statut PAR séance (saleState SOLDOUT/CANCELLED…) quand la page162 # ne porte pas déjà un drapeau global — jamais inventé. Certaines163 # séances du détail sont datées SANS heure (constaté 2026-08-25,164 # ~10 % des clés) : leur saleState vaut alors pour la journée.165 seance_status = status or normalize_status(166 states.get(f"{day}T{hh}:{mm}") or states.get(day, ""))167 events.append(Event(168 source=self.source_id,169 external_id=f"{page_id}-{day}T{hh}{mm}",170 url=url,171 title=title,172 description=description,173 raw_categories=[c for c in raw_cats if c] or [title],174 venue=clean_text(loc.get("name") or ""),175 address=clean_text(loc.get("addressLine1") or ""),176 city=clean_text(loc.get("city") or ""),177 postal_code=clean_text(loc.get("postalCode") or ""),178 lat=loc.get("latitude"),179 lng=loc.get("longitude"),180 start_date=day,181 start_time=f"{hh}:{mm}",182 status=seance_status,183 artists=[artist] if artist else [],184 is_free=False if price_min else None,185 price_min=price_min,186 image=image,187 ))188 return events189190 def fetch(self) -> list[Event]:191 config = self._load_config()192 tenants = [t["domaine"] for t in config.get("tenants", [])]193 excluded = [_lieu_key(x) for x in config.get("exclure_lieux", [])]194 if not tenants:195 return []196197 cache = self.load_cache()198 now = time.time()199 budget = PRICE_MAX200 today = date.today().isoformat()201202 events: list[Event] = []203 seen_pages: set = set() # ids de page globaux inter-tenants204 for tenant in tenants:205 try:206 data = self.get_json(207 f"https://{tenant}.ticketpro.ca/api/v1/pages")208 except Exception as exc: # un tenant en panne ne bloque pas209 print(f"[sorti-ka] ticketpro : tenant {tenant} ignoré ({exc})")210 continue211 for page in data.get("pages") or []:212 if page.get("id") in seen_pages:213 continue214 seen_pages.add(page.get("id"))215 # tarifs & statuts par séance : incrémental, plafonné/sync —216 # seulement pour les pages ayant au moins une séance à venir217 key = str(page.get("id"))218 entry = cache.get(key)219 has_future = any(str(d)[:10] >= today220 for d in page.get("dates") or [])221 stale = (entry is None or entry.get("v", 0) < PRICE_V222 or now - entry.get("ts", 0) > REFRESH_AFTER)223 if budget > 0 and has_future and stale:224 budget -= 1225 try:226 entry = self._fetch_prices(tenant, page.get("id"))227 except Exception as exc:228 print(f"[sorti-ka] ticketpro : tarifs {tenant}/{key} "229 f"ignorés ({exc})")230 entry = {"price_min": None, "states": {}}231 entry.update(ts=now, v=PRICE_V)232 cache[key] = entry233 events += self._events_from_page(tenant, page, excluded, entry)234 # ne garder que les pages encore publiées (le cache suit la source)235 cache = {k: v for k, v in cache.items()236 if k in {str(p) for p in seen_pages}}237 self.save_cache(cache)238 return events239