docs: rewrite all files with AniKotoAPI documentation style

- 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
This commit is contained in:
Sʜɪɴᴇɪ Nᴏᴜᴢᴇɴ
2026-06-09 14:27:29 +05:30
parent b0aa5d2281
commit 4b083a9bf7
5 changed files with 1104 additions and 291 deletions
+229 -22
View File
@@ -1,7 +1,50 @@
/*
* ======= • ======= • ======= • ======= • =======• =======
* 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 }
@@ -27,6 +70,14 @@ const MEDIA_LIST_FIELDS = `
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
@@ -109,17 +160,53 @@ const MEDIA_FULL_FIELDS = `
}
`;
async function anilistQuery(query, variables = {}) {
// ══════════════════════════════════════════════════════════════
// 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;
}
};
async function searchAnime(query, page = 1, perPage = 20) {
// ══════════════════════════════════════════════════════════════
// 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) {
@@ -132,6 +219,7 @@ async function searchAnime(query, page = 1, perPage = 20) {
`;
const data = await anilistQuery(gql, { search: query, page, perPage });
const pageData = data.Page;
return {
page: pageData.pageInfo.currentPage,
perPage: pageData.pageInfo.perPage,
@@ -139,9 +227,21 @@ async function searchAnime(query, page = 1, perPage = 20) {
hasNextPage: pageData.pageInfo.hasNextPage,
results: pageData.media,
};
}
};
async function getSuggestions(query) {
// ---- 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) {
@@ -158,6 +258,7 @@ async function getSuggestions(query) {
}
`;
const data = await anilistQuery(gql, { search: query });
return data.Page.media.map((item) => ({
id: item.id,
title: item.title.english || item.title.romaji,
@@ -168,9 +269,26 @@ async function getSuggestions(query) {
year: item.startDate?.year,
episodes: item.episodes,
}));
}
};
async function getAnimeInfo(id) {
// ══════════════════════════════════════════════════════════════
// 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) {
@@ -180,9 +298,19 @@ async function getAnimeInfo(id) {
`;
const data = await anilistQuery(gql, { id });
return data.Media;
}
};
async function getAnimeCharacters(id, page = 1, perPage = 25) {
// ---- 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) {
@@ -201,6 +329,7 @@ async function getAnimeCharacters(id, page = 1, perPage = 25) {
`;
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,
@@ -209,9 +338,17 @@ async function getAnimeCharacters(id, page = 1, perPage = 25) {
hasNextPage: chars.pageInfo.hasNextPage,
characters: chars.edges,
};
}
};
async function getAnimeRelations(id) {
// ---- 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) {
@@ -231,14 +368,25 @@ async function getAnimeRelations(id) {
`;
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,
};
}
};
async function getAnimeRecommendations(id, page = 1, perPage = 10) {
// ---- 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) {
@@ -259,6 +407,7 @@ async function getAnimeRecommendations(id, page = 1, perPage = 10) {
`;
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,
@@ -267,9 +416,24 @@ async function getAnimeRecommendations(id, page = 1, perPage = 10) {
hasNextPage: recs.pageInfo.hasNextPage,
recommendations: recs.nodes,
};
}
};
async function getCollection(sortType, status = null, page = 1, perPage = 20) {
// ══════════════════════════════════════════════════════════════
// 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) {
@@ -283,6 +447,7 @@ async function getCollection(sortType, status = null, page = 1, perPage = 20) {
`;
const data = await anilistQuery(gql, { page, perPage });
const pageData = data.Page;
return {
page: pageData.pageInfo.currentPage,
perPage: pageData.pageInfo.perPage,
@@ -290,9 +455,16 @@ async function getCollection(sortType, status = null, page = 1, perPage = 20) {
hasNextPage: pageData.pageInfo.hasNextPage,
results: pageData.media,
};
}
};
async function getSpotlight() {
// ---- 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) {
@@ -304,9 +476,18 @@ async function getSpotlight() {
`;
const data = await anilistQuery(gql);
return data.Page.media;
}
};
async function getSchedule(page = 1, perPage = 20) {
// ---- 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) {
@@ -322,12 +503,14 @@ async function getSchedule(page = 1, perPage = 20) {
`;
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,
@@ -335,9 +518,29 @@ async function getSchedule(page = 1, perPage = 20) {
hasNextPage: pageData.pageInfo.hasNextPage,
results,
};
}
};
async function filterAnime({ genre, tag, year, season, format, status, sort = "POPULARITY_DESC", page = 1, perPage = 20 }) {
// ---- 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",
@@ -347,6 +550,7 @@ async function filterAnime({ genre, tag, year, season, format, status, sort = "P
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"];
@@ -370,6 +574,7 @@ async function filterAnime({ genre, tag, year, season, format, status, sort = "P
`;
const data = await anilistQuery(gql, variables);
const pageData = data.Page;
return {
page: pageData.pageInfo.currentPage,
perPage: pageData.pageInfo.perPage,
@@ -377,7 +582,7 @@ async function filterAnime({ genre, tag, year, season, format, status, sort = "P
hasNextPage: pageData.pageInfo.hasNextPage,
results: pageData.media,
};
}
};
module.exports = {
anilistQuery,
@@ -394,3 +599,5 @@ module.exports = {
MEDIA_LIST_FIELDS,
MEDIA_FULL_FIELDS,
};
// ══════════════════════════════════════════════════════════════ END: anilist.js