mirror of
https://github.com/Shineii86/AniKotoAPI.git
synced 2026-10-22 10:55:10 +00:00
feat: Add M3U8/TS proxy, fix mirror fallback, fix seasons/watch-order
New endpoints: - GET /api/stream/proxy?url= — M3U8 playlist proxy with URL rewriting Rewrites relative .m3u8 and .ts URLs to proxy endpoints Adds CORS headers for cross-origin playback Validates allowed streaming domains - GET /api/stream/ts-proxy?url= — TS video segment proxy Proxies .ts segments with proper Content-Type and caching Fixes: - seasons.extractor.js: Now extracts from watch page sidebar instead of broken AJAX endpoint - watchOrder.extractor.js: Now extracts from watch page sidebar (trending/related) - mirror.helper.js: 404 responses no longer mark mirrors as failed Only connection errors trigger mirror failover Endpoint-specific 404s break immediately without retry 38 total endpoints, 34/34 passing with 0 errors
This commit is contained in:
@@ -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,59 +21,32 @@ 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>} 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);
|
||||
|
||||
// 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 html = typeof data === "string" ? data : String(data);
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
const animeId = parseInt($("#watch-main").attr("data-id")) || 0;
|
||||
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: 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 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, .title").text().trim() || link.text().trim() || "";
|
||||
const slug = href.split("/watch/").pop() || "";
|
||||
|
||||
if (slug || title) {
|
||||
@@ -81,13 +54,28 @@ const extractSeasons = async (animeId) => {
|
||||
}
|
||||
});
|
||||
|
||||
if (seasons.length > 0) break;
|
||||
// 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 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 && slug !== slugOrId) {
|
||||
seasons.push({ title, slug, poster, url: href, score, episodes });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
animeId,
|
||||
slug: slugOrId,
|
||||
totalSeasons: seasons.length,
|
||||
seasons
|
||||
seasons,
|
||||
};
|
||||
} catch (error) {
|
||||
throw error;
|
||||
|
||||
@@ -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>} 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);
|
||||
|
||||
// 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 html = typeof data === "string" ? data : String(data);
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
const $ = cheerio.load(raw);
|
||||
|
||||
// Extract related anime
|
||||
const animeId = parseInt($("#watch-main").attr("data-id")) || 0;
|
||||
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: 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 = link.text().trim() || "";
|
||||
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";
|
||||
|
||||
// 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) {
|
||||
related.push({ title, slug, poster, url: href, relation });
|
||||
}
|
||||
});
|
||||
|
||||
if (related.length > 0) break;
|
||||
// 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 = $(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() || "";
|
||||
|
||||
if (slug && slug !== slugOrId) {
|
||||
related.push({ title, slug, poster, url: href, relation: "trending", score, type, episodes });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
animeId,
|
||||
slug: slugOrId,
|
||||
totalRelated: related.length,
|
||||
related
|
||||
related,
|
||||
};
|
||||
} catch (error) {
|
||||
throw error;
|
||||
|
||||
@@ -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,10 +217,12 @@ async function fetchWithMirror(path, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
// Mark mirror as failed after all retries
|
||||
// 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}`);
|
||||
}
|
||||
|
||||
+85
-1
@@ -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"] } },
|
||||
|
||||
Reference in New Issue
Block a user