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%
11.7 KB · 239 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/ticketpro.py : Ticketpro — billetterie canadienne, tenants Québec5#   (Phase 5). Grande billetterie indépendante (Théâtre St-Denis, Fun Nation,6#   ExpoCité, Salle André-Gagnon, Palais des congrès, salles régionales…).7#   Source    : nouvelle plateforme SPA par tenant <org>.ticketpro.ca dont8#               l'API JSON est PUBLIQUE et sans anti-bot (vérifié 2026-08-22,9#               robots.txt : seuls /admin/ /gui/ /report/ interdits) :10#               GET /api/v1/pages = 1 fiche par spectacle avec titre, artiste,11#               sous-titre, description, image, lieu COMPLET (adresse, ville,12#               province, code postal, lat/lng), TOUTES les dates horodatées13#               des représentations, tags (catégories) et drapeaux14#               cancelled/soldout. AUCUN rendu JS ni Scrapfly nécessaire.15#   Extraction: 1 GET /api/v1/pages par tenant (6 tenants QC configurés dans16#               data/ticketpro_tenants.json — tproqc agrège la province mais17#               PAS tprotsd, vérifié) → 1 Event par (page, date à venir),18#               dédup inter-tenants par id de page (ids globaux). Les lieux19#               de restauration (soupers-spectacles) et le musée à créneaux20#               sont exclus par config (exclure_lieux). Filtre province:21#               provinceState == QC. startingPrice et prices[] sont toujours22#               vides dans la LISTE, mais les tarifs réels sont publics en23#               2 GET par spectacle (vérifié 2026-08-25) :24#               /api/v1/pages/<id> (détail → itemsDates.items[].dates[] =25#               séances avec saleState + URL relative de l'offre) puis26#               /api/v1<url de la 1re séance à venir> (?options=prices) →27#               itemHours[].priceLevels[].price_min en CENTS (taxes et28#               frais inclus : taxIncluded/serviceChargesIncluded=true).29#               Prix plancher de la page appliqué à ses séances + statut30#               PAR séance (saleState SOLDOUT/CANCELLED…). Enrichissement31#               incrémental : cache data/ticketpro_cache.json,32#               TP_PRICE_MAX pages/sync, re-visite 3 j, version de parse33#               « v » (patron lavitrine).34#   Accès     : billetterie publique, API du site officiel, GET throttlés35#               1 s, identification honnête, lien d'achat vers la fiche36#               officielle /fr/pages/<id> du tenant.37# -----------------------------------------------------------------------------38from __future__ import annotations3940import json41import os42import re43import time44from datetime import date45from pathlib import Path4647from ..normalize import clean_text, strip_accents48from ..schema import Event, normalize_status49from .base import BaseConnector5051CONFIG_PATH = Path(__file__).resolve().parents[2] / "data" / "ticketpro_tenants.json"52# enrichissement tarifs : 2 GET par page (détail + offre de la 1re séance à53# venir) — plafonné par sync, re-visité pour suivre soldout/changements de prix54PRICE_MAX = int(os.environ.get("TP_PRICE_MAX", "40"))55REFRESH_AFTER = 3 * 8640056PRICE_V = 1                        # version du parseur d'offre (bump = re-visite)5758_TAG_RE = re.compile(r"<[^>]+>")59_BLOCK_RE = re.compile(r"<(style|script)[^>]*>.*?</\1>", re.S | re.I)60# CSS inline hors balise (les fiches en contiennent parfois du résiduel)61_CSS_RE = re.compile(r"(?:@media[^{]*|[\w\.\#\-]+\s*)\{[^}]*\}")62_DATE_RE = re.compile(r"^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2})")636465def _strip_html(raw: str) -> str:66    """HTML de description → texte plat (les fiches sont en HTML riche)."""67    txt = _BLOCK_RE.sub(" ", raw or "")68    txt = _CSS_RE.sub(" ", _TAG_RE.sub(" ", txt))69    return clean_text(txt)707172def _lieu_key(name: str) -> str:73    return strip_accents(clean_text(name or "")).lower()747576class TicketproConnector(BaseConnector):77    source_id = "ticketpro"78    request_delay = 1.07980    def _load_config(self) -> dict:81        try:82            return json.loads(CONFIG_PATH.read_text(encoding="utf-8"))83        except Exception:84            return {"tenants": [], "exclure_lieux": []}8586    # -- tarifs & statuts par séance (2 GET par page, incrémental) -------------87    def _fetch_prices(self, tenant: str, page_id) -> dict:88        """{price_min: float|None, states: {"AAAA-MM-JJTHH:MM": saleState}}.8990        Détail /api/v1/pages/<id> → itemsDates (séances + saleState) ; l'offre91        de la 1re séance à venir (?options=prices) donne les priceLevels en92        cents. Le plancher d'une page vaut pour ses séances (patron93        ticketacces : prix plancher de la fiche). Jamais inventé : pages sans94        itemsDates (cartes-cadeaux…) ou sans priceLevels → aucun prix.95        """96        detail = self.get_json(97            f"https://{tenant}.ticketpro.ca/api/v1/pages/{page_id}")98        seances = [d for item in (detail.get("itemsDates") or {}).get("items")99                   or [] for d in item.get("dates") or []]100        states = {str(s.get("date"))[:16]: str(s.get("saleState") or "")101                  for s in seances if s.get("date")}102        today = date.today().isoformat()103        upcoming = sorted((s for s in seances104                           if s.get("url") and str(s.get("date"))[:10] >= today),105                          key=lambda s: str(s.get("date")))106        # 1re séance encore en vente, sinon 1re à venir (offre = mêmes niveaux)107        best = next((s for s in upcoming if s.get("saleState") == "ONSALE"),108                    upcoming[0] if upcoming else None)109        price_min = None110        if best is not None:111            offer = self.get_json(112                f"https://{tenant}.ticketpro.ca/api/v1{best['url']}")113            cents = [pl.get("price_min")114                     for ih in offer.get("itemHours") or []115                     for pl in ih.get("priceLevels") or []]116            cents = [c for c in cents if isinstance(c, (int, float)) and c > 0]117            if cents:118                price_min = round(min(cents) / 100, 2)119        return {"price_min": price_min, "states": states}120121    def _events_from_page(self, tenant: str, page: dict, excluded: list[str],122                          enrich: dict | None = None) -> list[Event]:123        title = clean_text(page.get("title") or "")124        dates = page.get("dates") or []125        loc = (page.get("locations") or [{}])[0] or {}126        if not title or not dates:127            return []128        if (loc.get("provinceState") or "QC").upper() != "QC":129            return []                       # tenant QC mais salle hors province130        venue_key = _lieu_key(loc.get("name") or "")131        if any(x in venue_key for x in excluded):132            return []                       # restauration / musée à créneaux133134        page_id = page.get("id")135        url = f"https://{tenant}.ticketpro.ca/fr/pages/{page.get('slug') or page_id}"136        image = page.get("imageUrlBig") or page.get("imageUrlSmall") or ""137        if image and not image.startswith("http"):138            # « /v1/resources/… » → servi par l'API du tenant (vérifié 200)139            image = f"https://{tenant}.ticketpro.ca/api{image}"140        status = ("cancelled" if page.get("cancelled")141                  else "soldout" if page.get("soldout") else "")142        artist = clean_text(page.get("artist") or "")143        raw_cats = [t.get("smallName") or t.get("name") or ""144                    for t in page.get("tags") or []]145        description = _strip_html(page.get("description")146                                  or page.get("miniDescription") or "")147148        enrich = enrich or {}149        price_min = enrich.get("price_min")150        states = enrich.get("states") or {}151152        today = date.today().isoformat()153        events: list[Event] = []154        for d in dates:155            m = _DATE_RE.match(str(d))156            if not m:157                continue158            day, hh, mm = m.group(1), m.group(2), m.group(3)159            if day < today:                 # l'API conserve les dates passées160                continue161            # statut PAR séance (saleState SOLDOUT/CANCELLED…) quand la page162            # ne porte pas déjà un drapeau global — jamais inventé. Certaines163            # séances du détail sont datées SANS heure (constaté 2026-08-25,164            # ~10 % des clés) : leur saleState vaut alors pour la journée.165            seance_status = status or normalize_status(166                states.get(f"{day}T{hh}:{mm}") or states.get(day, ""))167            events.append(Event(168                source=self.source_id,169                external_id=f"{page_id}-{day}T{hh}{mm}",170                url=url,171                title=title,172                description=description,173                raw_categories=[c for c in raw_cats if c] or [title],174                venue=clean_text(loc.get("name") or ""),175                address=clean_text(loc.get("addressLine1") or ""),176                city=clean_text(loc.get("city") or ""),177                postal_code=clean_text(loc.get("postalCode") or ""),178                lat=loc.get("latitude"),179                lng=loc.get("longitude"),180                start_date=day,181                start_time=f"{hh}:{mm}",182                status=seance_status,183                artists=[artist] if artist else [],184                is_free=False if price_min else None,185                price_min=price_min,186                image=image,187            ))188        return events189190    def fetch(self) -> list[Event]:191        config = self._load_config()192        tenants = [t["domaine"] for t in config.get("tenants", [])]193        excluded = [_lieu_key(x) for x in config.get("exclure_lieux", [])]194        if not tenants:195            return []196197        cache = self.load_cache()198        now = time.time()199        budget = PRICE_MAX200        today = date.today().isoformat()201202        events: list[Event] = []203        seen_pages: set = set()             # ids de page globaux inter-tenants204        for tenant in tenants:205            try:206                data = self.get_json(207                    f"https://{tenant}.ticketpro.ca/api/v1/pages")208            except Exception as exc:        # un tenant en panne ne bloque pas209                print(f"[sorti-ka] ticketpro : tenant {tenant} ignoré ({exc})")210                continue211            for page in data.get("pages") or []:212                if page.get("id") in seen_pages:213                    continue214                seen_pages.add(page.get("id"))215                # tarifs & statuts par séance : incrémental, plafonné/sync —216                # seulement pour les pages ayant au moins une séance à venir217                key = str(page.get("id"))218                entry = cache.get(key)219                has_future = any(str(d)[:10] >= today220                                 for d in page.get("dates") or [])221                stale = (entry is None or entry.get("v", 0) < PRICE_V222                         or now - entry.get("ts", 0) > REFRESH_AFTER)223                if budget > 0 and has_future and stale:224                    budget -= 1225                    try:226                        entry = self._fetch_prices(tenant, page.get("id"))227                    except Exception as exc:228                        print(f"[sorti-ka] ticketpro : tarifs {tenant}/{key} "229                              f"ignorés ({exc})")230                        entry = {"price_min": None, "states": {}}231                    entry.update(ts=now, v=PRICE_V)232                    cache[key] = entry233                events += self._events_from_page(tenant, page, excluded, entry)234        # ne garder que les pages encore publiées (le cache suit la source)235        cache = {k: v for k, v in cache.items()236                 if k in {str(p) for p in seen_pages}}237        self.save_cache(cache)238        return events239