1
0

anilist.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435
  1. import asyncio
  2. import datetime
  3. import time
  4. from enum import Enum
  5. from typing import Dict, List
  6. from discord import activity
  7. import requests
  8. import myanimebot.globals as globals
  9. import myanimebot.utils as utils
  10. from myanimebot.discord import send_embed_wrapper, build_embed
  11. ANILIST_GRAPHQL_URL = 'https://graphql.anilist.co'
  12. def get_media_name(activity):
  13. ''' Returns the media name in english if possible '''
  14. english_name = activity["media"]["title"]["english"]
  15. if english_name is not None:
  16. return english_name
  17. romaji_name = activity["media"]["title"]["romaji"]
  18. if romaji_name is not None:
  19. return romaji_name
  20. native_name = activity["media"]["title"]["native"]
  21. if native_name is not None:
  22. return native_name
  23. return ''
  24. def get_progress(feed : utils.Feed, activity : dict):
  25. ''' Tries to get progress from activity '''
  26. progress = activity["progress"]
  27. if progress is None:
  28. if feed.status == utils.MediaStatus.COMPLETED:
  29. return feed.media.episodes
  30. elif feed.status == utils.MediaStatus.PLANNING:
  31. return '0'
  32. else:
  33. return '?'
  34. return progress
  35. def get_number_episodes(activity, media_type : utils.MediaType):
  36. episodes = '?'
  37. if media_type == utils.MediaType.ANIME:
  38. episodes = activity["media"]["episodes"]
  39. elif media_type == utils.MediaType.MANGA:
  40. episodes = activity["media"]["chapters"]
  41. else:
  42. raise NotImplementedError('Error: Unknown media type "{}"'.format(media_type))
  43. if episodes is None:
  44. episodes = '?'
  45. return episodes
  46. def build_feed_from_activity(activity, user : utils.User) -> utils.Feed:
  47. if activity is None: return None
  48. media_type = utils.MediaType.from_str(activity["type"])
  49. media = utils.Media(id=activity["media"]["id"],
  50. name=get_media_name(activity),
  51. url=activity["media"]["siteUrl"],
  52. episodes=get_number_episodes(activity, media_type),
  53. image=activity["media"]["coverImage"]["large"],
  54. type=media_type)
  55. feed = utils.Feed(service=utils.Service.ANILIST,
  56. date_publication=datetime.datetime.fromtimestamp(activity["createdAt"], globals.timezone),
  57. user=user,
  58. status=utils.MediaStatus.from_str(activity["status"]),
  59. description=None,
  60. media=media,
  61. progress=None,
  62. score=None,
  63. score_format=None)
  64. feed.progress = get_progress(feed, activity)
  65. return feed
  66. def get_user_score_from_media(media_id : int, user_id : int) -> int:
  67. """ Look for the user's score on a specific media """
  68. query = '''query($userId: Int, $mediaId: Int){
  69. MediaList(userId: $userId, mediaId: $mediaId) {
  70. score
  71. user {
  72. mediaListOptions {
  73. scoreFormat
  74. }
  75. }
  76. }
  77. }'''
  78. variables = {
  79. 'userId': user_id,
  80. 'mediaId': media_id,
  81. }
  82. try:
  83. response = requests.post(ANILIST_GRAPHQL_URL, json={'query': query, 'variables': variables})
  84. response.raise_for_status()
  85. media_list = response.json()["data"]["MediaList"]
  86. score_fmt = media_list["user"]["mediaListOptions"]["scoreFormat"]
  87. score = media_list["score"]
  88. if media_list is None or score == 0: # Not scored
  89. return None, score_fmt
  90. else:
  91. return score, score_fmt
  92. except requests.HTTPError as e:
  93. globals.logging.error('''HTTP Error while getting the user's score (ID="{}") for media "{}". Error: {}'''.format(user_id, media_id, e))
  94. except Exception as e:
  95. globals.logging.error('''Unknown error while getting the user's score (ID="{}") for media "{}". Error: {}'''.format(user_id, media_id, e))
  96. return None, None
  97. def get_anilist_userId_from_name(user_name : str) -> int:
  98. """ Searches an AniList user by its name and returns its ID """
  99. query = '''query($userName: String){
  100. User(name: $userName) {
  101. id
  102. }
  103. }'''
  104. variables = {
  105. 'userName': user_name
  106. }
  107. try:
  108. response = requests.post(ANILIST_GRAPHQL_URL, json={'query': query, 'variables': variables})
  109. response.raise_for_status()
  110. return response.json()["data"]["User"]["id"]
  111. except requests.HTTPError as e:
  112. globals.logging.error('HTTP Error while getting the AniList user ID for "{}". Error: {}'.format(user_name, e))
  113. except Exception as e:
  114. globals.logging.error('Unknown error while getting the AniList user ID for "{}". Error: {}'.format(user_name, e))
  115. return None
  116. def get_latest_users_activities(users : List[utils.User], page: int, perPage = 5) -> List[utils.Feed]:
  117. """ Get latest users' activities """
  118. query = '''query ($userIds: [Int], $page: Int, $perPage: Int) {
  119. Page (page: $page, perPage: $perPage) {
  120. activities (userId_in: $userIds, sort: ID_DESC) {
  121. __typename
  122. ... on ListActivity {
  123. id
  124. type
  125. status
  126. progress
  127. isLocked
  128. createdAt
  129. user {
  130. id
  131. name
  132. }
  133. media {
  134. id
  135. siteUrl
  136. episodes
  137. chapters
  138. type
  139. title {
  140. romaji
  141. english
  142. native
  143. }
  144. coverImage {
  145. large
  146. }
  147. }
  148. }
  149. }
  150. }
  151. }'''
  152. variables = {
  153. "userIds": [user.service_id for user in users],
  154. "perPage": perPage,
  155. "page": page
  156. }
  157. try:
  158. # Execute GraphQL query
  159. response = requests.post(ANILIST_GRAPHQL_URL, json={'query': query, 'variables': variables})
  160. response.raise_for_status()
  161. data = response.json()["data"]["Page"]["activities"]
  162. # Create feeds from data
  163. feeds = []
  164. for activity in data:
  165. # Check if activity is a ListActivity
  166. if activity["__typename"] != 'ListActivity':
  167. continue
  168. # Find corresponding user for this ListActivity
  169. user = next((user for user in users if user.name == activity["user"]["name"]), None)
  170. if user is None:
  171. raise RuntimeError('Cannot find {} in our registered users'.format(activity["user"]["name"]))
  172. # Add new builded feed
  173. feeds.append(build_feed_from_activity(activity, user))
  174. return feeds
  175. except requests.HTTPError as e:
  176. globals.logging.error('HTTP Error while getting the latest users\' AniList activities for {} on page {} with {} items per page. Error: {}'.format(users, page, perPage, e))
  177. except Exception as e:
  178. globals.logging.error('Unknown Error while getting the latest users\' AniList activities for {} on page {} with {} items per page. Error: {}'.format(users, page, perPage, e))
  179. return None
  180. def check_username_validity(username) -> bool:
  181. """ Check if the AniList username exists """
  182. query = '''query($name: String) {
  183. User(name: $name) {
  184. name
  185. }
  186. }'''
  187. variables = {
  188. 'name': username
  189. }
  190. try:
  191. response = requests.post(ANILIST_GRAPHQL_URL, json={'query': query, 'variables': variables})
  192. response.raise_for_status()
  193. return response.json()["data"]["User"]["name"] == username
  194. except requests.HTTPError as e:
  195. status_code = e.response.status_code
  196. if status_code != 404:
  197. globals.logging.error('HTTP Error while trying to check this username validity: "{}". Error: {}'.format(username, e))
  198. except Exception as e:
  199. globals.logging.error('Unknown error while trying to check this username validity: "{}". Error: {}'.format(username, e))
  200. return False
  201. def get_latest_activity(users : List[utils.User]):
  202. """ Get the latest users' activity """
  203. # TODO Will fail if last activity is not a ListActivity
  204. query = '''query ($userIds: [Int]) {
  205. Activity(userId_in: $userIds, sort: ID_DESC) {
  206. __typename
  207. ... on ListActivity {
  208. id
  209. userId
  210. createdAt
  211. }
  212. }
  213. }'''
  214. variables = {
  215. "userIds": [user.service_id for user in users]
  216. }
  217. try:
  218. response = requests.post(ANILIST_GRAPHQL_URL, json={'query': query, 'variables': variables})
  219. response.raise_for_status()
  220. return response.json()["data"]["Activity"]
  221. except requests.HTTPError as e:
  222. globals.logging.error('HTTP Error while getting the latest AniList activity : {}'.format(e))
  223. except Exception as e:
  224. globals.logging.error('Unknown error while getting the latest AniList activity : {}'.format(e))
  225. return None
  226. def get_users_db():
  227. ''' Returns the registered users using AniList '''
  228. # TODO Make generic execute
  229. cursor = globals.conn.cursor(buffered=True, dictionary=True)
  230. cursor.execute("SELECT id, {}, servers FROM t_users WHERE service = %s".format(globals.DB_USER_NAME), [globals.SERVICE_ANILIST])
  231. users_data = cursor.fetchall()
  232. cursor.close()
  233. return users_data
  234. def get_users() -> List[utils.User]:
  235. users = []
  236. users_data = get_users_db()
  237. if users_data is not None:
  238. for user_data in users_data:
  239. users.append(utils.User(id=user_data["id"],
  240. service_id=get_anilist_userId_from_name(user_data[globals.DB_USER_NAME]),
  241. name=user_data[globals.DB_USER_NAME],
  242. servers=user_data["servers"].split(',')))
  243. return users
  244. def get_users_id(users_data) -> List[int]:
  245. ''' Returns the id of the registered users using AniList '''
  246. users_ids = []
  247. # Get users using AniList
  248. if users_data is not None:
  249. for user_data in users_data:
  250. users_ids.append(get_anilist_userId_from_name(user_data[globals.DB_USER_NAME]))
  251. # TODO Normalement pas besoin de recuperer les ids vu que je peux faire la recherche avec les noms
  252. return users_ids
  253. async def send_embed_to_channels(activity : utils.Feed):
  254. ''' Send an embed message describing the activity to user's channel '''
  255. for server in activity.user.servers:
  256. data_channels = utils.get_channels(server)
  257. if data_channels is not None:
  258. for channel in data_channels:
  259. await send_embed_wrapper(None,
  260. channel["channel"],
  261. globals.client,
  262. build_embed(activity))
  263. def insert_feed_db(feed: utils.Feed):
  264. ''' Insert an AniList feed into database '''
  265. cursor = globals.conn.cursor(buffered=True)
  266. cursor.execute("INSERT INTO t_feeds (published, title, url, user, found, type, service) VALUES (FROM_UNIXTIME(%s), %s, %s, %s, NOW(), %s, %s)",
  267. (feed.date_publication.timestamp(),
  268. feed.media.name,
  269. feed.media.url,
  270. feed.user.name,
  271. feed.get_status_str(),
  272. globals.SERVICE_ANILIST))
  273. globals.conn.commit()
  274. async def process_new_activities(last_activity_date, users : List[utils.User]):
  275. """ Fetch and process all newest activities """
  276. continue_fetching = True
  277. page_number = 1
  278. while continue_fetching:
  279. # Get activities
  280. activities = get_latest_users_activities(users, page_number)
  281. if activities == None: # An error occured, break the loop
  282. return
  283. # Processing them
  284. for activity in activities:
  285. # Get time difference between now and activity creation date
  286. diffTime = datetime.datetime.now(globals.timezone) - activity.date_publication
  287. # If the activity is older than the last_activity_date, we processed all the newest activities
  288. # Also, if the time difference is bigger than the config's "secondMax", we can stop processing them
  289. if activity.date_publication.timestamp() <= last_activity_date \
  290. or diffTime.total_seconds() > globals.secondMax:
  291. # FIXME If two or more feeds are published at the same time, this would skip them
  292. continue_fetching = False
  293. break
  294. # Process activity
  295. globals.logger.info('Adding new feed for "{}({})" about "{}"'.format(activity.user.name, activity.service.name, activity.media.name))
  296. insert_feed_db(activity)
  297. if activity.status == utils.MediaStatus.COMPLETED:
  298. activity.score, activity.score_format = get_user_score_from_media(activity.media.id, activity.user.service_id)
  299. await send_embed_to_channels(activity)
  300. # Load next activities page
  301. # TODO How can I avoid duplicate if insertion in between? With storing ids?
  302. if continue_fetching:
  303. page_number += 1
  304. time.sleep(1)
  305. def get_last_activity_date_db() -> float:
  306. # Refresh database
  307. globals.conn.commit()
  308. # Get last activity date
  309. cursor = globals.conn.cursor(buffered=True, dictionary=True)
  310. cursor.execute("SELECT published FROM t_feeds WHERE service=%s ORDER BY published DESC LIMIT 1", [globals.SERVICE_ANILIST])
  311. data = cursor.fetchone()
  312. if data is None or len(data) == 0:
  313. return 0.0
  314. else:
  315. return data["published"].timestamp()
  316. async def check_new_activities():
  317. """ Check if there is new activities and process them """
  318. last_activity_date = get_last_activity_date_db()
  319. # Get latest activity on AniList
  320. users = get_users()
  321. latest_activity = get_latest_activity(users)
  322. if latest_activity is not None:
  323. # If the latest activity is more recent than the last we stored
  324. globals.logger.debug('Comparing last registered feed ({}) with latest found feed ({})'.format(last_activity_date, latest_activity["createdAt"]))
  325. if last_activity_date < latest_activity["createdAt"]:
  326. globals.logger.debug("Found a more recent AniList feed")
  327. await process_new_activities(last_activity_date, users)
  328. async def background_check_feed(asyncioloop):
  329. ''' Main function that check the AniList feeds '''
  330. globals.logger.info("Starting up Anilist.background_check_feed")
  331. await globals.client.wait_until_ready()
  332. globals.logger.debug("Discord client connected, unlocking Anilist.background_check_feed...")
  333. while not globals.client.is_closed():
  334. globals.logger.debug('Fetching Anilist feeds')
  335. try:
  336. await check_new_activities()
  337. except Exception as e:
  338. globals.logger.error('Error while fetching Anilist feeds : ({})'.format(e))
  339. await asyncio.sleep(globals.ANILIST_SECONDS_BETWEEN_FETCHES)
  340. # TODO Bien renvoyer vers AniList (Liens/Liste/Anime)
  341. # TODO Comment eviter doublons MAL/AniList -> Ne pas faire je pense
  342. # TODO Insert anime into DB
  343. # TODO Uniformiser labels status feed entre MAL et ANILIST