helpers

Helpers library for AniList Edit Multiple Media Simultaneously

此脚本不应直接安装。它是供其他脚本使用的外部库,要使用该库请加入元指令 // @require https://update.gf.qytechs.cn/scripts/496875/1387743/helpers.js

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

You will need to install an extension such as Tampermonkey to install this script.

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name        helpers
// @license     MIT
// @namespace   rtonne
// @match       https://anilist.co/*
// @version     1.0
// @author      Rtonne
// @description Helpers library for AniList Edit Multiple Media Simultaneously
// ==/UserScript==

/**
 * @typedef {{message: string, status: number, locations: {line: number, column: number}[]}} AniListError
 * @typedef {{message: string} | AniListError} FetchError
 */

/**
 * Uses a MutationObserver to wait until the element we want exists.
 * This function is required because elements take a while to appear sometimes.
 * https://stackoverflow.com/questions/5525071/how-to-wait-until-an-element-exists
 * @param {string} selector A string for document.querySelector describing the elements we want.
 * @returns {Promise<HTMLElement[]>} The list of elements found.
 */
function waitForElements(selector) {
  return new Promise((resolve) => {
    if (document.querySelector(selector)) {
      return resolve(document.querySelectorAll(selector));
    }

    const observer = new MutationObserver(() => {
      if (document.querySelector(selector)) {
        observer.disconnect();
        resolve(document.querySelectorAll(selector));
      }
    });

    observer.observe(document.body, {
      childList: true,
      subtree: true,
    });
  });
}

/**
 * Returns if anime or manga has advanced scoring enabled.
 * @returns {Promise<{data: {anime: boolean, manga: boolean}} | {data: {}, errors: FetchError[]}>}
 */
async function isAdvancedScoringEnabled() {
  const query = `
    query {
      User(name: "${window.location.href.split("/")[4]}") {
        mediaListOptions {
          animeList {
            advancedScoringEnabled
          }
          mangaList {
            advancedScoringEnabled
          }
        }
      }
    }
  `;

  const { data, errors } = await anilistFetch(
    JSON.stringify({ query: query, variables: {} })
  );
  if (errors) {
    return { data: {}, errors };
  }
  const return_data = {
    anime:
      data["User"]["mediaListOptions"]["animeList"]["advancedScoringEnabled"],
    manga:
      data["User"]["mediaListOptions"]["mangaList"]["advancedScoringEnabled"],
  };
  return { data: return_data };
}

/**
 * Get data from a group of entries.
 * @param {int[]} media_ids
 * @param {"id"|"isFavourite"|"customLists"|"advancedScores"} field
 * @returns {Promise<{data: any[]} | {data: any[], errors: FetchError[]}>}
 */
async function getDataFromEntries(media_ids, field) {
  const query = `query ($media_ids: [Int], $page: Int, $per_page: Int) {
    Page(page: $page, perPage: $per_page) {
      mediaList(mediaId_in: $media_ids, userName: "${
        window.location.href.split("/")[4]
      }", compareWithAuthList: true) {
        ${field !== "isFavourite" ? field : "media{isFavourite}"}
      }
    }
  }`;
  const page_size = 50;

  let errors;
  let data = [];
  for (let i = 0; i < media_ids.length; i += page_size) {
    const page = media_ids.slice(i, i + page_size);
    const variables = {
      media_ids: page,
      page: 1,
      per_page: page_size,
    };
    const response = await anilistFetch(
      JSON.stringify({
        query: query,
        variables: variables,
      })
    );
    if (response.errors) {
      errors = response.errors;
      break;
    }
    data.push(
      ...response.data["Page"]["mediaList"].map((entry) => {
        if (field === "isFavourite") {
          return entry["media"]["isFavourite"];
        }
        return entry[field];
      })
    );
  }
  if (errors) {
    return { data, errors };
  }
  return { data };
}

