This commit is contained in:
shafat420
2025-09-09 21:41:09 +06:00
parent 9e30eb471e
commit fefba45a43
6 changed files with 490 additions and 63 deletions

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
node_modules

View File

@@ -1,10 +1,9 @@
import express from 'express'; import express from 'express';
import { ANIME } from '@consumet/extensions'; import { ANIME } from '@consumet/extensions';
import { HiAnime } from 'aniwatch';
import { mapAnilistToAnimePahe, mapAnilistToHiAnime, mapAnilistToAnimeKai } from './mappers/index.js'; import { mapAnilistToAnimePahe, mapAnilistToHiAnime, mapAnilistToAnimeKai } from './mappers/index.js';
import { AniList } from './providers/anilist.js'; import { AniList } from './providers/anilist.js';
import { AnimeKai } from './providers/animekai.js'; import { AnimeKai } from './providers/animekai.js';
import { getEpisodeServers } from './providers/hianime-servers.js'; import { getEpisodeServers, getEpisodeSources } from './providers/hianime-servers.js';
import { cache } from './utils/cache.js'; import { cache } from './utils/cache.js';
const app = express(); const app = express();
@@ -86,7 +85,7 @@ app.get('/hianime/servers/:animeId', cache('15 minutes'), async (req, res) => {
} }
}); });
// Get streaming sources for HiAnime episode using AniWatch // Get streaming sources for HiAnime episode using local extractor
app.get('/hianime/sources/:animeId', cache('15 minutes'), async (req, res) => { app.get('/hianime/sources/:animeId', cache('15 minutes'), async (req, res) => {
try { try {
const { animeId } = req.params; const { animeId } = req.params;
@@ -99,9 +98,8 @@ app.get('/hianime/sources/:animeId', cache('15 minutes'), async (req, res) => {
// Combine animeId and ep to form the expected episodeId format // Combine animeId and ep to form the expected episodeId format
const episodeId = `${animeId}?ep=${ep}`; const episodeId = `${animeId}?ep=${ep}`;
// Use the AniWatch library directly // Use our local extractor which supports MegaCloud
const hianime = new HiAnime.Scraper(); const sources = await getEpisodeSources(episodeId, server, category);
const sources = await hianime.getEpisodeSources(episodeId, server, category);
return res.json({ return res.json({
success: true, success: true,

View File

@@ -1,61 +1,373 @@
import { ANIME } from '@consumet/extensions'; import { load } from 'cheerio';
import { client } from '../utils/client.js';
/** const DEFAULT_BASE = 'https://animekai.to';
* AnimeKai provider class that wraps the Consumet library const KAISVA_URL = 'https://ilovekai.simplepostrequest.workers.dev'; // Cloudflare Worker decoder
*/
export class AnimeKai {
constructor() {
this.client = new ANIME.AnimeKai();
}
/** function fixUrl(url, base = DEFAULT_BASE) {
* Search for anime on AnimeKai if (!url) return '';
* @param {string} query - The search query if (url.startsWith('http')) return url;
* @returns {Promise<Object>} Search results return `${base.replace(/\/$/, '')}/${url.replace(/^\//, '')}`;
*/ }
async search(query) {
async function requestWithRetry(url, config = {}, retries = 2, perRequestTimeoutMs = 60000) {
let lastErr;
for (let i = 0; i <= retries; i++) {
try { try {
const results = await this.client.search(query); const { data } = await client.get(url, { timeout: perRequestTimeoutMs, ...config });
return results; return data;
} catch (error) { } catch (e) {
console.error('Error searching AnimeKai:', error); lastErr = e;
throw new Error('Failed to search AnimeKai'); if (i < retries) await new Promise(r => setTimeout(r, 500 * (i + 1)));
} }
} }
throw lastErr;
}
/** // Optional: use Playwright headless browser to intercept .m3u8 and .vtt
* Fetch anime information including episodes async function extractFromMegaUpHeadless(pageUrl, baseHeaders) {
* @param {string} id - The anime ID try {
* @returns {Promise<Object>} Anime info with episodes const { chromium } = await import('playwright').catch(() => ({ chromium: null }));
*/ if (!chromium) return null; // Playwright not installed
async fetchAnimeInfo(id) {
try {
const info = await this.client.fetchAnimeInfo(id);
return info;
} catch (error) {
console.error('Error fetching anime info from AnimeKai:', error);
throw new Error('Failed to fetch anime info from AnimeKai');
}
}
/** const ua = baseHeaders['User-Agent'] || 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36';
* Fetch episode streaming sources const browser = await chromium.launch({ headless: true });
* @param {string} episodeId - The episode ID const context = await browser.newContext({
* @param {string} server - Optional streaming server userAgent: ua,
* @param {boolean} dub - Whether to fetch dubbed sources (true) or subbed (false) extraHTTPHeaders: {
* @returns {Promise<Object>} Streaming sources Referer: baseHeaders.Referer || 'https://megaup.site',
*/ 'Accept-Language': baseHeaders['Accept-Language'] || 'en-US,en;q=0.9',
async fetchEpisodeSources(episodeId, server = undefined, dub = false) { },
try { });
// Use the SubOrSub enum from Consumet if dub is true const page = await context.newPage();
const subOrDub = dub ? 'dub' : 'sub';
const sources = await this.client.fetchEpisodeSources(episodeId, server, subOrDub); const seenM3U8 = new Set();
return sources; const seenVTT = new Set();
} catch (error) {
console.error('Error fetching episode sources from AnimeKai:', error); page.on('request', (req) => {
throw new Error('Failed to fetch episode sources from AnimeKai'); try {
const url = req.url();
if (/\.m3u8(\?|$)/i.test(url)) seenM3U8.add(url);
if (/\.vtt(\?|$)/i.test(url) && !/thumbnails/i.test(url)) seenVTT.add(url);
} catch {}
});
await page.goto(pageUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
// Try multiple common play selectors
const playSelectors = ['button', '.vjs-big-play-button', '.plyr__control', '.jw-icon-playback'];
for (const sel of playSelectors) {
const btn = page.locator(sel).first();
if (await btn.count().catch(() => 0)) {
await btn.click({ timeout: 3000 }).catch(() => {});
}
} }
// Explicitly wait for an HLS request
await Promise.race([
page.waitForRequest(req => /\.m3u8(\?|$)/i.test(req.url()), { timeout: 10000 }).catch(() => null),
page.waitForTimeout(7000),
]);
const m3u8 = Array.from(seenM3U8)[0];
const subtitles = Array.from(seenVTT).map((u) => ({ file: u, label: extractLangLabelFromUrl(u), kind: 'captions' }));
await context.close();
await browser.close();
if (m3u8) {
const pageUrlObj = new URL(pageUrl);
const origin = `${pageUrlObj.protocol}//${pageUrlObj.host}`;
return {
headers: { Referer: origin, 'User-Agent': ua },
sources: [ { url: m3u8, isM3U8: true } ],
subtitles,
};
}
return null;
} catch {
return null;
} }
} }
async function decodeParam(value, mode = 'e') {
// Always use worker-style: ilovefeet (encode) and ilovearmpits (decode)
const url = new URL(KAISVA_URL);
const paramName = mode === 'e' ? 'ilovefeet' : 'ilovearmpits';
url.searchParams.set(paramName, value);
return await requestWithRetry(url.toString(), { responseType: 'text' }, 2, 30000);
}
async function getJson(url, params = {}, headers = {}) {
return await requestWithRetry(url, { params, headers }, 2, 30000);
}
function extractBackgroundUrl(style) {
if (!style) return '';
const m = style.match(/url\(([^)]+)\)/i);
if (!m) return '';
return m[1].replace(/^['"]|['"]$/g, '');
}
/**
* AnimeKai provider implemented via scraping
*/
export class AnimeKai {
constructor(baseUrl = DEFAULT_BASE) {
this.baseUrl = baseUrl.replace(/\/$/, '');
}
/**
* Search AnimeKai by keyword
*/
async search(query) {
const url = `${this.baseUrl}/browser?keyword=${encodeURIComponent(query)}`;
const { data: html } = await client.get(url, { responseType: 'text', headers: { Referer: this.baseUrl } });
const $ = load(html);
const results = $("div.aitem-wrapper div.aitem").map((_, el) => {
const item = $(el);
const href = fixUrl(item.find('a.poster').attr('href'), this.baseUrl);
const title = item.find('a.title').text().trim();
const subCount = parseInt(item.find('div.info span.sub').text().trim() || '0', 10) || 0;
const dubCount = parseInt(item.find('div.info span.dub').text().trim() || '0', 10) || 0;
const posterUrl = fixUrl(item.find('a.poster img').attr('data-src') || item.find('a.poster img').attr('src'), this.baseUrl);
const type = (item.find('div.fd-infor > span.fdi-item').text().trim() || '').toLowerCase();
return {
id: href,
url: href,
title,
image: posterUrl,
type,
subCount,
dubCount,
};
}).get();
return { results };
}
/**
* Fetch anime info and episodes from a show page URL
*/
async fetchAnimeInfo(idOrUrl) {
const url = fixUrl(idOrUrl, this.baseUrl);
const { data: html } = await client.get(url, { responseType: 'text', headers: { Referer: this.baseUrl } });
const $ = load(html);
const title = $('h1.title').first().text().trim();
const japaneseTitle = $('h1.title').first().attr('data-jp') || '';
const animeId = $('div.rate-box').attr('data-id');
const malId = $('div.watch-section').attr('data-mal-id') || null;
const aniId = $('div.watch-section').attr('data-al-id') || null;
const subCount = parseInt($('#main-entity div.info span.sub').text().trim() || '0', 10) || 0;
const dubCount = parseInt($('#main-entity div.info span.dub').text().trim() || '0', 10) || 0;
const bgStyle = $('div.watch-section-bg').attr('style') || '';
const posterFromBg = extractBackgroundUrl(bgStyle);
const underscore = await decodeParam(animeId, 'e');
const listJson = await getJson(`${this.baseUrl}/ajax/episodes/list`, { ani_id: animeId, _: underscore }, { Referer: url });
const listHtml = listJson?.result || '';
const $$ = load(listHtml);
const episodes = [];
$$("div.eplist a").each((index, el) => {
const a = $$(el);
const token = a.attr('token');
const name = a.find('span').text().trim();
const numAttr = a.attr('num');
const number = numAttr ? parseInt(numAttr, 10) : (index + 1);
if (token) {
episodes.push({ id: token, number, title: name });
}
});
// Optional enrichment from Ani.zip when MAL id is present
let aniZip = null;
if (malId) {
try {
const { data: aniZipData } = await client.get(`https://api.ani.zip/mappings`, { params: { mal_id: malId } });
aniZip = aniZipData || null;
} catch {
aniZip = null;
}
if (aniZip && aniZip.episodes) {
// Attach episode metadata when index matches
episodes.forEach((ep) => {
const meta = aniZip.episodes?.[String(ep.number)];
if (meta) {
ep.image = meta.image || undefined;
ep.overview = meta.overview || undefined;
const r = parseFloat(meta.rating || '0');
ep.rating = Number.isFinite(r) ? Math.round(r * 10) : 0;
}
});
}
}
// Genres
const genres = $('div.detail a')
.toArray()
.map((el) => ({ href: $(el).attr('href') || '', text: $(el).text().trim() }))
.filter((x) => x.href.includes('/genres/'))
.map((x) => x.text);
// Status: avoid :containsOwn which isn't supported by css-select
let statusText = undefined;
const statusDiv = $('div').filter((_, el) => /\bstatus\b/i.test($(el).text()));
if (statusDiv.length) {
const spanTxt = statusDiv.first().find('span').first().text().trim();
if (spanTxt) statusText = spanTxt;
}
return {
id: url,
title,
japaneseTitle,
url,
image: posterFromBg ? fixUrl(posterFromBg, this.baseUrl) : undefined,
type: 'anime',
totalEpisodes: episodes.length,
episodes,
hasSub: subCount > 0,
hasDub: dubCount > 0,
subOrDub: subCount && dubCount ? 'both' : (dubCount ? 'dub' : 'sub'),
status: statusText,
season: undefined,
genres,
malId: malId ? Number(malId) : undefined,
anilistId: aniId ? Number(aniId) : undefined,
};
}
/**
* Fetch episode sources for a given episode token
* @param {string} episodeToken
* @param {string} serverName optional server display name filter
* @param {boolean} dub fetch dubbed if true (also tries softsub when false)
*/
async fetchEpisodeSources(episodeToken, serverName = undefined, dub = false) {
const underscoreToken = await decodeParam(episodeToken, 'e');
const listJson = await getJson(`${this.baseUrl}/ajax/links/list`, { token: episodeToken, _: underscoreToken }, { Referer: this.baseUrl });
const listHtml = listJson?.result || '';
const $ = load(listHtml);
const preferredTypes = dub ? ['dub'] : ['sub', 'softsub'];
const serverCandidates = [];
preferredTypes.forEach((type) => {
$(`div.server-items[data-id=${type}] span.server[data-lid]`).each((_, el) => {
const span = $(el);
serverCandidates.push({
type,
lid: span.attr('data-lid'),
name: span.text().trim(),
});
});
});
if (serverCandidates.length === 0) {
throw new Error('No servers found for this episode');
}
let chosen = serverCandidates[0];
if (serverName) {
const found = serverCandidates.find(s => s.name.toLowerCase() === serverName.toLowerCase());
if (found) chosen = found;
}
const underscoreLid = await decodeParam(chosen.lid, 'e');
const viewJson = await getJson(`${this.baseUrl}/ajax/links/view`, { id: chosen.lid, _: underscoreLid }, { Referer: this.baseUrl });
const result = viewJson?.result || '';
const decodedText = await decodeParam(result, 'd');
let iframeUrl = '';
try {
const parsed = JSON.parse(decodedText);
iframeUrl = parsed.url || '';
} catch {
const m = decodedText.match(/\"url\"\s*:\s*\"(.*?)\"/);
if (m) iframeUrl = m[1].replace(/\\\//g, '/');
}
if (!iframeUrl) {
throw new Error('Failed to resolve iframe URL');
}
// If MegaUp, try to extract direct m3u8 and subtitles
if (/megaup\.(site|cc)/i.test(iframeUrl)) {
const resolved = await extractFromMegaUp(iframeUrl);
if (resolved) return resolved;
}
return {
headers: {
Referer: this.baseUrl,
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
},
sources: [
{ url: iframeUrl, isM3U8: /\.m3u8($|\?)/.test(iframeUrl) }
],
subtitles: [],
};
}
}
// Try to resolve .m3u8 and .vtt subtitle links from MegaUp pages without a WebView
async function extractFromMegaUp(pageUrl) {
try {
const pageUrlObj = new URL(pageUrl);
const origin = `${pageUrlObj.protocol}//${pageUrlObj.host}`;
const headers = {
Referer: 'https://megaup.site',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:134.0) Gecko/20100101 Firefox/134.0',
Accept: '*/*',
'Accept-Language': 'en-US,en;q=0.9',
Pragma: 'no-cache',
'Cache-Control': 'no-cache',
};
const html = await requestWithRetry(pageUrl, { responseType: 'text', headers }, 1, 30000);
// Look for HLS URLs in HTML/inline scripts
const m3u8Matches = String(html).match(/https?:[^\"'\s]+\.m3u8[^\"'\s]*/gi) || [];
// Collect subtitle .vtt links
const vttMatches = String(html).match(/https?:[^\"'\s]+\.vtt[^\"'\s]*/gi) || [];
const subtitles = vttMatches
.filter((u) => !/thumbnails/i.test(u))
.map((u) => ({
file: u,
label: extractLangLabelFromUrl(u),
kind: 'captions',
}));
if (m3u8Matches.length > 0) {
const file = m3u8Matches[0];
return {
headers: { Referer: origin, 'User-Agent': headers['User-Agent'] },
sources: [ { url: file, isM3U8: true } ],
subtitles,
};
}
// If not found via static scraping, try headless interception if available
const headless = await extractFromMegaUpHeadless(pageUrl, headers);
if (headless) return headless;
// If still not found, return null to fall back to iframe
return null;
} catch {
return null;
}
}
function extractLangLabelFromUrl(url) {
try {
const file = url.split('/').pop() || '';
const code = (file.split('_')[0] || '').toLowerCase();
const map = {
eng: 'English', ger: 'German', deu: 'German', spa: 'Spanish', fre: 'French', fra: 'French',
ita: 'Italian', jpn: 'Japanese', chi: 'Chinese', zho: 'Chinese', kor: 'Korean', rus: 'Russian',
ara: 'Arabic', hin: 'Hindi', por: 'Portuguese', vie: 'Vietnamese', pol: 'Polish', ukr: 'Ukrainian',
swe: 'Swedish', ron: 'Romanian', rum: 'Romanian', ell: 'Greek', gre: 'Greek', hun: 'Hungarian',
fas: 'Persian', per: 'Persian', tha: 'Thai'
};
return map[code] || code.toUpperCase() || 'Subtitle';
} catch { return 'Subtitle'; }
}
export default AnimeKai; export default AnimeKai;

View File

@@ -110,7 +110,13 @@ export async function getEpisodeSources(episodeId, serverName = 'vidstreaming',
} }
); );
// Return sources format similar to the AniWatch package // If the target is a MegaCloud embed, extract the direct source URL
if (data?.link && /megacloud\./i.test(data.link)) {
const extracted = await extractFromMegaCloud(data.link);
return extracted;
}
// Return sources format similar to the AniWatch package for other hosts
return { return {
headers: { headers: {
Referer: data.link, Referer: data.link,
@@ -119,7 +125,7 @@ export async function getEpisodeSources(episodeId, serverName = 'vidstreaming',
sources: [ sources: [
{ {
url: data.link, url: data.link,
isM3U8: data.link.includes('.m3u8'), isM3U8: data.link?.includes('.m3u8') || false,
} }
], ],
subtitles: [], subtitles: [],
@@ -130,6 +136,108 @@ export async function getEpisodeSources(episodeId, serverName = 'vidstreaming',
} }
} }
// --- Helpers ---
async function extractFromMegaCloud(embedUrl) {
// Parse domain for Referer
const urlObj = new URL(embedUrl);
const defaultDomain = `${urlObj.protocol}//${urlObj.host}`;
// Use a mobile UA to match site expectations
const mobileUA = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Mobile Safari/537.36";
// Load embed page HTML
const { data: html } = await client.get(embedUrl, {
responseType: 'text',
headers: {
Accept: '*/*',
'X-Requested-With': 'XMLHttpRequest',
Referer: defaultDomain,
'User-Agent': mobileUA,
},
});
const $ = load(html);
// Get file id from #megacloud-player
const videoTag = $('#megacloud-player');
const fileId = videoTag?.attr('data-id');
if (!fileId) {
throw new Error('MegaCloud: missing file id (possibly expired URL)');
}
// Extract nonce - either 48 chars or 3x16 concatenated
let nonce = null;
const nonceRegex48 = /\b[a-zA-Z0-9]{48}\b/;
const match48 = html.match(nonceRegex48);
if (match48) {
nonce = match48[0];
} else {
const match3x16 = html.match(/\b([a-zA-Z0-9]{16})\b[\s\S]*?\b([a-zA-Z0-9]{16})\b[\s\S]*?\b([a-zA-Z0-9]{16})\b/);
if (match3x16) nonce = `${match3x16[1]}${match3x16[2]}${match3x16[3]}`;
}
if (!nonce) {
throw new Error('MegaCloud: failed to capture nonce');
}
// Get decryption key from public repo
const { data: keyJson } = await client.get('https://raw.githubusercontent.com/yogesh-hacker/MegacloudKeys/refs/heads/main/keys.json', {
headers: { 'User-Agent': mobileUA }
});
const secret = keyJson?.mega;
// Try to get sources JSON
const { data: sourcesResp } = await client.get(`${defaultDomain}/embed-2/v3/e-1/getSources`, {
params: { id: fileId, _k: nonce },
headers: {
Accept: 'application/json, text/plain, */*',
Referer: defaultDomain,
'User-Agent': mobileUA,
}
});
let fileUrl = null;
if (Array.isArray(sourcesResp?.sources) && sourcesResp.sources[0]?.file) {
fileUrl = sourcesResp.sources[0].file;
} else if (sourcesResp?.sources) {
// Encrypted payload; use remote decoder
const decodeBase = 'https://script.google.com/macros/s/AKfycbxHbYHbrGMXYD2-bC-C43D3njIbU-wGiYQuJL61H4vyy6YVXkybMNNEPJNPPuZrD1gRVA/exec';
const params = new URLSearchParams({
encrypted_data: String(sourcesResp.sources),
nonce: nonce, // keep for compatibility if server expects this key
secret: String(secret || ''),
});
// Some servers expect 'nonce' as '_k' or 'nonce'; try both key names
if (!params.has('_k')) params.append('_k', nonce);
const { data: decodedText } = await client.get(`${decodeBase}?${params.toString()}`, {
responseType: 'text',
headers: { 'User-Agent': mobileUA }
});
const match = /\"file\":\"(.*?)\"/.exec(decodedText);
if (match) fileUrl = match[1].replace(/\\\//g, '/');
}
if (!fileUrl) {
throw new Error('MegaCloud: failed to extract file URL');
}
return {
headers: {
Referer: defaultDomain,
'User-Agent': mobileUA,
},
tracks: [],
intro: { start: 0, end: 0 },
outro: { start: 0, end: 0 },
sources: [
{
url: fileUrl,
isM3U8: /\.m3u8($|\?)/.test(fileUrl),
}
],
};
}
export default { export default {
getEpisodeServers, getEpisodeServers,
getEpisodeSources getEpisodeSources

View File

@@ -30,9 +30,17 @@ export function cache(duration) {
// Override the json method to cache the response // Override the json method to cache the response
res.json = function(data) { res.json = function(data) {
// Cache the data try {
console.log(`Caching response for: ${key}`); // Only cache successful responses (statusCode < 400)
apiCache.set(key, data); if ((res.statusCode || 200) < 400) {
console.log(`Caching response for: ${key}`);
apiCache.set(key, data);
} else {
console.log(`Skip caching error for: ${key} (status ${res.statusCode})`);
}
} catch (e) {
console.warn(`Cache middleware error: ${e?.message || e}`);
}
// Call the original json method // Call the original json method
return originalJson.call(this, data); return originalJson.call(this, data);
@@ -40,4 +48,4 @@ export function cache(duration) {
next(); next();
}; };
} }

View File

@@ -6,7 +6,7 @@ export const client = axios.create({
'Accept': 'application/json, text/plain, */*', 'Accept': 'application/json, text/plain, */*',
'Accept-Language': 'en-US,en;q=0.9' 'Accept-Language': 'en-US,en;q=0.9'
}, },
timeout: 10000 timeout: 60000
}); });
export default client; export default client;