fix: episodes endpoint accepts numeric ID, episodes-ajax returns parsed JSON

This commit is contained in:
Sʜɪɴᴇɪ Nᴏᴜᴢᴇɴ
2026-06-09 04:55:26 +05:30
parent e203761789
commit 49243ccca1
2 changed files with 80 additions and 25 deletions
+16 -10
View File
@@ -39,18 +39,24 @@ import { URLS, BASE_URL } from "../configs/dataUrl.js";
* console.log(episodeData.totalEpisodes); // e.g. 1100+
* console.log(episodeData.episodes[0].episode_no); // 1
*/
const extractEpisodeList = async (slug) => {
const extractEpisodeList = async (slugOrId) => {
try {
// NOTE: First fetch the watch page to extract the internal anime ID
const infoUrl = URLS.watch(slug);
const { data: infoData } = await axios.get(infoUrl, { headers });
const $ = cheerio.load(infoData);
let animeId = 0;
let resolvedSlug = slugOrId;
// NOTE: data-id on #watch-main is the anime ID used for the AJAX episode list call
const animeId = parseInt($("#watch-main").attr("data-id")) || 0;
// NOTE: Accept either numeric anime ID or slug — if numeric, skip watch page fetch
if (/^\d+$/.test(slugOrId)) {
animeId = parseInt(slugOrId);
} else {
// NOTE: Fetch the watch page to extract the internal anime ID from the slug
const infoUrl = URLS.watch(slugOrId);
const { data: infoData } = await axios.get(infoUrl, { headers });
const $ = cheerio.load(infoData);
animeId = parseInt($("#watch-main").attr("data-id")) || 0;
}
if (!animeId) {
return { animeId: 0, slug, totalEpisodes: 0, episodes: [] };
return { animeId: 0, slug: resolvedSlug, totalEpisodes: 0, episodes: [] };
}
try {
@@ -98,7 +104,7 @@ const extractEpisodeList = async (slug) => {
return {
animeId,
slug,
slug: resolvedSlug,
totalEpisodes: episodes.length,
episodes
};
@@ -106,7 +112,7 @@ const extractEpisodeList = async (slug) => {
// NOTE: Gracefully return empty list if AJAX call fails (e.g. network issue)
return {
animeId,
slug,
slug: resolvedSlug,
totalEpisodes: 0,
episodes: []
};
+64 -15
View File
@@ -16,6 +16,7 @@
* ======= • ======= • ======= • ======= • =======• =======
*/
import * as cheerio from "cheerio";
import axios from "axios";
import { headers } from "../configs/header.config.js";
import { BASE_URL } from "../configs/dataUrl.js";
@@ -24,43 +25,91 @@ import { BASE_URL } from "../configs/dataUrl.js";
// EPISODE LIST AJAX EXTRACTOR
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Fetch raw episode list HTML via AJAX with optional params ----
// ---- FEATURE: Fetch and parse episode list HTML via AJAX ----
/**
* Directly queries the AJAX episode list endpoint for a given anime ID.
* Returns the raw HTML response from the server — does not parse or
* transform the data. Use this when you need the raw response or want
* to apply custom style/VRF parameters.
* Queries the AJAX episode list endpoint for a given anime ID.
* Parses the HTML response and returns structured episode data
* with server IDs, timestamps, and MAL IDs.
*
* @param {number|string} animeId - The internal anime ID from anikototv.to
* @param {string} [style=""] - Optional style parameter for episode list rendering
* @param {string} [vrf=""] - Optional VRF parameter (verification/decryption key)
* @returns {Promise<string>} Raw HTML response from the AJAX endpoint
* @returns {Promise<Object>} Parsed episode data with filters, ranges, and episodes array
*
* @example
* const rawHtml = await extractEpisodeListAjax(12345);
* console.log(rawHtml); // HTML fragment with episode links
*
* @example
* const rawHtml = await extractEpisodeListAjax(12345, "modern", "abc123");
* const data = await extractEpisodeListAjax(7457);
* console.log(data.episodes[0].server_ids); // base64-encoded server IDs
*/
const extractEpisodeListAjax = async (animeId, style = "", vrf = "") => {
try {
let url = `${BASE_URL}/ajax/episode/list/${animeId}`;
// NOTE: Optional params are appended only if non-empty to avoid trailing ?/&
const params = [];
if (style) params.push(`style=${style}`);
if (vrf) params.push(`vrf=${vrf}`);
if (params.length) url += `?${params.join("&")}`;
// NOTE: X-Requested-With header is required — without it the server returns the full page
const { data } = await axios.get(url, {
const { data: raw } = await axios.get(url, {
headers: {
...headers,
"X-Requested-With": "XMLHttpRequest"
}
});
return data;
// NOTE: Response is JSON {status, result} where result is HTML
const html = typeof raw === "string" ? raw : (raw?.result || "");
const $ = cheerio.load(html);
// NOTE: Extract filter options (Sub/Dub, episode ranges)
const filters = [];
$(".dropdown.filter").each((_, el) => {
const filterName = $(el).find("button").text().trim();
const options = [];
$(el).find(".dropdown-item").each((__, item) => {
options.push({
value: $(item).attr("data-value") || "",
label: $(item).text().trim()
});
});
filters.push({ name: filterName, options });
});
// NOTE: Extract episode ranges
const ranges = [];
$(".ep-range").each((_, el) => {
ranges.push($(el).attr("data-range") || "");
});
// NOTE: Parse episodes from list items
const episodes = [];
$("li[data-html]").each((_, li) => {
const a = $(li).find("a").first();
if (!a.length) return;
const title = $(li).attr("title") || "";
const jpTitle = a.find(".d-title").attr("data-jp") || "";
episodes.push({
id: a.attr("data-id") || "",
episode_no: parseInt(a.attr("data-num")) || 0,
slug: a.attr("data-slug") || "",
title,
jp_title: jpTitle,
mal_id: a.attr("data-mal") || "",
timestamp: a.attr("data-timestamp") || "",
has_sub: a.attr("data-sub") === "1",
has_dub: a.attr("data-dub") === "1",
server_ids: a.attr("data-ids") || "",
active: a.hasClass("active") || false
});
});
return {
animeId,
filters,
ranges,
totalEpisodes: episodes.length,
episodes
};
} catch (error) {
throw error;
}