mysticflute / AniList Unlimited - Score in Header

// ==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();
})();