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%
12.2 KB · 268 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/ticketmaster.py : connecteur Ticketmaster Canada (Québec) — SANS clé5#   Mode principal (sans clé) : scraping léger des pages découverte publiques6#     www.ticketmaster.ca/discover/<ville>?page=N (montreal, quebec-city,7#     gatineau). Chaque page embarque son état Next.js (__NEXT_DATA__) : la8#     requête serveur `cityEvents` y est déjà résolue avec 20 événements/page9#     (titre, date UTC + fuseau, salle géolocalisée lat/lng, adresse, code10#     postal, ville, URL billetterie, catégorie majeure, seatmap). Pagination11#     par ?page=N (vérifié 2026-08-18 : montreal total=1165, quebec-city=136).12#     Aucun prix publié dans ce flux → is_free/price_* inconnus (jamais13#     inventés). Artistes : patrons sûrs des titres (artists_from_title).14#     robots.txt : /discover/ non interdit (seuls /api/ismds/* le sont).15#     Anti-bot : GET direct d'abord ; si bloqué → Scrapfly ASP SANS render_js.16#     Plafonds : TM_MAX_EVENTS (défaut 360), TM_PAGES_PER_CITY (défaut 8).17#   Mode repli automatique (si TICKETMASTER_API_KEY apparaît dans .env) :18#     Discovery API officielle v2 — plus riche (priceRanges, attractions →19#     artistes, images 16:9). Les identifiants d'événements sont IDENTIQUES20#     dans les deux modes → aucun doublon lors d'un changement de mode.21# -----------------------------------------------------------------------------22from __future__ import annotations2324import json25import os2627from ..normalize import artists_from_title, utc_to_local28from ..schema import Event, normalize_status29from .base import BaseConnector3031# --- mode API officielle (avec clé) ------------------------------------------32API = "https://app.ticketmaster.com/discovery/v2/events.json"33PAGE_SIZE = 20034DEEP_PAGE_LIMIT = 1000        # l'API refuse page*size >= 1000 (pagination profonde)3536# --- mode scraping (sans clé) -------------------------------------------------37DISCOVER = "https://www.ticketmaster.ca/discover/{city}?page={page}"38CITIES = ("montreal", "quebec-city", "gatineau")   # villes découverte QC publiées39TM_MAX_EVENTS = int(os.environ.get("TM_MAX_EVENTS", "360"))40TM_PAGES_PER_CITY = int(os.environ.get("TM_PAGES_PER_CITY", "8"))41SCRAPE_PAGE_SIZE = 20         # taille fixe du flux cityEvents des pages découverte4243# catégories majeures Ticketmaster (segments Discovery) → libellés source44_CAT_NAMES = {45    "KZFzniwnSyZfZ7v7nJ": "Concerts",46    "KZFzniwnSyZfZ7v7na": "Arts & Theatre",47    "KZFzniwnSyZfZ7v7nE": "Sports",48    "KZFzniwnSyZfZ7v7n1": "Family",49    "KZFzniwnSyZfZ7v7n7": "Film",50}515253def _best_image(images: list[dict]) -> str:54    """Plus grande image 16:9 (sinon la plus large disponible)."""55    if not images:56        return ""57    ranked = sorted(images, key=lambda i: ((i.get("ratio") == "16_9"),58                                           i.get("width") or 0), reverse=True)59    return ranked[0].get("url") or ""606162class TicketmasterConnector(BaseConnector):63    source_id = "ticketmaster"64    request_delay = 1.0           # politesse scraping ; l'API tolère 5 req/s6566    def __init__(self) -> None:67        super().__init__()68        self._force_scrapfly = False69        self.session.headers["Accept-Language"] = "fr-CA,fr;q=0.9,en;q=0.5"7071    # ==========================================================================72    # Mode scraping SANS clé (pages découverte publiques)73    # ==========================================================================74    def _discover_html(self, url: str) -> str:75        """HTML d'une page découverte : GET direct, Scrapfly ASP en secours."""76        if not self._force_scrapfly:77            try:78                html = self.get(url).text79                if "__NEXT_DATA__" in html:80                    return html81            except Exception:82                pass83            if not os.environ.get("SCRAPFLY_API_KEY"):84                raise RuntimeError("GET direct bloqué et SCRAPFLY_API_KEY absent")85            self._force_scrapfly = True86            print("[sorti-ka] ticketmaster : GET direct bloqué → "87                  "Scrapfly ASP (sans render_js)")88        return self.get_rendered(url, render_js=False)8990    @staticmethod91    def _next_data(html: str) -> dict:92        i = html.find("__NEXT_DATA__")93        if i < 0:94            raise ValueError("__NEXT_DATA__ introuvable (anti-bot ?)")95        start = html.find(">", i) + 196        data, _ = json.JSONDecoder().raw_decode(html[start:].lstrip())97        return data9899    def _city_page(self, city: str, page: int) -> tuple[list[dict], int]:100        """Événements (hits) + total annoncé d'une page découverte."""101        data = self._next_data(self._discover_html(102            DISCOVER.format(city=city, page=page)))103        queries = (((data.get("props") or {}).get("pageProps") or {})104                   .get("initialReduxState") or {}).get("api", {}).get("queries", {})105        hits: list[dict] = []106        total = 0107        for key, q in queries.items():108            if key.startswith("cityEvents(") and f'"page":{page}' in key:109                d = q.get("data") or {}110                hits.extend(d.get("events") or [])111                total = max(total, int(d.get("total") or 0))112        return hits, total113114    def _scrape_hit_to_event(self, hit: dict) -> Event | None:115        ext_id = hit.get("id") or ""116        title = hit.get("title") or ""117        if not ext_id or not title:118            return None119        if hit.get("virtual"):120            return None121        # Phase 2 : un événement annulé est CONSERVÉ avec status=cancelled122        # (bandeau côté frontend + eventStatus schema.org) au lieu d'être jeté.123        status = "cancelled" if hit.get("cancelled") else ""124        v = hit.get("venue") or {}125        dates = hit.get("dates") or {}126        # startDate publié en UTC + fuseau → date/heure locales Québec ;127        # heure retenue seulement si la source l'affiche (showDateTime, pas TBA)128        start_date, start_time = utc_to_local(dates.get("startDate"))129        if hit.get("tba") or dates.get("dateDisplay") != "showDateTime":130            start_time = None131        cat_id = (hit.get("majorCategory") or {}).get("id") or ""132        venue_name = v.get("name") or ""133        return Event(134            source=self.source_id,135            external_id=ext_id,136            url=hit.get("url") or "",137            title=title,138            raw_categories=[_CAT_NAMES[cat_id]] if cat_id in _CAT_NAMES else [],139            venue=venue_name,140            address=v.get("addressLineOne") or "",141            city=v.get("city") or "",142            postal_code=v.get("code") or "",143            lat=v.get("latitude"),144            lng=v.get("longitude"),145            start_date=start_date,146            start_time=start_time,147            end_date=start_date,148            status=status,149            artists=artists_from_title(title, venue_name),150            # seul visuel publié dans ce flux : le plan de salle officiel151            image=hit.get("seatmapUrl") or "",152        )153154    def _fetch_scrape(self) -> list[Event]:155        by_id: dict[str, Event] = {}156        for city in CITIES:157            for page in range(TM_PAGES_PER_CITY):158                if len(by_id) >= TM_MAX_EVENTS:159                    break160                try:161                    hits, total = self._city_page(city, page)162                except Exception as exc:163                    print(f"[sorti-ka] ticketmaster : {city} page {page} "164                          f"ignorée : {exc}")165                    break166                if not hits:167                    break168                for hit in hits:169                    ev = self._scrape_hit_to_event(hit)170                    if ev is not None:171                        by_id.setdefault(ev.external_id, ev)172                if total and (page + 1) * SCRAPE_PAGE_SIZE >= total:173                    break174            if len(by_id) >= TM_MAX_EVENTS:175                break176        return list(by_id.values())177178    # ==========================================================================179    # Mode API officielle (repli automatique dès qu'une clé est fournie)180    # ==========================================================================181    def _page(self, key: str, page: int) -> dict:182        return self.get_json(API, params={183            "apikey": key, "countryCode": "CA", "stateCode": "QC",184            "locale": "fr-ca,en", "size": PAGE_SIZE, "page": page,185            "sort": "date,asc"})186187    def _hit_to_event(self, hit: dict) -> Event | None:188        ext_id = hit.get("id") or ""189        title = hit.get("name") or ""190        if not ext_id or not title:191            return None192        emb = hit.get("_embedded") or {}193        venues = emb.get("venues") or [{}]194        v = venues[0]195        loc = v.get("location") or {}196        dates = (hit.get("dates") or {}).get("start") or {}197        end_obj = (hit.get("dates") or {}).get("end") or {}198        end = end_obj.get("localDate")199        # statut officiel Discovery : onsale/offsale/cancelled/postponed/rescheduled200        status = normalize_status(201            ((hit.get("dates") or {}).get("status") or {}).get("code"))202        # classifications → libellés source (segment + genre + sous-genre)203        cats: list[str] = []204        for c in hit.get("classifications") or []:205            for k in ("segment", "genre", "subGenre"):206                name = (c.get(k) or {}).get("name") or ""207                if name and name.lower() != "undefined" and name not in cats:208                    cats.append(name)209        # fourchette de prix officielle (si publiée par la billetterie)210        price_min, price_label = None, ""211        ranges = [r for r in hit.get("priceRanges") or []212                  if r.get("min") is not None]213        if ranges:214            lo = min(float(r["min"]) for r in ranges)215            hi = max(float(r.get("max") or r["min"]) for r in ranges)216            price_min = lo if lo > 0 else None217            price_label = (f"{lo:g} $ à {hi:g} $ (Ticketmaster)" if hi > lo218                           else f"{lo:g} $ (Ticketmaster)")219        artists = [a.get("name") for a in emb.get("attractions") or []220                   if a.get("name")]221        return Event(222            source=self.source_id,223            external_id=ext_id,224            url=hit.get("url") or "",225            title=title,226            description=hit.get("info") or hit.get("pleaseNote") or "",227            raw_categories=cats,228            venue=v.get("name") or "",229            address=((v.get("address") or {}).get("line1") or ""),230            city=((v.get("city") or {}).get("name") or ""),231            postal_code=v.get("postalCode") or "",232            lat=loc.get("latitude"),233            lng=loc.get("longitude"),234            start_date=dates.get("localDate"),235            start_time=(None if dates.get("timeTBA") or dates.get("noSpecificTime")236                        else dates.get("localTime")),237            end_date=end or dates.get("localDate"),238            end_time=end_obj.get("localTime"),239            status=status,240            artists=artists,241            price_min=price_min,242            price_label=price_label,243            is_free=(False if price_min else None),244            organizer=((hit.get("promoter") or {}).get("name") or ""),245            image=_best_image(hit.get("images") or []),246        )247248    def _fetch_api(self, key: str) -> list[Event]:249        self.request_delay = 0.25     # quota officiel : 5 req/s250        events: list[Event] = []251        page, total_pages = 0, 1252        while page < total_pages and (page + 1) * PAGE_SIZE <= DEEP_PAGE_LIMIT:253            data = self._page(key, page)254            total_pages = int((data.get("page") or {}).get("totalPages") or 0)255            for hit in (data.get("_embedded") or {}).get("events") or []:256                ev = self._hit_to_event(hit)257                if ev is not None:258                    events.append(ev)259            page += 1260        return events261262    # ==========================================================================263    def fetch(self) -> list[Event]:264        key = os.environ.get("TICKETMASTER_API_KEY", "").strip()265        if key:266            return self._fetch_api(key)      # mode riche dès qu'une clé existe267        return self._fetch_scrape()          # mode sans clé (par défaut)268