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%
8.9 KB · 178 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/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