anilist.py 14 KB

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