# ----------------------------------------------------------------------------- # Sorti-Ka — Agrégateur de sorties & événements (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/showpass.py : connecteur Showpass (showpass.com) — billetterie # Source : billetterie canadienne (surtout AB/C.-B., mais des dizaines # d'événements QC : soirées, visites, festivals, Candlelight…). # Extraction: API JSON publique /api/public/events/ (paginée), filtrée par # points géographiques QC via location__point_location= # LAT,LNG,RAYON + starts_on__gte=aujourd'hui. Chaque événement # porte location.province → on ne garde QUE le Québec (le rayon # autour de Gatineau attrape Ottawa, vérifié live 2026-08-25). # Accès : robots.txt « Allow: /api/ » (API publique explicitement # autorisée) ; GET throttlés 0,8 s, identification honnête. # Prix : ticket_types[].price publiés par la billetterie → price_min # (plus bas tarif payant), is_free=True si TOUS les tarifs à 0 $. # Heure : local_starts_on (fuseau de l'événement) prioritaire, sinon # starts_on UTC converti heure de Montréal (utc_to_local). # ----------------------------------------------------------------------------- from __future__ import annotations import os from ..normalize import _key, clean_text, parse_time, utc_to_local from ..schema import Event from .base import BaseConnector BASE = "https://www.showpass.com" API = f"{BASE}/api/public/events/" # Points de couverture du Québec habité (lat, lng, rayon km) — l'API ne filtre # que par point+rayon, pas par province : on couvre le territoire par cercles # puis on filtre location.province côté connecteur (jamais d'événement ON/É.-U.). POINTS: list[tuple[float, float, int]] = [ (45.5019, -73.5674, 100), # Grand Montréal + Montérégie + Laurentides sud (46.8131, -71.2075, 120), # Québec + Chaudière-Appalaches + Charlevoix (45.4765, -75.7013, 60), # Gatineau / Outaouais (Ottawa filtrée ensuite) (45.4042, -71.8929, 80), # Sherbrooke / Estrie (46.3432, -72.5430, 80), # Trois-Rivières / Mauricie + Centre-du-Québec (48.4280, -71.0686, 120), # Saguenay–Lac-Saint-Jean (48.4488, -68.5236, 150), # Bas-Saint-Laurent (48.2359, -79.0289, 120), # Abitibi-Témiscamingue (48.8354, -64.4816, 150), # Gaspésie (50.2033, -66.3816, 150), # Côte-Nord ] PAGE_SIZE = int(os.environ.get("SHOWPASS_PAGE_SIZE", "100")) MAX_PER_POINT = int(os.environ.get("SHOWPASS_MAX_PER_POINT", "2000")) class ShowpassConnector(BaseConnector): source_id = "showpass" request_delay = 0.8 # -- API ---------------------------------------------------------------- def _point_events(self, lat: float, lng: float, radius: int, today: str) -> list[dict]: """Tous les événements à venir autour d'un point (pagination `next`).""" out: list[dict] = [] url: str | None = API params: dict | None = { "location__point_location": f"{lat},{lng},{radius}", "starts_on__gte": today, "page_size": PAGE_SIZE, } while url and len(out) < MAX_PER_POINT: data = self.get_json(url, params=params) out += data.get("results") or [] url, params = data.get("next"), None # `next` porte déjà tout return out # -- normalisation -------------------------------------------------------- def _to_event(self, e: dict) -> Event | None: loc = e.get("location") or {} if _key(str(loc.get("province") or "")) not in ("quebec", "qc"): return None # hors Québec : hors périmètre venue_obj = e.get("venue") or {} # heure locale : local_starts_on porte le fuseau de l'événement start_date, start_time = None, None local = e.get("local_starts_on") or "" if local: start_date, start_time = local[:10], parse_time(local) else: start_date, start_time = utc_to_local(e.get("starts_on")) end_date, end_time = utc_to_local(e.get("ends_on")) # tarifs réels publiés par la billetterie (jamais inventés) prices, labels = [], [] for t in e.get("ticket_types") or []: try: p = float(t.get("price")) except (TypeError, ValueError): continue prices.append(p) labels.append(f"{clean_text(t.get('name') or '')} {p:g} $") is_free, price_min, price_label = None, None, "" if prices: price_label = " · ".join(labels[:5]) + (" · …" if len(labels) > 5 else "") if max(prices) == 0: is_free, price_min = True, None else: is_free = False price_min = min(p for p in prices if p > 0) lat = lng = None pos = str(loc.get("position") or "") if "," in pos: lat, lng = pos.split(",", 1) return Event( source=self.source_id, external_id=str(e.get("id")), url=e.get("frontend_details_url") or f"{BASE}/{e.get('slug', '')}/", title=e.get("name") or "", description=clean_text(e.get("description_without_html") or "")[:2000], raw_categories=[e.get("name") or ""], venue=clean_text(loc.get("name") or venue_obj.get("name") or ""), address=clean_text(loc.get("street_name") or ""), city=clean_text(loc.get("city") or ""), postal_code=loc.get("postal_code") or "", lat=lat, lng=lng, start_date=start_date, start_time=start_time, end_date=end_date, end_time=end_time, status="soldout" if (e.get("sold_out") or e.get("inventory_sold_out")) else "", is_free=is_free, price_min=price_min, price_label=price_label, organizer=clean_text(venue_obj.get("name") or ""), website=venue_obj.get("web_address") or "", image=e.get("image") or e.get("image_banner") or "", ) # -- pipeline ------------------------------------------------------------- def fetch(self) -> list[Event]: from datetime import date today = date.today().isoformat() events: list[Event] = [] seen: set[str] = set() for lat, lng, radius in POINTS: try: batch = self._point_events(lat, lng, radius, today) except Exception as exc: # un point cassé ne bloque pas la source print(f"[sorti-ka] showpass : point ({lat},{lng}) ignoré : {exc}") continue for raw in batch: ev = self._to_event(raw) if ev is None or ev.external_id in seen: continue # hors QC, ou déjà vu (cercles se chevauchent) seen.add(ev.external_id) events.append(ev) return events