Toutes les sorties et tous les événements du Québec, un seul endroit — 7 connecteurs, fiches SSR, design Groupe KA.
HTML 82.9%
Python 15.2%
TypeScript 0.9%
JavaScript 0.7%
1# -----------------------------------------------------------------------------2# Sorti-Ka — Agrégateur de sorties & événements (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# connectors/evenko.py : connecteur evenko — grands concerts & festivals5# Source : evenko.ca (promoteur : Centre Bell, Place Bell, MTELUS, Osheaga…)6# Extraction: endpoint /api/search (proxy Algolia interne) — EXPLICITEMENT7# permis par robots.txt (« Allow: /api/search* »). Format percé8# dans leur bundle JS : GET ?body=base64(JSON{params:[query,9# options], lang:"fr-CA"}). ~14 requêtes/sync (100 hits/page).10# Accès : robots.txt Allow /api/search* ; GET throttlés, identification11# honnête ; lien billets = URL Ticketmaster officielle du hit.12# Prix : NON PUBLIÉS par la source. Champ `free` → is_free, MAIS sondé13# null sur 100 % des hits (462/462 vérifié 2026-08 ; re-vérifié14# 660/660 le 2026-08-25) et l'index15# ne porte aucun montant (additional_information.regular_ticket16# = libellés + URLs Ticketmaster seulement, filtre free:true →17# 0 hit ; event_discount = {percentage, amount, minimum} d'un18# rabais, pas un tarif). Re-sondé live 2026-08-25 sur l'index19# COMPLET (660 hits, tous attributs) ET sur les pages événement20# /fr/evenements/<salle>/<slug> de 6 salles : le JSON-LD Event21# y porte offers SANS price/priceCurrency (URL Ticketmaster +22# availability seulement) — les montants ne vivent que chez23# Ticketmaster (couvert par le connecteur ticketmaster, API24# officielle priceRanges). Complément honnête : étiquettes25# explicites « gratuit » dans event_tag / promotional_tag →26# is_free=True. On n'invente rien.27# -----------------------------------------------------------------------------28from __future__ import annotations2930import base6431import json32from datetime import datetime, timezone3334from ..schema import Event, normalize_status35from .base import BaseConnector3637API = "https://evenko.ca/api/search"38PAGE_SIZE = 1003940ATTRS = ["objectID", "title", "subtitle", "headliners", "supports", "venue",41 "show_date", "show_time", "hide_show_time", "door_time", "tour_name",42 "category", "genre", "description",43 "free", "age", "thumbnail", "status", "additional_information",44 "presented_by", "event_tag", "promotional_tag"]4546# les salles evenko hors Québec sont exclues du périmètre (Sorti-Ka = QC)47_EXCLUDED_REGIONS = ("ottawa", "ontario", "nouveau-brunswick", "nouvelle-écosse",48 "nouvelle-ecosse", "terre-neuve", "états-unis", "etats-unis",49 "île-du-prince-édouard", "ile-du-prince-edouard", "manitoba")505152def _unix_to_iso(ts) -> str | None:53 try:54 return datetime.fromtimestamp(int(ts), tz=timezone.utc).strftime("%Y-%m-%d")55 except (TypeError, ValueError, OSError):56 return None575859def _unix_to_local_time(ts) -> str | None:60 """show_time (epoch) → heure locale du spectacle ("19:30"), Québec."""61 from zoneinfo import ZoneInfo62 try:63 t = datetime.fromtimestamp(int(ts), tz=ZoneInfo("America/Montreal"))64 return None if t.strftime("%H:%M") == "00:00" else t.strftime("%H:%M")65 except (TypeError, ValueError, OSError):66 return None676869def _ticket_url(hit: dict) -> str:70 """URL billets officielle (fr_CA) depuis additional_information."""71 reg = (hit.get("additional_information") or {}).get("regular_ticket") or {}72 for entry in reg.get("regular_ticket_url") or []:73 if entry.get("locale") == "fr_CA" and entry.get("data"):74 return entry["data"]75 for entry in reg.get("regular_ticket_url") or []:76 if entry.get("data"):77 return entry["data"]78 return ""798081class EvenkoConnector(BaseConnector):82 source_id = "evenko"8384 def _search_page(self, page: int) -> dict:85 payload = {"params": ["", {"hitsPerPage": PAGE_SIZE, "page": page,86 "filters": "entity_type:evenko_show",87 "attributesToRetrieve": ATTRS}],88 "lang": "fr-CA"}89 body = base64.b64encode(json.dumps(payload).encode()).decode()90 return self.get_json(API, params={"body": body})9192 def fetch(self) -> list[Event]:93 events: list[Event] = []94 page, nb_pages = 0, 195 while page < nb_pages:96 data = self._search_page(page)97 nb_pages = min(int(data.get("nbPages", 0)), 50) # garde-fou98 for hit in data.get("hits", []):99 ext_id = (hit.get("objectID") or "").removesuffix("_fr")100 title = hit.get("title") or ""101 if not ext_id or not title:102 continue103 venue = hit.get("venue") or {}104 region_lbl = (venue.get("region") or "").lower()105 if any(x in region_lbl for x in _EXCLUDED_REGIONS):106 continue107 if hit.get("subtitle"):108 title = f"{title} — {hit['subtitle']}"109 cats = [g.get("name", "") for g in hit.get("genre") or []]110 cat = hit.get("category")111 if isinstance(cat, list):112 cats += [str(c) for c in cat]113 elif cat:114 cats.append(str(cat))115 thumb = hit.get("thumbnail") or ""116 if thumb.startswith("//"):117 thumb = "https:" + thumb118 day = _unix_to_iso(hit.get("show_date")) or _unix_to_iso(hit.get("show_time"))119 # heure précise : show_time (epoch) sauf si la source demande120 # explicitement de la cacher (hide_show_time)121 start_time = (None if hit.get("hide_show_time")122 else _unix_to_local_time(hit.get("show_time")))123 # artistes : têtes d'affiche (`headliners`) + premières124 # parties (`supports`) — champs explicites de la source125 artists = [str(a) for a in hit.get("headliners") or [] if a]126 artists += [str(a) for a in hit.get("supports") or [] if a]127 # statut billetterie : additional_information.representation_status128 # (['buy_now'], ['sold_out'], ['cancelled']…) — jamais inventé129 rep = (hit.get("additional_information") or {}) \130 .get("representation_status") or []131 status = ""132 for token in (rep if isinstance(rep, list) else [rep]):133 status = normalize_status(str(token))134 if status:135 break136 # description enrichie : tournée + heure des portes (factuels,137 # publiés par la source ; aucun champ dédié au schéma)138 description = hit.get("description") or ""139 extras = []140 if hit.get("tour_name"):141 extras.append(f"Tournée : {str(hit['tour_name']).strip()}")142 door = _unix_to_local_time(hit.get("door_time"))143 if door:144 extras.append(f"Portes : {door.replace(':', ' h ')}")145 if extras:146 description = " — ".join(147 p for p in (description, " · ".join(extras)) if p)148 # gratuité : champ `free` (aujourd'hui toujours null côté149 # source, gardé au cas où il serait re-rempli) + étiquettes150 # explicites « gratuit » (event_tag / promotional_tag).151 is_free = bool(hit["free"]) if hit.get("free") is not None else None152 tags = f"{hit.get('event_tag') or ''},{hit.get('promotional_tag') or ''}"153 if is_free is None and "gratuit" in tags.lower():154 is_free = True155 events.append(Event(156 source=self.source_id,157 external_id=ext_id,158 url=_ticket_url(hit) or (venue.get("website") or "https://evenko.ca/fr/calendrier"),159 title=title,160 description=description,161 raw_categories=[c for c in cats if c] or ["Concert"],162 audience={"everyone": "Pour tous", "all_ages": "Pour tous",163 "18+": "18 ans et plus", "16+": "16 ans et plus",164 }.get(hit.get("age") or "", hit.get("age") or ""),165 venue=venue.get("name") or "",166 city=venue.get("city") or "",167 start_date=day,168 start_time=start_time,169 end_date=day,170 status=status,171 artists=artists,172 is_free=is_free,173 organizer="evenko",174 image=thumb,175 ))176 page += 1177 return events178