/**
 * Make a GraphQL mutation to a single entry on AniList
 * @param {number} id The id of the entry to update. Not media_id (use turnMediaIdsIntoIds() to get the actual id). Should be an int.
 * @param {Object} values The values to update
 * @param {("CURRENT"|"PLANNING"|"COMPLETED"|"DROPPED"|"PAUSED"|"REPEATING")} [values.status]
 * @param {number} [values.score]
 * @param {number} [values.scoreRaw] Should be an int.
 * @param {number} [values.progress] Should be an int.
 * @param {number} [values.progressVolumes] Should be an int.
 * @param {number} [values.repeat] Should be an int.
 * @param {number} [values.priority] Should be an int.
 * @param {boolean} [values.private]
 * @param {string} [values.notes]
 * @param {boolean} [values.hiddenFromStatusLists]
 * @param {string[]} [values.customLists]
 * @param {number[]} [values.advancedScores]
 * @param {Object} [values.startedAt]
 * @param {number} [values.startedAt.year] Should be an int.
 * @param {number} [values.startedAt.month] Should be an int.
 * @param {number} [values.startedAt.day] Should be an int.
 * @param {Object} [values.completedAt]
 * @param {number} [values.completedAt.year] Should be an int.
 * @param {number} [values.completedAt.month] Should be an int.
 * @param {number} [values.completedAt.day] Should be an int.
 * @returns {Promise<{errors: FetchError[]} | {}>}
 */
async function updateEntry(id, values) {
  const query = `
  mutation (
    $id: Int
    $status: MediaListStatus
    $score: Float
    $scoreRaw: Int
    $progress: Int
    $progressVolumes: Int
    $repeat: Int
    $priority: Int
    $private: Boolean
    $notes: String
    $hiddenFromStatusLists: Boolean
    $customLists: [String]
    $advancedScores: [Float]
    $startedAt: FuzzyDateInput
    $completedAt: FuzzyDateInput
  ) {SaveMediaListEntry(
      id: $id
      status: $status
      score: $score
      scoreRaw: $scoreRaw
      progress: $progress
      progressVolumes: $progressVolumes
      repeat: $repeat
      priority: $priority
      private: $private
      notes: $notes
      hiddenFromStatusLists: $hiddenFromStatusLists
      customLists: $customLists
      advancedScores: $advancedScores
      startedAt: $startedAt
      completedAt: $completedAt
    ) {
      id
    }
  }`;

  const variables = {
    id,
    ...values,
  };

  const { errors } = await anilistFetch(
    JSON.stringify({
      query: query,
      variables: variables,
    })
  );
  //TODO maybe get all media fields on update to check if they're the same, as validation
  if (errors) {
    return { errors };
  }
  // I'm returning empty object instead of void so that checking for errors outside is easier
  return {};
}

/**
 * Make a GraphQL mutation to update multiple entries on AniList
 * @param {number[]} ids The ids of the entries to update. Not media_ids (use turnMediaIdsIntoIds() to get the actual ids). Should be ints.
 * @param {Object} values The values to update
 * @param {("CURRENT"|"PLANNING"|"COMPLETED"|"DROPPED"|"PAUSED"|"REPEATING")} [values.status]
 * @param {number} [values.score]
 * @param {number} [values.scoreRaw] Should be an int.
 * @param {number} [values.progress] Should be an int.
 * @param {number} [values.progressVolumes] Should be an int.
 * @param {number} [values.repeat] Should be an int.
 * @param {number} [values.priority] Should be an int.
 * @param {boolean} [values.private]
 * @param {string} [values.notes]
 * @param {boolean} [values.hiddenFromStatusLists]
 * @param {number[]} [values.advancedScores]
 * @param {Object} [values.startedAt]
 * @param {number} [values.startedAt.year] Should be an int.
 * @param {number} [values.startedAt.month] Should be an int.
 * @param {number} [values.startedAt.day] Should be an int.
 * @param {Object} [values.completedAt]
 * @param {number} [values.completedAt.year] Should be an int.
 * @param {number} [values.completedAt.month] Should be an int.
 * @param {number} [values.completedAt.day] Should be an int.
 * @returns {Promise<{errors: FetchError[]} | {}>}
 */
