1
0

presence_sources.py 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200
  1. """Animes, musiques et jeux réels pour le statut Discord, lus sur AniList, Deezer et Steam."""
  2. from __future__ import annotations
  3. import asyncio
  4. import html
  5. import json
  6. import logging
  7. from dataclasses import asdict, dataclass
  8. from pathlib import Path
  9. import aiohttp
  10. logger = logging.getLogger("chatbot.presence")
  11. ANILIST_URL = "https://graphql.anilist.co"
  12. ANILIST_QUERY = """
  13. query ($page: Int) {
  14. Page(page: $page, perPage: 50) {
  15. media(type: ANIME, sort: POPULARITY_DESC, isAdult: false) {
  16. title { romaji } episodes duration nextAiringEpisode { episode }
  17. }
  18. }
  19. }
  20. """
  21. ANIME_PAGES = 10 # 10 pages de 50 : le top 500 des animes les plus populaires
  22. # Les tops Deezer contiennent 100 titres.
  23. DEEZER_TRACKS_URL = "https://api.deezer.com/playlist/{}/tracks?limit=100"
  24. STEAM_APPS_URL = "https://api.steampowered.com/IStoreService/GetAppList/v1/"
  25. STEAM_PAGE_SIZE = 50_000 # maximum accepté par Steam
  26. STEAM_MAX_PAGES = 10
  27. STEAM_DETAILS_URL = "https://store.steampowered.com/api/appdetails"
  28. # Descripteurs de contenu Steam écartés : 3 = contenu sexuel réservé aux adultes,
  29. # 4 = nudité ou contenu sexuel fréquents (1 = « un peu de nudité » reste accepté).
  30. STEAM_EXCLUDED_DESCRIPTORS = {3, 4}
  31. STEAM_DESCRIPTION_MAX_LENGTH = 300
  32. DEFAULT_TRACK_DURATION = 180 # secondes, si Deezer n'indique pas la durée
  33. DEFAULT_EPISODE_DURATION = 24 # minutes, si AniList n'indique pas la durée
  34. TIMEOUT = aiohttp.ClientTimeout(total=30)
  35. class SourceError(Exception):
  36. """Lecture d'une source impossible. Le message ne contient jamais la clé Steam."""
  37. @dataclass(frozen=True)
  38. class Anime:
  39. title: str
  40. episodes: int | None # épisodes déjà sortis (None : inconnu) ; 1 pour un film
  41. duration: int # minutes par épisode
  42. @dataclass(frozen=True)
  43. class Game:
  44. name: str
  45. description: str | None # présentation courte de la boutique Steam
  46. @dataclass(frozen=True)
  47. class Track:
  48. title: str
  49. artist: str
  50. duration: int # secondes
  51. async def get_json(session: aiohttp.ClientSession, method: str, url: str, **kwargs) -> dict:
  52. try:
  53. async with session.request(method, url, **kwargs) as response:
  54. if response.status in (401, 403):
  55. raise SourceError(f"accès refusé (HTTP {response.status})")
  56. if response.status != 200:
  57. raise SourceError(f"réponse HTTP {response.status}")
  58. return await response.json(content_type=None)
  59. except (aiohttp.ClientError, asyncio.TimeoutError, ValueError) as exc:
  60. # Pas de str(exc) : l'adresse de la requête, qu'il peut contenir, porte la clé Steam.
  61. raise SourceError(type(exc).__name__) from None
  62. class PresenceSources:
  63. """Listes téléchargées au démarrage puis une fois par jour, gardées en cache sur le disque.
  64. Si une source ne répond pas, sa liste précédente (ou celle du cache) est conservée."""
  65. def __init__(self, cache_file: Path, deezer_playlists: tuple[int, ...],
  66. steam_api_key: str | None):
  67. self.cache_file = cache_file
  68. self.deezer_playlists = deezer_playlists
  69. self.steam_api_key = steam_api_key
  70. self.anime: list[Anime] = []
  71. self.music: list[Track] = []
  72. self.games: list[int] = [] # identifiants de tous les jeux de la boutique Steam
  73. @property
  74. def steam_enabled(self) -> bool:
  75. return bool(self.steam_api_key)
  76. def load_cache(self) -> None:
  77. try:
  78. data = json.loads(self.cache_file.read_text(encoding="utf-8"))
  79. self.anime = [Anime(**a) for a in data.get("anime", [])]
  80. self.music = [Track(**t) for t in data.get("music", [])]
  81. if self.steam_enabled:
  82. self.games = [g for g in data.get("games", []) if isinstance(g, int)]
  83. except FileNotFoundError:
  84. pass
  85. except (OSError, ValueError, TypeError) as exc:
  86. logger.warning("Cache des statuts %s illisible, ignoré : %s", self.cache_file, exc)
  87. def save_cache(self) -> None:
  88. data = {"anime": [asdict(a) for a in self.anime], "music": [asdict(t) for t in self.music], "games": self.games}
  89. try:
  90. # Sans indentation : la liste des jeux compte plus de 100 000 identifiants.
  91. self.cache_file.write_text(json.dumps(data, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
  92. except OSError as exc:
  93. logger.warning("Impossible d'écrire le cache des statuts %s : %s", self.cache_file, exc)
  94. async def refresh(self) -> None:
  95. sources = [("anime", "Animes (AniList)", self.fetch_anime), ("music", "Musiques (Deezer)", self.fetch_music)]
  96. if self.steam_enabled:
  97. sources.append(("games", "Jeux (Steam)", self.fetch_games))
  98. async with aiohttp.ClientSession(timeout=TIMEOUT) as session:
  99. for attr, label, fetch in sources:
  100. try:
  101. items = await fetch(session)
  102. if not items:
  103. raise SourceError("liste vide")
  104. except SourceError as exc:
  105. logger.warning("%s : lecture impossible (%s), %d élément(s) conservé(s).",
  106. label, exc, len(getattr(self, attr)))
  107. else:
  108. setattr(self, attr, items)
  109. logger.info("%s : %d élément(s).", label, len(items))
  110. self.save_cache()
  111. @staticmethod
  112. async def fetch_anime(session: aiohttp.ClientSession) -> list[Anime]:
  113. anime: dict[str, Anime] = {}
  114. for page in range(1, ANIME_PAGES + 1):
  115. data = await get_json(session, "POST", ANILIST_URL,
  116. json={"query": ANILIST_QUERY, "variables": {"page": page}})
  117. try:
  118. media = data["data"]["Page"]["media"]
  119. except (KeyError, TypeError):
  120. raise SourceError("réponse inattendue") from None
  121. for m in media:
  122. title = (m.get("title") or {}).get("romaji")
  123. if not title:
  124. continue
  125. # Anime en cours de diffusion : seuls les épisodes déjà sortis comptent.
  126. episodes = m.get("episodes")
  127. if episodes is None and m.get("nextAiringEpisode"):
  128. episodes = m["nextAiringEpisode"]["episode"] - 1 or None
  129. anime[title] = Anime(title, episodes, m.get("duration") or DEFAULT_EPISODE_DURATION)
  130. return list(anime.values())
  131. async def fetch_music(self, session: aiohttp.ClientSession) -> list[Track]:
  132. tracks: dict[int, Track] = {} # par identifiant Deezer : un titre présent dans deux tops ne compte qu'une fois
  133. for playlist in self.deezer_playlists:
  134. data = await get_json(session, "GET", DEEZER_TRACKS_URL.format(playlist))
  135. if "error" in data: # Deezer signale ses erreurs avec un code HTTP 200
  136. raise SourceError(f"playlist {playlist} : {data['error'].get('message', 'erreur')}")
  137. for t in data.get("data", []):
  138. if t.get("id") and t.get("title") and t.get("artist", {}).get("name"):
  139. tracks[t["id"]] = Track(t["title"], t["artist"]["name"], t.get("duration") or DEFAULT_TRACK_DURATION)
  140. return list(tracks.values())
  141. async def fetch_games(self, session: aiohttp.ClientSession) -> list[int]:
  142. """Identifiants de tous les jeux de la boutique (sans DLC, logiciels ni vidéos), page par page."""
  143. games: list[int] = []
  144. last_appid = 0
  145. for _ in range(STEAM_MAX_PAGES):
  146. params = {"key": self.steam_api_key, "include_games": "true", "include_dlc": "false",
  147. "include_software": "false", "include_videos": "false", "include_hardware": "false",
  148. "max_results": str(STEAM_PAGE_SIZE), "last_appid": str(last_appid)}
  149. data = (await get_json(session, "GET", STEAM_APPS_URL, params=params)).get("response", {})
  150. games += [app["appid"] for app in data.get("apps", []) if app.get("appid")]
  151. if not data.get("have_more_results") or not data.get("last_appid"):
  152. break
  153. last_appid = data["last_appid"]
  154. return games
  155. @staticmethod
  156. async def game_details(appid: int) -> Game | None:
  157. """Fiche d'un jeu sur la boutique, ou None s'il ne convient pas (introuvable, pas un jeu, pour adultes)."""
  158. async with aiohttp.ClientSession(timeout=TIMEOUT) as session:
  159. data = await get_json(session, "GET", STEAM_DETAILS_URL,
  160. params={"appids": str(appid), "l": "french", "cc": "fr"})
  161. entry = data.get(str(appid)) or {}
  162. info = entry.get("data") if entry.get("success") else None
  163. if not info or info.get("type") != "game" or not info.get("name"):
  164. return None
  165. descriptors = set((info.get("content_descriptors") or {}).get("ids") or [])
  166. try:
  167. adult = int(info.get("required_age") or 0) >= 18
  168. except ValueError:
  169. adult = False
  170. if adult or descriptors & STEAM_EXCLUDED_DESCRIPTORS:
  171. return None
  172. description = html.unescape(info.get("short_description") or "").strip()
  173. return Game(info["name"], description[:STEAM_DESCRIPTION_MAX_LENGTH] or None)