diff --git a/src/extractors/episodeList.extractor.js b/src/extractors/episodeList.extractor.js index 8fee0e7..17ac629 100644 --- a/src/extractors/episodeList.extractor.js +++ b/src/extractors/episodeList.extractor.js @@ -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: [] }; diff --git a/src/extractors/episodeListAjax.extractor.js b/src/extractors/episodeListAjax.extractor.js index faf3b30..e302f9a 100644 --- a/src/extractors/episodeListAjax.extractor.js +++ b/src/extractors/episodeListAjax.extractor.js @@ -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} Raw HTML response from the AJAX endpoint + * @returns {Promise} 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; }