From fd29b021588d41d69baf1420b616507293e5b53d Mon Sep 17 00:00:00 2001 From: Shinei Nouzen <157171073+Shineii86@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:45:25 +0530 Subject: [PATCH] =?UTF-8?q?feat:=20Add=20new=20endpoints=20and=20enhance?= =?UTF-8?q?=20filter=20=E2=80=94=20v2.2.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 8 ++ src/configs/ids.config.js | 87 +++++++++++++++++- src/controllers/completedAnime.controller.js | 56 ++++++++++++ .../recentlyUpdatedTabs.controller.js | 58 ++++++++++++ .../topAnimeRankings.controller.js | 58 ++++++++++++ src/controllers/upcomingAnime.controller.js | 56 ++++++++++++ src/extractors/completedAnime.extractor.js | 76 ++++++++++++++++ src/extractors/filter.extractor.js | 50 +++++++++-- .../recentlyUpdatedTabs.extractor.js | 88 +++++++++++++++++++ src/extractors/topAnimeRankings.extractor.js | 88 +++++++++++++++++++ src/extractors/upcomingAnime.extractor.js | 73 +++++++++++++++ src/routes/apiRoutes.js | 62 ++++++++++++- 12 files changed, 751 insertions(+), 9 deletions(-) create mode 100644 src/controllers/completedAnime.controller.js create mode 100644 src/controllers/recentlyUpdatedTabs.controller.js create mode 100644 src/controllers/topAnimeRankings.controller.js create mode 100644 src/controllers/upcomingAnime.controller.js create mode 100644 src/extractors/completedAnime.extractor.js create mode 100644 src/extractors/recentlyUpdatedTabs.extractor.js create mode 100644 src/extractors/topAnimeRankings.extractor.js create mode 100644 src/extractors/upcomingAnime.extractor.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 969ad70..07a1f7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/configs/ids.config.js b/src/configs/ids.config.js index afeb6a3..2131367 100644 --- a/src/configs/ids.config.js +++ b/src/configs/ids.config.js @@ -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 \ No newline at end of file diff --git a/src/controllers/completedAnime.controller.js b/src/controllers/completedAnime.controller.js new file mode 100644 index 0000000..07271ae --- /dev/null +++ b/src/controllers/completedAnime.controller.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 diff --git a/src/controllers/recentlyUpdatedTabs.controller.js b/src/controllers/recentlyUpdatedTabs.controller.js new file mode 100644 index 0000000..0c44d1e --- /dev/null +++ b/src/controllers/recentlyUpdatedTabs.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 diff --git a/src/controllers/topAnimeRankings.controller.js b/src/controllers/topAnimeRankings.controller.js new file mode 100644 index 0000000..a8e3059 --- /dev/null +++ b/src/controllers/topAnimeRankings.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 diff --git a/src/controllers/upcomingAnime.controller.js b/src/controllers/upcomingAnime.controller.js new file mode 100644 index 0000000..5d7b434 --- /dev/null +++ b/src/controllers/upcomingAnime.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 diff --git a/src/extractors/completedAnime.extractor.js b/src/extractors/completedAnime.extractor.js new file mode 100644 index 0000000..8c6cd39 --- /dev/null +++ b/src/extractors/completedAnime.extractor.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 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 diff --git a/src/extractors/filter.extractor.js b/src/extractors/filter.extractor.js index 31c6e13..b4a1083 100644 --- a/src/extractors/filter.extractor.js +++ b/src/extractors/filter.extractor.js @@ -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 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); diff --git a/src/extractors/recentlyUpdatedTabs.extractor.js b/src/extractors/recentlyUpdatedTabs.extractor.js new file mode 100644 index 0000000..4831aa4 --- /dev/null +++ b/src/extractors/recentlyUpdatedTabs.extractor.js @@ -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 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 diff --git a/src/extractors/topAnimeRankings.extractor.js b/src/extractors/topAnimeRankings.extractor.js new file mode 100644 index 0000000..30aff9b --- /dev/null +++ b/src/extractors/topAnimeRankings.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 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 diff --git a/src/extractors/upcomingAnime.extractor.js b/src/extractors/upcomingAnime.extractor.js new file mode 100644 index 0000000..16d5b37 --- /dev/null +++ b/src/extractors/upcomingAnime.extractor.js @@ -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 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 diff --git a/src/routes/apiRoutes.js b/src/routes/apiRoutes.js index f5fe4ca..a20eb1c 100644 --- a/src/routes/apiRoutes.js +++ b/src/routes/apiRoutes.js @@ -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" },