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%
7.1 KB · 152 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/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