mirror of
https://github.com/Shineii86/AniKotoAPI.git
synced 2026-10-22 19:05:11 +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
|
* @description
|
||||||
* Extracts season information for a specific anime from the
|
* Extracts season information for a specific anime from the
|
||||||
* anikototv.to AJAX endpoint. Returns all seasons/OVAs/specials
|
* watch page HTML. Returns available seasons from the page
|
||||||
* for a given anime series.
|
* sidebar and metadata.
|
||||||
*
|
*
|
||||||
* @exports
|
* @exports
|
||||||
* extractSeasons
|
* extractSeasons
|
||||||
@@ -21,73 +21,61 @@ import { fetchWithMirror } from "../helper/mirror.helper.js";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches and parses season information for an anime.
|
* 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
|
* @returns {Promise<Object>} Object with animeId, totalSeasons, and seasons array
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* const seasons = await extractSeasons(7174);
|
* const seasons = await extractSeasons("one-piece-odmau");
|
||||||
* console.log(seasons.totalSeasons); // number of seasons
|
* console.log(seasons.totalSeasons);
|
||||||
* console.log(seasons.seasons[0].title); // first season title
|
|
||||||
*/
|
*/
|
||||||
const extractSeasons = async (animeId) => {
|
const extractSeasons = async (slugOrId) => {
|
||||||
try {
|
try {
|
||||||
const { data } = await fetchWithMirror(`/api/seasons/${animeId}`, {
|
const path = /^\d+$/.test(slugOrId) ? `/watch/${slugOrId}` : `/watch/${slugOrId}`;
|
||||||
headers: { "X-Requested-With": "XMLHttpRequest" }
|
const { data } = await fetchWithMirror(path);
|
||||||
});
|
|
||||||
|
|
||||||
// Handle both string HTML and JSON responses
|
const html = typeof data === "string" ? data : String(data);
|
||||||
let raw = "";
|
const $ = cheerio.load(html);
|
||||||
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 animeId = parseInt($("#watch-main").attr("data-id")) || 0;
|
||||||
const seasons = [];
|
const seasons = [];
|
||||||
|
|
||||||
// Try multiple selectors to handle different HTML structures
|
// NOTE: Extract seasons from #ani-seasons section (if populated)
|
||||||
const selectors = [
|
$("#ani-seasons .swiper-slide, .seasons .item, #w-seasons .item").each((_, el) => {
|
||||||
".swiper-slide.season",
|
const link = $(el).find("a").first();
|
||||||
".seasons .swiper-slide",
|
const href = link.attr("href") || "";
|
||||||
"#w-seasons .swiper-slide",
|
const poster = $(el).find("img").attr("src") || "";
|
||||||
".swiper-slide"
|
const title = $(el).find(".name, .title").text().trim() || link.text().trim() || "";
|
||||||
];
|
const slug = href.split("/watch/").pop() || "";
|
||||||
|
|
||||||
for (const selector of selectors) {
|
if (slug || title) {
|
||||||
$(selector).each((_, el) => {
|
seasons.push({ title, slug, poster, url: href });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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 link = $(el).find("a").first();
|
||||||
const href = link.attr("href") || "";
|
const href = link.attr("href") || "";
|
||||||
const style = link.attr("style") || "";
|
const poster = $(el).find("img").attr("src") || "";
|
||||||
const poster = style.match(/url\(['"]?(.+?)['"]?\)/)?.[1] || "";
|
const title = $(el).find(".name").text().trim() || "";
|
||||||
const title = $(el).find(".name").text().trim() ||
|
|
||||||
link.text().trim() || "";
|
|
||||||
|
|
||||||
// Extract slug from href
|
|
||||||
const slug = href.split("/watch/").pop() || "";
|
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) {
|
if (slug && slug !== slugOrId) {
|
||||||
seasons.push({ title, slug, poster, url: href });
|
seasons.push({ title, slug, poster, url: href, score, episodes });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (seasons.length > 0) break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
animeId,
|
animeId,
|
||||||
|
slug: slugOrId,
|
||||||
totalSeasons: seasons.length,
|
totalSeasons: seasons.length,
|
||||||
seasons
|
seasons,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
*
|
*
|
||||||
* @description
|
* @description
|
||||||
* Extracts watch order and related anime information from the
|
* Extracts watch order and related anime information from the
|
||||||
* anikototv.to AJAX endpoint. Returns related anime with their
|
* watch page sidebar. Returns trending/related anime with their
|
||||||
* relationship types (sequel, prequel, summary, etc.).
|
* metadata for continuous viewing.
|
||||||
*
|
*
|
||||||
* @exports
|
* @exports
|
||||||
* extractWatchOrder
|
* extractWatchOrder
|
||||||
@@ -21,81 +21,63 @@ import { fetchWithMirror } from "../helper/mirror.helper.js";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches and parses watch order / related anime for an anime.
|
* 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
|
* @returns {Promise<Object>} Object with animeId, totalRelated, related array
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* const data = await extractWatchOrder(7174);
|
* const data = await extractWatchOrder("one-piece-odmau");
|
||||||
* console.log(data.totalRelated); // number of related anime
|
* console.log(data.totalRelated);
|
||||||
* console.log(data.related[0].title); // first related anime
|
|
||||||
*/
|
*/
|
||||||
const extractWatchOrder = async (animeId) => {
|
const extractWatchOrder = async (slugOrId) => {
|
||||||
try {
|
try {
|
||||||
const { data } = await fetchWithMirror(`/api/watch-order/${animeId}`, {
|
const path = /^\d+$/.test(slugOrId) ? `/watch/${slugOrId}` : `/watch/${slugOrId}`;
|
||||||
headers: { "X-Requested-With": "XMLHttpRequest" }
|
const { data } = await fetchWithMirror(path);
|
||||||
});
|
|
||||||
|
|
||||||
// Handle both string HTML and JSON responses
|
const html = typeof data === "string" ? data : String(data);
|
||||||
let raw = "";
|
const $ = cheerio.load(html);
|
||||||
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 animeId = parseInt($("#watch-main").attr("data-id")) || 0;
|
||||||
|
|
||||||
// Extract related anime
|
|
||||||
const related = [];
|
const related = [];
|
||||||
|
|
||||||
// Try multiple selectors
|
// NOTE: Extract from #w-related section
|
||||||
const selectors = [
|
$("#w-related .item, .w-side-section:has(.title:contains('Related')) .item").each((_, el) => {
|
||||||
"#w-related .item",
|
const link = $(el).find("a").first();
|
||||||
".item.flexserieslist",
|
const href = link.attr("href") || "";
|
||||||
".scaff.side.items .item",
|
const title = $(el).find(".name").text().trim() || "";
|
||||||
".item"
|
const poster = $(el).find("img").attr("src") || "";
|
||||||
];
|
const slug = href.split("/watch/").pop() || "";
|
||||||
|
const relation = $(el).find(".relation, .serieslabelitem").text().trim() || "related";
|
||||||
|
|
||||||
for (const selector of selectors) {
|
if (slug) {
|
||||||
$(selector).each((_, el) => {
|
related.push({ title, slug, poster, url: href, relation });
|
||||||
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 href = link.attr("href") || "";
|
||||||
const title = link.text().trim() || "";
|
const title = $(el).find(".name").text().trim() || "";
|
||||||
const poster = $(el).find("img").attr("src") || "";
|
const poster = $(el).find("img").attr("src") || "";
|
||||||
const slug = href.split("/watch/").pop() || "";
|
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
|
if (slug && slug !== slugOrId) {
|
||||||
const relationEl = $(el).find(".relation, .serieslabelitem").first();
|
related.push({ title, slug, poster, url: href, relation: "trending", score, type, episodes });
|
||||||
let relationType = relationEl.text().trim().toLowerCase() ||
|
|
||||||
relationEl.attr("id") || "related";
|
|
||||||
|
|
||||||
if (slug || title) {
|
|
||||||
related.push({
|
|
||||||
title,
|
|
||||||
slug,
|
|
||||||
poster,
|
|
||||||
url: href,
|
|
||||||
relation: relationType
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (related.length > 0) break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
animeId,
|
animeId,
|
||||||
|
slug: slugOrId,
|
||||||
totalRelated: related.length,
|
totalRelated: related.length,
|
||||||
related
|
related,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -199,8 +199,17 @@ async function fetchWithMirror(path, options = {}) {
|
|||||||
mirror
|
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) {
|
} catch (error) {
|
||||||
lastError = 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) {
|
if (attempt < retries) {
|
||||||
// Wait before retry
|
// Wait before retry
|
||||||
await new Promise(r => setTimeout(r, 500 * (attempt + 1)));
|
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
|
// NOTE: Only mark mirror as failed for connection errors, not endpoint 404s
|
||||||
failedMirrors.add(mirror);
|
if (!lastError.message?.includes("Endpoint not found")) {
|
||||||
console.log(`[MIRROR] Marked as failed: ${mirror}`);
|
failedMirrors.add(mirror);
|
||||||
|
console.log(`[MIRROR] Marked as failed: ${mirror}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`All mirrors failed. Last error: ${lastError?.message}`);
|
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
|
// SCHEDULE
|
||||||
// ══════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════
|
||||||
@@ -494,7 +576,7 @@ app.get("/api/stats", (req, res) => {
|
|||||||
ttl: "5 minutes",
|
ttl: "5 minutes",
|
||||||
description: "Map-based cache with TTL expiration",
|
description: "Map-based cache with TTL expiration",
|
||||||
},
|
},
|
||||||
endpoints: 36,
|
endpoints: 38,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -565,6 +647,8 @@ app.get("/api/openapi", (req, res) => {
|
|||||||
"/watch": { get: { summary: "Watch page data", tags: ["Streaming"] } },
|
"/watch": { get: { summary: "Watch page data", tags: ["Streaming"] } },
|
||||||
"/stream/resolve": { get: { summary: "Resolve actual stream URL (m3u8/mp4)", 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/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"] } },
|
"/search/suggest": { get: { summary: "Search suggestions", tags: ["Search"] } },
|
||||||
"/episodes-ajax/{id}": { get: { summary: "AJAX episode list", tags: ["Episodes"] } },
|
"/episodes-ajax/{id}": { get: { summary: "AJAX episode list", tags: ["Episodes"] } },
|
||||||
"/mapper-servers": { get: { summary: "Mapper servers", tags: ["Streaming"] } },
|
"/mapper-servers": { get: { summary: "Mapper servers", tags: ["Streaming"] } },
|
||||||
|
|||||||
Reference in New Issue
Block a user