Files
AniKotoAPI/src/extractors/streamInfo.extractor.js
T
Shinei Nouzen 75199d4dcd fix: add cookie-aware session establishment for stream AJAX endpoints
Root cause: upstream /ajax/server and /ajax/server/list endpoints require
session cookies set by visiting the watch page. fetchWithMirror() didn't
persist cookies between requests, causing upstream to return 500.

- Extract cookies from set-cookie headers when fetching watch/home page
- Pass cookies with AJAX requests for stream info and server list
- Accept optional slug query param in /api/stream for targeted session
- Both extractors now use cookie-aware axios for session establishment
2026-08-14 06:59:17 +05:30

303 lines
11 KiB
JavaScript

/*
* ======= • ======= • ======= • ======= • =======• =======
* AniKotoAPI — streamInfo.extractor.js
* Repository: https://github.com/Shineii86/AniKotoAPI
*
* @description
* Handles server/stream resolution for episodes — fetches individual
* stream URLs, server lists via AJAX, and alternative servers from
* the nekostream mapper API.
*
* @exports
* extractStreamInfo, extractServerList, extractMapperServers
*
* @author Shinei Nouzen
* @license MIT
* ======= • ======= • ======= • ======= • =======• =======
*/
import axios from "axios";
import * as cheerio from "cheerio";
import { headers } from "../configs/header.config.js";
import { BASE_URL } from "../configs/dataUrl.js";
import { fetchWithMirror, getWorkingMirror } from "../helper/mirror.helper.js";
import { normalizeServerName } from "./streamResolver.extractor.js";
// ══════════════════════════════════════════════════════════════
// STREAM INFO EXTRACTOR
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Resolve a single stream URL from a linkId ----
/**
* Fetches the actual stream URL and skip data for a given server link ID.
* First establishes a session by fetching the watch page, then queries
* the AJAX stream endpoint with proper session context.
*
* @param {string} linkId - The server link ID to resolve
* @param {string} [watchSlug] - Optional watch page slug to establish session
* @returns {Promise<Object>} Object with linkId, url, type, skipData (intro/outro timestamps)
*
* @example
* const stream = await extractStreamInfo("abc123", "one-piece-odmau");
* console.log(stream.url); // direct video URL
* console.log(stream.skipData); // intro/outro skip ranges
*/
const extractStreamInfo = async (linkId, watchSlug = null) => {
try {
// NOTE: Establish session with cookies — AJAX endpoints require session cookies
const mirror = await getWorkingMirror();
let cookieHeader = "";
if (watchSlug) {
try {
const sessionRes = await axios.get(`${mirror}/watch/${watchSlug}`, {
headers,
timeout: 10000,
maxRedirects: 5,
});
const setCookies = sessionRes.headers["set-cookie"];
if (Array.isArray(setCookies)) {
cookieHeader = setCookies.map(c => c.split(";")[0]).join("; ");
}
} catch { /* session establishment is best-effort */ }
}
// NOTE: Fallback — if no slug provided, try fetching any page to get session cookies
if (!cookieHeader) {
try {
const fallbackRes = await axios.get(`${mirror}/home`, {
headers,
timeout: 10000,
maxRedirects: 5,
});
const setCookies = fallbackRes.headers["set-cookie"];
if (Array.isArray(setCookies)) {
cookieHeader = setCookies.map(c => c.split(";")[0]).join("; ");
}
} catch { /* fallback is best-effort */ }
}
const path = `/ajax/server?get=${linkId}`;
const requestHeaders = {
...headers,
"X-Requested-With": "XMLHttpRequest",
...(cookieHeader ? { "Cookie": cookieHeader } : {}),
};
const { data: raw } = await fetchWithMirror(path, {
headers: requestHeaders
});
// NOTE: Handle both string JSON and parsed object responses
let data = raw;
if (typeof raw === "string") {
try { data = JSON.parse(raw); } catch { data = {}; }
}
// NOTE: Detect upstream API error responses (e.g. {status: 500, result: "Bad request"})
if (data && typeof data === "object" && data.status && Number(data.status) >= 400) {
throw new Error(`Upstream API error (${data.status}): ${data.result || "Unknown error"}`);
}
if (!data || !data.result) {
return { linkId, url: null, type: null, skipData: null };
}
// NOTE: If result is a non-JSON string (error message), treat as upstream failure
const result = typeof data.result === "string"
? (() => {
try { return JSON.parse(data.result); }
catch {
if (!data.result.startsWith("{") && !data.result.startsWith("[")) {
throw new Error(`Upstream returned invalid data: ${data.result}`);
}
return {};
}
})()
: data.result;
const streamUrl = result.url || null;
const streamType = streamUrl?.includes(".m3u8") ? "hls" : streamUrl ? "mp4" : null;
return {
linkId,
url: streamUrl,
type: streamType,
skipData: result.skip_data || null,
backup: result.backup || null,
};
} catch (error) {
throw error;
}
};
// ══════════════════════════════════════════════════════════════
// SERVER LIST EXTRACTOR
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Fetch available servers for a set of episode IDs ----
/**
* Retrieves the server list for the given episode IDs from the AJAX endpoint.
* First establishes a session by fetching the watch page, then queries
* the AJAX server list endpoint with proper session context.
*
* @param {string} episodeIds - Comma-separated or single episode ID string
* @param {string} [watchSlug] - Optional watch page slug to establish session
* @returns {Promise<Array>} Array of server objects with type, link_id, ep_id, etc.
*
* @example
* const servers = await extractServerList("12345", "one-piece-odmau");
* console.log(servers[0].link_id); // first server's link ID
*/
const extractServerList = async (episodeIds, watchSlug = null) => {
try {
// NOTE: Establish session with cookies — AJAX endpoints require session cookies
const mirror = await getWorkingMirror();
let cookieHeader = "";
if (watchSlug) {
try {
const sessionRes = await axios.get(`${mirror}/watch/${watchSlug}`, {
headers,
timeout: 10000,
maxRedirects: 5,
});
const setCookies = sessionRes.headers["set-cookie"];
if (Array.isArray(setCookies)) {
cookieHeader = setCookies.map(c => c.split(";")[0]).join("; ");
}
} catch { /* session establishment is best-effort */ }
}
if (!cookieHeader) {
try {
const fallbackRes = await axios.get(`${mirror}/home`, {
headers,
timeout: 10000,
maxRedirects: 5,
});
const setCookies = fallbackRes.headers["set-cookie"];
if (Array.isArray(setCookies)) {
cookieHeader = setCookies.map(c => c.split(";")[0]).join("; ");
}
} catch { /* fallback is best-effort */ }
}
const path = `/ajax/server/list?servers=${episodeIds}`;
const requestHeaders = {
...headers,
"X-Requested-With": "XMLHttpRequest",
...(cookieHeader ? { "Cookie": cookieHeader } : {}),
};
const { data: raw } = await fetchWithMirror(path, {
headers: requestHeaders
});
// NOTE: Parse JSON response first — fetchWithMirror returns raw text by default
let parsed = raw;
if (typeof raw === "string") {
try { parsed = JSON.parse(raw); } catch { parsed = {}; }
}
// NOTE: Detect upstream API error responses
if (parsed && typeof parsed === "object" && parsed.status && Number(parsed.status) >= 400) {
throw new Error(`Upstream API error (${parsed.status}): ${parsed.result || "Unknown error"}`);
}
const html = parsed?.result || "";
const $ = cheerio.load(html);
const servers = [];
$(".servers .type").each((_, typeEl) => {
const type = $(typeEl).attr("data-type") || "sub";
const label = $(typeEl).find("label").text().trim() || type.toUpperCase();
$(typeEl).find("li[data-link-id]").each((__, li) => {
const rawName = $(li).text().trim() || "";
servers.push({
type,
typeLabel: label,
ep_id: $(li).attr("data-ep-id") || "",
link_id: $(li).attr("data-link-id") || "",
cmid: $(li).attr("data-cmid") || "",
sv_id: $(li).attr("data-sv-id") || "",
name: rawName,
normalizedName: normalizeServerName(rawName),
});
});
});
return servers;
} catch (error) {
throw error;
}
};
// ══════════════════════════════════════════════════════════════
// MAPPER SERVERS EXTRACTOR
// ══════════════════════════════════════════════════════════════
// ---- FEATURE: Fetch alternative streaming servers from nekostream mapper API ----
/**
* Queries the nekostream mapper API for alternative streaming providers
* (sub and dub) for a given anime identified by MAL ID, slug, and timestamp.
* Returns a flat array of server objects per provider.
*
* @param {number|string} malId - MyAnimeList ID for the anime
* @param {string} slug - The anime slug on anikototv.to
* @param {string|number} timestamp - Episode timestamp identifier
* @returns {Promise<Array>} Array of server objects with provider, type, url, download fields
*
* @example
* const servers = await extractMapperServers(21, "one-piece", 1234567890);
* console.log(servers[0].provider); // "vidstreaming"
* console.log(servers[0].type); // "sub" or "dub"
*/
const extractMapperServers = async (malId, slug, timestamp) => {
try {
if (!Number.isFinite(Number(malId))) throw new Error("Invalid malId");
if (!/^[a-zA-Z0-9-]+$/.test(slug)) throw new Error("Invalid slug");
if (!Number.isFinite(Number(timestamp))) throw new Error("Invalid timestamp");
const path = `/ajax/mapper/${encodeURIComponent(malId)}/${encodeURIComponent(slug)}/${encodeURIComponent(timestamp)}`;
const { data: rawData } = await fetchWithMirror(path);
// NOTE: Parse JSON response first — fetchWithMirror returns raw text by default
let data = rawData;
if (typeof rawData === "string") {
try { data = JSON.parse(rawData); } catch { data = {}; }
}
const servers = [];
if (data && typeof data === "object") {
for (const [provider, sources] of Object.entries(data)) {
if (sources && sources.sub) {
servers.push({
provider,
type: "sub",
url: sources.sub.url || null,
download: sources.sub.download || null
});
}
if (sources && sources.dub) {
servers.push({
provider,
type: "dub",
url: sources.dub.url || null,
download: sources.dub.download || null
});
}
}
}
return servers;
} catch (error) {
return [];
}
};
export { extractStreamInfo, extractServerList, extractMapperServers };
// ══════════════════════════════════════════════════════════════ END: streamInfo.extractor.js