AniList Unlimited - Score in Header

For anilist.co, make manga and anime scores more prominent by moving them to the title.

  1. // ==UserScript==
  2. // @name AniList Unlimited - Score in Header
  3. // @namespace https://github.com/mysticflute
  4. // @version 1.0.3
  5. // @description For anilist.co, make manga and anime scores more prominent by moving them to the title.
  6. // @author mysticflute
  7. // @homepageURL https://github.com/mysticflute/ani-list-unlimited
  8. // @supportURL https://github.com/mysticflute/ani-list-unlimited/issues
  9. // @match https://anilist.co/*
  10. // @connect graphql.anilist.co
  11. // @connect api.jikan.moe
  12. // @connect kitsu.io
  13. // @grant GM_xmlhttpRequest
  14. // @grant GM_setValue
  15. // @grant GM_getValue
  16. // @grant GM.xmlHttpRequest
  17. // @grant GM.setValue
  18. // @grant GM.getValue
  19. // @license MIT
  20. // ==/UserScript==
  21.  
  22. // This user script was tested with the following user script managers:
  23. // - Violentmonkey (preferred): https://violentmonkey.github.io/
  24. // - TamperMonkey: https://www.tampermonkey.net/
  25. // - GreaseMonkey: https://www.greasespot.net/
  26.  
  27. (async function () {
  28. 'use strict';
  29.  
  30. /**
  31. * Default user configuration options.
  32. *
  33. * You can override these options if your user script runner supports it. Your
  34. * changes will persist across user script updates.
  35. *
  36. * In Violentmonkey:
  37. * 1. Install the user script.
  38. * 2. Let the script run at least once by loading an applicable url.
  39. * 3. Click the edit button for this script from the Violentmonkey menu.
  40. * 4. Click on the "Values" tab for this script.
  41. * 5. Click on the configuration option you want to change and edit the value
  42. * (change to true or false).
  43. * 6. Click the save button.
  44. * 7. Refresh or visit the page to see the changes.
  45. *
  46. * In TamperMonkey:
  47. * 1. Install the user script.
  48. * 2. Let the script run at least once by loading an applicable url.
  49. * 3. From the TamperMonkey dashboard, click the "Settings" tab.
  50. * 4. Change the "Config mode" mode to "Advanced".
  51. * 5. On the "Installed userscripts" tab (dashboard), click the edit button
  52. * for this script.
  53. * 6. Click the "Storage" tab. If you don't see this tab be sure the config
  54. * mode is set to "Advanced" as described above. Also be sure that you have
  55. * visited an applicable page with the user script enabled first.
  56. * 7. Change the value for any desired configuration options (change to true
  57. * or false).
  58. * 8. Click the "Save" button.
  59. * 9. Refresh or visit the page to see the changes. If it doesn't seem to be
  60. * working, refresh the TamperMonkey dashboard to double check your change
  61. * has stuck. If not try again and click the save button.
  62. *
  63. * Other user script managers:
  64. * 1. Change any of the options below directly in the code editor and save.
  65. * 2. Whenever you update this script or reinstall it you will have to make
  66. * your changes again.
  67. */
  68. const defaultConfig = {
  69. /** When true, adds the AniList average score to the header. */
  70. addAniListScoreToHeader: true,
  71.  
  72. /** When true, adds the MyAnimeList score to the header. */
  73. addMyAnimeListScoreToHeader: true,
  74.  
  75. /** When true, adds the Kitsu score to the header. */
  76. addKitsuScoreToHeader: false,
  77.  
  78. /** When true, show the smile/neutral/frown icons next to the AniList score. */
  79. showIconWithAniListScore: true,
  80.  
  81. /**
  82. * When true, show AniList's "Mean Score" instead of the "Average Score".
  83. * Regardless of this value, if the "Average Score" is not available
  84. * then the "Mean Score" will be shown.
  85. */
  86. preferAniListMeanScore: false,
  87.  
  88. /** When true, shows loading indicators when scores are being retrieved. */
  89. showLoadingIndicators: true,
  90. };
  91.  
  92. /**
  93. * Constants for this user script.
  94. */
  95. const constants = {
  96. /** Endpoint for the AniList API */
  97. ANI_LIST_API: 'https://graphql.anilist.co',
  98.  
  99. /** Endpoint for the MyAnimeList API */
  100. MAL_API: 'https://api.jikan.moe/v4',
  101.  
  102. /** Endpoint for the Kitsu API */
  103. KITSU_API: 'https://kitsu.io/api/edge',
  104.  
  105. /** Regex to extract the page type and media id from a AniList url path */
  106. ANI_LIST_URL_PATH_REGEX: /(anime|manga)\/([0-9]+)/i,
  107.  
  108. /** Prefix message for logs to the console */
  109. LOG_PREFIX: '[AniList Unlimited User Script]',
  110.  
  111. /** Prefix for class names added to created elements (prevent conflicts) */
  112. CLASS_PREFIX: 'user-script-ani-list-unlimited',
  113.  
  114. /** Title suffix added to created elements (for user information) */
  115. CUSTOM_ELEMENT_TITLE:
  116. '(this content was added by the ani-list-unlimited user script)',
  117.  
  118. /** When true, output additional logs to the console */
  119. DEBUG: false,
  120. };
  121.  
  122. /**
  123. * User script manager functions.
  124. *
  125. * Provides compatibility between Tampermonkey, Greasemonkey 4+, etc...
  126. */
  127. const userScriptAPI = (() => {
  128. const api = {};
  129.  
  130. if (typeof GM_xmlhttpRequest !== 'undefined') {
  131. api.GM_xmlhttpRequest = GM_xmlhttpRequest;
  132. } else if (
  133. typeof GM !== 'undefined' &&
  134. typeof GM.xmlHttpRequest !== 'undefined'
  135. ) {
  136. api.GM_xmlhttpRequest = GM.xmlHttpRequest;
  137. }
  138.  
  139. if (typeof GM_setValue !== 'undefined') {
  140. api.GM_setValue = GM_setValue;
  141. } else if (
  142. typeof GM !== 'undefined' &&
  143. typeof GM.setValue !== 'undefined'
  144. ) {
  145. api.GM_setValue = GM.setValue;
  146. }
  147.  
  148. if (typeof GM_getValue !== 'undefined') {
  149. api.GM_getValue = GM_getValue;
  150. } else if (
  151. typeof GM !== 'undefined' &&
  152. typeof GM.getValue !== 'undefined'
  153. ) {
  154. api.GM_getValue = GM.getValue;
  155. }
  156.  
  157. /** whether GM_xmlhttpRequest is supported. */
  158. api.supportsXHR = typeof api.GM_xmlhttpRequest !== 'undefined';
  159.  
  160. /** whether GM_setValue and GM_getValue are supported. */
  161. api.supportsStorage =
  162. typeof api.GM_getValue !== 'undefined' &&
  163. typeof api.GM_setValue !== 'undefined';
  164.  
  165. return api;
  166. })();
  167.  
  168. /**
  169. * Utility functions.
  170. */
  171. const utils = {
  172. /**
  173. * Logs an error message to the console.
  174. *
  175. * @param {string} message - The error message.
  176. * @param {...any} additional - Additional values to log.
  177. */
  178. error(message, ...additional) {
  179. console.error(`${constants.LOG_PREFIX} Error: ${message}`, ...additional);
  180. },
  181.  
  182. /**
  183. * Logs a group of related error messages to the console.
  184. *
  185. * @param {string} label - The group label.
  186. * @param {...any} additional - Additional error messages.
  187. */
  188. groupError(label, ...additional) {
  189. console.groupCollapsed(`${constants.LOG_PREFIX} Error: ${label}`);
  190. additional.forEach(entry => {
  191. console.log(entry);
  192. });
  193. console.groupEnd();
  194. },
  195.  
  196. /**
  197. * Logs a debug message which only shows when constants.DEBUG = true.
  198. *
  199. * @param {string} message The message.
  200. * @param {...any} additional - ADditional values to log.
  201. */
  202. debug(message, ...additional) {
  203. if (constants.DEBUG) {
  204. console.debug(`${constants.LOG_PREFIX} ${message}`, ...additional);
  205. }
  206. },
  207.  
  208. /**
  209. * Makes an XmlHttpRequest using the user script util.
  210. *
  211. * Common options include the following:
  212. *
  213. * - url (url endpoint, e.g., https://api.endpoint.com)
  214. * - method (e.g., GET or POST)
  215. * - headers (an object containing headers such as Content-Type)
  216. * - responseType (e.g., 'json')
  217. * - data (body data)
  218. *
  219. * See https://wiki.greasespot.net/GM.xmlHttpRequest for other options.
  220. *
  221. * If `options.responseType` is set then the response data is returned,
  222. * otherwise `responseText` is returned.
  223. *
  224. * @param {Object} options - The request options.
  225. *
  226. * @returns A Promise that resolves with the response or rejects on any
  227. * errors or status code other than 200.
  228. */
  229. xhr(options) {
  230. return new Promise((resolve, reject) => {
  231. const xhrOptions = Object.assign({}, options, {
  232. onabort: res => reject(res),
  233. ontimeout: res => reject(res),
  234. onerror: res => reject(res),
  235. onload: res => {
  236. if (res.status === 200) {
  237. if (options.responseType && res.response) {
  238. resolve(res.response);
  239. } else {
  240. resolve(res.responseText);
  241. }
  242. } else {
  243. reject(res);
  244. }
  245. },
  246. });
  247.  
  248. userScriptAPI.GM_xmlhttpRequest(xhrOptions);
  249. });
  250. },
  251.  
  252. /**
  253. * Waits for an element to load.
  254. *
  255. * @param {string} selector - Wait for the element matching this
  256. * selector to be found.
  257. * @param {Element} [container=document] - The root element for the
  258. * selector, defaults to `document`.
  259. * @param {number} [timeoutSecs=7] - The number of seconds to wait
  260. * before timing out.
  261. *
  262. * @returns {Promise<Element>} A Promise returning the DOM element, or a
  263. * rejection if a timeout occurred.
  264. */
  265. async waitForElement(selector, container = document, timeoutSecs = 7) {
  266. const element = container.querySelector(selector);
  267. if (element) {
  268. return Promise.resolve(element);
  269. }
  270.  
  271. return new Promise((resolve, reject) => {
  272. const timeoutTime = Date.now() + timeoutSecs * 1000;
  273.  
  274. const handler = () => {
  275. const element = document.querySelector(selector);
  276. if (element) {
  277. resolve(element);
  278. } else if (Date.now() > timeoutTime) {
  279. reject(new Error(`Timed out waiting for selector '${selector}'`));
  280. } else {
  281. setTimeout(handler, 100);
  282. }
  283. };
  284.  
  285. setTimeout(handler, 1);
  286. });
  287. },
  288.  
  289. /**
  290. * Loads user configuration from storage.
  291. *
  292. * @param {Object} defaultConfiguration - An object containing all of
  293. * the user configuration keys mapped to their default values. This
  294. * object will be used to set an initial value for any keys not currently
  295. * in storage.
  296. *
  297. * @param {Boolean} [setDefault=true] - When true, save the value from
  298. * defaultConfiguration for keys not present in storage for next time.
  299. * This lets the user edit the configuration more easily.
  300. *
  301. * @returns {Promise<Object>} A Promise returning an object that has the
  302. * config from storage, or an empty object if the storage APIs are not
  303. * defined.
  304. */
  305. async loadUserConfiguration(defaultConfiguration, setDefault = true) {
  306. if (!userScriptAPI.supportsStorage) {
  307. utils.debug('User configuration is not enabled');
  308. return {};
  309. }
  310.  
  311. const userConfig = {};
  312.  
  313. for (let [key, value] of Object.entries(defaultConfiguration)) {
  314. const userValue = await userScriptAPI.GM_getValue(key);
  315.  
  316. // initialize any config values that haven't been set
  317. if (setDefault && userValue === undefined) {
  318. utils.debug(`setting default config value for ${key}: ${value}`);
  319. userScriptAPI.GM_setValue(key, value);
  320. } else {
  321. userConfig[key] = userValue;
  322. }
  323. }
  324.  
  325. utils.debug('loaded user configuration from storage', userConfig);
  326. return userConfig;
  327. },
  328. };
  329.  
  330. /**
  331. * Functions to make API calls.
  332. */
  333. const api = {
  334. /**
  335. * Loads data from the AniList API.
  336. *
  337. * @param {('anime'|'manga')} type - The type of media content.
  338. * @param {string} aniListId - The AniList media id.
  339. *
  340. * @returns {Promise<Object>} A Promise returning the media's data, or a
  341. * rejection if there was a problem calling the API.
  342. */
  343. async loadAniListData(type, aniListId) {
  344. var query = `
  345. query ($id: Int, $type: MediaType) {
  346. Media (id: $id, type: $type) {
  347. idMal
  348. averageScore
  349. meanScore
  350. title {
  351. english
  352. romaji
  353. }
  354. }
  355. }
  356. `;
  357.  
  358. const variables = {
  359. id: aniListId,
  360. type: type.toUpperCase(),
  361. };
  362.  
  363. try {
  364. const response = await utils.xhr({
  365. url: constants.ANI_LIST_API,
  366. method: 'POST',
  367. headers: {
  368. 'Content-Type': 'application/json',
  369. Accept: 'application/json',
  370. },
  371. responseType: 'json',
  372. data: JSON.stringify({
  373. query,
  374. variables,
  375. }),
  376. });
  377. utils.debug('AniList API response:', response);
  378.  
  379. return response.data.Media;
  380. } catch (res) {
  381. const message = `AniList API request failed for media with ID '${aniListId}'`;
  382. utils.groupError(
  383. message,
  384. `Request failed with status ${res.status}`,
  385. ...(res.response ? res.response.errors : [res])
  386. );
  387. const error = new Error(message);
  388. error.response = res;
  389. throw error;
  390. }
  391. },
  392.  
  393. /**
  394. * Loads data from the MyAnimeList API.
  395. *
  396. * @param {('anime'|'manga')} type - The type of media content.
  397. * @param {string} myAnimeListId - The MyAnimeList media id.
  398. *
  399. * @returns {Promise<Object>} A Promise returning the media's data, or a
  400. * rejection if there was a problem calling the API.
  401. */
  402. async loadMyAnimeListData(type, myAnimeListId) {
  403. try {
  404. const response = await utils.xhr({
  405. url: `${constants.MAL_API}/${type}/${myAnimeListId}`,
  406. method: 'GET',
  407. responseType: 'json',
  408. });
  409. utils.debug('MyAnimeList API response:', response);
  410.  
  411. return response.data;
  412. } catch (res) {
  413. const message = `MyAnimeList API request failed for mapped MyAnimeList ID '${myAnimeListId}'`;
  414. utils.groupError(
  415. message,
  416. `Request failed with status ${res.status}`,
  417. res.response ? res.response.error || res.response.message : res
  418. );
  419. const error = new Error(message);
  420. error.response = res;
  421. throw error;
  422. }
  423. },
  424.  
  425. /**
  426. * Loads data from the Kitsu API.
  427. *
  428. * @param {('anime'|'manga')} type - The type of media content.
  429. * @param {string} englishTitle - Search for media with this title.
  430. * @param {string} romajiTitle - Search for media with this title.
  431. *
  432. * @returns {Promise<Object>} A Promise returning the media's data, or a
  433. * rejection if there was a problem calling the API.
  434. */
  435. async loadKitsuData(type, englishTitle, romajiTitle) {
  436. try {
  437. const fields = 'slug,averageRating,userCount,titles';
  438. const response = await utils.xhr({
  439. url: encodeURI(
  440. `${
  441. constants.KITSU_API
  442. }/${type}?page[limit]=3&fields[${type}]=${fields}&filter[text]=${
  443. englishTitle || romajiTitle
  444. }`
  445. ),
  446. method: 'GET',
  447. headers: {
  448. Accept: 'application/vnd.api+json',
  449. 'Content-Type': 'application/vnd.api+json',
  450. },
  451. responseType: 'json',
  452. });
  453. utils.debug('Kitsu API response:', response);
  454.  
  455. if (response.data && response.data.length) {
  456. let index = 0;
  457. let isExactMatch = false;
  458.  
  459. const collator = new Intl.Collator({
  460. usage: 'search',
  461. sensitivity: 'base',
  462. ignorePunctuation: true,
  463. });
  464.  
  465. const matchedIndex = response.data.findIndex(result => {
  466. return Object.values(result.attributes.titles).find(kitsuTitle => {
  467. return (
  468. collator.compare(englishTitle, kitsuTitle) === 0 ||
  469. collator.compare(romajiTitle, kitsuTitle) === 0
  470. );
  471. });
  472. });
  473.  
  474. if (matchedIndex > -1) {
  475. utils.debug(
  476. `matched title for Kitsu result at index ${matchedIndex}`,
  477. response.data[index]
  478. );
  479. index = matchedIndex;
  480. isExactMatch = true;
  481. } else {
  482. utils.debug('exact title match not found in Kitsu results');
  483. }
  484.  
  485. return {
  486. isExactMatch,
  487. data: response.data[index].attributes,
  488. };
  489. } else {
  490. utils.debug(`Kitsu API returned 0 results for '${englishTitle}'`);
  491. return {};
  492. }
  493. } catch (res) {
  494. const message = `Kitsu API request failed for text '${englishTitle}'`;
  495. utils.groupError(
  496. message,
  497. `Request failed with status ${res.status}`,
  498. ...(res.response ? res.response.errors : [])
  499. );
  500. const error = new Error(message);
  501. error.response = res;
  502. throw error;
  503. }
  504. },
  505. };
  506.  
  507. /**
  508. * AniList SVGs.
  509. */
  510. const svg = {
  511. /** from AniList */
  512. smile:
  513. '<svg aria-hidden="true" focusable="false" data-prefix="far" data-icon="smile" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512" color="rgb(var(--color-green))" class="icon svg-inline--fa fa-smile fa-w-16"><path fill="currentColor" d="M248 8C111 8 0 119 0 256s111 248 248 248 248-111 248-248S385 8 248 8zm0 448c-110.3 0-200-89.7-200-200S137.7 56 248 56s200 89.7 200 200-89.7 200-200 200zm-80-216c17.7 0 32-14.3 32-32s-14.3-32-32-32-32 14.3-32 32 14.3 32 32 32zm160 0c17.7 0 32-14.3 32-32s-14.3-32-32-32-32 14.3-32 32 14.3 32 32 32zm4 72.6c-20.8 25-51.5 39.4-84 39.4s-63.2-14.3-84-39.4c-8.5-10.2-23.7-11.5-33.8-3.1-10.2 8.5-11.5 23.6-3.1 33.8 30 36 74.1 56.6 120.9 56.6s90.9-20.6 120.9-56.6c8.5-10.2 7.1-25.3-3.1-33.8-10.1-8.4-25.3-7.1-33.8 3.1z" class=""></path></svg>',
  514. /** from AniList */
  515. straight:
  516. '<svg aria-hidden="true" focusable="false" data-prefix="far" data-icon="meh" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512" color="rgb(var(--color-orange))" class="icon svg-inline--fa fa-meh fa-w-16"><path fill="currentColor" d="M248 8C111 8 0 119 0 256s111 248 248 248 248-111 248-248S385 8 248 8zm0 448c-110.3 0-200-89.7-200-200S137.7 56 248 56s200 89.7 200 200-89.7 200-200 200zm-80-216c17.7 0 32-14.3 32-32s-14.3-32-32-32-32 14.3-32 32 14.3 32 32 32zm160-64c-17.7 0-32 14.3-32 32s14.3 32 32 32 32-14.3 32-32-14.3-32-32-32zm8 144H160c-13.2 0-24 10.8-24 24s10.8 24 24 24h176c13.2 0 24-10.8 24-24s-10.8-24-24-24z" class=""></path></svg>',
  517. /** from AniList */
  518. frown:
  519. '<svg aria-hidden="true" focusable="false" data-prefix="far" data-icon="frown" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512" color="rgb(var(--color-red))" class="icon svg-inline--fa fa-frown fa-w-16"><path fill="currentColor" d="M248 8C111 8 0 119 0 256s111 248 248 248 248-111 248-248S385 8 248 8zm0 448c-110.3 0-200-89.7-200-200S137.7 56 248 56s200 89.7 200 200-89.7 200-200 200zm-80-216c17.7 0 32-14.3 32-32s-14.3-32-32-32-32 14.3-32 32 14.3 32 32 32zm160-64c-17.7 0-32 14.3-32 32s14.3 32 32 32 32-14.3 32-32-14.3-32-32-32zm-80 128c-40.2 0-78 17.7-103.8 48.6-8.5 10.2-7.1 25.3 3.1 33.8 10.2 8.4 25.3 7.1 33.8-3.1 16.6-19.9 41-31.4 66.9-31.4s50.3 11.4 66.9 31.4c8.1 9.7 23.1 11.9 33.8 3.1 10.2-8.5 11.5-23.6 3.1-33.8C326 321.7 288.2 304 248 304z" class=""></path></svg>',
  520. /** From https://github.com/SamHerbert/SVG-Loaders */
  521. // License/accreditation https://github.com/SamHerbert/SVG-Loaders/blob/master/LICENSE.md
  522. loading:
  523. '<svg width="60" height="8" viewbox="0 0 130 32" style="fill: rgb(var(--color-text-light, 80%, 80%, 80%))" xmlns="http://www.w3.org/2000/svg" fill="#fff"><circle cx="15" cy="15" r="15"><animate attributeName="r" from="15" to="15" begin="0s" dur="0.8s" values="15;9;15" calcMode="linear" repeatCount="indefinite"/><animate attributeName="fill-opacity" from="1" to="1" begin="0s" dur="0.8s" values="1;.5;1" calcMode="linear" repeatCount="indefinite"/></circle><circle cx="60" cy="15" r="9" fill-opacity=".3"><animate attributeName="r" from="9" to="9" begin="0s" dur="0.8s" values="9;15;9" calcMode="linear" repeatCount="indefinite"/><animate attributeName="fill-opacity" from=".5" to=".5" begin="0s" dur="0.8s" values=".5;1;.5" calcMode="linear" repeatCount="indefinite"/></circle><circle cx="105" cy="15" r="15"><animate attributeName="r" from="15" to="15" begin="0s" dur="0.8s" values="15;9;15" calcMode="linear" repeatCount="indefinite"/><animate attributeName="fill-opacity" from="1" to="1" begin="0s" dur="0.8s" values="1;.5;1" calcMode="linear" repeatCount="indefinite"/></circle></svg>',
  524. };
  525.  
  526. /**
  527. * Handles manipulating the current AniList page.
  528. */
  529. class AniListPage {
  530. /**
  531. * @param {Object} config - The user script configuration.
  532. */
  533. constructor(config) {
  534. this.selectors = {
  535. pageTitle: 'head > title',
  536. header: '.page-content .header .content',
  537. };
  538.  
  539. this.config = config;
  540. this.lastCheckedUrlPath = null;
  541. }
  542.  
  543. /**
  544. * Initialize the page and apply page modifications.
  545. */
  546. initialize() {
  547. utils.debug('initializing page');
  548. this.applyPageModifications().catch(e =>
  549. utils.error(`Unable to apply modifications to the page - ${e.message}`)
  550. );
  551.  
  552. // eslint-disable-next-line no-unused-vars
  553. const observer = new MutationObserver((mutations, observer) => {
  554. utils.debug('mutation observer', mutations);
  555. this.applyPageModifications().catch(e =>
  556. utils.error(
  557. `Unable to apply modifications to the page - ${e.message}`
  558. )
  559. );
  560. });
  561.  
  562. const target = document.querySelector(this.selectors.pageTitle);
  563. observer.observe(target, { childList: true, characterData: true });
  564. }
  565.  
  566. /**
  567. * Applies modifications to the page based on config settings.
  568. *
  569. * This will only add content if we are on a relevant page in the app.
  570. */
  571. async applyPageModifications() {
  572. const pathname = window.location.pathname;
  573. utils.debug('checking page url', pathname);
  574.  
  575. if (this.lastCheckedUrlPath === pathname) {
  576. utils.debug('url path did not change, skipping');
  577. return;
  578. }
  579. this.lastCheckedUrlPath = pathname;
  580.  
  581. const matches = constants.ANI_LIST_URL_PATH_REGEX.exec(pathname);
  582. if (!matches) {
  583. utils.debug('url did not match');
  584. return;
  585. }
  586.  
  587. const pageType = matches[1];
  588. const mediaId = matches[2];
  589. utils.debug('pageType:', pageType, 'mediaId:', mediaId);
  590.  
  591. const aniListData = await api.loadAniListData(pageType, mediaId);
  592.  
  593. if (this.config.addAniListScoreToHeader) {
  594. this.addAniListScoreToHeader(pageType, mediaId, aniListData);
  595. }
  596.  
  597. if (this.config.addMyAnimeListScoreToHeader) {
  598. this.addMyAnimeListScoreToHeader(pageType, mediaId, aniListData);
  599. }
  600.  
  601. if (this.config.addKitsuScoreToHeader) {
  602. this.addKitsuScoreToHeader(pageType, mediaId, aniListData);
  603. }
  604. }
  605.  
  606. /**
  607. * Adds the AniList score to the header.
  608. *
  609. * @param {('anime'|'manga')} type - The type of media content.
  610. * @param {string} mediaId - The AniList media id.
  611. * @param {Object} aniListData - The data from the AniList api.
  612. */
  613. async addAniListScoreToHeader(pageType, mediaId, aniListData) {
  614. const slot = 1;
  615. const source = 'AniList';
  616.  
  617. let rawScore, info;
  618. if (
  619. aniListData.meanScore &&
  620. (this.config.preferAniListMeanScore || !aniListData.averageScore)
  621. ) {
  622. rawScore = aniListData.meanScore;
  623. info = ' (mean)';
  624. } else if (aniListData.averageScore) {
  625. rawScore = aniListData.averageScore;
  626. info = ' (average)';
  627. }
  628.  
  629. const score = rawScore ? `${rawScore}%` : '(N/A)';
  630.  
  631. let iconMarkup;
  632. if (this.config.showIconWithAniListScore) {
  633. if (rawScore === null || rawScore == undefined) {
  634. iconMarkup = svg.straight;
  635. } else if (rawScore >= 75) {
  636. iconMarkup = svg.smile;
  637. } else if (rawScore >= 60) {
  638. iconMarkup = svg.straight;
  639. } else {
  640. iconMarkup = svg.frown;
  641. }
  642. }
  643.  
  644. this.addToHeader({ slot, source, score, iconMarkup, info }).catch(e => {
  645. utils.error(
  646. `Unable to add the ${source} score to the header: ${e.message}`
  647. );
  648. });
  649. }
  650.  
  651. /**
  652. * Adds the MyAnimeList score to the header.
  653. *
  654. * @param {('anime'|'manga')} type - The type of media content.
  655. * @param {string} mediaId - The AniList media id.
  656. * @param {Object} aniListData - The data from the AniList api.
  657. */
  658. async addMyAnimeListScoreToHeader(pageType, mediaId, aniListData) {
  659. const slot = 2;
  660. const source = 'MyAnimeList';
  661.  
  662. if (!aniListData.idMal) {
  663. utils.error(`no ${source} id found for media ${mediaId}`);
  664. return this.clearHeaderSlot(slot);
  665. }
  666.  
  667. if (this.config.showLoadingIndicators) {
  668. await this.showSlotLoading(slot);
  669. }
  670.  
  671. api
  672. .loadMyAnimeListData(pageType, aniListData.idMal)
  673. .then(data => {
  674. const score = data.score;
  675. const href = data.url;
  676.  
  677. return this.addToHeader({ slot, source, score, href });
  678. })
  679. .catch(e => {
  680. utils.error(
  681. `Unable to add the ${source} score to the header: ${e.message}`
  682. );
  683.  
  684. // https://github.com/jikan-me/jikan-rest/issues/102
  685. if (e.response && e.response.status === 503) {
  686. return this.addToHeader({
  687. slot,
  688. source,
  689. score: 'Unavailable',
  690. info: ': The Jikan API is temporarily unavailable. Please try again later',
  691. });
  692. } else if (e.response && e.response.status === 429) {
  693. // rate limited
  694. return this.addToHeader({
  695. slot,
  696. source,
  697. score: 'Unavailable*',
  698. info: ': Temporarily unavailable due to rate-limiting, since you made too many requests to the MyAnimeList API. Reload in a few seconds to try again',
  699. });
  700. }
  701. });
  702. }
  703.  
  704. /**
  705. * Adds the Kitsu score to the header.
  706. *
  707. * @param {('anime'|'manga')} type - The type of media content.
  708. * @param {string} mediaId - The AniList media id.
  709. * @param {Object} aniListData - The data from the AniList api.
  710. */
  711. async addKitsuScoreToHeader(pageType, mediaId, aniListData) {
  712. const slot = 3;
  713. const source = 'Kitsu';
  714.  
  715. const englishTitle = aniListData.title.english;
  716. const romajiTitle = aniListData.title.romaji;
  717. if (!englishTitle && !romajiTitle) {
  718. utils.error(
  719. `Unable to search ${source} - no media title found for ${mediaId}`
  720. );
  721. return this.clearHeaderSlot(slot);
  722. }
  723.  
  724. if (this.config.showLoadingIndicators) {
  725. await this.showSlotLoading(slot);
  726. }
  727.  
  728. api
  729. .loadKitsuData(pageType, englishTitle, romajiTitle)
  730. .then(entry => {
  731. if (!entry.data) {
  732. utils.error(`no ${source} matches found for media ${mediaId}`);
  733. return this.clearHeaderSlot(slot);
  734. }
  735.  
  736. const data = entry.data;
  737.  
  738. let score = null;
  739. if (data.averageRating !== undefined && data.averageRating !== null) {
  740. score = `${data.averageRating}%`;
  741. if (!entry.isExactMatch) {
  742. score += '*';
  743. }
  744. }
  745.  
  746. const href = `https://kitsu.io/${pageType}/${data.slug}`;
  747.  
  748. let info = '';
  749. if (!entry.isExactMatch) {
  750. info += ', *exact match not found';
  751. }
  752. const kitsuTitles = Object.values(data.titles).join(', ');
  753. info += `, matched on "${kitsuTitles}"`;
  754.  
  755. return this.addToHeader({ slot, source, score, href, info });
  756. })
  757. .catch(e => {
  758. utils.error(
  759. `Unable to add the ${source} score to the header: ${e.message}`
  760. );
  761. });
  762. }
  763.  
  764. /**
  765. * Shows a loading indicator in the given slot position.
  766. *
  767. * @param {number} slot - The slot position.
  768. */
  769. async showSlotLoading(slot) {
  770. const slotEl = await this.getSlotElement(slot);
  771. if (slotEl) {
  772. slotEl.innerHTML = svg.loading;
  773. }
  774. }
  775.  
  776. /**
  777. * Removes markup from the header for the given slot position.
  778. *
  779. * @param {number} slot - The slot position.
  780. */
  781. async clearHeaderSlot(slot) {
  782. const slotEl = await this.getSlotElement(slot);
  783. if (slotEl) {
  784. while (slotEl.lastChild) {
  785. slotEl.removeChild(slotEl.lastChild);
  786. }
  787. slotEl.style.marginRight = '0';
  788. }
  789. }
  790.  
  791. /**
  792. * Add score data to a slot in the header section.
  793. *
  794. * @param {Object} info - Data about the score.
  795. * @param {number} info.slot - The ordering position within the header.
  796. * @param {string} info.source - The source of the data.
  797. * @param {string} [info.score] - The score text.
  798. * @param {string} [info.href] - The link for the media from the source.
  799. * @param {string} [info.iconMarkup] - Icon markup representing the score.
  800. * @param {string} [info=''] - Additional info about the score.
  801. */
  802. async addToHeader({ slot, source, score, href, iconMarkup, info = '' }) {
  803. const slotEl = await this.getSlotElement(slot);
  804. if (slotEl) {
  805. const newSlotEl = slotEl.cloneNode(false);
  806. newSlotEl.title = `${source} Score${info} ${constants.CUSTOM_ELEMENT_TITLE}`;
  807. newSlotEl.style.marginRight = '1rem';
  808. if (slot > 1) {
  809. newSlotEl.style.fontSize = '.875em';
  810. }
  811.  
  812. if (iconMarkup) {
  813. newSlotEl.insertAdjacentHTML('afterbegin', iconMarkup);
  814. newSlotEl.firstElementChild.style.marginRight = '6px';
  815. }
  816.  
  817. const scoreEl = document.createElement('span');
  818. if (slot > 1) {
  819. scoreEl.style.fontWeight = 'bold';
  820. }
  821. scoreEl.append(document.createTextNode(score || 'No Score'));
  822. newSlotEl.appendChild(scoreEl);
  823.  
  824. if (href) {
  825. newSlotEl.appendChild(document.createTextNode(' on '));
  826.  
  827. const link = document.createElement('a');
  828. link.href = href;
  829. link.title = `View this entry on ${source} ${constants.CUSTOM_ELEMENT_TITLE}`;
  830. link.textContent = source;
  831. newSlotEl.appendChild(link);
  832. }
  833.  
  834. slotEl.replaceWith(newSlotEl);
  835. } else {
  836. throw new Error(`Unable to find element to place ${source} score`);
  837. }
  838. }
  839.  
  840. /**
  841. * Gets the slot element at the given position.
  842. *
  843. * @param {number} slot - Get the slot element at this ordering position.
  844. */
  845. async getSlotElement(slot) {
  846. const containerEl = await this.getContainerElement();
  847. const slotClass = `${constants.CLASS_PREFIX}-slot${slot}`;
  848. return containerEl.querySelector(`.${slotClass}`);
  849. }
  850.  
  851. /**
  852. * Gets the container for new content, adding it to the DOM if
  853. * necessary.
  854. */
  855. async getContainerElement() {
  856. const headerEl = await utils.waitForElement(this.selectors.header);
  857. const insertionPoint =
  858. headerEl.querySelector('h1') || headerEl.firstElementChild;
  859.  
  860. const containerClass = `${constants.CLASS_PREFIX}-scores`;
  861. let containerEl = headerEl.querySelector(`.${containerClass}`);
  862. if (!containerEl) {
  863. containerEl = document.createElement('div');
  864. containerEl.className = containerClass;
  865. containerEl.style.display = 'flex';
  866. containerEl.style.marginTop = '1em';
  867. containerEl.style.alignItems = 'center';
  868.  
  869. const numSlots = 3;
  870. for (let i = 0; i < numSlots; i++) {
  871. const slotEl = document.createElement('div');
  872. slotEl.className = `${constants.CLASS_PREFIX}-slot${i + 1}`;
  873. containerEl.appendChild(slotEl);
  874. }
  875.  
  876. insertionPoint.insertAdjacentElement('afterend', containerEl);
  877. }
  878.  
  879. return containerEl;
  880. }
  881. }
  882.  
  883. // execution:
  884.  
  885. // check for compatibility
  886. if (!userScriptAPI.supportsXHR) {
  887. utils.error(
  888. 'The current version of your user script manager ' +
  889. 'does not support required features. Please update ' +
  890. 'it to the latest version and try again.'
  891. );
  892. return;
  893. }
  894.  
  895. // setup configuration
  896. const userConfig = await utils.loadUserConfiguration(defaultConfig);
  897. const config = Object.assign({}, defaultConfig, userConfig);
  898. utils.debug('configuration values:', config);
  899.  
  900. const page = new AniListPage(config);
  901. page.initialize();
  902. })();