# ----------------------------------------------------------------------------- # Sorti-Ka — Agrégateur de sorties & événements (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/ticketpro.py : Ticketpro — billetterie canadienne, tenants Québec # (Phase 5). Grande billetterie indépendante (Théâtre St-Denis, Fun Nation, # ExpoCité, Salle André-Gagnon, Palais des congrès, salles régionales…). # Source : nouvelle plateforme SPA par tenant .ticketpro.ca dont # l'API JSON est PUBLIQUE et sans anti-bot (vérifié 2026-08-22, # robots.txt : seuls /admin/ /gui/ /report/ interdits) : # GET /api/v1/pages = 1 fiche par spectacle avec titre, artiste, # sous-titre, description, image, lieu COMPLET (adresse, ville, # province, code postal, lat/lng), TOUTES les dates horodatées # des représentations, tags (catégories) et drapeaux # cancelled/soldout. AUCUN rendu JS ni Scrapfly nécessaire. # Extraction: 1 GET /api/v1/pages par tenant (6 tenants QC configurés dans # data/ticketpro_tenants.json — tproqc agrège la province mais # PAS tprotsd, vérifié) → 1 Event par (page, date à venir), # dédup inter-tenants par id de page (ids globaux). Les lieux # de restauration (soupers-spectacles) et le musée à créneaux # sont exclus par config (exclure_lieux). Filtre province: # provinceState == QC. startingPrice et prices[] sont toujours # vides dans la LISTE, mais les tarifs réels sont publics en # 2 GET par spectacle (vérifié 2026-08-25) : # /api/v1/pages/ (détail → itemsDates.items[].dates[] = # séances avec saleState + URL relative de l'offre) puis # /api/v1 (?options=prices) → # itemHours[].priceLevels[].price_min en CENTS (taxes et # frais inclus : taxIncluded/serviceChargesIncluded=true). # Prix plancher de la page appliqué à ses séances + statut # PAR séance (saleState SOLDOUT/CANCELLED…). Enrichissement # incrémental : cache data/ticketpro_cache.json, # TP_PRICE_MAX pages/sync, re-visite 3 j, version de parse # « v » (patron lavitrine). # Accès : billetterie publique, API du site officiel, GET throttlés # 1 s, identification honnête, lien d'achat vers la fiche # officielle /fr/pages/ du tenant. # ----------------------------------------------------------------------------- from __future__ import annotations import json import os import re import time from datetime import date from pathlib import Path from ..normalize import clean_text, strip_accents from ..schema import Event, normalize_status from .base import BaseConnector CONFIG_PATH = Path(__file__).resolve().parents[2] / "data" / "ticketpro_tenants.json" # enrichissement tarifs : 2 GET par page (détail + offre de la 1re séance à # venir) — plafonné par sync, re-visité pour suivre soldout/changements de prix PRICE_MAX = int(os.environ.get("TP_PRICE_MAX", "40")) REFRESH_AFTER = 3 * 86400 PRICE_V = 1 # version du parseur d'offre (bump = re-visite) _TAG_RE = re.compile(r"<[^>]+>") _BLOCK_RE = re.compile(r"<(style|script)[^>]*>.*?", re.S | re.I) # CSS inline hors balise (les fiches en contiennent parfois du résiduel) _CSS_RE = re.compile(r"(?:@media[^{]*|[\w\.\#\-]+\s*)\{[^}]*\}") _DATE_RE = re.compile(r"^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2})") def _strip_html(raw: str) -> str: """HTML de description → texte plat (les fiches sont en HTML riche).""" txt = _BLOCK_RE.sub(" ", raw or "") txt = _CSS_RE.sub(" ", _TAG_RE.sub(" ", txt)) return clean_text(txt) def _lieu_key(name: str) -> str: return strip_accents(clean_text(name or "")).lower() class TicketproConnector(BaseConnector): source_id = "ticketpro" request_delay = 1.0 def _load_config(self) -> dict: try: return json.loads(CONFIG_PATH.read_text(encoding="utf-8")) except Exception: return {"tenants": [], "exclure_lieux": []} # -- tarifs & statuts par séance (2 GET par page, incrémental) ------------- def _fetch_prices(self, tenant: str, page_id) -> dict: """{price_min: float|None, states: {"AAAA-MM-JJTHH:MM": saleState}}. Détail /api/v1/pages/ → itemsDates (séances + saleState) ; l'offre de la 1re séance à venir (?options=prices) donne les priceLevels en cents. Le plancher d'une page vaut pour ses séances (patron ticketacces : prix plancher de la fiche). Jamais inventé : pages sans itemsDates (cartes-cadeaux…) ou sans priceLevels → aucun prix. """ detail = self.get_json( f"https://{tenant}.ticketpro.ca/api/v1/pages/{page_id}") seances = [d for item in (detail.get("itemsDates") or {}).get("items") or [] for d in item.get("dates") or []] states = {str(s.get("date"))[:16]: str(s.get("saleState") or "") for s in seances if s.get("date")} today = date.today().isoformat() upcoming = sorted((s for s in seances if s.get("url") and str(s.get("date"))[:10] >= today), key=lambda s: str(s.get("date"))) # 1re séance encore en vente, sinon 1re à venir (offre = mêmes niveaux) best = next((s for s in upcoming if s.get("saleState") == "ONSALE"), upcoming[0] if upcoming else None) price_min = None if best is not None: offer = self.get_json( f"https://{tenant}.ticketpro.ca/api/v1{best['url']}") cents = [pl.get("price_min") for ih in offer.get("itemHours") or [] for pl in ih.get("priceLevels") or []] cents = [c for c in cents if isinstance(c, (int, float)) and c > 0] if cents: price_min = round(min(cents) / 100, 2) return {"price_min": price_min, "states": states} def _events_from_page(self, tenant: str, page: dict, excluded: list[str], enrich: dict | None = None) -> list[Event]: title = clean_text(page.get("title") or "") dates = page.get("dates") or [] loc = (page.get("locations") or [{}])[0] or {} if not title or not dates: return [] if (loc.get("provinceState") or "QC").upper() != "QC": return [] # tenant QC mais salle hors province venue_key = _lieu_key(loc.get("name") or "") if any(x in venue_key for x in excluded): return [] # restauration / musée à créneaux page_id = page.get("id") url = f"https://{tenant}.ticketpro.ca/fr/pages/{page.get('slug') or page_id}" image = page.get("imageUrlBig") or page.get("imageUrlSmall") or "" if image and not image.startswith("http"): # « /v1/resources/… » → servi par l'API du tenant (vérifié 200) image = f"https://{tenant}.ticketpro.ca/api{image}" status = ("cancelled" if page.get("cancelled") else "soldout" if page.get("soldout") else "") artist = clean_text(page.get("artist") or "") raw_cats = [t.get("smallName") or t.get("name") or "" for t in page.get("tags") or []] description = _strip_html(page.get("description") or page.get("miniDescription") or "") enrich = enrich or {} price_min = enrich.get("price_min") states = enrich.get("states") or {} today = date.today().isoformat() events: list[Event] = [] for d in dates: m = _DATE_RE.match(str(d)) if not m: continue day, hh, mm = m.group(1), m.group(2), m.group(3) if day < today: # l'API conserve les dates passées continue # statut PAR séance (saleState SOLDOUT/CANCELLED…) quand la page # ne porte pas déjà un drapeau global — jamais inventé. Certaines # séances du détail sont datées SANS heure (constaté 2026-08-25, # ~10 % des clés) : leur saleState vaut alors pour la journée. seance_status = status or normalize_status( states.get(f"{day}T{hh}:{mm}") or states.get(day, "")) events.append(Event( source=self.source_id, external_id=f"{page_id}-{day}T{hh}{mm}", url=url, title=title, description=description, raw_categories=[c for c in raw_cats if c] or [title], venue=clean_text(loc.get("name") or ""), address=clean_text(loc.get("addressLine1") or ""), city=clean_text(loc.get("city") or ""), postal_code=clean_text(loc.get("postalCode") or ""), lat=loc.get("latitude"), lng=loc.get("longitude"), start_date=day, start_time=f"{hh}:{mm}", status=seance_status, artists=[artist] if artist else [], is_free=False if price_min else None, price_min=price_min, image=image, )) return events def fetch(self) -> list[Event]: config = self._load_config() tenants = [t["domaine"] for t in config.get("tenants", [])] excluded = [_lieu_key(x) for x in config.get("exclure_lieux", [])] if not tenants: return [] cache = self.load_cache() now = time.time() budget = PRICE_MAX today = date.today().isoformat() events: list[Event] = [] seen_pages: set = set() # ids de page globaux inter-tenants for tenant in tenants: try: data = self.get_json( f"https://{tenant}.ticketpro.ca/api/v1/pages") except Exception as exc: # un tenant en panne ne bloque pas print(f"[sorti-ka] ticketpro : tenant {tenant} ignoré ({exc})") continue for page in data.get("pages") or []: if page.get("id") in seen_pages: continue seen_pages.add(page.get("id")) # tarifs & statuts par séance : incrémental, plafonné/sync — # seulement pour les pages ayant au moins une séance à venir key = str(page.get("id")) entry = cache.get(key) has_future = any(str(d)[:10] >= today for d in page.get("dates") or []) stale = (entry is None or entry.get("v", 0) < PRICE_V or now - entry.get("ts", 0) > REFRESH_AFTER) if budget > 0 and has_future and stale: budget -= 1 try: entry = self._fetch_prices(tenant, page.get("id")) except Exception as exc: print(f"[sorti-ka] ticketpro : tarifs {tenant}/{key} " f"ignorés ({exc})") entry = {"price_min": None, "states": {}} entry.update(ts=now, v=PRICE_V) cache[key] = entry events += self._events_from_page(tenant, page, excluded, entry) # ne garder que les pages encore publiées (le cache suit la source) cache = {k: v for k, v in cache.items() if k in {str(p) for p in seen_pages}} self.save_cache(cache) return events