Greasy Fork is available in English.

helpers

Helpers library for AniList Edit Multiple Media Simultaneously

Dieses Skript sollte nicht direkt installiert werden. Es handelt sich hier um eine Bibliothek für andere Skripte, welche über folgenden Befehl in den Metadaten eines Skriptes eingebunden wird // @require https://update.greasyfork.org/scripts/496875/1387743/helpers.js

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