# ----------------------------------------------------------------------------- # Sorti-Ka — Agrégateur de sorties & événements (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/evenko.py : connecteur evenko — grands concerts & festivals # Source : evenko.ca (promoteur : Centre Bell, Place Bell, MTELUS, Osheaga…) # Extraction: endpoint /api/search (proxy Algolia interne) — EXPLICITEMENT # permis par robots.txt (« Allow: /api/search* »). Format percé # dans leur bundle JS : GET ?body=base64(JSON{params:[query, # options], lang:"fr-CA"}). ~14 requêtes/sync (100 hits/page). # Accès : robots.txt Allow /api/search* ; GET throttlés, identification # honnête ; lien billets = URL Ticketmaster officielle du hit. # Prix : NON PUBLIÉS par la source. Champ `free` → is_free, MAIS sondé # null sur 100 % des hits (462/462 vérifié 2026-08 ; re-vérifié # 660/660 le 2026-08-25) et l'index # ne porte aucun montant (additional_information.regular_ticket # = libellés + URLs Ticketmaster seulement, filtre free:true → # 0 hit ; event_discount = {percentage, amount, minimum} d'un # rabais, pas un tarif). Re-sondé live 2026-08-25 sur l'index # COMPLET (660 hits, tous attributs) ET sur les pages événement # /fr/evenements// de 6 salles : le JSON-LD Event # y porte offers SANS price/priceCurrency (URL Ticketmaster + # availability seulement) — les montants ne vivent que chez # Ticketmaster (couvert par le connecteur ticketmaster, API # officielle priceRanges). Complément honnête : étiquettes # explicites « gratuit » dans event_tag / promotional_tag → # is_free=True. On n'invente rien. # ----------------------------------------------------------------------------- from __future__ import annotations import base64 import json from datetime import datetime, timezone from ..schema import Event, normalize_status from .base import BaseConnector API = "https://evenko.ca/api/search" PAGE_SIZE = 100 ATTRS = ["objectID", "title", "subtitle", "headliners", "supports", "venue", "show_date", "show_time", "hide_show_time", "door_time", "tour_name", "category", "genre", "description", "free", "age", "thumbnail", "status", "additional_information", "presented_by", "event_tag", "promotional_tag"] # les salles evenko hors Québec sont exclues du périmètre (Sorti-Ka = QC) _EXCLUDED_REGIONS = ("ottawa", "ontario", "nouveau-brunswick", "nouvelle-écosse", "nouvelle-ecosse", "terre-neuve", "états-unis", "etats-unis", "île-du-prince-édouard", "ile-du-prince-edouard", "manitoba") def _unix_to_iso(ts) -> str | None: try: return datetime.fromtimestamp(int(ts), tz=timezone.utc).strftime("%Y-%m-%d") except (TypeError, ValueError, OSError): return None def _unix_to_local_time(ts) -> str | None: """show_time (epoch) → heure locale du spectacle ("19:30"), Québec.""" from zoneinfo import ZoneInfo try: t = datetime.fromtimestamp(int(ts), tz=ZoneInfo("America/Montreal")) return None if t.strftime("%H:%M") == "00:00" else t.strftime("%H:%M") except (TypeError, ValueError, OSError): return None def _ticket_url(hit: dict) -> str: """URL billets officielle (fr_CA) depuis additional_information.""" reg = (hit.get("additional_information") or {}).get("regular_ticket") or {} for entry in reg.get("regular_ticket_url") or []: if entry.get("locale") == "fr_CA" and entry.get("data"): return entry["data"] for entry in reg.get("regular_ticket_url") or []: if entry.get("data"): return entry["data"] return "" class EvenkoConnector(BaseConnector): source_id = "evenko" def _search_page(self, page: int) -> dict: payload = {"params": ["", {"hitsPerPage": PAGE_SIZE, "page": page, "filters": "entity_type:evenko_show", "attributesToRetrieve": ATTRS}], "lang": "fr-CA"} body = base64.b64encode(json.dumps(payload).encode()).decode() return self.get_json(API, params={"body": body}) def fetch(self) -> list[Event]: events: list[Event] = [] page, nb_pages = 0, 1 while page < nb_pages: data = self._search_page(page) nb_pages = min(int(data.get("nbPages", 0)), 50) # garde-fou for hit in data.get("hits", []): ext_id = (hit.get("objectID") or "").removesuffix("_fr") title = hit.get("title") or "" if not ext_id or not title: continue venue = hit.get("venue") or {} region_lbl = (venue.get("region") or "").lower() if any(x in region_lbl for x in _EXCLUDED_REGIONS): continue if hit.get("subtitle"): title = f"{title} — {hit['subtitle']}" cats = [g.get("name", "") for g in hit.get("genre") or []] cat = hit.get("category") if isinstance(cat, list): cats += [str(c) for c in cat] elif cat: cats.append(str(cat)) thumb = hit.get("thumbnail") or "" if thumb.startswith("//"): thumb = "https:" + thumb day = _unix_to_iso(hit.get("show_date")) or _unix_to_iso(hit.get("show_time")) # heure précise : show_time (epoch) sauf si la source demande # explicitement de la cacher (hide_show_time) start_time = (None if hit.get("hide_show_time") else _unix_to_local_time(hit.get("show_time"))) # artistes : têtes d'affiche (`headliners`) + premières # parties (`supports`) — champs explicites de la source artists = [str(a) for a in hit.get("headliners") or [] if a] artists += [str(a) for a in hit.get("supports") or [] if a] # statut billetterie : additional_information.representation_status # (['buy_now'], ['sold_out'], ['cancelled']…) — jamais inventé rep = (hit.get("additional_information") or {}) \ .get("representation_status") or [] status = "" for token in (rep if isinstance(rep, list) else [rep]): status = normalize_status(str(token)) if status: break # description enrichie : tournée + heure des portes (factuels, # publiés par la source ; aucun champ dédié au schéma) description = hit.get("description") or "" extras = [] if hit.get("tour_name"): extras.append(f"Tournée : {str(hit['tour_name']).strip()}") door = _unix_to_local_time(hit.get("door_time")) if door: extras.append(f"Portes : {door.replace(':', ' h ')}") if extras: description = " — ".join( p for p in (description, " · ".join(extras)) if p) # gratuité : champ `free` (aujourd'hui toujours null côté # source, gardé au cas où il serait re-rempli) + étiquettes # explicites « gratuit » (event_tag / promotional_tag). is_free = bool(hit["free"]) if hit.get("free") is not None else None tags = f"{hit.get('event_tag') or ''},{hit.get('promotional_tag') or ''}" if is_free is None and "gratuit" in tags.lower(): is_free = True events.append(Event( source=self.source_id, external_id=ext_id, url=_ticket_url(hit) or (venue.get("website") or "https://evenko.ca/fr/calendrier"), title=title, description=description, raw_categories=[c for c in cats if c] or ["Concert"], audience={"everyone": "Pour tous", "all_ages": "Pour tous", "18+": "18 ans et plus", "16+": "16 ans et plus", }.get(hit.get("age") or "", hit.get("age") or ""), venue=venue.get("name") or "", city=venue.get("city") or "", start_date=day, start_time=start_time, end_date=day, status=status, artists=artists, is_free=is_free, organizer="evenko", image=thumb, )) page += 1 return events