async function batchUpdateEntries(ids, values) {
  const query = `
  mutation (
    $ids: [Int]
    $status: MediaListStatus
    $score: Float
    $scoreRaw: Int
    $progress: Int
    $progressVolumes: Int
    $repeat: Int
    $priority: Int
    $private: Boolean
    $notes: String
    $hiddenFromStatusLists: Boolean
    $advancedScores: [Float]
    $startedAt: FuzzyDateInput
    $completedAt: FuzzyDateInput
  ) {UpdateMediaListEntries(
      ids: $ids
      status: $status
      score: $score
      scoreRaw: $scoreRaw
      progress: $progress
      progressVolumes: $progressVolumes
      repeat: $repeat
      priority: $priority
      private: $private
      notes: $notes
      hiddenFromStatusLists: $hiddenFromStatusLists
      advancedScores: $advancedScores
      startedAt: $startedAt
      completedAt: $completedAt
    ) {
      id
    }
  }`;

  const variables = {
    ids,
    ...values,
  };

  const { errors } = await anilistFetch(
    JSON.stringify({
      query: query,
      variables: variables,
    })
  );
  //TODO maybe get all media fields on update to check if they're the same, as validation
  if (errors) {
    return { errors };
  }
  // I'm returning empty object instead of void so that checking for errors outside is easier
  return {};
}

/**
 * Make a GraphQL mutation to toggle the favourite status for an entry on AniList
 * @param {{animeId: number} | {mangaId: number}} id Should be ints.
 * @returns {Promise<{errors: FetchError[]} | {}>}
 */
async function toggleFavouriteForEntry(id) {
  const query = `
  mutation {ToggleFavourite(
    ${id.animeId ? "animeId: " + id.animeId : ""}
    ${id.mangaId ? "mangaId: " + id.mangaId : ""}
    ) {
      ${id.mangaId ? "manga" : "anime"} {
        nodes {
          id
        }
      }
    }
  }
  `;

  const { errors } = await anilistFetch(
    JSON.stringify({
      query: query,
      variables: {},
    })
  );
  // Not doing extra validation because the data returned depends on if its toggled on or off.
  // We could check if the entry id has been added/removed to the node list but if we have more
  // than 50 favourites I think we would need to query multiple pages, and I don't feel like doing it.
  if (errors) {
    return { errors };
  }
  // I'm returning empty object instead of void so that checking for errors outside is easier
  return {};
}

/**
 * Make a GraphQL mutation to delete an entry on AniList
 * @param {number} id Should be an int.
 * @returns {Promise<{errors: FetchError[]} | {}>}
 */
async function deleteEntry(id) {
  const query = `
  mutation (
    $id: Int
  ) {DeleteMediaListEntry(
        id: $id
    ) {
      deleted
    }
  }`;

  const variables = {
    id,
  };

  const { data, errors } = await anilistFetch(
    JSON.stringify({
      query: query,
      variables: variables,
    })
  );

  if (errors) {
    return { errors };
  } else if (data && !data["DeleteMediaListEntry"]["deleted"]) {
    console.error(
      `The deletion request threw no errors but id ${id} was not deleted.`
    );
    return {
      errors: [
        {
          message: `The deletion request threw no errors but id ${id} was not deleted.`,
        },
      ],
    };
  }
  // I'm returning empty object instead of void so that checking for errors outside is easier
  return {};
}

/**
 * Requests from the AniList GraphQL API.
 * Uses a url and token specific to the website for simplicity
 * (the user doesn't need to get a token) and for no rate limiting.
 * @param {string} body A GraphQL query string.
 * @returns A dict with the json data or the errors.
 */
async function anilistFetch(body) {
  const tokenScript = document
    .evaluate("/html/head/script[contains(., 'window.al_token')]", document)
    .iterateNext();
  const token = tokenScript.innerText.substring(
    tokenScript.innerText.indexOf('"') + 1,
    tokenScript.innerText.lastIndexOf('"')
  );

  let url = "https://anilist.co/graphql";
  let options = {
    method: "POST",
    headers: {
      "X-Csrf-Token": token,
      "Content-Type": "application/json",
      Accept: "application/json",
    },
    body,
  };

  function handleResponse(response) {
    return response.json().then(function (json) {
      return response.ok ? json : Promise.reject(json);
    });
  }

  /**
   * @param {{data: any}} response
   */
  function handleData(response) {
    return response;
  }

  /**
   * @param {{data: {[_: string]: null}, errors: AniListError[]} | Error} e
   * @returns {{errors: FetchError[]}}
   */
  function handleErrors(e) {
    // alert(
    //   "An error ocurred when requesting from the AniList API. Check the console for more details."
    // );
    console.error(e);
    if (e instanceof Error) {
      return { errors: [{ message: e.toString() }] };
    }
    return { errors: e.errors };
  }

  return await fetch(url, options)
    .then(handleResponse)
    .then(handleData)
    .catch(handleErrors);
}