# -----------------------------------------------------------------------------
# Sorti-Ka — Agrégateur de sorties & événements (province de Québec)
# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
# connectors/ticketacces.py : TicketAcces.net — billetterie régionale
# québécoise (Phase 4). Gros levier régional : 47 organisations au Québec
# dont l'Abitibi-Témiscamingue (14 !), le Bas-Saint-Laurent, la Côte-Nord,
# Charlevoix, la Gaspésie… (sondé 2026-08-21).
# Source : https://www.ticketacces.net (ColdFusion, encodage cp1252).
# L'annuaire COMPLET des organisations est dans le portail
# (cartes data-nom/data-ville/data-region/data-domaine) ; le
# calendrier AGRÉGÉ multi-organisations expose chaque
# représentation datée en 4 requêtes (catégories 1=Spectacles,
# 2=Cinéma, 3=Sport, 4=Autre).
# Extraction: portail (1 GET, annuaire + filtre Québec — le réseau compte
# 12 orgs au Nouveau-Brunswick et 1 en Ontario, EXCLUES) →
# calendrier par catégorie (4 GET) : titre + sous-titre, date
# et heure FR (« Samedi 22 août 2026 à 12 h 30 »), « Salle,
# Ville », image, lien fiche. Fiches détail incrémentales
# (cache data/ticketacces_cache.json, TA_DETAIL_MAX/sync —
# 47 par défaut = 1 fiche par organisation/hôte, re-visite
# 7 j, version de parse « v » bumpée quand le parseur
# s'enrichit → re-visite progressive, patron lavitrine) :
# og:description, adresse civique de la salle (bloc
# « Salle | 315, rue X, Ville » des représentations, ville
# finale retirée), prix plancher si les ventes sont
# ouvertes. 1 Event par représentation (une même fiche
# multi-dates = plusieurs séances, patron lepointdevente).
# Accès : billetterie publique. robots.txt (lu 2026-08-21, identique
# sur tous les hôtes *.ticketacces.net) : Crawl-delay: 120 +
# Disallow /achat/ et representations/prix.cfm (jamais
# requêtés) → GET espacés de 120 s PAR HÔTE (www porte le
# portail + les 4 calendriers = 5 GET ≈ 8 min ; les fiches
# vivent sur ~47 sous-domaines distincts, ≤ 1 fiche par hôte
# par passage). Le passage réseau complet n'a lieu qu'à
# l'expiration d'un TTL (TA_TTL_HOURS, 6 h) ; entre deux
# passages, le connecteur réémet son cache. Cookie de détection
# ColdFusion géré par la session (302 suivi). Lien d'achat =
# la fiche officielle chez la source.
# -----------------------------------------------------------------------------
from __future__ import annotations
import os
import re
import time
from ..normalize import clean_text, parse_date_fr, parse_time
from ..schema import Event
from .base import BaseConnector
PORTAL = "https://www.ticketacces.net/fr/portail/index.cfm"
CALENDAR = ("https://www.ticketacces.net/fr/portail/evenements/calendrier.cfm"
"?RepresentationCategoriePrincipaleID={cat}")
CATEGORIES = {1: "Spectacles", 2: "Cinéma", 3: "Sport", 4: "Autre"}
# 47 organisations/hôtes au Québec, ≤ 1 fiche par hôte par passage → 47 fiches
# par défaut = un passage complet sans jamais attendre le Crawl-delay par hôte
DETAIL_MAX = int(os.environ.get("TA_DETAIL_MAX", "47"))
REFRESH_AFTER = 7 * 86400
DETAIL_V = 2 # version du parseur de fiche (bump = re-visite)
TTL = float(os.environ.get("TA_TTL_HOURS", "6")) * 3600
HOST_DELAY = 120.0 # robots.txt : Crawl-delay: 120 (par hôte)
_ORG_RE = re.compile(
r'data-nom="([^"]*)"\s+data-ville="([^"]*)"\s+data-region="([^"]*)"\s+'
r'data-domaine="([a-z0-9-]+)\.ticketacces\.net"')
_ROW_RE = re.compile(
r''
r'([^<]+)(?:
([^<]*))?\s*\s*
\s*'
r'

