SPB Git forge

spb/sorti-ka

Public

Toutes les sorties et tous les événements du Québec, un seul endroit — 7 connecteurs, fiches SSR, design Groupe KA.

58commits 1branches 0releases
13.7 MBsize
maindefault branch
17 days agolast push
HTML 82.9% Python 15.2% TypeScript 0.9% JavaScript 0.7%
14.1 KB · 291 lines python
Raw Blame History
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