mirror of
https://github.com/Shineii86/MiruroAPI.git
synced 2026-10-22 10:15:10 +00:00
- Box-style file headers (======= border, @description, @exports, @author, @license) - Section headers (═════════════════════════════════════════) - Function-level JSDoc comments with @param, @returns, @example - Inline notes for non-obvious logic (lazy eviction, pipe decoding, slug resolution) - Feature markers (---- FEATURE: description ----) - Footer on every module (═══════════ END: filename ═══════════) - Added /stats and /openapi endpoints to router
604 lines
20 KiB
JavaScript
604 lines
20 KiB
JavaScript
/*
|
|
* ======= • ======= • ======= • ======= • =======• =======
|
|
* MiruroAPI — anilist.js
|
|
* Repository: https://github.com/Shineii86/MiruroAPI
|
|
*
|
|
* @description
|
|
* AniList GraphQL API integration module. Provides functions for
|
|
* searching anime, fetching details, browsing collections, and
|
|
* querying the AniList GraphQL API. All metadata (titles, covers,
|
|
* genres, scores, characters, relations) comes from AniList.
|
|
*
|
|
* @exports
|
|
* searchAnime, getSuggestions, getAnimeInfo, getAnimeCharacters,
|
|
* getAnimeRelations, getAnimeRecommendations, getCollection,
|
|
* getSpotlight, getSchedule, filterAnime
|
|
*
|
|
* @author Shinei Nouzen
|
|
* @license MIT
|
|
* ======= • ======= • ======= • ======= • =======• =======
|
|
*/
|
|
|
|
const axios = require("axios");
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// ANILIST API CONFIGURATION
|
|
// ══════════════════════════════════════════════════════════════
|
|
|
|
/**
|
|
* AniList GraphQL API endpoint.
|
|
* Used for all metadata queries (search, trending, info, filter).
|
|
*
|
|
* @type {string}
|
|
*/
|
|
const ANILIST_URL = "https://graphql.anilist.co";
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// GRAPHQL FRAGMENTS
|
|
// ══════════════════════════════════════════════════════════════
|
|
|
|
// ---- FEATURE: Media list fields fragment (used in collections/search) ----
|
|
/**
|
|
* Standard set of fields for anime list responses.
|
|
* Used in trending, popular, search, filter, and schedule endpoints.
|
|
* Includes titles, cover, format, scores, genres, studios, and airing info.
|
|
*
|
|
* @type {string}
|
|
*/
|
|
const MEDIA_LIST_FIELDS = `
|
|
id
|
|
title { romaji english native }
|
|
coverImage { large extraLarge }
|
|
bannerImage
|
|
format
|
|
season
|
|
seasonYear
|
|
episodes
|
|
duration
|
|
status
|
|
averageScore
|
|
meanScore
|
|
popularity
|
|
favourites
|
|
genres
|
|
source
|
|
countryOfOrigin
|
|
isAdult
|
|
studios(isMain: true) { nodes { name isAnimationStudio } }
|
|
nextAiringEpisode { episode airingAt timeUntilAiring }
|
|
startDate { year month day }
|
|
endDate { year month day }
|
|
`;
|
|
|
|
// ---- FEATURE: Media full fields fragment (used in info endpoint) ----
|
|
/**
|
|
* Extended set of fields for detailed anime info responses.
|
|
* Includes everything in MEDIA_LIST_FIELDS plus characters, staff,
|
|
* relations, recommendations, trailer, stats, and external links.
|
|
*
|
|
* @type {string}
|
|
*/
|
|
const MEDIA_FULL_FIELDS = `
|
|
id
|
|
idMal
|
|
title { romaji english native }
|
|
description(asHtml: false)
|
|
coverImage { large extraLarge color }
|
|
bannerImage
|
|
format
|
|
season
|
|
seasonYear
|
|
episodes
|
|
duration
|
|
status
|
|
averageScore
|
|
meanScore
|
|
popularity
|
|
favourites
|
|
trending
|
|
genres
|
|
tags { name rank isMediaSpoiler }
|
|
source
|
|
countryOfOrigin
|
|
isAdult
|
|
hashtag
|
|
synonyms
|
|
siteUrl
|
|
trailer { id site thumbnail }
|
|
studios { nodes { id name isAnimationStudio siteUrl } }
|
|
nextAiringEpisode { episode airingAt timeUntilAiring }
|
|
startDate { year month day }
|
|
endDate { year month day }
|
|
characters(sort: [ROLE, RELEVANCE], perPage: 25) {
|
|
edges {
|
|
role
|
|
node { id name { full native } image { large } }
|
|
voiceActors(language: JAPANESE) { id name { full native } image { large } languageV2 }
|
|
}
|
|
}
|
|
staff(sort: RELEVANCE, perPage: 25) {
|
|
edges {
|
|
role
|
|
node { id name { full native } image { large } }
|
|
}
|
|
}
|
|
relations {
|
|
edges {
|
|
relationType(version: 2)
|
|
node {
|
|
id
|
|
title { romaji english native }
|
|
coverImage { large }
|
|
format
|
|
type
|
|
status
|
|
episodes
|
|
meanScore
|
|
}
|
|
}
|
|
}
|
|
recommendations(sort: RATING_DESC, perPage: 10) {
|
|
nodes {
|
|
rating
|
|
mediaRecommendation {
|
|
id
|
|
title { romaji english native }
|
|
coverImage { large }
|
|
format
|
|
episodes
|
|
status
|
|
meanScore
|
|
averageScore
|
|
}
|
|
}
|
|
}
|
|
externalLinks { url site type }
|
|
streamingEpisodes { title thumbnail url site }
|
|
stats {
|
|
scoreDistribution { score amount }
|
|
statusDistribution { status amount }
|
|
}
|
|
`;
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// CORE QUERY FUNCTION
|
|
// ══════════════════════════════════════════════════════════════
|
|
|
|
// ---- FEATURE: Execute AniList GraphQL query ----
|
|
/**
|
|
* Executes a GraphQL query against the AniList API.
|
|
* Handles request formatting and error checking.
|
|
*
|
|
* @param {string} query - The GraphQL query string
|
|
* @param {object} [variables={}] - Query variables
|
|
* @returns {Promise<object>} The `data` field from the AniList response
|
|
* @throws {Error} If the AniList API returns a non-200 status
|
|
*
|
|
* @example
|
|
* const data = await anilistQuery(MEDIA_LIST_FIELDS, { search: "naruto" });
|
|
*/
|
|
const anilistQuery = async (query, variables = {}) => {
|
|
const body = { query, variables };
|
|
const res = await axios.post(ANILIST_URL, body, {
|
|
headers: { "Content-Type": "application/json" },
|
|
timeout: 15000,
|
|
});
|
|
|
|
if (res.status !== 200) throw new Error("AniList query failed");
|
|
return res.data.data;
|
|
};
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// SEARCH ENDPOINTS
|
|
// ══════════════════════════════════════════════════════════════
|
|
|
|
// ---- FEATURE: Full-text anime search with pagination ----
|
|
/**
|
|
* Searches AniList for anime matching the given query.
|
|
* Returns paginated results with full metadata per result.
|
|
*
|
|
* @param {string} query - The search query string
|
|
* @param {number} [page=1] - Page number for pagination
|
|
* @param {number} [perPage=20] - Results per page (max 50)
|
|
* @returns {Promise<object>} Paginated search results with page, perPage, total, hasNextPage, results[]
|
|
*
|
|
* @example
|
|
* const results = await searchAnime("naruto", 1, 10);
|
|
* console.log(results.total); // 5000+
|
|
*/
|
|
const searchAnime = async (query, page = 1, perPage = 20) => {
|
|
const gql = `
|
|
query ($search: String, $page: Int, $perPage: Int) {
|
|
Page(page: $page, perPage: $perPage) {
|
|
pageInfo { total currentPage lastPage hasNextPage perPage }
|
|
media(search: $search, type: ANIME, sort: SEARCH_MATCH) {
|
|
${MEDIA_LIST_FIELDS}
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
const data = await anilistQuery(gql, { search: query, page, perPage });
|
|
const pageData = data.Page;
|
|
|
|
return {
|
|
page: pageData.pageInfo.currentPage,
|
|
perPage: pageData.pageInfo.perPage,
|
|
total: pageData.pageInfo.total,
|
|
hasNextPage: pageData.pageInfo.hasNextPage,
|
|
results: pageData.media,
|
|
};
|
|
};
|
|
|
|
// ---- FEATURE: Lightweight autocomplete suggestions (max 8) ----
|
|
/**
|
|
* Fetches search suggestions for autocomplete dropdowns.
|
|
* Returns minimal data (id, title, poster, format, status, year).
|
|
*
|
|
* @param {string} query - The search query string
|
|
* @returns {Promise<Array>} Array of up to 8 suggestion objects
|
|
*
|
|
* @example
|
|
* const suggestions = await getSuggestions("one piece");
|
|
* console.log(suggestions[0].title); // "ONE PIECE"
|
|
*/
|
|
const getSuggestions = async (query) => {
|
|
const gql = `
|
|
query ($search: String) {
|
|
Page(page: 1, perPage: 8) {
|
|
media(search: $search, type: ANIME, sort: SEARCH_MATCH) {
|
|
id
|
|
title { romaji english }
|
|
coverImage { large }
|
|
format
|
|
status
|
|
startDate { year }
|
|
episodes
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
const data = await anilistQuery(gql, { search: query });
|
|
|
|
return data.Page.media.map((item) => ({
|
|
id: item.id,
|
|
title: item.title.english || item.title.romaji,
|
|
title_romaji: item.title.romaji,
|
|
poster: item.coverImage.large,
|
|
format: item.format,
|
|
status: item.status,
|
|
year: item.startDate?.year,
|
|
episodes: item.episodes,
|
|
}));
|
|
};
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// ANIME DETAILS
|
|
// ══════════════════════════════════════════════════════════════
|
|
|
|
// ---- FEATURE: Complete anime info by AniList ID ----
|
|
/**
|
|
* Fetches full anime metadata including characters, staff,
|
|
* relations, recommendations, trailer, stats, and external links.
|
|
*
|
|
* @param {number} id - AniList anime ID
|
|
* @returns {Promise<object>} Complete anime metadata object
|
|
* @throws {Error} If anime is not found
|
|
*
|
|
* @example
|
|
* const info = await getAnimeInfo(20); // Naruto
|
|
* console.log(info.title.romaji); // "NARUTO"
|
|
*/
|
|
const getAnimeInfo = async (id) => {
|
|
const gql = `
|
|
query ($id: Int) {
|
|
Media(id: $id, type: ANIME) {
|
|
${MEDIA_FULL_FIELDS}
|
|
}
|
|
}
|
|
`;
|
|
const data = await anilistQuery(gql, { id });
|
|
return data.Media;
|
|
};
|
|
|
|
// ---- FEATURE: Paginated character list with voice actors ----
|
|
/**
|
|
* Fetches paginated character list for an anime.
|
|
* Each character includes name, image, role, and Japanese voice actors.
|
|
*
|
|
* @param {number} id - AniList anime ID
|
|
* @param {number} [page=1] - Page number
|
|
* @param {number} [perPage=25] - Results per page (max 50)
|
|
* @returns {Promise<object>} Paginated character list
|
|
*/
|
|
const getAnimeCharacters = async (id, page = 1, perPage = 25) => {
|
|
const gql = `
|
|
query ($id: Int, $page: Int, $perPage: Int) {
|
|
Media(id: $id, type: ANIME) {
|
|
id
|
|
title { romaji english }
|
|
characters(sort: [ROLE, RELEVANCE], page: $page, perPage: $perPage) {
|
|
pageInfo { total currentPage lastPage hasNextPage perPage }
|
|
edges {
|
|
role
|
|
node { id name { full native userPreferred } image { large medium } description gender favourites siteUrl }
|
|
voiceActors { id name { full native } image { large } languageV2 }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
const data = await anilistQuery(gql, { id, page, perPage });
|
|
if (!data.Media) throw new Error("Anime not found");
|
|
|
|
const chars = data.Media.characters;
|
|
return {
|
|
page: chars.pageInfo.currentPage,
|
|
perPage: chars.pageInfo.perPage,
|
|
total: chars.pageInfo.total,
|
|
hasNextPage: chars.pageInfo.hasNextPage,
|
|
characters: chars.edges,
|
|
};
|
|
};
|
|
|
|
// ---- FEATURE: Related anime (sequels, prequels, spin-offs) ----
|
|
/**
|
|
* Fetches all related media for an anime — sequels, prequels,
|
|
* side stories, spin-offs, and source material.
|
|
*
|
|
* @param {number} id - AniList anime ID
|
|
* @returns {Promise<object>} Relations with id, title, and relations[]
|
|
*/
|
|
const getAnimeRelations = async (id) => {
|
|
const gql = `
|
|
query ($id: Int) {
|
|
Media(id: $id, type: ANIME) {
|
|
id
|
|
title { romaji english }
|
|
relations {
|
|
edges {
|
|
relationType(version: 2)
|
|
node {
|
|
id title { romaji english native } coverImage { large }
|
|
format type status episodes meanScore averageScore popularity
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
const data = await anilistQuery(gql, { id });
|
|
if (!data.Media) throw new Error("Anime not found");
|
|
|
|
return {
|
|
id: data.Media.id,
|
|
title: data.Media.title,
|
|
relations: data.Media.relations.edges,
|
|
};
|
|
};
|
|
|
|
// ---- FEATURE: Community recommendations sorted by rating ----
|
|
/**
|
|
* Fetches paginated community recommendations for an anime.
|
|
* "If you liked X, you'll like Y" — sorted by highest rating.
|
|
*
|
|
* @param {number} id - AniList anime ID
|
|
* @param {number} [page=1] - Page number
|
|
* @param {number} [perPage=10] - Results per page (max 25)
|
|
* @returns {Promise<object>} Paginated recommendations
|
|
*/
|
|
const getAnimeRecommendations = async (id, page = 1, perPage = 10) => {
|
|
const gql = `
|
|
query ($id: Int, $page: Int, $perPage: Int) {
|
|
Media(id: $id, type: ANIME) {
|
|
id
|
|
title { romaji english }
|
|
recommendations(sort: RATING_DESC, page: $page, perPage: $perPage) {
|
|
pageInfo { total currentPage lastPage hasNextPage perPage }
|
|
nodes {
|
|
rating
|
|
mediaRecommendation {
|
|
id title { romaji english native } coverImage { large extraLarge }
|
|
format episodes status meanScore averageScore popularity genres
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
const data = await anilistQuery(gql, { id, page, perPage });
|
|
if (!data.Media) throw new Error("Anime not found");
|
|
|
|
const recs = data.Media.recommendations;
|
|
return {
|
|
page: recs.pageInfo.currentPage,
|
|
perPage: recs.pageInfo.perPage,
|
|
total: recs.pageInfo.total,
|
|
hasNextPage: recs.pageInfo.hasNextPage,
|
|
recommendations: recs.nodes,
|
|
};
|
|
};
|
|
|
|
// ══════════════════════════════════════════════════════════════
|
|
// COLLECTIONS
|
|
// ══════════════════════════════════════════════════════════════
|
|
|
|
// ---- FEATURE: Generic collection fetcher with sort and status ----
|
|
/**
|
|
* Internal helper for fetching anime collections (trending, popular, etc.).
|
|
* Builds a dynamic GraphQL query based on sort type and optional status filter.
|
|
*
|
|
* @param {string} sortType - AniList sort enum (e.g., "TRENDING_DESC", "POPULARITY_DESC")
|
|
* @param {string|null} status - Optional status filter (e.g., "RELEASING", "NOT_YET_RELEASED")
|
|
* @param {number} [page=1] - Page number
|
|
* @param {number} [perPage=20] - Results per page
|
|
* @returns {Promise<object>} Paginated collection results
|
|
*/
|
|
const getCollection = async (sortType, status = null, page = 1, perPage = 20) => {
|
|
const statusFilter = status ? `, status: ${status}` : "";
|
|
const gql = `
|
|
query ($page: Int, $perPage: Int) {
|
|
Page(page: $page, perPage: $perPage) {
|
|
pageInfo { total currentPage lastPage hasNextPage perPage }
|
|
media(type: ANIME, sort: [${sortType}]${statusFilter}) {
|
|
${MEDIA_LIST_FIELDS}
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
const data = await anilistQuery(gql, { page, perPage });
|
|
const pageData = data.Page;
|
|
|
|
return {
|
|
page: pageData.pageInfo.currentPage,
|
|
perPage: pageData.pageInfo.perPage,
|
|
total: pageData.pageInfo.total,
|
|
hasNextPage: pageData.pageInfo.hasNextPage,
|
|
results: pageData.media,
|
|
};
|
|
};
|
|
|
|
// ---- FEATURE: Spotlight/featured anime (top 10 trending + popular) ----
|
|
/**
|
|
* Fetches the top 10 trending + popular anime for hero banners.
|
|
* Combines TRENDING_DESC and POPULARITY_DESC sorts.
|
|
*
|
|
* @returns {Promise<Array>} Array of up to 10 anime objects
|
|
*/
|
|
const getSpotlight = async () => {
|
|
const gql = `
|
|
query {
|
|
Page(page: 1, perPage: 10) {
|
|
media(sort: [TRENDING_DESC, POPULARITY_DESC], type: ANIME) {
|
|
${MEDIA_LIST_FIELDS}
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
const data = await anilistQuery(gql);
|
|
return data.Page.media;
|
|
};
|
|
|
|
// ---- FEATURE: Airing schedule with timestamps ----
|
|
/**
|
|
* Fetches upcoming anime episodes with UNIX timestamps.
|
|
* Each result includes next_episode number, airingAt, and timeUntilAiring.
|
|
*
|
|
* @param {number} [page=1] - Page number
|
|
* @param {number} [perPage=20] - Results per page
|
|
* @returns {Promise<object>} Paginated schedule with airing timestamps
|
|
*/
|
|
const getSchedule = async (page = 1, perPage = 20) => {
|
|
const gql = `
|
|
query ($page: Int, $perPage: Int) {
|
|
Page(page: $page, perPage: $perPage) {
|
|
pageInfo { total currentPage lastPage hasNextPage perPage }
|
|
airingSchedules(notYetAired: true, sort: TIME) {
|
|
episode
|
|
airingAt
|
|
timeUntilAiring
|
|
media { ${MEDIA_LIST_FIELDS} }
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
const data = await anilistQuery(gql, { page, perPage });
|
|
const pageData = data.Page;
|
|
|
|
const results = pageData.airingSchedules.map((item) => ({
|
|
...item.media,
|
|
next_episode: item.episode,
|
|
airingAt: item.airingAt,
|
|
timeUntilAiring: item.timeUntilAiring,
|
|
}));
|
|
|
|
return {
|
|
page: pageData.pageInfo.currentPage,
|
|
perPage: pageData.pageInfo.perPage,
|
|
total: pageData.pageInfo.total,
|
|
hasNextPage: pageData.pageInfo.hasNextPage,
|
|
results,
|
|
};
|
|
};
|
|
|
|
// ---- FEATURE: Advanced anime filter with multiple parameters ----
|
|
/**
|
|
* Advanced anime filter supporting genre, tag, year, season,
|
|
* format, status, and sort. All parameters are optional.
|
|
*
|
|
* @param {object} options - Filter options
|
|
* @param {string} [options.genre] - Genre name (e.g., "Action", "Romance")
|
|
* @param {string} [options.tag] - Tag name (e.g., "Isekai", "Time Skip")
|
|
* @param {number} [options.year] - Season year (e.g., 2025)
|
|
* @param {string} [options.season] - Season (WINTER, SPRING, SUMMER, FALL)
|
|
* @param {string} [options.format] - Format (TV, MOVIE, OVA, ONA, SPECIAL)
|
|
* @param {string} [options.status] - Status (RELEASING, FINISHED, NOT_YET_RELEASED)
|
|
* @param {string} [options.sort="POPULARITY_DESC"] - Sort order
|
|
* @param {number} [options.page=1] - Page number
|
|
* @param {number} [options.perPage=20] - Results per page
|
|
* @returns {Promise<object>} Paginated filtered results
|
|
*/
|
|
const filterAnime = async ({
|
|
genre, tag, year, season, format, status,
|
|
sort = "POPULARITY_DESC", page = 1, perPage = 20,
|
|
}) => {
|
|
const SORT_MAP = {
|
|
SCORE_DESC: "SCORE_DESC",
|
|
POPULARITY_DESC: "POPULARITY_DESC",
|
|
TRENDING_DESC: "TRENDING_DESC",
|
|
START_DATE_DESC: "START_DATE_DESC",
|
|
FAVOURITES_DESC: "FAVOURITES_DESC",
|
|
UPDATED_AT_DESC: "UPDATED_AT_DESC",
|
|
};
|
|
|
|
// NOTE: Build dynamic GraphQL arguments based on provided filters
|
|
const args = ["type: ANIME", `sort: [${SORT_MAP[sort] || "POPULARITY_DESC"}]`];
|
|
const variables = { page, perPage };
|
|
const varTypes = ["$page: Int", "$perPage: Int"];
|
|
|
|
if (genre) { args.push("genre: $genre"); variables.genre = genre; varTypes.push("$genre: String"); }
|
|
if (tag) { args.push("tag: $tag"); variables.tag = tag; varTypes.push("$tag: String"); }
|
|
if (year) { args.push("seasonYear: $seasonYear"); variables.seasonYear = year; varTypes.push("$seasonYear: Int"); }
|
|
if (season) { args.push("season: $season"); variables.season = season.toUpperCase(); varTypes.push("$season: MediaSeason"); }
|
|
if (format) { args.push("format: $format"); variables.format = format.toUpperCase(); varTypes.push("$format: MediaFormat"); }
|
|
if (status) { args.push("status: $status"); variables.status = status.toUpperCase(); varTypes.push("$status: MediaStatus"); }
|
|
|
|
const gql = `
|
|
query (${varTypes.join(", ")}) {
|
|
Page(page: $page, perPage: $perPage) {
|
|
pageInfo { total currentPage lastPage hasNextPage perPage }
|
|
media(${args.join(", ")}) {
|
|
${MEDIA_LIST_FIELDS}
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
const data = await anilistQuery(gql, variables);
|
|
const pageData = data.Page;
|
|
|
|
return {
|
|
page: pageData.pageInfo.currentPage,
|
|
perPage: pageData.pageInfo.perPage,
|
|
total: pageData.pageInfo.total,
|
|
hasNextPage: pageData.pageInfo.hasNextPage,
|
|
results: pageData.media,
|
|
};
|
|
};
|
|
|
|
module.exports = {
|
|
anilistQuery,
|
|
searchAnime,
|
|
getSuggestions,
|
|
getAnimeInfo,
|
|
getAnimeCharacters,
|
|
getAnimeRelations,
|
|
getAnimeRecommendations,
|
|
getCollection,
|
|
getSpotlight,
|
|
getSchedule,
|
|
filterAnime,
|
|
MEDIA_LIST_FIELDS,
|
|
MEDIA_FULL_FIELDS,
|
|
};
|
|
|
|
// ══════════════════════════════════════════════════════════════ END: anilist.js
|