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/showpass.py : connecteur Showpass (showpass.com) — billetterie5# Source : billetterie canadienne (surtout AB/C.-B., mais des dizaines6# d'événements QC : soirées, visites, festivals, Candlelight…).7# Extraction: API JSON publique /api/public/events/ (paginée), filtrée par8# points géographiques QC via location__point_location=9# LAT,LNG,RAYON + starts_on__gte=aujourd'hui. Chaque événement10# porte location.province → on ne garde QUE le Québec (le rayon11# autour de Gatineau attrape Ottawa, vérifié live 2026-08-25).12# Accès : robots.txt « Allow: /api/ » (API publique explicitement13# autorisée) ; GET throttlés 0,8 s, identification honnête.14# Prix : ticket_types[].price publiés par la billetterie → price_min15# (plus bas tarif payant), is_free=True si TOUS les tarifs à 0 $.16# Heure : local_starts_on (fuseau de l'événement) prioritaire, sinon17# starts_on UTC converti heure de Montréal (utc_to_local).18# -----------------------------------------------------------------------------19from __future__ import annotations2021import os2223from ..normalize import _key, clean_text, parse_time, utc_to_local24from ..schema import Event25from .base import BaseConnector2627BASE = "https://www.showpass.com"28API = f"{BASE}/api/public/events/"2930# Points de couverture du Québec habité (lat, lng, rayon km) — l'API ne filtre31# que par point+rayon, pas par province : on couvre le territoire par cercles32# puis on filtre location.province côté connecteur (jamais d'événement ON/É.-U.).33POINTS: list[tuple[float, float, int]] = [34 (45.5019, -73.5674, 100), # Grand Montréal + Montérégie + Laurentides sud35 (46.8131, -71.2075, 120), # Québec + Chaudière-Appalaches + Charlevoix36 (45.4765, -75.7013, 60), # Gatineau / Outaouais (Ottawa filtrée ensuite)37 (45.4042, -71.8929, 80), # Sherbrooke / Estrie38 (46.3432, -72.5430, 80), # Trois-Rivières / Mauricie + Centre-du-Québec39 (48.4280, -71.0686, 120), # Saguenay–Lac-Saint-Jean40 (48.4488, -68.5236, 150), # Bas-Saint-Laurent41 (48.2359, -79.0289, 120), # Abitibi-Témiscamingue42 (48.8354, -64.4816, 150), # Gaspésie43 (50.2033, -66.3816, 150), # Côte-Nord44]4546PAGE_SIZE = int(os.environ.get("SHOWPASS_PAGE_SIZE", "100"))47MAX_PER_POINT = int(os.environ.get("SHOWPASS_MAX_PER_POINT", "2000"))484950class ShowpassConnector(BaseConnector):51 source_id = "showpass"52 request_delay = 0.85354 # -- API ----------------------------------------------------------------55 def _point_events(self, lat: float, lng: float, radius: int,56 today: str) -> list[dict]:57 """Tous les événements à venir autour d'un point (pagination `next`)."""58 out: list[dict] = []59 url: str | None = API60 params: dict | None = {61 "location__point_location": f"{lat},{lng},{radius}",62 "starts_on__gte": today, "page_size": PAGE_SIZE,63 }64 while url and len(out) < MAX_PER_POINT:65 data = self.get_json(url, params=params)66 out += data.get("results") or []67 url, params = data.get("next"), None # `next` porte déjà tout68 return out6970 # -- normalisation --------------------------------------------------------71 def _to_event(self, e: dict) -> Event | None:72 loc = e.get("location") or {}73 if _key(str(loc.get("province") or "")) not in ("quebec", "qc"):74 return None # hors Québec : hors périmètre75 venue_obj = e.get("venue") or {}7677 # heure locale : local_starts_on porte le fuseau de l'événement78 start_date, start_time = None, None79 local = e.get("local_starts_on") or ""80 if local:81 start_date, start_time = local[:10], parse_time(local)82 else:83 start_date, start_time = utc_to_local(e.get("starts_on"))84 end_date, end_time = utc_to_local(e.get("ends_on"))8586 # tarifs réels publiés par la billetterie (jamais inventés)87 prices, labels = [], []88 for t in e.get("ticket_types") or []:89 try:90 p = float(t.get("price"))91 except (TypeError, ValueError):92 continue93 prices.append(p)94 labels.append(f"{clean_text(t.get('name') or '')} {p:g} $")95 is_free, price_min, price_label = None, None, ""96 if prices:97 price_label = " · ".join(labels[:5]) + (" · …" if len(labels) > 5 else "")98 if max(prices) == 0:99 is_free, price_min = True, None100 else:101 is_free = False102 price_min = min(p for p in prices if p > 0)103104 lat = lng = None105 pos = str(loc.get("position") or "")106 if "," in pos:107 lat, lng = pos.split(",", 1)108109 return Event(110 source=self.source_id,111 external_id=str(e.get("id")),112 url=e.get("frontend_details_url")113 or f"{BASE}/{e.get('slug', '')}/",114 title=e.get("name") or "",115 description=clean_text(e.get("description_without_html")116 or "")[:2000],117 raw_categories=[e.get("name") or ""],118 venue=clean_text(loc.get("name") or venue_obj.get("name") or ""),119 address=clean_text(loc.get("street_name") or ""),120 city=clean_text(loc.get("city") or ""),121 postal_code=loc.get("postal_code") or "",122 lat=lat, lng=lng,123 start_date=start_date, start_time=start_time,124 end_date=end_date, end_time=end_time,125 status="soldout" if (e.get("sold_out")126 or e.get("inventory_sold_out")) else "",127 is_free=is_free, price_min=price_min, price_label=price_label,128 organizer=clean_text(venue_obj.get("name") or ""),129 website=venue_obj.get("web_address") or "",130 image=e.get("image") or e.get("image_banner") or "",131 )132133 # -- pipeline -------------------------------------------------------------134 def fetch(self) -> list[Event]:135 from datetime import date136 today = date.today().isoformat()137 events: list[Event] = []138 seen: set[str] = set()139 for lat, lng, radius in POINTS:140 try:141 batch = self._point_events(lat, lng, radius, today)142 except Exception as exc: # un point cassé ne bloque pas la source143 print(f"[sorti-ka] showpass : point ({lat},{lng}) ignoré : {exc}")144 continue145 for raw in batch:146 ev = self._to_event(raw)147 if ev is None or ev.external_id in seen:148 continue # hors QC, ou déjà vu (cercles se chevauchent)149 seen.add(ev.external_id)150 events.append(ev)151 return events152