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/tuxedobillet.py : Tuxedo Billet — billetterie québécoise5# multi-tenants (Phase 4). Reprise de la source écartée en Phase 3 (site en6# panne) : l'apex reste vide mais les tenants <org>.tuxedobillet.com vivent.7# Source : SPA Angular sur Firebase par tenant — RTDB verrouillée (4018# même avec jeton anonyme, vérifié 2026-08-21) → rendu9# navigateur OBLIGATOIRE. L'extraction est déléguée à l'acteur10# Apify maison gorgeous_thistle/ka-tuxedo (Playwright, proxy11# résidentiel CA, code dans apify/ka-tuxedo/) qui rend chaque12# tenant et retourne les tuiles spectacles : titre, sous-titre,13# date FR, image, lien /main/<slug>.14# Extraction: 1 run d'acteur par ≈ TUX_TTL_HOURS (20 h par défaut — le15# rendu JS coûte des crédits Apify ; les programmations de16# salles bougent lentement). Résultat mis en cache disque17# (data/tuxedobillet_cache.json) et réémis entre deux runs.18# Tenants configurés dans data/tuxedo_tenants.json (Québec19# seulement, ville vérifiée).20# FICHES /main/<slug> rendues via Scrapfly render_js (le GET21# direct ne renvoie que la coquille Angular, vérifié22# 2026-08-25) : chaque fiche publie ses SÉANCES (jour + date23# + HEURE « 20H00 » + étiquette « COMPLET »), la catégorie et24# la description → 1 Event par séance datée, heure et statut25# soldout captés. Enrichissement incrémental (clé « fiches »26# du cache, TUX_FICHE_MAX/sync, re-visite 7 j, version de27# parse « v » — patron lavitrine ; v2 : abréviations FR28# « janv./févr. » comprises, tuiles et séances de janvier-29# février récupérées). Les fiches ne publient NI30# prix NI adresse de salle (vérifié : le tarif n'apparaît31# qu'au choix des sièges) → jamais inventés.32# Accès : billetteries publiques ; rendu navigateur modéré (1 page par33# tenant par run Apify ~1/jour + TUX_FICHE_MAX fiches34# Scrapfly/sync throttlées), lien d'achat = la fiche35# officielle du tenant.36# Secrets : APIFY_TOKEN requis (dans .env) — sans lui, le connecteur37# réémet le cache et n'échoue que s'il n'y a aucun cache.38# SCRAPFLY_API_KEY requis pour l'enrichissement des fiches —39# sans lui, la liste seule est émise (comportement d'avant).40# -----------------------------------------------------------------------------41from __future__ import annotations4243import json44import os45import re46import time47from pathlib import Path4849import requests5051from ..normalize import clean_text, parse_date_fr, parse_time, strip_accents52from ..schema import Event, normalize_status53from .base import SCRAPFLY_API, BaseConnector5455ACTOR = "gorgeous_thistle~ka-tuxedo"56RUN_URL = f"https://api.apify.com/v2/acts/{ACTOR}/runs"57CONFIG_PATH = Path(__file__).resolve().parents[2] / "data" / "tuxedo_tenants.json"58TTL = float(os.environ.get("TUX_TTL_HOURS", "20")) * 360059POLL_TIMEOUT = int(os.environ.get("TUX_POLL_TIMEOUT", "1500")) # s60# fiches détail (séances + heure + statut + catégorie + description) rendues61# via Scrapfly : plafond par sync, re-visite 7 j, version de parse « v »62FICHE_MAX = int(os.environ.get("TUX_FICHE_MAX", "20"))63FICHE_REFRESH = 7 * 8640064FICHE_V = 2 # v2 : mois « janv./févr. » compris (séances jan-fév récupérées)65# le tile affiche parfois l'anglais selon le fuseau du proxy — mois EN → FR ;66# + abréviations FR « janv. »/« févr. » (tuiles ET fiches, constaté live67# 2026-08-25) que normalize.parse_date_fr ne connaît pas → séances de68# janvier/février silencieusement écartées sans cette table69_EN_MONTHS = {"jan": "jan", "feb": "fev", "mar": "mars", "apr": "avr",70 "may": "mai", "jun": "juin", "jul": "juil", "aug": "aout",71 "sep": "sept", "oct": "oct", "nov": "nov", "dec": "dec",72 "janv": "jan", "fevr": "fev"}7374# fragments de la fiche rendue (classes stables tuxCmsEdit__webEventSelection)75_F_CAT_RE = re.compile(76 r'webEventSelection--category"[^>]*>\s*<span[^>]*>\s*([^<]+?)\s*</span>')77_F_DESC_RE = re.compile(r'webEventSelection--description"[^>]*>(.*?)</div>',78 re.S)79# un bouton de séance par représentation : jour / date / heure (+ étiquette80# « COMPLET » en surimpression) — découpage par app-event-summary81_F_DATE_RE = re.compile(r'class="date"[^>]*>\s*([^<]+?)\s*<')82_F_HEURE_RE = re.compile(r'class="heure"[^>]*>\s*([^<]+?)\s*<')83_F_OVER_RE = re.compile(r'webEventDateButton--overLabel"[^>]*>\s*([^<]+?)\s*<')848586class TuxedoBilletConnector(BaseConnector):87 source_id = "tuxedobillet"88 request_delay = 1.08990 def _load_tenants(self) -> list[dict]:91 try:92 return json.loads(CONFIG_PATH.read_text(encoding="utf-8"))["tenants"]93 except Exception:94 return []9596 # -- acteur Apify ---------------------------------------------------------97 def _run_actor(self, tenants: list[str]) -> list[dict]:98 token = os.environ.get("APIFY_TOKEN")99 if not token:100 raise RuntimeError("APIFY_TOKEN manquant (voir .env)")101 headers = {"Authorization": f"Bearer {token}"}102 resp = self.session.post(103 RUN_URL, headers=headers, timeout=60,104 json={"tenants": tenants, "renderWaitMs": 6000})105 resp.raise_for_status()106 run = resp.json()["data"]107 run_id, dataset_id = run["id"], run["defaultDatasetId"]108 deadline = time.time() + POLL_TIMEOUT109 status = run["status"]110 while status in ("READY", "RUNNING") and time.time() < deadline:111 time.sleep(15)112 status = self.session.get(113 f"https://api.apify.com/v2/actor-runs/{run_id}",114 headers=headers, timeout=30).json()["data"]["status"]115 if status != "SUCCEEDED":116 raise RuntimeError(f"run Apify {run_id} : statut {status}")117 items = self.session.get(118 f"https://api.apify.com/v2/datasets/{dataset_id}/items",119 headers=headers, params={"clean": "true"}, timeout=60).json()120 return items if isinstance(items, list) else []121122 @staticmethod123 def _parse_date(raw: str) -> str | None:124 """« 4 sept. 2026 » / « 4 Sep 2026 » / « 15 janv. 2027 » → ISO.125126 Accents retirés AVANT la table (« févr. » → « fevr » sinon le token127 [a-z]+ se fragmente), mois EN et abréviations FR inconnues de128 normalize traduits vers des clés que parse_date_fr comprend.129 """130 k = strip_accents(clean_text(raw)).lower()131 k = re.sub(r"[a-z]+", lambda m: _EN_MONTHS.get(m.group(0), m.group(0)),132 k, count=2)133 return parse_date_fr(k)134135 # -- fiches détail (Scrapfly rendu JS, incrémental) -------------------------136 def _render_fiche(self, url: str) -> str:137 """HTML rendu d'une fiche /main/<slug> (SPA : rendu JS obligatoire).138139 Appel Scrapfly direct plutôt que get_rendered() : il faut attendre le140 composant Angular (wait_for_selector) et tolérer le statut 422 que141 Scrapfly renvoie quand l'attente expire alors que le contenu est là.142 """143 key = os.environ.get("SCRAPFLY_API_KEY")144 if not key:145 raise RuntimeError("SCRAPFLY_API_KEY manquant (voir .env)")146 self._throttle()147 resp = requests.get(SCRAPFLY_API, params={148 "key": key, "url": url, "country": "ca", "asp": "true",149 "render_js": "true", "rendering_wait": "8000",150 "wait_for_selector": ".tuxCmsEdit__webEventSelection--title",151 }, timeout=150)152 self._last_request = time.time()153 try:154 content = (resp.json().get("result") or {}).get("content") or ""155 except Exception:156 content = ""157 if "webEventSelection--" not in content:158 raise RuntimeError(f"fiche non rendue (HTTP {resp.status_code})")159 return content160161 def _parse_fiche(self, html: str) -> dict:162 """Fiche rendue → catégorie, description, séances datées + heure/statut."""163 cat = _F_CAT_RE.search(html)164 desc = _F_DESC_RE.search(html)165 seances: list[dict] = []166 seen: set[tuple] = set()167 for chunk in re.split(r"app-event-summary", html)[1:]:168 d = _F_DATE_RE.search(chunk)169 date_iso = self._parse_date(d.group(1)) if d else None170 if not date_iso:171 continue # bouton sans date sûre172 h = _F_HEURE_RE.search(chunk)173 over = _F_OVER_RE.search(chunk)174 heure = parse_time(h.group(1)) if h else None175 if (date_iso, heure) in seen: # rendu responsive dupliqué176 continue177 seen.add((date_iso, heure))178 seances.append({179 "date": date_iso,180 "time": heure,181 # « COMPLET » → soldout (normalisé, jamais inventé)182 "status": normalize_status(clean_text(over.group(1))183 if over else ""),184 })185 return {186 "category": clean_text(cat.group(1)) if cat else "",187 "description": clean_text(re.sub(r"<[^>]+>", " ", desc.group(1)))188 if desc else "",189 "seances": seances,190 }191192 def fetch(self) -> list[Event]:193 tenants = {t["domaine"]: t for t in self._load_tenants()}194 if not tenants:195 return []196 cache = self.load_cache()197 now = time.time()198 if now - cache.get("ts", 0) > TTL:199 try:200 items = self._run_actor(sorted(tenants))201 cache = {"ts": now, "items": items,202 "fiches": cache.get("fiches") or {}}203 self.save_cache(cache)204 except Exception as exc:205 if not cache.get("items"):206 raise207 print(f"[sorti-ka] tuxedobillet : run Apify échoué ({exc}) — "208 "réémission du cache")209210 # fiches détail incrémentales : jamais vue > parseur périmé > plus211 # ancienne — chaque fiche coûte un rendu Scrapfly, plafonné par sync212 urls = {f"{it['tenant']}-{it['slug']}": it.get("url", "")213 for it in cache.get("items", [])214 if it.get("tenant") in tenants and it.get("slug")215 and it.get("url")}216 fiches = {k: v for k, v in (cache.get("fiches") or {}).items()217 if k in urls}218 stale = sorted(219 (k for k in urls220 if k not in fiches221 or fiches[k].get("v", 0) < FICHE_V222 or now - fiches[k].get("ts", 0) > FICHE_REFRESH),223 key=lambda k: (fiches.get(k, {}).get("v", 0) if k in fiches else -1,224 fiches.get(k, {}).get("ts", 0)))225 if stale and not os.environ.get("SCRAPFLY_API_KEY"):226 print("[sorti-ka] tuxedobillet : SCRAPFLY_API_KEY absent — "227 "fiches non enrichies (liste seule)")228 stale = []229 for k in stale[:FICHE_MAX]:230 try:231 row = self._parse_fiche(self._render_fiche(urls[k]))232 except Exception as exc:233 print(f"[sorti-ka] tuxedobillet : fiche {k} non enrichie ({exc})")234 row = None # re-tentée après FICHE_REFRESH235 fiches[k] = {"ts": now, "v": FICHE_V, "row": row}236 cache["fiches"] = fiches237 self.save_cache(cache)238239 events: list[Event] = []240 seen: set[str] = set()241 for it in cache.get("items", []):242 tenant = tenants.get(it.get("tenant", ""))243 slug = it.get("slug", "")244 title = clean_text(it.get("title", ""))245 if not tenant or not slug or not title:246 continue247 key = f"{it['tenant']}-{slug}"248 if key in seen:249 continue250 seen.add(key)251 subtitle = clean_text(it.get("subtitle", ""))252 fiche = (fiches.get(key) or {}).get("row") or {}253 base = dict(254 source=self.source_id,255 url=it.get("url", ""),256 # le sous-titre est tantôt l'artiste, tantôt une série — on le257 # garde dans le titre mais jamais comme artiste (pas inventé)258 title=f"{title} — {subtitle}" if subtitle else title,259 description=fiche.get("description", ""),260 raw_categories=[fiche["category"]] if fiche.get("category")261 else [title],262 venue=tenant["nom"],263 city=tenant["ville"],264 organizer=tenant["nom"],265 image=it.get("image", ""),266 )267 seances = fiche.get("seances") or []268 if seances:269 # 1 Event par séance datée de la fiche (heure + statut) ; les270 # séances multiples d'un même jour restent distinctes271 per_day: dict[str, int] = {}272 for s in seances:273 per_day[s["date"]] = per_day.get(s["date"], 0) + 1274 for s in seances:275 ext = f"{key}-{s['date']}"276 if per_day[s["date"]] > 1 and s.get("time"):277 ext += f"-{s['time'].replace(':', '')}"278 events.append(Event(279 external_id=ext,280 start_date=s["date"],281 start_time=s.get("time"),282 status=s.get("status", ""),283 **base,284 ))285 continue286 date_iso = self._parse_date(it.get("date_raw", ""))287 if not date_iso: # tuile sans date sûre288 continue289 events.append(Event(external_id=key, start_date=date_iso, **base))290 return events291