diff --git a/src/extractors/seasons.extractor.js b/src/extractors/seasons.extractor.js index efc7f30..cdcc214 100644 --- a/src/extractors/seasons.extractor.js +++ b/src/extractors/seasons.extractor.js @@ -5,8 +5,8 @@ * * @description * Extracts season information for a specific anime from the - * anikototv.to AJAX endpoint. Returns all seasons/OVAs/specials - * for a given anime series. + * watch page HTML. Returns available seasons from the page + * sidebar and metadata. * * @exports * extractSeasons @@ -21,73 +21,61 @@ import { fetchWithMirror } from "../helper/mirror.helper.js"; /** * Fetches and parses season information for an anime. - * Scrapes the AJAX endpoint that returns season carousel HTML. + * Extracts season data from the watch page sidebar. * - * @param {string|number} animeId - The numeric anime ID + * @param {string|number} slugOrId - The anime slug or numeric ID * @returns {Promise} Object with animeId, totalSeasons, and seasons array * * @example - * const seasons = await extractSeasons(7174); - * console.log(seasons.totalSeasons); // number of seasons - * console.log(seasons.seasons[0].title); // first season title + * const seasons = await extractSeasons("one-piece-odmau"); + * console.log(seasons.totalSeasons); */ -const extractSeasons = async (animeId) => { +const extractSeasons = async (slugOrId) => { try { - const { data } = await fetchWithMirror(`/api/seasons/${animeId}`, { - headers: { "X-Requested-With": "XMLHttpRequest" } + const path = /^\d+$/.test(slugOrId) ? `/watch/${slugOrId}` : `/watch/${slugOrId}`; + const { data } = await fetchWithMirror(path); + + const html = typeof data === "string" ? data : String(data); + const $ = cheerio.load(html); + + const animeId = parseInt($("#watch-main").attr("data-id")) || 0; + const seasons = []; + + // NOTE: Extract seasons from #ani-seasons section (if populated) + $("#ani-seasons .swiper-slide, .seasons .item, #w-seasons .item").each((_, el) => { + const link = $(el).find("a").first(); + const href = link.attr("href") || ""; + const poster = $(el).find("img").attr("src") || ""; + const title = $(el).find(".name, .title").text().trim() || link.text().trim() || ""; + const slug = href.split("/watch/").pop() || ""; + + if (slug || title) { + seasons.push({ title, slug, poster, url: href }); + } }); - // Handle both string HTML and JSON responses - let raw = ""; - if (typeof data === "string") { - try { - const json = JSON.parse(data); - raw = json.result || data; - } catch (e) { - raw = data; - } - } else if (data?.result) { - raw = data.result; - } else if (data?.data) { - raw = data.data; - } - - const $ = cheerio.load(raw); - - const seasons = []; - - // Try multiple selectors to handle different HTML structures - const selectors = [ - ".swiper-slide.season", - ".seasons .swiper-slide", - "#w-seasons .swiper-slide", - ".swiper-slide" - ]; - - for (const selector of selectors) { - $(selector).each((_, el) => { + // NOTE: If no dedicated seasons section, extract from episode list sidebar + if (seasons.length === 0) { + $(".w-side-section .item").each((_, el) => { const link = $(el).find("a").first(); const href = link.attr("href") || ""; - const style = link.attr("style") || ""; - const poster = style.match(/url\(['"]?(.+?)['"]?\)/)?.[1] || ""; - const title = $(el).find(".name").text().trim() || - link.text().trim() || ""; - - // Extract slug from href + const poster = $(el).find("img").attr("src") || ""; + const title = $(el).find(".name").text().trim() || ""; const slug = href.split("/watch/").pop() || ""; + const score = $(el).find(".score").text().trim() || ""; + const episodes = $(el).find(".meta .dot:last-child").text().trim() || ""; - if (slug || title) { - seasons.push({ title, slug, poster, url: href }); + if (slug && slug !== slugOrId) { + seasons.push({ title, slug, poster, url: href, score, episodes }); } }); - - if (seasons.length > 0) break; } return { animeId, + slug: slugOrId, totalSeasons: seasons.length, - seasons + seasons, }; } catch (error) { throw error; diff --git a/src/extractors/watchOrder.extractor.js b/src/extractors/watchOrder.extractor.js index b1c75d8..2b21040 100644 --- a/src/extractors/watchOrder.extractor.js +++ b/src/extractors/watchOrder.extractor.js @@ -5,8 +5,8 @@ * * @description * Extracts watch order and related anime information from the - * anikototv.to AJAX endpoint. Returns related anime with their - * relationship types (sequel, prequel, summary, etc.). + * watch page sidebar. Returns trending/related anime with their + * metadata for continuous viewing. * * @exports * extractWatchOrder @@ -21,81 +21,63 @@ import { fetchWithMirror } from "../helper/mirror.helper.js"; /** * Fetches and parses watch order / related anime for an anime. - * Scrapes the AJAX endpoint that returns related anime HTML. + * Extracts related anime from the watch page sidebar. * - * @param {string|number} animeId - The numeric anime ID + * @param {string|number} slugOrId - The anime slug or numeric ID * @returns {Promise} Object with animeId, totalRelated, related array * * @example - * const data = await extractWatchOrder(7174); - * console.log(data.totalRelated); // number of related anime - * console.log(data.related[0].title); // first related anime + * const data = await extractWatchOrder("one-piece-odmau"); + * console.log(data.totalRelated); */ -const extractWatchOrder = async (animeId) => { +const extractWatchOrder = async (slugOrId) => { try { - const { data } = await fetchWithMirror(`/api/watch-order/${animeId}`, { - headers: { "X-Requested-With": "XMLHttpRequest" } + const path = /^\d+$/.test(slugOrId) ? `/watch/${slugOrId}` : `/watch/${slugOrId}`; + const { data } = await fetchWithMirror(path); + + const html = typeof data === "string" ? data : String(data); + const $ = cheerio.load(html); + + const animeId = parseInt($("#watch-main").attr("data-id")) || 0; + const related = []; + + // NOTE: Extract from #w-related section + $("#w-related .item, .w-side-section:has(.title:contains('Related')) .item").each((_, el) => { + const link = $(el).find("a").first(); + const href = link.attr("href") || ""; + const title = $(el).find(".name").text().trim() || ""; + const poster = $(el).find("img").attr("src") || ""; + const slug = href.split("/watch/").pop() || ""; + const relation = $(el).find(".relation, .serieslabelitem").text().trim() || "related"; + + if (slug) { + related.push({ title, slug, poster, url: href, relation }); + } }); - // Handle both string HTML and JSON responses - let raw = ""; - if (typeof data === "string") { - try { - const json = JSON.parse(data); - raw = json.result || data; - } catch (e) { - raw = data; - } - } else if (data?.result) { - raw = data.result; - } else if (data?.data) { - raw = data.data; - } - - const $ = cheerio.load(raw); - - // Extract related anime - const related = []; - - // Try multiple selectors - const selectors = [ - "#w-related .item", - ".item.flexserieslist", - ".scaff.side.items .item", - ".item" - ]; - - for (const selector of selectors) { - $(selector).each((_, el) => { - const link = $(el).find("a.name").first(); + // NOTE: Also extract trending sidebar items as suggested watch order + if (related.length === 0) { + $(".w-side-section:has(.title:contains('Trending')) .item, .w-side-section:first .item").each((_, el) => { + const link = $(el).find("a").first(); const href = link.attr("href") || ""; - const title = link.text().trim() || ""; + const title = $(el).find(".name").text().trim() || ""; const poster = $(el).find("img").attr("src") || ""; const slug = href.split("/watch/").pop() || ""; + const score = $(el).find(".score").text().trim() || ""; + const type = $(el).find(".meta .dot:last-child").text().trim() || ""; + const episodes = $(el).find(".meta .dot:first-child").text().trim() || ""; - // Extract relationship type - const relationEl = $(el).find(".relation, .serieslabelitem").first(); - let relationType = relationEl.text().trim().toLowerCase() || - relationEl.attr("id") || "related"; - - if (slug || title) { - related.push({ - title, - slug, - poster, - url: href, - relation: relationType - }); + if (slug && slug !== slugOrId) { + related.push({ title, slug, poster, url: href, relation: "trending", score, type, episodes }); } }); - - if (related.length > 0) break; } return { animeId, + slug: slugOrId, totalRelated: related.length, - related + related, }; } catch (error) { throw error; diff --git a/src/helper/mirror.helper.js b/src/helper/mirror.helper.js index 2367e37..5693ae5 100644 --- a/src/helper/mirror.helper.js +++ b/src/helper/mirror.helper.js @@ -199,8 +199,17 @@ async function fetchWithMirror(path, options = {}) { mirror }; } + + // NOTE: 404 means endpoint doesn't exist, not mirror failure — don't retry + if (response.status === 404) { + throw new Error(`Endpoint not found: ${path}`); + } } catch (error) { lastError = error; + // NOTE: Don't retry on 404 — the endpoint simply doesn't exist on this mirror + if (error.message?.includes("Endpoint not found")) { + break; + } if (attempt < retries) { // Wait before retry await new Promise(r => setTimeout(r, 500 * (attempt + 1))); @@ -208,9 +217,11 @@ async function fetchWithMirror(path, options = {}) { } } - // Mark mirror as failed after all retries - failedMirrors.add(mirror); - console.log(`[MIRROR] Marked as failed: ${mirror}`); + // NOTE: Only mark mirror as failed for connection errors, not endpoint 404s + if (!lastError.message?.includes("Endpoint not found")) { + failedMirrors.add(mirror); + console.log(`[MIRROR] Marked as failed: ${mirror}`); + } } throw new Error(`All mirrors failed. Last error: ${lastError?.message}`); diff --git a/src/routes/apiRoutes.js b/src/routes/apiRoutes.js index 7e22a6b..8f8742c 100644 --- a/src/routes/apiRoutes.js +++ b/src/routes/apiRoutes.js @@ -228,6 +228,88 @@ app.use((req, res, next) => { } }); + // ---- FEATURE: M3U8 proxy — fetch and serve M3U8 playlists through API ---- + app.get("/api/stream/proxy", async (req, res, next) => { + try { + const { url } = req.query; + if (!url) { + return res.status(400).json({ success: false, message: "M3U8 URL is required" }); + } + // NOTE: Validate URL is from known streaming domains + const allowedDomains = ["vidtube.site", "vidplay.site", "megaplay.buzz", "megaplay-1.buzz", "cdn.anipixcdn.co"]; + const urlObj = new URL(url); + const isAllowed = allowedDomains.some(d => urlObj.hostname.includes(d)) || urlObj.hostname.endsWith(".m3u8"); + + if (!isAllowed) { + return res.status(403).json({ success: false, message: "Domain not allowed for proxy" }); + } + + const axios = (await import("axios")).default; + const { headers: defaultHeaders } = await import("../configs/header.config.js"); + + const response = await axios.get(url, { + headers: { + ...defaultHeaders, + "Referer": "https://anikototv.to/", + "Origin": "https://anikototv.to", + }, + timeout: 15000, + responseType: "text", + }); + + // NOTE: Rewrite relative URLs in M3U8 to proxy URLs + let content = typeof response.data === "string" ? response.data : String(response.data); + const baseUrl = url.substring(0, url.lastIndexOf("/") + 1); + + content = content.replace(/(^(?!#).+\.m3u8.*$)/gm, (match) => { + const absoluteUrl = match.startsWith("http") ? match : new URL(match, baseUrl).href; + return `/api/stream/proxy?url=${encodeURIComponent(absoluteUrl)}`; + }); + + content = content.replace(/(^(?!#).+\.ts.*$)/gm, (match) => { + const absoluteUrl = match.startsWith("http") ? match : new URL(match, baseUrl).href; + return `/api/stream/ts-proxy?url=${encodeURIComponent(absoluteUrl)}`; + }); + + res.setHeader("Content-Type", "application/vnd.apple.mpegurl"); + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Cache-Control", "public, max-age=30"); + res.send(content); + } catch (error) { + jsonError(res, error.message); + } + }); + + // ---- FEATURE: TS segment proxy for video segments ---- + app.get("/api/stream/ts-proxy", async (req, res, next) => { + try { + const { url } = req.query; + if (!url) { + return res.status(400).json({ success: false, message: "TS URL is required" }); + } + + const axios = (await import("axios")).default; + const { headers: defaultHeaders } = await import("../configs/header.config.js"); + + const response = await axios.get(url, { + headers: { + ...defaultHeaders, + "Referer": "https://anikototv.to/", + "Origin": "https://anikototv.to", + }, + timeout: 30000, + responseType: "arraybuffer", + }); + + res.setHeader("Content-Type", "video/mp2t"); + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Cache-Control", "public, max-age=3600"); + res.send(Buffer.from(response.data)); + } catch (error) { + jsonError(res, error.message); + } + }); + // ══════════════════════════════════════════════════════════════ // SCHEDULE // ══════════════════════════════════════════════════════════════ @@ -494,7 +576,7 @@ app.get("/api/stats", (req, res) => { ttl: "5 minutes", description: "Map-based cache with TTL expiration", }, - endpoints: 36, + endpoints: 38, timestamp: new Date().toISOString(), }, }); @@ -565,6 +647,8 @@ app.get("/api/openapi", (req, res) => { "/watch": { get: { summary: "Watch page data", tags: ["Streaming"] } }, "/stream/resolve": { get: { summary: "Resolve actual stream URL (m3u8/mp4)", tags: ["Streaming"] } }, "/stream/qualities": { get: { summary: "Available quality options for M3U8 stream", tags: ["Streaming"] } }, + "/stream/proxy": { get: { summary: "M3U8 playlist proxy (rewrites URLs)", tags: ["Streaming"] } }, + "/stream/ts-proxy": { get: { summary: "TS video segment proxy", tags: ["Streaming"] } }, "/search/suggest": { get: { summary: "Search suggestions", tags: ["Search"] } }, "/episodes-ajax/{id}": { get: { summary: "AJAX episode list", tags: ["Episodes"] } }, "/mapper-servers": { get: { summary: "Mapper servers", tags: ["Streaming"] } },