None:
super().__init__()
self._host_last: dict[str, float] = {}
def _get_cp1252(self, url: str) -> str:
"""GET décodé cp1252 (ColdFusion), Crawl-delay: 120 honoré PAR HÔTE."""
host = url.split("/")[2]
wait = HOST_DELAY - (time.time() - self._host_last.get(host, 0.0))
if wait > 0:
time.sleep(wait)
resp = self.get(url)
self._host_last[host] = time.time()
return resp.content.decode("cp1252", errors="replace")
def _orgs_quebec(self) -> dict[str, dict]:
"""{sous-domaine: {nom, ville, region}} des organisations AU QUÉBEC."""
html = self._get_cp1252(PORTAL)
orgs: dict[str, dict] = {}
from ..regions import _k
for nom, ville, region, dom in _ORG_RE.findall(html):
if _k(region) in _EXCLUDE_REGIONS:
continue
orgs[dom] = {"nom": clean_text(nom), "ville": clean_text(ville),
"region": clean_text(region)}
return orgs
def _parse_detail(self, html: str) -> dict:
desc = _OG_DESC_RE.search(html)
# bloc coordonnées de la salle : « |La Chapelle| 620, avenue Plante| … »
plain = re.sub(r"<[^>]+>", "|", html)
plain = re.sub(r"[\t\r\n ]+", " ", plain)
addr = ""
for m in _ADDR_SEG_RE.finditer(plain):
seg = m.group(1)
if not _STREET_RE.search(seg):
continue # « 1h40 Récit… », « 5 BALLES… »
seg = clean_text(seg)
# la source suffixe souvent la ville (« …, Québec ») : on la
# retire si la fin n'a ni chiffre ni mot de voirie — l'Event
# porte déjà la ville, geocode.py préfère l'adresse nue
head, _, tail = seg.rpartition(",")
if head and not any(c.isdigit() for c in tail) \
and not _STREET_RE.search(tail):
seg = head.strip()
addr = seg
break
# « 0,00 $ » = ligne tarifaire vide (constaté live 2026-08-25, lsq) —
# jamais un prix plancher réel, on l'écarte (patron ticketpro c > 0)
prices = [float(p.replace(",", ".")) for p in _PRICE_RE.findall(plain)]
prices = [p for p in prices if p > 0]
return {
"description": clean_text(desc.group(1)) if desc else "",
"address": addr,
"price_min": min(prices) if prices else None,
}
def fetch(self) -> list[Event]:
cache = self.load_cache()
now = time.time()
meta = cache.get("_meta") or {}
if now - meta.get("ts", 0) <= TTL and cache.get("_rows"):
return self._emit(cache) # cache frais : aucun réseau
orgs = self._orgs_quebec()
if not orgs:
raise RuntimeError("ticketacces : annuaire du portail vide")
rows: dict[str, dict] = {} # clé de séance → ligne
for cat, label in CATEGORIES.items():
try:
html = self._get_cp1252(CALENDAR.format(cat=cat))
except Exception:
print(f"[sorti-ka] ticketacces : calendrier {label} inaccessible")
continue
images = dict(_IMG_RE.findall(html))
for org, evid, title, subtitle, date_raw, place in _ROW_RE.findall(html):
if org not in orgs: # hors Québec ou org inconnue
continue
date_iso = parse_date_fr(date_raw)
heure = parse_time(date_raw)
if not date_iso:
continue
venue, city = "", orgs[org]["ville"]
place = clean_text(place)
if "," in place:
venue, city = (p.strip() for p in place.rsplit(",", 1))
elif place:
venue = place
key = f"{org}-{evid}-{date_iso}-{heure or ''}"
if key in rows: # le HTML responsive duplique
continue
rows[key] = {
"external_id": key,
"org": org, "evid": evid,
"url": (f"https://{org}.ticketacces.net/fr/organisation/"
f"representations/index.cfm?EvenementID={evid}"),
"title": clean_text(title),
"subtitle": clean_text(subtitle or ""),
"raw_category": label,
"venue": venue, "city": city,
"region": orgs[org]["region"],
"organizer": orgs[org]["nom"],
"start_date": date_iso, "start_time": heure,
"image": images.get(evid, ""),
}
# fiches détail incrémentales (description, adresse, prix plancher) —
# Crawl-delay par hôte : au plus UNE fiche par organisation par passage
fiches = {f"{r['org']}-{r['evid']}": (r["org"], r["url"])
for r in rows.values()}
details = {k: v for k, v in cache.items()
if k not in ("_meta", "_rows") and k in fiches}
# jamais vue > parseur périmé (v < DETAIL_V, patron lavitrine) > plus
# ancienne — le bump de DETAIL_V re-visite progressivement le stock
stale = sorted(
(k for k in fiches
if k not in details
or details[k].get("v", 1) < DETAIL_V
or now - details[k].get("ts", 0) > REFRESH_AFTER),
key=lambda k: (details.get(k, {}).get("v", 1) if k in details else 0,
details.get(k, {}).get("ts", 0)))
seen_hosts: set[str] = set()
picked: list[str] = []
for k in stale:
org = fiches[k][0]
if org in seen_hosts:
continue
seen_hosts.add(org)
picked.append(k)
if len(picked) >= DETAIL_MAX:
break
for k in picked:
try:
detail = self._parse_detail(self._get_cp1252(fiches[k][1]))
except Exception:
detail = {}
details[k] = {"ts": now, "v": DETAIL_V, "detail": detail}
cache = dict(details)
cache["_meta"] = {"ts": now}
cache["_rows"] = list(rows.values())
self.save_cache(cache)
return self._emit(cache)
def _emit(self, cache: dict) -> list[Event]:
events: list[Event] = []
for r in cache.get("_rows", []):
detail = cache.get(f"{r['org']}-{r['evid']}", {}).get("detail") or {}
title = r["title"]
if r["subtitle"]:
title = f"{title} — {r['subtitle']}"
events.append(Event(
source=self.source_id,
external_id=r["external_id"],
url=r["url"],
title=title,
description=detail.get("description", ""),
raw_categories=[r["raw_category"]],
venue=r["venue"],
address=detail.get("address", ""),
city=r["city"],
region=r["region"], # gardée si déjà administrative
tourist_region=r["region"], # sinon Charlevoix/Bellechasse…
start_date=r["start_date"],
start_time=r["start_time"],
price_min=detail.get("price_min"),
organizer=r["organizer"],
image=r["image"],
))
return events