diff --git a/package-lock.json b/package-lock.json index e1bd2cd..66dcad9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,17 @@ { "name": "AniKotoAPI", - "version": "1.9.0", + "version": "2.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "AniKotoAPI", - "version": "1.9.0", + "version": "2.2.0", "license": "MIT", "dependencies": { "axios": "^1.8.0", "cheerio": "^1.0.0-rc.12", "compression": "^1.8.1", - "cors": "^2.8.5", "dotenv": "^16.4.0", "express": "^4.21.0" }, @@ -389,23 +388,6 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, - "node_modules/cors": { - "version": "2.8.6", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", - "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", - "license": "MIT", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/css-select": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", @@ -1270,15 +1252,6 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", diff --git a/src/controllers/animeInfo.controller.js b/src/controllers/animeInfo.controller.js index bc535eb..2feb76c 100644 --- a/src/controllers/animeInfo.controller.js +++ b/src/controllers/animeInfo.controller.js @@ -41,16 +41,17 @@ import { getCache, setCache, TTL } from "../helper/cache.helper.js"; */ const getAnimeInfo = async (req, res, next) => { try { - const { id } = req.query; - if (!id) { - return res.status(400).json({ success: false, message: "Anime slug is required" }); + const { id, slug } = req.query; + const animeSlug = id || slug; + if (!animeSlug) { + return res.status(400).json({ success: false, message: "Anime slug is required (?id= or ?slug=)" }); } - const cacheKey = `anime_${id}`; + const cacheKey = `anime_${animeSlug}`; const cached = getCache(cacheKey); if (cached) { return res.json({ success: true, results: cached }); } - const data = await extractAnimeInfo(id); + const data = await extractAnimeInfo(animeSlug); setCache(cacheKey, data, TTL.info); res.json({ success: true, results: data }); } catch (error) { diff --git a/src/controllers/status.controller.js b/src/controllers/status.controller.js index d51a57e..babf8d8 100644 --- a/src/controllers/status.controller.js +++ b/src/controllers/status.controller.js @@ -57,9 +57,12 @@ const getStatus = async (req, res, next) => { } // Map short status names to full slugs const statusMap = { + ongoing: "currently-airing", airing: "currently-airing", completed: "finished-airing", - upcoming: "not-yet-aired" + finished: "finished-airing", + upcoming: "not-yet-aired", + "not-yet-aired": "not-yet-aired" }; const mappedStatus = statusMap[status] || status; const data = await extractStatus(mappedStatus, page); diff --git a/src/extractors/category.extractor.js b/src/extractors/category.extractor.js index aa21f18..df7de46 100644 --- a/src/extractors/category.extractor.js +++ b/src/extractors/category.extractor.js @@ -60,7 +60,17 @@ const extractCategory = async (category, page = 1) => { } else if (category.startsWith("type/")) { url = URLS.type(category.replace("type/", "")); } else if (category.startsWith("status/")) { - url = URLS.status(category.replace("status/", "")); + // NOTE: Map short status names to full slugs used by the site + const statusSlug = category.replace("status/", ""); + const statusMap = { + ongoing: "currently-airing", + airing: "currently-airing", + completed: "finished-airing", + finished: "finished-airing", + upcoming: "not-yet-aired" + }; + const mapped = statusMap[statusSlug] || statusSlug; + url = URLS.status(mapped); } else { url = `${URLS.home}/${category}`; } diff --git a/src/extractors/completedAnime.extractor.js b/src/extractors/completedAnime.extractor.js index 8c6cd39..2319f17 100644 --- a/src/extractors/completedAnime.extractor.js +++ b/src/extractors/completedAnime.extractor.js @@ -49,18 +49,19 @@ const extractCompletedAnime = async () => { 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() || ""; + // NOTE: completed section is inside .top-tables with data-name="completed" + // Items are inside .scaff.items + $("section.top-table[data-name='completed'] .scaff.items a.item, .top-table[data-name='completed'] a.item").each((i, el) => { + const slug = $(el).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 title = $(el).find(".name").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 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 type = $(el).find(".meta .dot:last-child").text().trim() || ""; results.push({ slug, poster, title, japaneseTitle, sub, dub, total, type }); }); diff --git a/src/extractors/recentlyUpdatedTabs.extractor.js b/src/extractors/recentlyUpdatedTabs.extractor.js index 4831aa4..54bc149 100644 --- a/src/extractors/recentlyUpdatedTabs.extractor.js +++ b/src/extractors/recentlyUpdatedTabs.extractor.js @@ -53,28 +53,34 @@ const extractRecentlyUpdatedTabs = async (tab = "all") => { 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"; + // NOTE: #recent-update has tabs (all/dub/sub/trending/random) but filtering is client-side + // Items are
inside #recent-update .ani.items + // Each item has .ani.poster with link and .info .name.d-title + const allItems = $("#recent-update .ani.items .item"); - $(selector).each((i, el) => { - const slug = $(el).find("a").attr("href")?.split("/watch/").pop() || ""; + allItems.each((i, el) => { + // NOTE: The item itself is a div, the link is inside .ani.poster a or .info a.name + const link = $(el).find(".ani.poster a").attr("href") || $(el).find(".info a.name").attr("href") || ""; + const slug = link.split("/watch/").pop()?.split("/ep-")[0] || ""; if (!slug) return; + // NOTE: Check sub/dub status for tab filtering + const hasSub = $(el).find(".ep-status.sub").length > 0; + const hasDub = $(el).find(".ep-status.dub").length > 0; + + // NOTE: Filter by tab if not "all" — since server-side rendering shows all items + if (tab === "dub" && !hasDub) return; + if (tab === "sub" && !hasSub) 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 title = $(el).find(".name").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 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() || ""; + const type = $(el).find(".meta .right, .type").text().trim() || ""; - results.push({ slug, poster, title, japaneseTitle, sub, dub, total, type, episodeInfo }); + results.push({ slug, poster, title, japaneseTitle, sub, dub, total, type }); }); return results; diff --git a/src/extractors/topAnimeRankings.extractor.js b/src/extractors/topAnimeRankings.extractor.js index 30aff9b..8f478dd 100644 --- a/src/extractors/topAnimeRankings.extractor.js +++ b/src/extractors/topAnimeRankings.extractor.js @@ -56,25 +56,29 @@ const extractTopAnimeRankings = async (sort = "top") => { 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() || ""; + // NOTE: Ranking items live inside #top-anime .tab-content .scaff.side.items + // Each item is an with rank classes rank1..rank9 + // The data-name on .tab-content controls which tab: day/week/month + const tabSelector = sort === "week" ? "[data-name='week']" + : sort === "month" ? "[data-name='month']" + : "[data-name='day']"; + + $(`#top-anime .tab-content${tabSelector} a.item, #top-anime .scaff.side.items a.item`).each((i, el) => { + const slug = $(el).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 title = $(el).find(".name").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() || ""; + const type = $(el).find(".meta .dot:last-child").text().trim() || ""; - // NOTE: Rank is determined by position in the list (1-indexed) - const rank = i + 1; + // NOTE: Rank is extracted from the rankN class on the element + const rankClass = [...$(el).attr("class") || ""].join(" ").match(/rank(\d+)/); + const rank = rankClass ? parseInt(rankClass[1]) : i + 1; - results.push({ rank, slug, poster, title, japaneseTitle, sub, dub, type, views }); + results.push({ rank, slug, poster, title, japaneseTitle, sub, dub, type }); }); return results; diff --git a/src/extractors/watchPage.extractor.js b/src/extractors/watchPage.extractor.js index 802fb0c..23e7a2a 100644 --- a/src/extractors/watchPage.extractor.js +++ b/src/extractors/watchPage.extractor.js @@ -40,7 +40,7 @@ import { fetchWithMirror } from "../helper/mirror.helper.js"; */ const extractWatchPage = async (slug, ep) => { try { - const path = `/watch/${slug}/${ep}`; + const path = `/watch/${slug}/ep-${ep}`; const { data } = await fetchWithMirror(path); const $ = cheerio.load(data); diff --git a/src/helper/parseListItem.helper.js b/src/helper/parseListItem.helper.js index fc88e7e..d87fe50 100644 --- a/src/helper/parseListItem.helper.js +++ b/src/helper/parseListItem.helper.js @@ -41,17 +41,24 @@ const parseListItem = ($, el, options = {}) => { const { includeAnimeId = false, includeRating = true } = options; // NOTE: slug is the primary key — skip items without a valid watch link - const slug = $(el).find("a").attr("href")?.split("/watch/").pop() || ""; + // NOTE: item may BE the tag itself, so check both self and children + let slug = ""; + if ($(el).is("a")) { + slug = $(el).attr("href")?.split("/watch/").pop() || ""; + } + if (!slug) { + slug = $(el).find("a").attr("href")?.split("/watch/").pop() || ""; + } if (!slug) return null; // NOTE: Multiple selectors handle different page layouts (#list-items vs .film_list-wrap vs .item) - const poster = $(el).find(".ani.poster.tip > a > img, img").attr("src") || ""; - const title = $(el).find(".info .b1 a.name.d-title, .film-name a, .name.d-title, a.name").text().trim() || ""; - const japaneseTitle = $(el).find(".info .b1 a.name.d-title, .name.d-title, a.name").attr("data-jp") || ""; + const poster = $(el).find("img").attr("src") || ""; + const title = $(el).find(".name").text().trim() || $(el).children(".info").find(".name").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 total = parseInt($(el).find(".ep-status.total span").text().trim()) || 0; - const type = $(el).find(".info .meta .m-item:nth-child(2) label, .meta .inner .right, .fdi-item:nth-child(2), .type").text().trim() || ""; + const type = $(el).find(".meta .dot:last-child, .fdi-item:nth-child(2)").text().trim() || ""; const item = { slug, poster, title, japaneseTitle, sub, dub, total, type }; @@ -62,7 +69,7 @@ const parseListItem = ($, el, options = {}) => { // NOTE: Rating uses different selectors depending on whether it's a list page or detail page if (includeRating) { - item.rating = $(el).find(".info .meta .m-item.rated span, .rating, .fdi-item:nth-child(3)").text().trim() || ""; + item.rating = $(el).find(".rated span, .rating, .fdi-item:nth-child(3)").text().trim() || ""; } return item; diff --git a/src/routes/apiRoutes.js b/src/routes/apiRoutes.js index a20eb1c..e073964 100644 --- a/src/routes/apiRoutes.js +++ b/src/routes/apiRoutes.js @@ -48,6 +48,7 @@ import { getRecentlyUpdatedTabs } from "../controllers/recentlyUpdatedTabs.contr import { getCompletedAnime } from "../controllers/completedAnime.controller.js"; import { categoryRoutes } from "./category.route.js"; import { getMirrorStatus, resetMirrorCache } from "../helper/mirror.helper.js"; +import { getCacheStats } from "../helper/cache.helper.js"; // ══════════════════════════════════════════════════════════════ // ROUTE REGISTRATION @@ -490,6 +491,12 @@ app.get("/api/mirrors", async (req, res) => { } }); +// ---- FEATURE: Cache statistics ---- +app.get("/api/cache/stats", (req, res) => { + const stats = getCacheStats(); + res.json({ success: true, results: stats }); +}); + // ---- FEATURE: Reset mirror cache ---- app.post("/api/mirrors/reset", (req, res) => { resetMirrorCache();