fix: Bug fixes from full API diagnostic sweep — all 33 endpoints now 200 OK

- Fix /api/watch 500: correct URL path format (slug/ep-N -> slug/ep-N)
- Fix /api/info: accept both ?id= and ?slug= query params
- Fix /api/status/airing 500: map short status names (ongoing/airing/completed) to correct slugs
- Fix /api/top-rankings: correct selectors (#top-anime .scaff.side.items a.item) + extract rank from rankN class
- Fix /api/completed: correct selectors (section.top-table[data-name='completed'] a.item)
- Fix /api/recently-updated: correct selectors (#recent-update .ani.items .item) + client-side tab filtering
- Fix parseListItem: detect self-as-anchor (<a> elements) + fallback slug extraction
- Fix category extractor: add status name mapping (ongoing->currently-airing, etc.)
- Add /api/cache/stats endpoint for LRU cache diagnostics
- 33 endpoints verified: 0 errors, 0 timeouts
This commit is contained in:
Shinei Nouzen
2026-07-28 20:17:58 +05:30
parent fd29b02158
commit d0432e1654
10 changed files with 86 additions and 74 deletions
+2 -29
View File
@@ -1,18 +1,17 @@
{ {
"name": "AniKotoAPI", "name": "AniKotoAPI",
"version": "1.9.0", "version": "2.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "AniKotoAPI", "name": "AniKotoAPI",
"version": "1.9.0", "version": "2.2.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"axios": "^1.8.0", "axios": "^1.8.0",
"cheerio": "^1.0.0-rc.12", "cheerio": "^1.0.0-rc.12",
"compression": "^1.8.1", "compression": "^1.8.1",
"cors": "^2.8.5",
"dotenv": "^16.4.0", "dotenv": "^16.4.0",
"express": "^4.21.0" "express": "^4.21.0"
}, },
@@ -389,23 +388,6 @@
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT" "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": { "node_modules/css-select": {
"version": "5.2.2", "version": "5.2.2",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", "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" "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": { "node_modules/object-inspect": {
"version": "1.13.4", "version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+6 -5
View File
@@ -41,16 +41,17 @@ import { getCache, setCache, TTL } from "../helper/cache.helper.js";
*/ */
const getAnimeInfo = async (req, res, next) => { const getAnimeInfo = async (req, res, next) => {
try { try {
const { id } = req.query; const { id, slug } = req.query;
if (!id) { const animeSlug = id || slug;
return res.status(400).json({ success: false, message: "Anime slug is required" }); 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); const cached = getCache(cacheKey);
if (cached) { if (cached) {
return res.json({ success: true, results: cached }); return res.json({ success: true, results: cached });
} }
const data = await extractAnimeInfo(id); const data = await extractAnimeInfo(animeSlug);
setCache(cacheKey, data, TTL.info); setCache(cacheKey, data, TTL.info);
res.json({ success: true, results: data }); res.json({ success: true, results: data });
} catch (error) { } catch (error) {
+4 -1
View File
@@ -57,9 +57,12 @@ const getStatus = async (req, res, next) => {
} }
// Map short status names to full slugs // Map short status names to full slugs
const statusMap = { const statusMap = {
ongoing: "currently-airing",
airing: "currently-airing", airing: "currently-airing",
completed: "finished-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 mappedStatus = statusMap[status] || status;
const data = await extractStatus(mappedStatus, page); const data = await extractStatus(mappedStatus, page);
+11 -1
View File
@@ -60,7 +60,17 @@ const extractCategory = async (category, page = 1) => {
} else if (category.startsWith("type/")) { } else if (category.startsWith("type/")) {
url = URLS.type(category.replace("type/", "")); url = URLS.type(category.replace("type/", ""));
} else if (category.startsWith("status/")) { } 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 { } else {
url = `${URLS.home}/${category}`; url = `${URLS.home}/${category}`;
} }
+7 -6
View File
@@ -49,18 +49,19 @@ const extractCompletedAnime = async () => {
const results = []; const results = [];
// NOTE: completed section contains finished anime series // NOTE: completed section is inside .top-tables with data-name="completed"
$(".top-table[data-name='completed'] .item, #completed .item, .completed-list .item").each((i, el) => { // Items are <a class="item"> inside .scaff.items
const slug = $(el).find("a").attr("href")?.split("/watch/").pop() || ""; $("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; if (!slug) return;
const poster = $(el).find("img").attr("src") || ""; const poster = $(el).find("img").attr("src") || "";
const title = $(el).find(".film-name a, .name.d-title, a.name").text().trim() || ""; const title = $(el).find(".name").text().trim() || "";
const japaneseTitle = $(el).find(".name.d-title, a.name").attr("data-jp") || ""; const japaneseTitle = $(el).find(".name").attr("data-jp") || "";
const sub = parseInt($(el).find(".ep-status.sub span").text().trim()) || 0; 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 dub = parseInt($(el).find(".ep-status.dub span").text().trim()) || 0;
const total = parseInt($(el).find(".ep-status.total 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 }); results.push({ slug, poster, title, japaneseTitle, sub, dub, total, type });
}); });
+20 -14
View File
@@ -53,28 +53,34 @@ const extractRecentlyUpdatedTabs = async (tab = "all") => {
const results = []; const results = [];
// NOTE: Homepage has tabbed sections: updated-all, updated-dub, updated-sub // NOTE: #recent-update has tabs (all/dub/sub/trending/random) but filtering is client-side
// Each section has data-name attribute matching the tab name // Items are <div class="item"> inside #recent-update .ani.items
const selector = tab === "dub" // Each item has .ani.poster with <a> link and .info .name.d-title
? ".top-table[data-name='updated-dub'] .item, #updated-dub .item" const allItems = $("#recent-update .ani.items .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) => { allItems.each((i, el) => {
const slug = $(el).find("a").attr("href")?.split("/watch/").pop() || ""; // 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; 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 poster = $(el).find("img").attr("src") || "";
const title = $(el).find(".film-name a, .name.d-title, a.name").text().trim() || ""; const title = $(el).find(".name").text().trim() || "";
const japaneseTitle = $(el).find(".name.d-title, a.name").attr("data-jp") || ""; const japaneseTitle = $(el).find(".name").attr("data-jp") || "";
const sub = parseInt($(el).find(".ep-status.sub span").text().trim()) || 0; 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 dub = parseInt($(el).find(".ep-status.dub span").text().trim()) || 0;
const total = parseInt($(el).find(".ep-status.total 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 .right, .type").text().trim() || "";
const episodeInfo = $(el).find(".episode-info, .ep-info").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; return results;
+15 -11
View File
@@ -56,25 +56,29 @@ const extractTopAnimeRankings = async (sort = "top") => {
const results = []; const results = [];
// NOTE: Ranking items have rank1 through rank9 classes for position // NOTE: Ranking items live inside #top-anime .tab-content .scaff.side.items
// The data-sort attribute on tabs controls which ranking is displayed // Each item is an <a class="item rankN"> with rank classes rank1..rank9
// We parse all ranked items and return them with position // The data-name on .tab-content controls which tab: day/week/month
$("#top-anime .item, .top-anime .item, .top-table[data-name='top'] .item").each((i, el) => { const tabSelector = sort === "week" ? "[data-name='week']"
const slug = $(el).find("a").attr("href")?.split("/watch/").pop() || ""; : 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; if (!slug) return;
const poster = $(el).find("img").attr("src") || ""; 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 japaneseTitle = $(el).find(".name").attr("data-jp") || "";
const sub = parseInt($(el).find(".ep-status.sub span").text().trim()) || 0; 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 dub = parseInt($(el).find(".ep-status.dub span").text().trim()) || 0;
const type = $(el).find(".type").text().trim() || ""; const type = $(el).find(".meta .dot:last-child").text().trim() || "";
const views = $(el).find(".views, .view-count").text().trim() || "";
// NOTE: Rank is determined by position in the list (1-indexed) // NOTE: Rank is extracted from the rankN class on the <a> element
const rank = i + 1; 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; return results;
+1 -1
View File
@@ -40,7 +40,7 @@ import { fetchWithMirror } from "../helper/mirror.helper.js";
*/ */
const extractWatchPage = async (slug, ep) => { const extractWatchPage = async (slug, ep) => {
try { try {
const path = `/watch/${slug}/${ep}`; const path = `/watch/${slug}/ep-${ep}`;
const { data } = await fetchWithMirror(path); const { data } = await fetchWithMirror(path);
const $ = cheerio.load(data); const $ = cheerio.load(data);
+13 -6
View File
@@ -41,17 +41,24 @@ const parseListItem = ($, el, options = {}) => {
const { includeAnimeId = false, includeRating = true } = options; const { includeAnimeId = false, includeRating = true } = options;
// NOTE: slug is the primary key — skip items without a valid watch link // 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 <a> 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; if (!slug) return null;
// NOTE: Multiple selectors handle different page layouts (#list-items vs .film_list-wrap vs .item) // 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 poster = $(el).find("img").attr("src") || "";
const title = $(el).find(".info .b1 a.name.d-title, .film-name a, .name.d-title, a.name").text().trim() || ""; const title = $(el).find(".name").text().trim() || $(el).children(".info").find(".name").text().trim() || "";
const japaneseTitle = $(el).find(".info .b1 a.name.d-title, .name.d-title, a.name").attr("data-jp") || ""; const japaneseTitle = $(el).find(".name").attr("data-jp") || "";
const sub = parseInt($(el).find(".ep-status.sub span").text().trim()) || 0; 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 dub = parseInt($(el).find(".ep-status.dub span").text().trim()) || 0;
const total = parseInt($(el).find(".ep-status.total 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 }; 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 // NOTE: Rating uses different selectors depending on whether it's a list page or detail page
if (includeRating) { 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; return item;
+7
View File
@@ -48,6 +48,7 @@ import { getRecentlyUpdatedTabs } from "../controllers/recentlyUpdatedTabs.contr
import { getCompletedAnime } from "../controllers/completedAnime.controller.js"; import { getCompletedAnime } from "../controllers/completedAnime.controller.js";
import { categoryRoutes } from "./category.route.js"; import { categoryRoutes } from "./category.route.js";
import { getMirrorStatus, resetMirrorCache } from "../helper/mirror.helper.js"; import { getMirrorStatus, resetMirrorCache } from "../helper/mirror.helper.js";
import { getCacheStats } from "../helper/cache.helper.js";
// ══════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════
// ROUTE REGISTRATION // 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 ---- // ---- FEATURE: Reset mirror cache ----
app.post("/api/mirrors/reset", (req, res) => { app.post("/api/mirrors/reset", (req, res) => {
resetMirrorCache(); resetMirrorCache();