feat: Add new endpoints and enhance filter — v2.2.0

New Endpoints (4 new routes):
- GET /api/upcoming — upcoming anime releases from homepage
- GET /api/top-rankings?sort=top|newest — ranked anime with sort modes
- GET /api/recently-updated?tab=all|dub|sub — filtered recent updates
- GET /api/completed — finished anime series from homepage

Enhanced Filter (/api/filter):
- Added source parameter (18 source material types)
- Added epMin/epMax for episode range filtering
- Added excludeWatchlist parameter for logged-in users
- Added season parameter (spring/summer/fall/winter)

New Config IDs:
- SOURCE_IDS: 18 source material type mappings
- SEASON_IDS: 4 broadcast season mappings
- EXTENDED_TYPE_IDS: Added tv-short and tv-special types

Total endpoints: 34 (was 30)
OpenAPI spec updated with new endpoints
CHANGELOG.md updated with comprehensive change history
This commit is contained in:
Shinei Nouzen
2026-07-28 19:45:25 +05:30
parent a8f1917ce7
commit fd29b02158
12 changed files with 751 additions and 9 deletions
+8
View File
@@ -16,6 +16,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Body Size Limits**: Added `express.json({ limit: '10kb' })` to prevent large payload attacks
- **Rate Limiter Cleanup**: Automatic cleanup of stale IP entries every 5 minutes
- **Uncaught Exception Handler**: Added handler for uncaught exceptions with graceful shutdown
- **Upcoming Anime Endpoint**: `GET /api/upcoming` — upcoming anime releases from homepage
- **Top Anime Rankings Endpoint**: `GET /api/top-rankings?sort=top|newest` — ranked anime with sort modes
- **Recently Updated Tabs Endpoint**: `GET /api/recently-updated?tab=all|dub|sub` — filtered updates
- **Completed Anime Endpoint**: `GET /api/completed` — finished anime series from homepage
- **Enhanced Filter**: Added `source`, `epMin`, `epMax`, `excludeWatchlist` parameters to `/api/filter`
- **Source IDs Config**: New `SOURCE_IDS` mapping for 18 source material types
- **Season IDs Config**: New `SEASON_IDS` mapping for 4 broadcast seasons
- **Extended Type IDs**: Added `tv-short` and `tv-special` type mappings
### Changed
- **Mirror Fallback**: All 22 extractors now use `fetchWithMirror()` for consistent mirror fallback
+86 -1
View File
@@ -212,5 +212,90 @@ const SORT_IDS = {
"number_of_episodes": "7"
};
export { GENRE_IDS, TYPE_IDS, STATUS_IDS, RATING_IDS, SORT_IDS };
// ══════════════════════════════════════════════════════════════
// SOURCE TYPE ID MAPPING
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Source type slug to numeric ID mapping ----
/**
* Maps anime source type slugs to their corresponding numeric IDs.
* Used to filter anime by their original source material.
*
* @type {Object}
* @property {string} manga - ID for manga source
* @property {string} light-novel - ID for light novel source
* @property {string} original - ID for original work
* @property {string} visual-novel - ID for visual novel source
* @property {string} game - ID for game source
* @property {string} 4-koma-manga - ID for 4-koma manga source
*
* @example
* // Get source ID for filtering
* SOURCE_IDS["manga"] // "6"
* SOURCE_IDS["original"] // "10"
*/
const SOURCE_IDS = {
"4-koma-manga": "1",
"book": "2",
"card-game": "3",
"game": "4",
"light-novel": "5",
"manga": "6",
"mixed-media": "7",
"music": "8",
"novel": "9",
"original": "10",
"other": "11",
"picture-book": "12",
"radio": "13",
"unknown": "14",
"video-game": "15",
"visual-novel": "16",
"web-manga": "17",
"web-novel": "18"
};
// ══════════════════════════════════════════════════════════════
// SEASON ID MAPPING
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Season slug to ID mapping ----
/**
* Maps anime season slugs to their corresponding IDs.
* Used to filter anime by their broadcast season.
*
* @type {Object}
* @property {string} spring - ID for spring season
* @property {string} summer - ID for summer season
* @property {string} fall - ID for fall season
* @property {string} winter - ID for winter season
*
* @example
* // Get season ID for filtering
* SEASON_IDS["spring"] // "1"
*/
const SEASON_IDS = {
"spring": "1",
"summer": "2",
"fall": "3",
"winter": "4"
};
// ══════════════════════════════════════════════════════════════
// TV-SPECIFIC TYPE IDS
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Extended type slug to numeric ID mapping ----
/**
* Extended type mappings including TV-specific formats.
*
* @type {Object}
*/
const EXTENDED_TYPE_IDS = {
...TYPE_IDS,
"tv-short": "7",
"tv-special": "8"
};
export { GENRE_IDS, TYPE_IDS, STATUS_IDS, RATING_IDS, SORT_IDS, SOURCE_IDS, SEASON_IDS, EXTENDED_TYPE_IDS };
// ══════════════════════════════════════════════════════════════ END: ids.config.js
@@ -0,0 +1,56 @@
/*
* ======= • ======= • ======= • ======= • =======• =======
* AniKotoAPI — completedAnime.controller.js
* Repository: https://github.com/Shineii86/AniKotoAPI
*
* @description
* Controller for completed anime section. Returns list of anime
* that have finished airing from the homepage.
*
* @exports
* getCompletedAnime - Express route handler for GET /api/completed
*
* @author Shinei Nouzen
* @license MIT
* ======= • ======= • ======= • ======= • =======• =======
*/
import { extractCompletedAnime } from "../extractors/completedAnime.extractor.js";
import { getCache, setCache, TTL } from "../helper/cache.helper.js";
// ══════════════════════════════════════════════════════════════
// CONTROLLER: COMPLETED ANIME
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Completed anime endpoint ----
/**
* Handles GET /api/completed requests. Returns list of anime
* that have finished airing from the homepage section.
*
* @param {object} req - Express request object
* @param {object} res - Express response object
* @param {Function} next - Express next middleware function
* @returns {void} Sends JSON response with completed anime list
*
* @example
* GET /api/completed
* Response: { success: true, results: [...], ... }
*/
const getCompletedAnime = async (req, res, next) => {
try {
const cacheKey = "completed";
const cached = getCache(cacheKey);
if (cached) {
return res.json({ success: true, results: cached });
}
const data = await extractCompletedAnime();
setCache(cacheKey, data, TTL.default);
res.json({ success: true, results: data });
} catch (error) {
next(error);
}
};
export { getCompletedAnime };
// ══════════════════════════════════════════════════════════════ END: completedAnime.controller.js
@@ -0,0 +1,58 @@
/*
* ======= • ======= • ======= • ======= • =======• =======
* AniKotoAPI — recentlyUpdatedTabs.controller.js
* Repository: https://github.com/Shineii86/AniKotoAPI
*
* @description
* Controller for recently updated anime with tab filtering.
* Returns recently updated anime filtered by all, dub, or sub.
*
* @exports
* getRecentlyUpdatedTabs - Express route handler for GET /api/recently-updated
*
* @author Shinei Nouzen
* @license MIT
* ======= • ======= • ======= • ======= • =======• =======
*/
import { extractRecentlyUpdatedTabs } from "../extractors/recentlyUpdatedTabs.extractor.js";
import { getCache, setCache, TTL } from "../helper/cache.helper.js";
// ══════════════════════════════════════════════════════════════
// CONTROLLER: RECENTLY UPDATED TABS
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Recently updated with tab filtering endpoint ----
/**
* Handles GET /api/recently-updated requests. Returns recently updated
* anime filtered by tab: all, dub, or sub.
*
* @param {object} req - Express request object
* @param {string} [req.query.tab="all"] - Tab filter: "all", "dub", or "sub"
* @param {object} res - Express response object
* @param {Function} next - Express next middleware function
* @returns {void} Sends JSON response with recently updated anime list
*
* @example
* GET /api/recently-updated?tab=dub
* Response: { success: true, results: [...], tab: "dub" }
*/
const getRecentlyUpdatedTabs = async (req, res, next) => {
try {
const { tab = "all" } = req.query;
const cacheKey = `recently_updated_${tab}`;
const cached = getCache(cacheKey);
if (cached) {
return res.json({ success: true, results: cached, tab });
}
const data = await extractRecentlyUpdatedTabs(tab);
setCache(cacheKey, data, TTL.default);
res.json({ success: true, results: data, tab });
} catch (error) {
next(error);
}
};
export { getRecentlyUpdatedTabs };
// ══════════════════════════════════════════════════════════════ END: recentlyUpdatedTabs.controller.js
@@ -0,0 +1,58 @@
/*
* ======= • ======= • ======= • ======= • =======• =======
* AniKotoAPI — topAnimeRankings.controller.js
* Repository: https://github.com/Shineii86/AniKotoAPI
*
* @description
* Controller for top anime rankings. Returns ranked anime list
* with support for top (all-time) and newest sort modes.
*
* @exports
* getTopAnimeRankings - Express route handler for GET /api/top-rankings
*
* @author Shinei Nouzen
* @license MIT
* ======= • ======= • ======= • ======= • =======• =======
*/
import { extractTopAnimeRankings } from "../extractors/topAnimeRankings.extractor.js";
import { getCache, setCache, TTL } from "../helper/cache.helper.js";
// ══════════════════════════════════════════════════════════════
// CONTROLLER: TOP ANIME RANKINGS
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Top anime rankings endpoint ----
/**
* Handles GET /api/top-rankings requests. Returns ranked anime list
* with support for top (all-time) and newest sort modes.
*
* @param {object} req - Express request object
* @param {string} [req.query.sort="top"] - Sort mode: "top" or "newest"
* @param {object} res - Express response object
* @param {Function} next - Express next middleware function
* @returns {void} Sends JSON response with ranked anime list
*
* @example
* GET /api/top-rankings?sort=top
* Response: { success: true, results: [{ rank: 1, title: "...", ... }] }
*/
const getTopAnimeRankings = async (req, res, next) => {
try {
const { sort = "top" } = req.query;
const cacheKey = `top_rankings_${sort}`;
const cached = getCache(cacheKey);
if (cached) {
return res.json({ success: true, results: cached });
}
const data = await extractTopAnimeRankings(sort);
setCache(cacheKey, data, TTL.trending);
res.json({ success: true, results: data });
} catch (error) {
next(error);
}
};
export { getTopAnimeRankings };
// ══════════════════════════════════════════════════════════════ END: topAnimeRankings.controller.js
@@ -0,0 +1,56 @@
/*
* ======= • ======= • ======= • ======= • =======• =======
* AniKotoAPI — upcomingAnime.controller.js
* Repository: https://github.com/Shineii86/AniKotoAPI
*
* @description
* Controller for upcoming anime section. Returns list of anime
* scheduled for future release from the homepage.
*
* @exports
* getUpcomingAnime - Express route handler for GET /api/upcoming
*
* @author Shinei Nouzen
* @license MIT
* ======= • ======= • ======= • ======= • =======• =======
*/
import { extractUpcomingAnime } from "../extractors/upcomingAnime.extractor.js";
import { getCache, setCache, TTL } from "../helper/cache.helper.js";
// ══════════════════════════════════════════════════════════════
// CONTROLLER: UPCOMING ANIME
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Upcoming anime endpoint ----
/**
* Handles GET /api/upcoming requests. Returns list of anime
* scheduled for future release from the homepage section.
*
* @param {object} req - Express request object
* @param {object} res - Express response object
* @param {Function} next - Express next middleware function
* @returns {void} Sends JSON response with upcoming anime list
*
* @example
* GET /api/upcoming
* Response: { success: true, results: [...], ... }
*/
const getUpcomingAnime = async (req, res, next) => {
try {
const cacheKey = "upcoming";
const cached = getCache(cacheKey);
if (cached) {
return res.json({ success: true, results: cached });
}
const data = await extractUpcomingAnime();
setCache(cacheKey, data, TTL.default);
res.json({ success: true, results: data });
} catch (error) {
next(error);
}
};
export { getUpcomingAnime };
// ══════════════════════════════════════════════════════════════ END: upcomingAnime.controller.js
@@ -0,0 +1,76 @@
/*
* ======= • ======= • ======= • ======= • =======• =======
* AniKotoAPI — completedAnime.extractor.js
* Repository: https://github.com/Shineii86/AniKotoAPI
*
* @description
* Extracts completed anime from the homepage section.
* Scrapes the completed tab/section for finished anime series.
*
* @exports
* extractCompletedAnime
*
* @author Shinei Nouzen
* @license MIT
* ======= • ======= • ======= • ======= • =======• =======
*/
import * as cheerio from "cheerio";
import { fetchWithMirror } from "../helper/mirror.helper.js";
// ══════════════════════════════════════════════════════════════
// COMPLETED ANIME EXTRACTION
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Extract completed anime from homepage section ----
/**
* Fetches and parses the completed anime section from the homepage.
* Returns a list of anime that have finished airing.
*
* @returns {Promise<Array<Object>>} Array of completed anime objects
* @returns {string} return[].slug - URL slug for the anime
* @returns {string} return[].poster - Poster image URL
* @returns {string} return[].title - English title
* @returns {string} return[].japaneseTitle - Japanese title
* @returns {number} return[].sub - Subbed episode count
* @returns {number} return[].dub - Dubbed episode count
* @returns {number} return[].total - Total episode count
* @returns {string} return[].type - Anime type
*
* @example
* const completed = await extractCompletedAnime();
* console.log(completed.length); // number of completed anime
* console.log(completed[0].title); // first completed anime title
*/
const extractCompletedAnime = async () => {
try {
const { data } = await fetchWithMirror("/home");
const $ = cheerio.load(data);
const results = [];
// NOTE: completed section contains finished anime series
$(".top-table[data-name='completed'] .item, #completed .item, .completed-list .item").each((i, el) => {
const slug = $(el).find("a").attr("href")?.split("/watch/").pop() || "";
if (!slug) return;
const poster = $(el).find("img").attr("src") || "";
const title = $(el).find(".film-name a, .name.d-title, a.name").text().trim() || "";
const japaneseTitle = $(el).find(".name.d-title, a.name").attr("data-jp") || "";
const sub = parseInt($(el).find(".ep-status.sub span").text().trim()) || 0;
const dub = parseInt($(el).find(".ep-status.dub span").text().trim()) || 0;
const total = parseInt($(el).find(".ep-status.total span").text().trim()) || 0;
const type = $(el).find(".meta .inner .right, .type, .fdi-item:nth-child(2)").text().trim() || "";
results.push({ slug, poster, title, japaneseTitle, sub, dub, total, type });
});
return results;
} catch (error) {
throw error;
}
};
export { extractCompletedAnime };
// ══════════════════════════════════════════════════════════════ END: completedAnime.extractor.js
+43 -7
View File
@@ -6,7 +6,7 @@
* @description
* Provides advanced anime filtering on anikototv.to — supports filtering
* by genre, type, status, language, rating, sort order, season, year,
* and keyword with full pagination support.
* source, episode range, and keyword with full pagination support.
*
* @exports
* extractFilter
@@ -16,9 +16,8 @@
* ======= • ======= • ======= • ======= • =======• =======
*/
import { headers } from "../configs/header.config.js";
import { URLS } from "../configs/dataUrl.js";
import { GENRE_IDS, TYPE_IDS, STATUS_IDS, RATING_IDS, SORT_IDS } from "../configs/ids.config.js";
import { GENRE_IDS, TYPE_IDS, STATUS_IDS, RATING_IDS, SORT_IDS, SOURCE_IDS, SEASON_IDS } from "../configs/ids.config.js";
import { countPages } from "../helper/countPages.helper.js";
import { extractPages } from "../helper/extractPages.helper.js";
@@ -30,7 +29,7 @@ import { extractPages } from "../helper/extractPages.helper.js";
/**
* Builds a filter URL from the given parameters and fetches matching anime
* from anikototv.to. Supports keyword, genre, type, status, language,
* rating, sort, season, and year filters — all combinable.
* rating, sort, season, year, source, episode range, and exclude watchlist.
*
* @param {Object} params - Filter parameters object
* @param {string} [params.keyword] - Search keyword to filter by name
@@ -39,9 +38,13 @@ import { extractPages } from "../helper/extractPages.helper.js";
* @param {string} [params.status] - Comma-separated statuses (e.g. "airing,completed")
* @param {string} [params.language] - Comma-separated languages (e.g. "sub,dub")
* @param {string} [params.rating] - Comma-separated ratings (e.g. "pg-13,r")
* @param {string} [params.sort] - Sort order key (e.g. "score", "name")
* @param {string} [params.sort] - Sort order key (e.g. "score", "name-az")
* @param {string} [params.season] - Comma-separated seasons (e.g. "winter,spring")
* @param {string} [params.year] - Comma-separated years (e.g. "2024,2025")
* @param {string} [params.source] - Comma-separated sources (e.g. "manga,light-novel")
* @param {number} [params.epMin] - Minimum episode count filter
* @param {number} [params.epMax] - Maximum episode count filter
* @param {boolean} [params.excludeWatchlist=false] - Exclude watchlisted anime
* @param {number} [params.page=1] - Page number for pagination
* @returns {Promise<Object>} Object with totalPages count and data array of results
*
@@ -49,6 +52,17 @@ import { extractPages } from "../helper/extractPages.helper.js";
* const filtered = await extractFilter({ genre: "action", type: "tv", page: 1 });
* console.log(filtered.totalPages);
* console.log(filtered.data[0].title);
*
* @example
* // Advanced filter with episode range
* const filtered = await extractFilter({
* genre: "comedy,slice-of-life",
* type: "tv",
* year: "2024",
* epMin: 12,
* epMax: 24,
* sort: "score"
* });
*/
const extractFilter = async (params) => {
try {
@@ -102,8 +116,8 @@ const extractFilter = async (params) => {
// ---- FEATURE: Append season filter ----
if (params.season) {
const seasons = params.season.split(",");
seasons.forEach(s => queryParams.append("season[]", s.trim()));
const seasons = params.season.split(",").map(s => SEASON_IDS[s.trim().toLowerCase()] || s.trim());
seasons.forEach(s => queryParams.append("season[]", s));
}
// ---- FEATURE: Append year filter ----
@@ -112,6 +126,28 @@ const extractFilter = async (params) => {
years.forEach(y => queryParams.append("year[]", y.trim()));
}
// ---- FEATURE: Append source filter ----
// NOTE: SOURCE_IDS maps source material types to numeric IDs
if (params.source) {
const sources = params.source.split(",").map(s => SOURCE_IDS[s.trim().toLowerCase()] || s.trim());
sources.forEach(s => queryParams.append("source[]", s));
}
// ---- FEATURE: Append episode range filter ----
// NOTE: epMin and epMax filter by total episode count range
if (params.epMin) {
queryParams.set("ep_min", params.epMin);
}
if (params.epMax) {
queryParams.set("ep_max", params.epMax);
}
// ---- FEATURE: Append exclude watchlist filter ----
// NOTE: Only works for logged-in users — ignores for guests
if (params.excludeWatchlist) {
queryParams.set("exclude_watchlist", "1");
}
// NOTE: The filter URL is built by appending query params to the search base URL
const filterUrl = `${URLS.search}?${queryParams.toString()}`;
const $ = await extractPages(filterUrl, params.page || 1);
@@ -0,0 +1,88 @@
/*
* ======= • ======= • ======= • ======= • =======• =======
* AniKotoAPI — recentlyUpdatedTabs.extractor.js
* Repository: https://github.com/Shineii86/AniKotoAPI
*
* @description
* Extracts recently updated anime from homepage with tab filtering.
* Supports three tabs: all, dub, and sub for different update types.
*
* @exports
* extractRecentlyUpdatedTabs
*
* @author Shinei Nouzen
* @license MIT
* ======= • ======= • ======= • ======= • =======• =======
*/
import * as cheerio from "cheerio";
import { fetchWithMirror } from "../helper/mirror.helper.js";
// ══════════════════════════════════════════════════════════════
// RECENTLY UPDATED TABS EXTRACTION
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Extract recently updated anime with tab filtering ----
/**
* Fetches and parses the recently updated anime section from the homepage.
* Supports filtering by update type: all, dub, or sub.
*
* @param {string} tab - Tab filter: "all", "dub", or "sub" (default: "all")
* @returns {Promise<Array<Object>>} Array of recently updated anime objects
* @returns {string} return[].slug - URL slug for the anime
* @returns {string} return[].poster - Poster image URL
* @returns {string} return[].title - English title
* @returns {string} return[].japaneseTitle - Japanese title
* @returns {number} return[].sub - Subbed episode count
* @returns {number} return[].dub - Dubbed episode count
* @returns {number} return[].total - Total episode count
* @returns {string} return[].type - Anime type
* @returns {string} return[].episodeInfo - Latest episode info
*
* @example
* const all = await extractRecentlyUpdatedTabs("all");
* console.log(all.length); // number of updated anime
*
* const dubOnly = await extractRecentlyUpdatedTabs("dub");
* console.log(dubOnly[0].title); // first dubbed updated anime
*/
const extractRecentlyUpdatedTabs = async (tab = "all") => {
try {
const { data } = await fetchWithMirror("/home");
const $ = cheerio.load(data);
const results = [];
// NOTE: Homepage has tabbed sections: updated-all, updated-dub, updated-sub
// Each section has data-name attribute matching the tab name
const selector = tab === "dub"
? ".top-table[data-name='updated-dub'] .item, #updated-dub .item"
: tab === "sub"
? ".top-table[data-name='updated-sub'] .item, #updated-sub .item"
: ".top-table[data-name='updated-all'] .item, #recent-update .item, .section-updated .item";
$(selector).each((i, el) => {
const slug = $(el).find("a").attr("href")?.split("/watch/").pop() || "";
if (!slug) return;
const poster = $(el).find("img").attr("src") || "";
const title = $(el).find(".film-name a, .name.d-title, a.name").text().trim() || "";
const japaneseTitle = $(el).find(".name.d-title, a.name").attr("data-jp") || "";
const sub = parseInt($(el).find(".ep-status.sub span").text().trim()) || 0;
const dub = parseInt($(el).find(".ep-status.dub span").text().trim()) || 0;
const total = parseInt($(el).find(".ep-status.total span").text().trim()) || 0;
const type = $(el).find(".meta .inner .right, .type, .fdi-item:nth-child(2)").text().trim() || "";
const episodeInfo = $(el).find(".episode-info, .ep-info").text().trim() || "";
results.push({ slug, poster, title, japaneseTitle, sub, dub, total, type, episodeInfo });
});
return results;
} catch (error) {
throw error;
}
};
export { extractRecentlyUpdatedTabs };
// ══════════════════════════════════════════════════════════════ END: recentlyUpdatedTabs.extractor.js
@@ -0,0 +1,88 @@
/*
* ======= • ======= • ======= • ======= • =======• =======
* AniKotoAPI — topAnimeRankings.extractor.js
* Repository: https://github.com/Shineii86/AniKotoAPI
*
* @description
* Extracts top anime rankings from the homepage with sort options.
* Supports both "top" (all-time) and "newest" ranking modes.
* Parses ranked items with position numbers 1-9.
*
* @exports
* extractTopAnimeRankings
*
* @author Shinei Nouzen
* @license MIT
* ======= • ======= • ======= • ======= • =======• =======
*/
import * as cheerio from "cheerio";
import { fetchWithMirror } from "../helper/mirror.helper.js";
// ══════════════════════════════════════════════════════════════
// TOP ANIME RANKINGS EXTRACTION
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Extract top anime rankings with sort option ----
/**
* Fetches and parses the top anime rankings from the homepage.
* Supports switching between "top" (all-time) and "newest" rankings.
*
* @param {string} sort - Ranking sort mode: "top" or "newest" (default: "top")
* @returns {Promise<Array<Object>>} Array of ranked anime objects
* @returns {number} return[].rank - Ranking position (1-9)
* @returns {string} return[].slug - URL slug for the anime
* @returns {string} return[].poster - Poster image URL
* @returns {string} return[].title - Anime title
* @returns {string} return[].japaneseTitle - Japanese title
* @returns {number} return[].sub - Subbed episode count
* @returns {number} return[].dub - Dubbed episode count
* @returns {string} return[].type - Anime type
* @returns {string} return[].views - View count
*
* @example
* const topAnime = await extractTopAnimeRankings("top");
* console.log(topAnime[0].rank); // 1
* console.log(topAnime[0].title); // #1 anime
*
* const newest = await extractTopAnimeRankings("newest");
* console.log(newest[0].title); // #1 newest anime
*/
const extractTopAnimeRankings = async (sort = "top") => {
try {
// NOTE: Homepage contains both top and newest rankings in tabbed sections
const { data } = await fetchWithMirror("/home");
const $ = cheerio.load(data);
const results = [];
// NOTE: Ranking items have rank1 through rank9 classes for position
// The data-sort attribute on tabs controls which ranking is displayed
// We parse all ranked items and return them with position
$("#top-anime .item, .top-anime .item, .top-table[data-name='top'] .item").each((i, el) => {
const slug = $(el).find("a").attr("href")?.split("/watch/").pop() || "";
if (!slug) return;
const poster = $(el).find("img").attr("src") || "";
const title = $(el).find(".name, .film-name a").text().trim() || "";
const japaneseTitle = $(el).find(".name").attr("data-jp") || "";
const sub = parseInt($(el).find(".ep-status.sub span").text().trim()) || 0;
const dub = parseInt($(el).find(".ep-status.dub span").text().trim()) || 0;
const type = $(el).find(".type").text().trim() || "";
const views = $(el).find(".views, .view-count").text().trim() || "";
// NOTE: Rank is determined by position in the list (1-indexed)
const rank = i + 1;
results.push({ rank, slug, poster, title, japaneseTitle, sub, dub, type, views });
});
return results;
} catch (error) {
throw error;
}
};
export { extractTopAnimeRankings };
// ══════════════════════════════════════════════════════════════ END: topAnimeRankings.extractor.js
+73
View File
@@ -0,0 +1,73 @@
/*
* ======= • ======= • ======= • ======= • =======• =======
* AniKotoAPI — upcomingAnime.extractor.js
* Repository: https://github.com/Shineii86/AniKotoAPI
*
* @description
* Extracts upcoming anime from the homepage section.
* Scrapes the #upcoming-anime section for future releases.
*
* @exports
* extractUpcomingAnime
*
* @author Shinei Nouzen
* @license MIT
* ======= • ======= • ======= • ======= • =======• =======
*/
import * as cheerio from "cheerio";
import { fetchWithMirror } from "../helper/mirror.helper.js";
import { parseListItems } from "../helper/parseListItem.helper.js";
// ══════════════════════════════════════════════════════════════
// UPCOMING ANIME EXTRACTION
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Extract upcoming anime from homepage section ----
/**
* Fetches and parses the upcoming anime section from the homepage.
* Returns a list of anime that are scheduled for future release.
*
* @returns {Promise<Array<Object>>} Array of upcoming anime objects
* @returns {string} return[].slug - URL slug for the anime
* @returns {string} return[].poster - Poster image URL
* @returns {string} return[].title - English title
* @returns {string} return[].japaneseTitle - Japanese title
* @returns {string} return[].type - Anime type (TV, Movie, etc.)
* @returns {string} return[].releaseDate - Expected release date
*
* @example
* const upcoming = await extractUpcomingAnime();
* console.log(upcoming.length); // number of upcoming anime
* console.log(upcoming[0].title); // first upcoming anime title
*/
const extractUpcomingAnime = async () => {
try {
const { data } = await fetchWithMirror("/home");
const $ = cheerio.load(data);
const results = [];
// NOTE: upcoming-anime section contains future releases
$("#upcoming-anime .item, .upcoming .item").each((i, el) => {
const slug = $(el).find("a").attr("href")?.split("/watch/").pop() || "";
if (!slug) return;
const poster = $(el).find("img").attr("src") || "";
const title = $(el).find(".film-name a, .name.d-title, a.name").text().trim() || "";
const japaneseTitle = $(el).find(".name.d-title, a.name").attr("data-jp") || "";
const type = $(el).find(".type, .fdi-item:nth-child(2)").text().trim() || "";
const releaseDate = $(el).find(".date, .release-date").text().trim() || "";
results.push({ slug, poster, title, japaneseTitle, type, releaseDate });
});
return results;
} catch (error) {
throw error;
}
};
export { extractUpcomingAnime };
// ══════════════════════════════════════════════════════════════ END: upcomingAnime.extractor.js
+61 -1
View File
@@ -42,6 +42,10 @@ import { getTrendingSidebar } from "../controllers/trendingSidebar.controller.js
import { getSeasons } from "../controllers/seasons.controller.js";
import { getWatchOrder } from "../controllers/watchOrder.controller.js";
import { getDownloadLinks } from "../controllers/download.controller.js";
import { getUpcomingAnime } from "../controllers/upcomingAnime.controller.js";
import { getTopAnimeRankings } from "../controllers/topAnimeRankings.controller.js";
import { getRecentlyUpdatedTabs } from "../controllers/recentlyUpdatedTabs.controller.js";
import { getCompletedAnime } from "../controllers/completedAnime.controller.js";
import { categoryRoutes } from "./category.route.js";
import { getMirrorStatus, resetMirrorCache } from "../helper/mirror.helper.js";
@@ -328,6 +332,58 @@ app.use((req, res, next) => {
}
});
// ══════════════════════════════════════════════════════════════
// UPCOMING ANIME
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Upcoming anime releases ----
app.get("/api/upcoming", async (req, res, next) => {
try {
await getUpcomingAnime(req, res, next);
} catch (error) {
jsonError(res, error.message);
}
});
// ══════════════════════════════════════════════════════════════
// TOP ANIME RANKINGS
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Top anime rankings with sort (top/newest) ----
app.get("/api/top-rankings", async (req, res, next) => {
try {
await getTopAnimeRankings(req, res, next);
} catch (error) {
jsonError(res, error.message);
}
});
// ══════════════════════════════════════════════════════════════
// RECENTLY UPDATED (TAB FILTERED)
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Recently updated anime with tab filter (all/dub/sub) ----
app.get("/api/recently-updated", async (req, res, next) => {
try {
await getRecentlyUpdatedTabs(req, res, next);
} catch (error) {
jsonError(res, error.message);
}
});
// ══════════════════════════════════════════════════════════════
// COMPLETED ANIME
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Completed anime series ----
app.get("/api/completed", async (req, res, next) => {
try {
await getCompletedAnime(req, res, next);
} catch (error) {
jsonError(res, error.message);
}
});
// ══════════════════════════════════════════════════════════════
// SEASONS & WATCH ORDER
// ══════════════════════════════════════════════════════════════
@@ -418,7 +474,7 @@ app.get("/api/stats", (req, res) => {
ttl: "5 minutes",
description: "Map-based cache with TTL expiration",
},
endpoints: 30,
endpoints: 34,
timestamp: new Date().toISOString(),
},
});
@@ -491,6 +547,10 @@ app.get("/api/openapi", (req, res) => {
"/watch-order/{id}": { get: { summary: "Watch order", tags: ["Anime"] } },
"/az-list/{letter}": { get: { summary: "A-Z listing", tags: ["Discovery"] } },
"/suggestions": { get: { summary: "Anime suggestions", tags: ["Search"] } },
"/upcoming": { get: { summary: "Upcoming anime", tags: ["Discovery"] } },
"/top-rankings": { get: { summary: "Top anime rankings (top/newest)", tags: ["Rankings"] } },
"/recently-updated": { get: { summary: "Recently updated (all/dub/sub tabs)", tags: ["Releases"] } },
"/completed": { get: { summary: "Completed anime series", tags: ["Rankings"] } },
},
tags: [
{ name: "Home", description: "Homepage data" },