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%
9.2 KB · 191 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/bandsintown.py : connecteur Bandsintown (API publique par artiste)5#   Source    : rest.bandsintown.com/artists/{artiste}/events — API publique6#               (app_id d'identification ; le format « js_<domaine> » des7#               widgets officiels est accepté, testé 2026-08 — un app_id8#               arbitraire simple est refusé « not authorized »).9#   Approche  : l'API ne permet pas de recherche géographique → on interroge10#               les artistes déjà connus de la base (colonne `artists` extraite11#               d'evenko / lepointdevente / ticketmaster, événements à venir)12#               et on ne garde que leurs dates au QUÉBEC (venue.region = QC).13#   Volume    : plafond BIT_ARTIST_MAX artistes/sync (défaut 100), rotation par14#               ancienneté via cache disque (data/bandsintown_cache.json) ; les15#               événements des artistes déjà sondés sont ré-émis depuis le16#               cache entre deux visites (re-visite après 3 jours).17#   Champs    : datetime local (date + heure), salle géolocalisée (lat/lng),18#               lineup → artistes, `free` → is_free, offres/billets → url,19#               image de l'artiste. L'objet `artist` n'est renseigné que sur20#               CERTAINS événements de la réponse (dict vide sur d'autres —21#               constaté 2026-08-25, cause de l'image à 8 %) : l'image est22#               reprise de n'importe quel événement de la réponse, sinon du23#               profil /artists/{artiste} (1 GET de plus, seulement si24#               l'artiste a des dates QC sans image).25# -----------------------------------------------------------------------------26from __future__ import annotations2728import os29import time30import urllib.parse3132from ..schema import Event33from .base import BaseConnector3435API = "https://rest.bandsintown.com/artists/{artist}/events"36APP_ID = os.environ.get("BANDSINTOWN_APP_ID", "js_www.sorti-ka.com")37ARTIST_MAX = int(os.environ.get("BIT_ARTIST_MAX", "100"))   # artistes/sync38REFRESH_AFTER = 3 * 86400          # re-visite d'un artiste (tournées ajoutées)3940_QC_REGIONS = {"qc", "quebec", "québec"}414243def _known_artists() -> list[str]:44    """Artistes des événements à venir déjà en base (sources billetterie)."""45    import json46    from datetime import date4748    from .. import db49    today = date.today().isoformat()50    con = db.connect()51    rows = con.execute(52        "SELECT artists FROM events WHERE active=1 AND artists IS NOT NULL "53        "AND artists != '[]' AND (end_date >= ? OR start_date >= ?) "54        "AND source IN ('evenko','lepointdevente','ticketmaster')",55        (today, today)).fetchall()56    con.close()57    seen, out = set(), []58    for r in rows:59        for a in json.loads(r["artists"] or "[]"):60            k = a.strip().lower()61            if k and k not in seen:62                seen.add(k)63                out.append(a.strip())64    return out656667class BandsintownConnector(BaseConnector):68    source_id = "bandsintown"69    request_delay = 0.57071    def _artist_events(self, artist: str) -> list[dict]:72        """Dates québécoises à venir d'un artiste (champs utiles seulement)."""73        url = API.format(artist=urllib.parse.quote(artist, safe=""))74        data = self.get_json(url, params={"app_id": APP_ID, "date": "upcoming"})75        if not isinstance(data, list):      # {"Message": …} = refus/erreur76            raise RuntimeError(f"réponse inattendue pour {artist!r} : {data}")77        # l'objet `artist` (image_url) n'est renseigné que sur certains78        # événements de la réponse — on prend la 1ʳᵉ image trouvée n'importe où79        artist_img = next(80            (((e.get("artist") or {}).get("image_url") or "") for e in data81             if (e.get("artist") or {}).get("image_url")), "")82        rows = []83        for e in data:84            venue = e.get("venue") or {}85            region = (venue.get("region") or "").strip().lower()86            country = (venue.get("country") or "").strip().lower()87            if country != "canada" or region not in _QC_REGIONS:88                continue89            dt = e.get("datetime") or ""       # heure locale de la salle90            art = e.get("artist") or {}91            # offres billetterie : lien billets direct + statut de vente92            # (type Tickets ; status "available" / "sold out" chez la source)93            ticket_url, offer_status = "", ""94            for off in e.get("offers") or []:95                if (off.get("type") or "").lower() == "tickets" and off.get("url"):96                    ticket_url = off["url"]97                    offer_status = (off.get("status") or "").lower()98                    break99            rows.append({100                "id": str(e.get("id") or ""),101                "url": ticket_url or e.get("url") or "",102                "offer_status": offer_status,103                "title": e.get("title") or "",104                "description": e.get("description") or "",105                "artist_name": art.get("name") or artist,106                "lineup": [a for a in e.get("lineup") or [] if a],107                "venue": venue.get("name") or "",108                "address": venue.get("street_address") or "",109                "city": venue.get("city") or "",110                "postal_code": venue.get("postal_code") or "",111                "lat": venue.get("latitude"),112                "lng": venue.get("longitude"),113                "date": dt[:10] or None,114                "time": dt[11:16] or None,115                "free": e.get("free"),116                "image": art.get("image_url") or "",117            })118        # aucune image dans toute la réponse mais des dates QC à émettre :119        # 1 GET de repli sur le profil artiste (image_url canonique)120        if rows and not artist_img and not any(r["image"] for r in rows):121            try:122                prof = self.get_json(123                    "https://rest.bandsintown.com/artists/"124                    + urllib.parse.quote(artist, safe=""),125                    params={"app_id": APP_ID})126                if isinstance(prof, dict):127                    artist_img = prof.get("image_url") or ""128            except Exception:      # profil indisponible : on n'invente rien129                artist_img = ""130        for r in rows:131            if not r["image"]:132                r["image"] = artist_img133        return rows134135    def fetch(self) -> list[Event]:136        artists = _known_artists()137        cache = self.load_cache()138        # rotation : jamais sondés d'abord, puis les plus anciens ; on garde en139        # cache les artistes toujours présents en base (sinon purge douce)140        keyed = {a.lower(): a for a in artists}141        cache = {k: v for k, v in cache.items() if k in keyed}142        now = time.time()143        todo = sorted(144            (k for k in keyed145             if now - (cache.get(k) or {}).get("fetched_at", 0) > REFRESH_AFTER),146            key=lambda k: (cache.get(k) or {}).get("fetched_at", 0))[:ARTIST_MAX]147148        for k in todo:149            try:150                cache[k] = {"fetched_at": now,151                            "events": self._artist_events(keyed[k])}152            except Exception as exc:   # un artiste raté ne bloque pas la source153                print(f"[sorti-ka] bandsintown : artiste ignoré {keyed[k]!r} : {exc}")154                cache.setdefault(k, {"fetched_at": 0, "events": []})155                cache[k]["fetched_at"] = now      # ne pas boucler dessus156        self.save_cache(cache)157158        events, seen = [], set()159        for entry in cache.values():160            for row in entry.get("events") or []:161                if not row.get("id") or row["id"] in seen:162                    continue163                seen.add(row["id"])164                title = row["title"] or row["artist_name"]165                events.append(Event(166                    source=self.source_id,167                    external_id=row["id"],168                    url=row["url"] or "https://www.bandsintown.com",169                    title=title,170                    description=row.get("description") or "",171                    raw_categories=["Concert"],172                    venue=row.get("venue", ""),173                    address=row.get("address", ""),174                    city=row.get("city", ""),175                    postal_code=row.get("postal_code", ""),176                    lat=row.get("lat"), lng=row.get("lng"),177                    start_date=row.get("date"),178                    start_time=(None if row.get("time") == "00:00"179                                else row.get("time")),180                    end_date=row.get("date"),181                    # statut de vente de l'offre (« sold out » explicite)182                    status=("soldout" if "sold" in (row.get("offer_status") or "")183                            else ""),184                    artists=row.get("lineup") or [row["artist_name"]],185                    # `free` vaut false par défaut chez Bandsintown : seul un186                    # true explicite est significatif (on n'invente rien)187                    is_free=(True if row.get("free") is True else None),188                    image=row.get("image", ""),189                ))190        return events191