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/ticketacces.py : TicketAcces.net — billetterie régionale5# québécoise (Phase 4). Gros levier régional : 47 organisations au Québec6# dont l'Abitibi-Témiscamingue (14 !), le Bas-Saint-Laurent, la Côte-Nord,7# Charlevoix, la Gaspésie… (sondé 2026-08-21).8# Source : https://www.ticketacces.net (ColdFusion, encodage cp1252).9# L'annuaire COMPLET des organisations est dans le portail10# (cartes data-nom/data-ville/data-region/data-domaine) ; le11# calendrier AGRÉGÉ multi-organisations expose chaque12# représentation datée en 4 requêtes (catégories 1=Spectacles,13# 2=Cinéma, 3=Sport, 4=Autre).14# Extraction: portail (1 GET, annuaire + filtre Québec — le réseau compte15# 12 orgs au Nouveau-Brunswick et 1 en Ontario, EXCLUES) →16# calendrier par catégorie (4 GET) : titre + sous-titre, date17# et heure FR (« Samedi 22 août 2026 à 12 h 30 »), « Salle,18# Ville », image, lien fiche. Fiches détail incrémentales19# (cache data/ticketacces_cache.json, TA_DETAIL_MAX/sync —20# 47 par défaut = 1 fiche par organisation/hôte, re-visite21# 7 j, version de parse « v » bumpée quand le parseur22# s'enrichit → re-visite progressive, patron lavitrine) :23# og:description, adresse civique de la salle (bloc24# « Salle | 315, rue X, Ville » des représentations, ville25# finale retirée), prix plancher si les ventes sont26# ouvertes. 1 Event par représentation (une même fiche27# multi-dates = plusieurs séances, patron lepointdevente).28# Accès : billetterie publique. robots.txt (lu 2026-08-21, identique29# sur tous les hôtes *.ticketacces.net) : Crawl-delay: 120 +30# Disallow /achat/ et representations/prix.cfm (jamais31# requêtés) → GET espacés de 120 s PAR HÔTE (www porte le32# portail + les 4 calendriers = 5 GET ≈ 8 min ; les fiches33# vivent sur ~47 sous-domaines distincts, ≤ 1 fiche par hôte34# par passage). Le passage réseau complet n'a lieu qu'à35# l'expiration d'un TTL (TA_TTL_HOURS, 6 h) ; entre deux36# passages, le connecteur réémet son cache. Cookie de détection37# ColdFusion géré par la session (302 suivi). Lien d'achat =38# la fiche officielle chez la source.39# -----------------------------------------------------------------------------40from __future__ import annotations4142import os43import re44import time4546from ..normalize import clean_text, parse_date_fr, parse_time47from ..schema import Event48from .base import BaseConnector4950PORTAL = "https://www.ticketacces.net/fr/portail/index.cfm"51CALENDAR = ("https://www.ticketacces.net/fr/portail/evenements/calendrier.cfm"52 "?RepresentationCategoriePrincipaleID={cat}")53CATEGORIES = {1: "Spectacles", 2: "Cinéma", 3: "Sport", 4: "Autre"}54# 47 organisations/hôtes au Québec, ≤ 1 fiche par hôte par passage → 47 fiches55# par défaut = un passage complet sans jamais attendre le Crawl-delay par hôte56DETAIL_MAX = int(os.environ.get("TA_DETAIL_MAX", "47"))57REFRESH_AFTER = 7 * 8640058DETAIL_V = 2 # version du parseur de fiche (bump = re-visite)59TTL = float(os.environ.get("TA_TTL_HOURS", "6")) * 360060HOST_DELAY = 120.0 # robots.txt : Crawl-delay: 120 (par hôte)6162_ORG_RE = re.compile(63 r'data-nom="([^"]*)"\s+data-ville="([^"]*)"\s+data-region="([^"]*)"\s+'64 r'data-domaine="([a-z0-9-]+)\.ticketacces\.net"')65_ROW_RE = re.compile(66 r'<a href="https://([a-z0-9-]+)\.ticketacces\.net/fr/organisation/'67 r'representations/index\.cfm\?EvenementID=(\d+)">'68 r'([^<]+)(?:<br\s*/?>([^<]*))?</a>\s*</h3>\s*<p>\s*'69 r'<strong>([^<]+)</strong>\s*<br\s*/?>\s*([^<]+?)\s*</p>', re.S)70_IMG_RE = re.compile(71 r'EvenementID=(\d+)">\s*<div class="image-crop[^"]*">\s*'72 r'<img src="([^"]+)"', re.S)73_OG_DESC_RE = re.compile(r'property="og:description" content="([^"]*)"')74_PRICE_RE = re.compile(r"(\d+(?:,\d{2}))\s*\$")75# adresse civique de la salle dans les blocs de représentation (« Théâtre de76# la Bordée | 315, rue Saint-Joseph Est, Québec ») : segment commençant par77# un numéro civique ET contenant un mot de voirie — la version 1 du parseur78# ne testait que le PREMIER segment numérique de la page (souvent un titre79# « 5 BALLES… » ou une durée « 1h40 ») → 3 adresses sur 317 fiches.80_ADDR_SEG_RE = re.compile(r"\|\s*(\d{1,5}(?:,)?\s[^|]{3,90}?)\s*\|")81_STREET_RE = re.compile(82 r"\b(rue|avenue|av\.|boulevard|boul\.|chemin|ch\.|route|rang|place|"83 r"mont[ée]e|c[oô]te|carr[ée]|promenade|all[ée]e)\b", re.I)84# hors Québec (constaté 2026-08-21) : 12 organisations au Nouveau-Brunswick85# (comtés) + 1 en Ontario (« Ottawa et sa campagne » = Hawkesbury) — les86# libellés de région du portail n'utilisent pas les noms de provinces.87_EXCLUDE_REGIONS = (88 "comte de gloucester", "comte de madawaska", "comte de restigouche",89 "kent", "peninsule acadienne", "ottawa et sa campagne",90 "nouveau brunswick", "ontario",91)929394class TicketAccesConnector(BaseConnector):95 source_id = "ticketacces"96 request_delay = 1.0 # politesse entre HÔTES différents9798 def __init__(self) -> None:99 super().__init__()100 self._host_last: dict[str, float] = {}101102 def _get_cp1252(self, url: str) -> str:103 """GET décodé cp1252 (ColdFusion), Crawl-delay: 120 honoré PAR HÔTE."""104 host = url.split("/")[2]105 wait = HOST_DELAY - (time.time() - self._host_last.get(host, 0.0))106 if wait > 0:107 time.sleep(wait)108 resp = self.get(url)109 self._host_last[host] = time.time()110 return resp.content.decode("cp1252", errors="replace")111112 def _orgs_quebec(self) -> dict[str, dict]:113 """{sous-domaine: {nom, ville, region}} des organisations AU QUÉBEC."""114 html = self._get_cp1252(PORTAL)115 orgs: dict[str, dict] = {}116 from ..regions import _k117 for nom, ville, region, dom in _ORG_RE.findall(html):118 if _k(region) in _EXCLUDE_REGIONS:119 continue120 orgs[dom] = {"nom": clean_text(nom), "ville": clean_text(ville),121 "region": clean_text(region)}122 return orgs123124 def _parse_detail(self, html: str) -> dict:125 desc = _OG_DESC_RE.search(html)126 # bloc coordonnées de la salle : « |La Chapelle| 620, avenue Plante| … »127 plain = re.sub(r"<[^>]+>", "|", html)128 plain = re.sub(r"[\t\r\n ]+", " ", plain)129 addr = ""130 for m in _ADDR_SEG_RE.finditer(plain):131 seg = m.group(1)132 if not _STREET_RE.search(seg):133 continue # « 1h40 Récit… », « 5 BALLES… »134 seg = clean_text(seg)135 # la source suffixe souvent la ville (« …, Québec ») : on la136 # retire si la fin n'a ni chiffre ni mot de voirie — l'Event137 # porte déjà la ville, geocode.py préfère l'adresse nue138 head, _, tail = seg.rpartition(",")139 if head and not any(c.isdigit() for c in tail) \140 and not _STREET_RE.search(tail):141 seg = head.strip()142 addr = seg143 break144 # « 0,00 $ » = ligne tarifaire vide (constaté live 2026-08-25, lsq) —145 # jamais un prix plancher réel, on l'écarte (patron ticketpro c > 0)146 prices = [float(p.replace(",", ".")) for p in _PRICE_RE.findall(plain)]147 prices = [p for p in prices if p > 0]148 return {149 "description": clean_text(desc.group(1)) if desc else "",150 "address": addr,151 "price_min": min(prices) if prices else None,152 }153154 def fetch(self) -> list[Event]:155 cache = self.load_cache()156 now = time.time()157 meta = cache.get("_meta") or {}158 if now - meta.get("ts", 0) <= TTL and cache.get("_rows"):159 return self._emit(cache) # cache frais : aucun réseau160161 orgs = self._orgs_quebec()162 if not orgs:163 raise RuntimeError("ticketacces : annuaire du portail vide")164165 rows: dict[str, dict] = {} # clé de séance → ligne166 for cat, label in CATEGORIES.items():167 try:168 html = self._get_cp1252(CALENDAR.format(cat=cat))169 except Exception:170 print(f"[sorti-ka] ticketacces : calendrier {label} inaccessible")171 continue172 images = dict(_IMG_RE.findall(html))173 for org, evid, title, subtitle, date_raw, place in _ROW_RE.findall(html):174 if org not in orgs: # hors Québec ou org inconnue175 continue176 date_iso = parse_date_fr(date_raw)177 heure = parse_time(date_raw)178 if not date_iso:179 continue180 venue, city = "", orgs[org]["ville"]181 place = clean_text(place)182 if "," in place:183 venue, city = (p.strip() for p in place.rsplit(",", 1))184 elif place:185 venue = place186 key = f"{org}-{evid}-{date_iso}-{heure or ''}"187 if key in rows: # le HTML responsive duplique188 continue189 rows[key] = {190 "external_id": key,191 "org": org, "evid": evid,192 "url": (f"https://{org}.ticketacces.net/fr/organisation/"193 f"representations/index.cfm?EvenementID={evid}"),194 "title": clean_text(title),195 "subtitle": clean_text(subtitle or ""),196 "raw_category": label,197 "venue": venue, "city": city,198 "region": orgs[org]["region"],199 "organizer": orgs[org]["nom"],200 "start_date": date_iso, "start_time": heure,201 "image": images.get(evid, ""),202 }203204 # fiches détail incrémentales (description, adresse, prix plancher) —205 # Crawl-delay par hôte : au plus UNE fiche par organisation par passage206 fiches = {f"{r['org']}-{r['evid']}": (r["org"], r["url"])207 for r in rows.values()}208 details = {k: v for k, v in cache.items()209 if k not in ("_meta", "_rows") and k in fiches}210 # jamais vue > parseur périmé (v < DETAIL_V, patron lavitrine) > plus211 # ancienne — le bump de DETAIL_V re-visite progressivement le stock212 stale = sorted(213 (k for k in fiches214 if k not in details215 or details[k].get("v", 1) < DETAIL_V216 or now - details[k].get("ts", 0) > REFRESH_AFTER),217 key=lambda k: (details.get(k, {}).get("v", 1) if k in details else 0,218 details.get(k, {}).get("ts", 0)))219 seen_hosts: set[str] = set()220 picked: list[str] = []221 for k in stale:222 org = fiches[k][0]223 if org in seen_hosts:224 continue225 seen_hosts.add(org)226 picked.append(k)227 if len(picked) >= DETAIL_MAX:228 break229 for k in picked:230 try:231 detail = self._parse_detail(self._get_cp1252(fiches[k][1]))232 except Exception:233 detail = {}234 details[k] = {"ts": now, "v": DETAIL_V, "detail": detail}235236 cache = dict(details)237 cache["_meta"] = {"ts": now}238 cache["_rows"] = list(rows.values())239 self.save_cache(cache)240 return self._emit(cache)241242 def _emit(self, cache: dict) -> list[Event]:243 events: list[Event] = []244 for r in cache.get("_rows", []):245 detail = cache.get(f"{r['org']}-{r['evid']}", {}).get("detail") or {}246 title = r["title"]247 if r["subtitle"]:248 title = f"{title} — {r['subtitle']}"249 events.append(Event(250 source=self.source_id,251 external_id=r["external_id"],252 url=r["url"],253 title=title,254 description=detail.get("description", ""),255 raw_categories=[r["raw_category"]],256 venue=r["venue"],257 address=detail.get("address", ""),258 city=r["city"],259 region=r["region"], # gardée si déjà administrative260 tourist_region=r["region"], # sinon Charlevoix/Bellechasse…261 start_date=r["start_date"],262 start_time=r["start_time"],263 price_min=detail.get("price_min"),264 organizer=r["organizer"],265 image=r["image"],266 ))267 return events268