# ----------------------------------------------------------------------------- # Sorti-Ka — Agrégateur de sorties & événements (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/tuxedobillet.py : Tuxedo Billet — billetterie québécoise # multi-tenants (Phase 4). Reprise de la source écartée en Phase 3 (site en # panne) : l'apex reste vide mais les tenants .tuxedobillet.com vivent. # Source : SPA Angular sur Firebase par tenant — RTDB verrouillée (401 # même avec jeton anonyme, vérifié 2026-08-21) → rendu # navigateur OBLIGATOIRE. L'extraction est déléguée à l'acteur # Apify maison gorgeous_thistle/ka-tuxedo (Playwright, proxy # résidentiel CA, code dans apify/ka-tuxedo/) qui rend chaque # tenant et retourne les tuiles spectacles : titre, sous-titre, # date FR, image, lien /main/. # Extraction: 1 run d'acteur par ≈ TUX_TTL_HOURS (20 h par défaut — le # rendu JS coûte des crédits Apify ; les programmations de # salles bougent lentement). Résultat mis en cache disque # (data/tuxedobillet_cache.json) et réémis entre deux runs. # Tenants configurés dans data/tuxedo_tenants.json (Québec # seulement, ville vérifiée). # FICHES /main/ rendues via Scrapfly render_js (le GET # direct ne renvoie que la coquille Angular, vérifié # 2026-08-25) : chaque fiche publie ses SÉANCES (jour + date # + HEURE « 20H00 » + étiquette « COMPLET »), la catégorie et # la description → 1 Event par séance datée, heure et statut # soldout captés. Enrichissement incrémental (clé « fiches » # du cache, TUX_FICHE_MAX/sync, re-visite 7 j, version de # parse « v » — patron lavitrine ; v2 : abréviations FR # « janv./févr. » comprises, tuiles et séances de janvier- # février récupérées). Les fiches ne publient NI # prix NI adresse de salle (vérifié : le tarif n'apparaît # qu'au choix des sièges) → jamais inventés. # Accès : billetteries publiques ; rendu navigateur modéré (1 page par # tenant par run Apify ~1/jour + TUX_FICHE_MAX fiches # Scrapfly/sync throttlées), lien d'achat = la fiche # officielle du tenant. # Secrets : APIFY_TOKEN requis (dans .env) — sans lui, le connecteur # réémet le cache et n'échoue que s'il n'y a aucun cache. # SCRAPFLY_API_KEY requis pour l'enrichissement des fiches — # sans lui, la liste seule est émise (comportement d'avant). # ----------------------------------------------------------------------------- from __future__ import annotations import json import os import re import time from pathlib import Path import requests from ..normalize import clean_text, parse_date_fr, parse_time, strip_accents from ..schema import Event, normalize_status from .base import SCRAPFLY_API, BaseConnector ACTOR = "gorgeous_thistle~ka-tuxedo" RUN_URL = f"https://api.apify.com/v2/acts/{ACTOR}/runs" CONFIG_PATH = Path(__file__).resolve().parents[2] / "data" / "tuxedo_tenants.json" TTL = float(os.environ.get("TUX_TTL_HOURS", "20")) * 3600 POLL_TIMEOUT = int(os.environ.get("TUX_POLL_TIMEOUT", "1500")) # s # fiches détail (séances + heure + statut + catégorie + description) rendues # via Scrapfly : plafond par sync, re-visite 7 j, version de parse « v » FICHE_MAX = int(os.environ.get("TUX_FICHE_MAX", "20")) FICHE_REFRESH = 7 * 86400 FICHE_V = 2 # v2 : mois « janv./févr. » compris (séances jan-fév récupérées) # le tile affiche parfois l'anglais selon le fuseau du proxy — mois EN → FR ; # + abréviations FR « janv. »/« févr. » (tuiles ET fiches, constaté live # 2026-08-25) que normalize.parse_date_fr ne connaît pas → séances de # janvier/février silencieusement écartées sans cette table _EN_MONTHS = {"jan": "jan", "feb": "fev", "mar": "mars", "apr": "avr", "may": "mai", "jun": "juin", "jul": "juil", "aug": "aout", "sep": "sept", "oct": "oct", "nov": "nov", "dec": "dec", "janv": "jan", "fevr": "fev"} # fragments de la fiche rendue (classes stables tuxCmsEdit__webEventSelection) _F_CAT_RE = re.compile( r'webEventSelection--category"[^>]*>\s*]*>\s*([^<]+?)\s*') _F_DESC_RE = re.compile(r'webEventSelection--description"[^>]*>(.*?)', re.S) # un bouton de séance par représentation : jour / date / heure (+ étiquette # « COMPLET » en surimpression) — découpage par app-event-summary _F_DATE_RE = re.compile(r'class="date"[^>]*>\s*([^<]+?)\s*<') _F_HEURE_RE = re.compile(r'class="heure"[^>]*>\s*([^<]+?)\s*<') _F_OVER_RE = re.compile(r'webEventDateButton--overLabel"[^>]*>\s*([^<]+?)\s*<') class TuxedoBilletConnector(BaseConnector): source_id = "tuxedobillet" request_delay = 1.0 def _load_tenants(self) -> list[dict]: try: return json.loads(CONFIG_PATH.read_text(encoding="utf-8"))["tenants"] except Exception: return [] # -- acteur Apify --------------------------------------------------------- def _run_actor(self, tenants: list[str]) -> list[dict]: token = os.environ.get("APIFY_TOKEN") if not token: raise RuntimeError("APIFY_TOKEN manquant (voir .env)") headers = {"Authorization": f"Bearer {token}"} resp = self.session.post( RUN_URL, headers=headers, timeout=60, json={"tenants": tenants, "renderWaitMs": 6000}) resp.raise_for_status() run = resp.json()["data"] run_id, dataset_id = run["id"], run["defaultDatasetId"] deadline = time.time() + POLL_TIMEOUT status = run["status"] while status in ("READY", "RUNNING") and time.time() < deadline: time.sleep(15) status = self.session.get( f"https://api.apify.com/v2/actor-runs/{run_id}", headers=headers, timeout=30).json()["data"]["status"] if status != "SUCCEEDED": raise RuntimeError(f"run Apify {run_id} : statut {status}") items = self.session.get( f"https://api.apify.com/v2/datasets/{dataset_id}/items", headers=headers, params={"clean": "true"}, timeout=60).json() return items if isinstance(items, list) else [] @staticmethod def _parse_date(raw: str) -> str | None: """« 4 sept. 2026 » / « 4 Sep 2026 » / « 15 janv. 2027 » → ISO. Accents retirés AVANT la table (« févr. » → « fevr » sinon le token [a-z]+ se fragmente), mois EN et abréviations FR inconnues de normalize traduits vers des clés que parse_date_fr comprend. """ k = strip_accents(clean_text(raw)).lower() k = re.sub(r"[a-z]+", lambda m: _EN_MONTHS.get(m.group(0), m.group(0)), k, count=2) return parse_date_fr(k) # -- fiches détail (Scrapfly rendu JS, incrémental) ------------------------- def _render_fiche(self, url: str) -> str: """HTML rendu d'une fiche /main/ (SPA : rendu JS obligatoire). Appel Scrapfly direct plutôt que get_rendered() : il faut attendre le composant Angular (wait_for_selector) et tolérer le statut 422 que Scrapfly renvoie quand l'attente expire alors que le contenu est là. """ key = os.environ.get("SCRAPFLY_API_KEY") if not key: raise RuntimeError("SCRAPFLY_API_KEY manquant (voir .env)") self._throttle() resp = requests.get(SCRAPFLY_API, params={ "key": key, "url": url, "country": "ca", "asp": "true", "render_js": "true", "rendering_wait": "8000", "wait_for_selector": ".tuxCmsEdit__webEventSelection--title", }, timeout=150) self._last_request = time.time() try: content = (resp.json().get("result") or {}).get("content") or "" except Exception: content = "" if "webEventSelection--" not in content: raise RuntimeError(f"fiche non rendue (HTTP {resp.status_code})") return content def _parse_fiche(self, html: str) -> dict: """Fiche rendue → catégorie, description, séances datées + heure/statut.""" cat = _F_CAT_RE.search(html) desc = _F_DESC_RE.search(html) seances: list[dict] = [] seen: set[tuple] = set() for chunk in re.split(r"app-event-summary", html)[1:]: d = _F_DATE_RE.search(chunk) date_iso = self._parse_date(d.group(1)) if d else None if not date_iso: continue # bouton sans date sûre h = _F_HEURE_RE.search(chunk) over = _F_OVER_RE.search(chunk) heure = parse_time(h.group(1)) if h else None if (date_iso, heure) in seen: # rendu responsive dupliqué continue seen.add((date_iso, heure)) seances.append({ "date": date_iso, "time": heure, # « COMPLET » → soldout (normalisé, jamais inventé) "status": normalize_status(clean_text(over.group(1)) if over else ""), }) return { "category": clean_text(cat.group(1)) if cat else "", "description": clean_text(re.sub(r"<[^>]+>", " ", desc.group(1))) if desc else "", "seances": seances, } def fetch(self) -> list[Event]: tenants = {t["domaine"]: t for t in self._load_tenants()} if not tenants: return [] cache = self.load_cache() now = time.time() if now - cache.get("ts", 0) > TTL: try: items = self._run_actor(sorted(tenants)) cache = {"ts": now, "items": items, "fiches": cache.get("fiches") or {}} self.save_cache(cache) except Exception as exc: if not cache.get("items"): raise print(f"[sorti-ka] tuxedobillet : run Apify échoué ({exc}) — " "réémission du cache") # fiches détail incrémentales : jamais vue > parseur périmé > plus # ancienne — chaque fiche coûte un rendu Scrapfly, plafonné par sync urls = {f"{it['tenant']}-{it['slug']}": it.get("url", "") for it in cache.get("items", []) if it.get("tenant") in tenants and it.get("slug") and it.get("url")} fiches = {k: v for k, v in (cache.get("fiches") or {}).items() if k in urls} stale = sorted( (k for k in urls if k not in fiches or fiches[k].get("v", 0) < FICHE_V or now - fiches[k].get("ts", 0) > FICHE_REFRESH), key=lambda k: (fiches.get(k, {}).get("v", 0) if k in fiches else -1, fiches.get(k, {}).get("ts", 0))) if stale and not os.environ.get("SCRAPFLY_API_KEY"): print("[sorti-ka] tuxedobillet : SCRAPFLY_API_KEY absent — " "fiches non enrichies (liste seule)") stale = [] for k in stale[:FICHE_MAX]: try: row = self._parse_fiche(self._render_fiche(urls[k])) except Exception as exc: print(f"[sorti-ka] tuxedobillet : fiche {k} non enrichie ({exc})") row = None # re-tentée après FICHE_REFRESH fiches[k] = {"ts": now, "v": FICHE_V, "row": row} cache["fiches"] = fiches self.save_cache(cache) events: list[Event] = [] seen: set[str] = set() for it in cache.get("items", []): tenant = tenants.get(it.get("tenant", "")) slug = it.get("slug", "") title = clean_text(it.get("title", "")) if not tenant or not slug or not title: continue key = f"{it['tenant']}-{slug}" if key in seen: continue seen.add(key) subtitle = clean_text(it.get("subtitle", "")) fiche = (fiches.get(key) or {}).get("row") or {} base = dict( source=self.source_id, url=it.get("url", ""), # le sous-titre est tantôt l'artiste, tantôt une série — on le # garde dans le titre mais jamais comme artiste (pas inventé) title=f"{title} — {subtitle}" if subtitle else title, description=fiche.get("description", ""), raw_categories=[fiche["category"]] if fiche.get("category") else [title], venue=tenant["nom"], city=tenant["ville"], organizer=tenant["nom"], image=it.get("image", ""), ) seances = fiche.get("seances") or [] if seances: # 1 Event par séance datée de la fiche (heure + statut) ; les # séances multiples d'un même jour restent distinctes per_day: dict[str, int] = {} for s in seances: per_day[s["date"]] = per_day.get(s["date"], 0) + 1 for s in seances: ext = f"{key}-{s['date']}" if per_day[s["date"]] > 1 and s.get("time"): ext += f"-{s['time'].replace(':', '')}" events.append(Event( external_id=ext, start_date=s["date"], start_time=s.get("time"), status=s.get("status", ""), **base, )) continue date_iso = self._parse_date(it.get("date_raw", "")) if not date_iso: # tuile sans date sûre continue events.append(Event(external_id=key, start_date=date_iso, **base)) return events