AniList Unlimited - Score in Header

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

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name          AniList Unlimited - Score in Header
// @namespace     https://github.com/mysticflute
// @version       1.0.3
// @description   For anilist.co, make manga and anime scores more prominent by moving them to the title.
// @author        mysticflute
// @homepageURL   https://github.com/mysticflute/ani-list-unlimited
// @supportURL    https://github.com/mysticflute/ani-list-unlimited/issues
// @match         https://anilist.co/*
// @connect       graphql.anilist.co
// @connect       api.jikan.moe
// @connect       kitsu.io
// @grant         GM_xmlhttpRequest
// @grant         GM_setValue
// @grant         GM_getValue
// @grant         GM.xmlHttpRequest
// @grant         GM.setValue
// @grant         GM.getValue
// @license       MIT
// ==/UserScript==

// This user script was tested with the following user script managers:
// - Violentmonkey (preferred): https://violentmonkey.github.io/
// - TamperMonkey: https://www.tampermonkey.net/
// - GreaseMonkey: https://www.greasespot.net/

(async function () {
  'use strict';

  /**
   * Default user configuration options.
   *
   * You can override these options if your user script runner supports it. Your
   * changes will persist across user script updates.
   *
   * In Violentmonkey:
   * 1. Install the user script.
   * 2. Let the script run at least once by loading an applicable url.
   * 3. Click the edit button for this script from the Violentmonkey menu.
   * 4. Click on the "Values" tab for this script.
   * 5. Click on the configuration option you want to change and edit the value
   *    (change to true or false).
   * 6. Click the save button.
   * 7. Refresh or visit the page to see the changes.
   *
   * In TamperMonkey:
   * 1. Install the user script.
   * 2. Let the script run at least once by loading an applicable url.
   * 3. From the TamperMonkey dashboard, click the "Settings" tab.
   * 4. Change the "Config mode" mode to "Advanced".
   * 5. On the "Installed userscripts" tab (dashboard), click the edit button
   *    for this script.
   * 6. Click the "Storage" tab. If you don't see this tab be sure the config
   *    mode is set to "Advanced" as described above. Also be sure that you have
   *    visited an applicable page with the user script enabled first.
   * 7. Change the value for any desired configuration options (change to true
   *    or false).
   * 8. Click the "Save" button.
   * 9. Refresh or visit the page to see the changes. If it doesn't seem to be
   *    working, refresh the TamperMonkey dashboard to double check your change
   *    has stuck. If not try again and click the save button.
   *
   * Other user script managers:
   * 1. Change any of the options below directly in the code editor and save.
   * 2. Whenever you update this script or reinstall it you will have to make
   *    your changes again.
   */
  const defaultConfig = {
    /** When true, adds the AniList average score to the header. */
    addAniListScoreToHeader: true,

    /** When true, adds the MyAnimeList score to the header. */
    addMyAnimeListScoreToHeader: true,

    /** When true, adds the Kitsu score to the header. */
    addKitsuScoreToHeader: false,

    /** When true, show the smile/neutral/frown icons next to the AniList score. */
    showIconWithAniListScore: true,

    /**
     * When true, show AniList's "Mean Score" instead of the "Average Score".
     * Regardless of this value, if the "Average Score" is not available
     * then the "Mean Score" will be shown.
     */
    preferAniListMeanScore: false,

    /** When true, shows loading indicators when scores are being retrieved. */
    showLoadingIndicators: true,
  };

  /**
   * Constants for this user script.
   */
  const constants = {
    /** Endpoint for the AniList API */
    ANI_LIST_API: 'https://graphql.anilist.co',

    /** Endpoint for the MyAnimeList API */
    MAL_API: 'https://api.jikan.moe/v4',

    /** Endpoint for the Kitsu API */
    KITSU_API: 'https://kitsu.io/api/edge',

    /** Regex to extract the page type and media id from a AniList url path */
    ANI_LIST_URL_PATH_REGEX: /(anime|manga)\/([0-9]+)/i,

    /** Prefix message for logs to the console */
    LOG_PREFIX: '[AniList Unlimited User Script]',

    /** Prefix for class names added to created elements (prevent conflicts) */
    CLASS_PREFIX: 'user-script-ani-list-unlimited',

    /** Title suffix added to created elements (for user information) */
    CUSTOM_ELEMENT_TITLE:
      '(this content was added by the ani-list-unlimited user script)',

    /** When true, output additional logs to the console */
    DEBUG: false,
  };

  /**
   * User script manager functions.
   *
   * Provides compatibility between Tampermonkey, Greasemonkey 4+, etc...
   */
  const userScriptAPI = (() => {
    const api = {};

    if (typeof GM_xmlhttpRequest !== 'undefined') {
      api.GM_xmlhttpRequest = GM_xmlhttpRequest;
    } else if (
      typeof GM !== 'undefined' &&
      typeof GM.xmlHttpRequest !== 'undefined'
    ) {
      api.GM_xmlhttpRequest = GM.xmlHttpRequest;
    }

    if (typeof GM_setValue !== 'undefined') {
      api.GM_setValue = GM_setValue;
    } else if (
      typeof GM !== 'undefined' &&
      typeof GM.setValue !== 'undefined'
    ) {
      api.GM_setValue = GM.setValue;
    }

    if (typeof GM_getValue !== 'undefined') {
      api.GM_getValue = GM_getValue;
    } else if (
      typeof GM !== 'undefined' &&
      typeof GM.getValue !== 'undefined'
    ) {
      api.GM_getValue = GM.getValue;
    }

    /** whether GM_xmlhttpRequest is supported. */
    api.supportsXHR = typeof api.GM_xmlhttpRequest !== 'undefined';

    /** whether GM_setValue and GM_getValue are supported. */
    api.supportsStorage =
      typeof api.GM_getValue !== 'undefined' &&
      typeof api.GM_setValue !== 'undefined';

    return api;
  })();

  /**
   * Utility functions.
   */
  const utils = {
    /**
     * Logs an error message to the console.
     *
     * @param {string} message - The error message.
     * @param  {...any} additional - Additional values to log.
     */
    error(message, ...additional) {
      console.error(`${constants.LOG_PREFIX} Error: ${message}`, ...additional);
    },

    /**
     * Logs a group of related error messages to the console.
     *
     * @param {string} label - The group label.
     * @param  {...any} additional - Additional error messages.
     */
    groupError(label, ...additional) {
      console.groupCollapsed(`${constants.LOG_PREFIX} Error: ${label}`);
      additional.forEach(entry => {
        console.log(entry);
      });
      console.groupEnd();
    },

    /**
     * Logs a debug message which only shows when constants.DEBUG = true.
     *
     * @param {string} message The message.
     * @param  {...any} additional - ADditional values to log.
     */
    debug(message, ...additional) {
      if (constants.DEBUG) {
        console.debug(`${constants.LOG_PREFIX} ${message}`, ...additional);
      }
    },

    /**
     * Makes an XmlHttpRequest using the user script util.
     *
     * Common options include the following:
     *
     * - url (url endpoint, e.g., https://api.endpoint.com)
     * - method (e.g., GET or POST)
     * - headers (an object containing headers such as Content-Type)
     * - responseType (e.g., 'json')
     * - data (body data)
     *
     * See https://wiki.greasespot.net/GM.xmlHttpRequest for other options.
     *
     * If `options.responseType` is set then the response data is returned,
     * otherwise `responseText` is returned.
     *
     * @param {Object} options - The request options.
     *
     * @returns A Promise that resolves with the response or rejects on any
     * errors or status code other than 200.
     */
    xhr(options) {
      return new Promise((resolve, reject) => {
        const xhrOptions = Object.assign({}, options, {
          onabort: res => reject(res),
          ontimeout: res => reject(res),
          onerror: res => reject(res),
          onload: res => {
            if (res.status === 200) {
              if (options.responseType && res.response) {
                resolve(res.response);
              } else {
                resolve(res.responseText);
              }
            } else {
              reject(res);
            }
          },
        });

        userScriptAPI.GM_xmlhttpRequest(xhrOptions);
      });
    },

    /**
     * Waits for an element to load.
     *
     * @param {string} selector - Wait for the element matching this
     * selector to be found.
     * @param {Element} [container=document] - The root element for the
     * selector, defaults to `document`.
     * @param {number} [timeoutSecs=7] - The number of seconds to wait
     * before timing out.
     *
     * @returns {Promise<Element>} A Promise returning the DOM element, or a
     * rejection if a timeout occurred.
     */
    async waitForElement(selector, container = document, timeoutSecs = 7) {
      const element = container.querySelector(selector);
      if (element) {
        return Promise.resolve(element);
      }

      return new Promise((resolve, reject) => {
        const timeoutTime = Date.now() + timeoutSecs * 1000;

        const handler = () => {
          const element = document.querySelector(selector);
          if (element) {
            resolve(element);
          } else if (Date.now() > timeoutTime) {
            reject(new Error(`Timed out waiting for selector '${selector}'`));
          } else {
            setTimeout(handler, 100);
          }
        };

        setTimeout(handler, 1);
      });
    },

    /**
     * Loads user configuration from storage.
     *
     * @param {Object} defaultConfiguration - An object containing all of
     * the user configuration keys mapped to their default values. This
     * object will be used to set an initial value for any keys not currently
     * in storage.
     *
     * @param {Boolean} [setDefault=true] - When true, save the value from
     * defaultConfiguration for keys not present in storage for next time.
     * This lets the user edit the configuration more easily.
     *
     * @returns {Promise<Object>} A Promise returning an object that has the
     * config from storage, or an empty object if the storage APIs are not
     * defined.
     */
    async loadUserConfiguration(defaultConfiguration, setDefault = true) {
      if (!userScriptAPI.supportsStorage) {
        utils.debug('User configuration is not enabled');
        return {};
      }

      const userConfig = {};

      for (let [key, value] of Object.entries(defaultConfiguration)) {
        const userValue = await userScriptAPI.GM_getValue(key);

        // initialize any config values that haven't been set
        if (setDefault && userValue === undefined) {
          utils.debug(`setting default config value for ${key}: ${value}`);
          userScriptAPI.GM_setValue(key, value);
        } else {
          userConfig[key] = userValue;
        }
      }

      utils.debug('loaded user configuration from storage', userConfig);
      return userConfig;
    },
  };

  /**
   * Functions to make API calls.
   */
  const api = {
    /**
     * Loads data from the AniList API.
     *
     * @param {('anime'|'manga')} type - The type of media content.
     * @param {string} aniListId - The AniList media id.
     *
     * @returns {Promise<Object>} A Promise returning the media's data, or a
     * rejection if there was a problem calling the API.
     */
    async loadAniListData(type, aniListId) {
      var query = `
                query ($id: Int, $type: MediaType) {
                    Media (id: $id, type: $type) {
                        idMal
                        averageScore
                        meanScore
                        title {
                          english
                          romaji
                        }
                    }
                }
            `;

      const variables = {
        id: aniListId,
        type: type.toUpperCase(),
      };

      try {
        const response = await utils.xhr({
          url: constants.ANI_LIST_API,
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            Accept: 'application/json',
          },
          responseType: 'json',
          data: JSON.stringify({
            query,
            variables,
          }),
        });
        utils.debug('AniList API response:', response);

        return response.data.Media;
      } catch (res) {
        const message = `AniList API request failed for media with ID '${aniListId}'`;
        utils.groupError(
          message,
          `Request failed with status ${res.status}`,
          ...(res.response ? res.response.errors : [res])
        );
        const error = new Error(message);
        error.response = res;
        throw error;
      }
    },

    /**
     * Loads data from the MyAnimeList API.
     *
     * @param {('anime'|'manga')} type - The type of media content.
     * @param {string} myAnimeListId - The MyAnimeList media id.
     *
     * @returns {Promise<Object>} A Promise returning the media's data, or a
     * rejection if there was a problem calling the API.
     */
    async loadMyAnimeListData(type, myAnimeListId) {
      try {
        const response = await utils.xhr({
          url: `${constants.MAL_API}/${type}/${myAnimeListId}`,
          method: 'GET',
          responseType: 'json',
        });
        utils.debug('MyAnimeList API response:', response);

        return response.data;
      } catch (res) {
        const message = `MyAnimeList API request failed for mapped MyAnimeList ID '${myAnimeListId}'`;
        utils.groupError(
          message,
          `Request failed with status ${res.status}`,
          res.response ? res.response.error || res.response.message : res
        );
        const error = new Error(message);
        error.response = res;
        throw error;
      }
    },

    /**
     * Loads data from the Kitsu API.
     *
     * @param {('anime'|'manga')} type - The type of media content.
     * @param {string} englishTitle - Search for media with this title.
     * @param {string} romajiTitle - Search for media with this title.
     *
     * @returns {Promise<Object>} A Promise returning the media's data, or a
     * rejection if there was a problem calling the API.
     */
    async loadKitsuData(type, englishTitle, romajiTitle) {
      try {
        const fields = 'slug,averageRating,userCount,titles';
        const response = await utils.xhr({
          url: encodeURI(
            `${
              constants.KITSU_API
            }/${type}?page[limit]=3&fields[${type}]=${fields}&filter[text]=${
              englishTitle || romajiTitle
            }`
          ),
          method: 'GET',
          headers: {
            Accept: 'application/vnd.api+json',
            'Content-Type': 'application/vnd.api+json',
          },
          responseType: 'json',
        });
        utils.debug('Kitsu API response:', response);

        if (response.data && response.data.length) {
          let index = 0;
          let isExactMatch = false;

          const collator = new Intl.Collator({
            usage: 'search',
            sensitivity: 'base',
            ignorePunctuation: true,
          });

          const matchedIndex = response.data.findIndex(result => {
            return Object.values(result.attributes.titles).find(kitsuTitle => {
              return (
                collator.compare(englishTitle, kitsuTitle) === 0 ||
                collator.compare(romajiTitle, kitsuTitle) === 0
              );
            });
          });

          if (matchedIndex > -1) {
            utils.debug(
              `matched title for Kitsu result at index ${matchedIndex}`,
              response.data[index]
            );
            index = matchedIndex;
            isExactMatch = true;
          } else {
            utils.debug('exact title match not found in Kitsu results');
          }

          return {
            isExactMatch,
            data: response.data[index].attributes,
          };
        } else {
          utils.debug(`Kitsu API returned 0 results for '${englishTitle}'`);
          return {};
        }
      } catch (res) {
        const message = `Kitsu API request failed for text '${englishTitle}'`;
        utils.groupError(
          message,
          `Request failed with status ${res.status}`,
          ...(res.response ? res.response.errors : [])
        );
        const error = new Error(message);
        error.response = res;
        throw error;
      }
    },
  };

  /**
   * AniList SVGs.
   */
  const svg = {
    /** from AniList */
    smile:
      '<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>',
    /** from AniList */
    straight:
      '<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>',
    /** from AniList */
    frown:
      '<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>',
    /**  From https://github.com/SamHerbert/SVG-Loaders */
    // License/accreditation https://github.com/SamHerbert/SVG-Loaders/blob/master/LICENSE.md
    loading:
      '<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>',
  };

  /**
   * Handles manipulating the current AniList page.
   */
  class AniListPage {
    /**
     * @param {Object} config - The user script configuration.
     */
    constructor(config) {
      this.selectors = {
        pageTitle: 'head > title',
        header: '.page-content .header .content',
      };

      this.config = config;
      this.lastCheckedUrlPath = null;
    }

    /**
     * Initialize the page and apply page modifications.
     */
    initialize() {
      utils.debug('initializing page');
      this.applyPageModifications().catch(e =>
        utils.error(`Unable to apply modifications to the page - ${e.message}`)
      );

      // eslint-disable-next-line no-unused-vars
      const observer = new MutationObserver((mutations, observer) => {
        utils.debug('mutation observer', mutations);
        this.applyPageModifications().catch(e =>
          utils.error(
            `Unable to apply modifications to the page - ${e.message}`
          )
        );
      });

      const target = document.querySelector(this.selectors.pageTitle);
      observer.observe(target, { childList: true, characterData: true });
    }

    /**
     * Applies modifications to the page based on config settings.
     *
     * This will only add content if we are on a relevant page in the app.
     */
    async applyPageModifications() {
      const pathname = window.location.pathname;
      utils.debug('checking page url', pathname);

      if (this.lastCheckedUrlPath === pathname) {
        utils.debug('url path did not change, skipping');
        return;
      }
      this.lastCheckedUrlPath = pathname;

      const matches = constants.ANI_LIST_URL_PATH_REGEX.exec(pathname);
      if (!matches) {
        utils.debug('url did not match');
        return;
      }

      const pageType = matches[1];
      const mediaId = matches[2];
      utils.debug('pageType:', pageType, 'mediaId:', mediaId);

      const aniListData = await api.loadAniListData(pageType, mediaId);

      if (this.config.addAniListScoreToHeader) {
        this.addAniListScoreToHeader(pageType, mediaId, aniListData);
      }

      if (this.config.addMyAnimeListScoreToHeader) {
        this.addMyAnimeListScoreToHeader(pageType, mediaId, aniListData);
      }

      if (this.config.addKitsuScoreToHeader) {
        this.addKitsuScoreToHeader(pageType, mediaId, aniListData);
      }
    }

    /**
     * Adds the AniList score to the header.
     *
     * @param {('anime'|'manga')} type - The type of media content.
     * @param {string} mediaId - The AniList media id.
     * @param {Object} aniListData - The data from the AniList api.
     */
    async addAniListScoreToHeader(pageType, mediaId, aniListData) {
      const slot = 1;
      const source = 'AniList';

      let rawScore, info;
      if (
        aniListData.meanScore &&
        (this.config.preferAniListMeanScore || !aniListData.averageScore)
      ) {
        rawScore = aniListData.meanScore;
        info = ' (mean)';
      } else if (aniListData.averageScore) {
        rawScore = aniListData.averageScore;
        info = ' (average)';
      }

      const score = rawScore ? `${rawScore}%` : '(N/A)';

      let iconMarkup;
      if (this.config.showIconWithAniListScore) {
        if (rawScore === null || rawScore == undefined) {
          iconMarkup = svg.straight;
        } else if (rawScore >= 75) {
          iconMarkup = svg.smile;
        } else if (rawScore >= 60) {
          iconMarkup = svg.straight;
        } else {
          iconMarkup = svg.frown;
        }
      }

      this.addToHeader({ slot, source, score, iconMarkup, info }).catch(e => {
        utils.error(
          `Unable to add the ${source} score to the header: ${e.message}`
        );
      });
    }

    /**
     * Adds the MyAnimeList score to the header.
     *
     * @param {('anime'|'manga')} type - The type of media content.
     * @param {string} mediaId - The AniList media id.
     * @param {Object} aniListData - The data from the AniList api.
     */
    async addMyAnimeListScoreToHeader(pageType, mediaId, aniListData) {
      const slot = 2;
      const source = 'MyAnimeList';

      if (!aniListData.idMal) {
        utils.error(`no ${source} id found for media ${mediaId}`);
        return this.clearHeaderSlot(slot);
      }

      if (this.config.showLoadingIndicators) {
        await this.showSlotLoading(slot);
      }

      api
        .loadMyAnimeListData(pageType, aniListData.idMal)
        .then(data => {
          const score = data.score;
          const href = data.url;

          return this.addToHeader({ slot, source, score, href });
        })
        .catch(e => {
          utils.error(
            `Unable to add the ${source} score to the header: ${e.message}`
          );

          // https://github.com/jikan-me/jikan-rest/issues/102
          if (e.response && e.response.status === 503) {
            return this.addToHeader({
              slot,
              source,
              score: 'Unavailable',
              info: ': The Jikan API is temporarily unavailable. Please try again later',
            });
          } else if (e.response && e.response.status === 429) {
            // rate limited
            return this.addToHeader({
              slot,
              source,
              score: 'Unavailable*',
              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',
            });
          }
        });
    }

    /**
     * Adds the Kitsu score to the header.
     *
     * @param {('anime'|'manga')} type - The type of media content.
     * @param {string} mediaId - The AniList media id.
     * @param {Object} aniListData - The data from the AniList api.
     */
    async addKitsuScoreToHeader(pageType, mediaId, aniListData) {
      const slot = 3;
      const source = 'Kitsu';

      const englishTitle = aniListData.title.english;
      const romajiTitle = aniListData.title.romaji;
      if (!englishTitle && !romajiTitle) {
        utils.error(
          `Unable to search ${source} - no media title found for ${mediaId}`
        );
        return this.clearHeaderSlot(slot);
      }

      if (this.config.showLoadingIndicators) {
        await this.showSlotLoading(slot);
      }

      api
        .loadKitsuData(pageType, englishTitle, romajiTitle)
        .then(entry => {
          if (!entry.data) {
            utils.error(`no ${source} matches found for media ${mediaId}`);
            return this.clearHeaderSlot(slot);
          }

          const data = entry.data;

          let score = null;
          if (data.averageRating !== undefined && data.averageRating !== null) {
            score = `${data.averageRating}%`;
            if (!entry.isExactMatch) {
              score += '*';
            }
          }

          const href = `https://kitsu.io/${pageType}/${data.slug}`;

          let info = '';
          if (!entry.isExactMatch) {
            info += ', *exact match not found';
          }
          const kitsuTitles = Object.values(data.titles).join(', ');
          info += `, matched on "${kitsuTitles}"`;

          return this.addToHeader({ slot, source, score, href, info });
        })
        .catch(e => {
          utils.error(
            `Unable to add the ${source} score to the header: ${e.message}`
          );
        });
    }

    /**
     * Shows a loading indicator in the given slot position.
     *
     * @param {number} slot - The slot position.
     */
    async showSlotLoading(slot) {
      const slotEl = await this.getSlotElement(slot);
      if (slotEl) {
        slotEl.innerHTML = svg.loading;
      }
    }

    /**
     * Removes markup from the header for the given slot position.
     *
     * @param {number} slot - The slot position.
     */
    async clearHeaderSlot(slot) {
      const slotEl = await this.getSlotElement(slot);
      if (slotEl) {
        while (slotEl.lastChild) {
          slotEl.removeChild(slotEl.lastChild);
        }
        slotEl.style.marginRight = '0';
      }
    }

    /**
     * Add score data to a slot in the header section.
     *
     * @param {Object} info - Data about the score.
     * @param {number} info.slot - The ordering position within the header.
     * @param {string} info.source - The source of the data.
     * @param {string} [info.score] - The score text.
     * @param {string} [info.href] - The link for the media from the source.
     * @param {string} [info.iconMarkup] - Icon markup representing the score.
     * @param {string} [info=''] - Additional info about the score.
     */
    async addToHeader({ slot, source, score, href, iconMarkup, info = '' }) {
      const slotEl = await this.getSlotElement(slot);
      if (slotEl) {
        const newSlotEl = slotEl.cloneNode(false);
        newSlotEl.title = `${source} Score${info} ${constants.CUSTOM_ELEMENT_TITLE}`;
        newSlotEl.style.marginRight = '1rem';
        if (slot > 1) {
          newSlotEl.style.fontSize = '.875em';
        }

        if (iconMarkup) {
          newSlotEl.insertAdjacentHTML('afterbegin', iconMarkup);
          newSlotEl.firstElementChild.style.marginRight = '6px';
        }

        const scoreEl = document.createElement('span');
        if (slot > 1) {
          scoreEl.style.fontWeight = 'bold';
        }
        scoreEl.append(document.createTextNode(score || 'No Score'));
        newSlotEl.appendChild(scoreEl);

        if (href) {
          newSlotEl.appendChild(document.createTextNode(' on '));

          const link = document.createElement('a');
          link.href = href;
          link.title = `View this entry on ${source} ${constants.CUSTOM_ELEMENT_TITLE}`;
          link.textContent = source;
          newSlotEl.appendChild(link);
        }

        slotEl.replaceWith(newSlotEl);
      } else {
        throw new Error(`Unable to find element to place ${source} score`);
      }
    }

    /**
     * Gets the slot element at the given position.
     *
     * @param {number} slot - Get the slot element at this ordering position.
     */
    async getSlotElement(slot) {
      const containerEl = await this.getContainerElement();
      const slotClass = `${constants.CLASS_PREFIX}-slot${slot}`;
      return containerEl.querySelector(`.${slotClass}`);
    }

    /**
     * Gets the container for new content, adding it to the DOM if
     * necessary.
     */
    async getContainerElement() {
      const headerEl = await utils.waitForElement(this.selectors.header);
      const insertionPoint =
        headerEl.querySelector('h1') || headerEl.firstElementChild;

      const containerClass = `${constants.CLASS_PREFIX}-scores`;
      let containerEl = headerEl.querySelector(`.${containerClass}`);
      if (!containerEl) {
        containerEl = document.createElement('div');
        containerEl.className = containerClass;
        containerEl.style.display = 'flex';
        containerEl.style.marginTop = '1em';
        containerEl.style.alignItems = 'center';

        const numSlots = 3;
        for (let i = 0; i < numSlots; i++) {
          const slotEl = document.createElement('div');
          slotEl.className = `${constants.CLASS_PREFIX}-slot${i + 1}`;
          containerEl.appendChild(slotEl);
        }

        insertionPoint.insertAdjacentElement('afterend', containerEl);
      }

      return containerEl;
    }
  }

  // execution:

  // check for compatibility
  if (!userScriptAPI.supportsXHR) {
    utils.error(
      'The current version of your user script manager ' +
        'does not support required features. Please update ' +
        'it to the latest version and try again.'
    );
    return;
  }

  // setup configuration
  const userConfig = await utils.loadUserConfiguration(defaultConfig);
  const config = Object.assign({}, defaultConfig, userConfig);
  utils.debug('configuration values:', config);

  const page = new AniListPage(config);
  page.initialize();
})();