diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b512c09 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules \ No newline at end of file diff --git a/src/index.js b/src/index.js index 1a73926..46d99f6 100644 --- a/src/index.js +++ b/src/index.js @@ -1,10 +1,9 @@ import express from 'express'; import { ANIME } from '@consumet/extensions'; -import { HiAnime } from 'aniwatch'; import { mapAnilistToAnimePahe, mapAnilistToHiAnime, mapAnilistToAnimeKai } from './mappers/index.js'; import { AniList } from './providers/anilist.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'; 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) => { try { 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 const episodeId = `${animeId}?ep=${ep}`; - // Use the AniWatch library directly - const hianime = new HiAnime.Scraper(); - const sources = await hianime.getEpisodeSources(episodeId, server, category); + // Use our local extractor which supports MegaCloud + const sources = await getEpisodeSources(episodeId, server, category); return res.json({ success: true, diff --git a/src/providers/animekai.js b/src/providers/animekai.js index bd8653a..e6da9b7 100644 --- a/src/providers/animekai.js +++ b/src/providers/animekai.js @@ -1,61 +1,373 @@ -import { ANIME } from '@consumet/extensions'; +import { load } from 'cheerio'; +import { client } from '../utils/client.js'; -/** - * AnimeKai provider class that wraps the Consumet library - */ -export class AnimeKai { - constructor() { - this.client = new ANIME.AnimeKai(); - } +const DEFAULT_BASE = 'https://animekai.to'; +const KAISVA_URL = 'https://ilovekai.simplepostrequest.workers.dev'; // Cloudflare Worker decoder - /** - * Search for anime on AnimeKai - * @param {string} query - The search query - * @returns {Promise} Search results - */ - async search(query) { +function fixUrl(url, base = DEFAULT_BASE) { + if (!url) return ''; + if (url.startsWith('http')) return url; + return `${base.replace(/\/$/, '')}/${url.replace(/^\//, '')}`; +} + +async function requestWithRetry(url, config = {}, retries = 2, perRequestTimeoutMs = 60000) { + let lastErr; + for (let i = 0; i <= retries; i++) { try { - const results = await this.client.search(query); - return results; - } catch (error) { - console.error('Error searching AnimeKai:', error); - throw new Error('Failed to search AnimeKai'); + const { data } = await client.get(url, { timeout: perRequestTimeoutMs, ...config }); + return data; + } catch (e) { + lastErr = e; + if (i < retries) await new Promise(r => setTimeout(r, 500 * (i + 1))); } } + throw lastErr; +} - /** - * Fetch anime information including episodes - * @param {string} id - The anime ID - * @returns {Promise} Anime info with episodes - */ - 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'); - } - } +// Optional: use Playwright headless browser to intercept .m3u8 and .vtt +async function extractFromMegaUpHeadless(pageUrl, baseHeaders) { + try { + const { chromium } = await import('playwright').catch(() => ({ chromium: null })); + if (!chromium) return null; // Playwright not installed - /** - * Fetch episode streaming sources - * @param {string} episodeId - The episode ID - * @param {string} server - Optional streaming server - * @param {boolean} dub - Whether to fetch dubbed sources (true) or subbed (false) - * @returns {Promise} Streaming sources - */ - async fetchEpisodeSources(episodeId, server = undefined, dub = false) { - try { - // Use the SubOrSub enum from Consumet if dub is true - const subOrDub = dub ? 'dub' : 'sub'; - const sources = await this.client.fetchEpisodeSources(episodeId, server, subOrDub); - return sources; - } catch (error) { - console.error('Error fetching episode sources from AnimeKai:', error); - throw new Error('Failed to fetch episode sources 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'; + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ + userAgent: ua, + extraHTTPHeaders: { + Referer: baseHeaders.Referer || 'https://megaup.site', + 'Accept-Language': baseHeaders['Accept-Language'] || 'en-US,en;q=0.9', + }, + }); + const page = await context.newPage(); + + const seenM3U8 = new Set(); + const seenVTT = new Set(); + + page.on('request', (req) => { + 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; \ No newline at end of file diff --git a/src/providers/hianime-servers.js b/src/providers/hianime-servers.js index cc6e1be..3127763 100644 --- a/src/providers/hianime-servers.js +++ b/src/providers/hianime-servers.js @@ -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 { headers: { Referer: data.link, @@ -119,7 +125,7 @@ export async function getEpisodeSources(episodeId, serverName = 'vidstreaming', sources: [ { url: data.link, - isM3U8: data.link.includes('.m3u8'), + isM3U8: data.link?.includes('.m3u8') || false, } ], 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 { getEpisodeServers, getEpisodeSources diff --git a/src/utils/cache.js b/src/utils/cache.js index 896f3a3..52f871d 100644 --- a/src/utils/cache.js +++ b/src/utils/cache.js @@ -30,9 +30,17 @@ export function cache(duration) { // Override the json method to cache the response res.json = function(data) { - // Cache the data - console.log(`Caching response for: ${key}`); - apiCache.set(key, data); + try { + // Only cache successful responses (statusCode < 400) + 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 return originalJson.call(this, data); @@ -40,4 +48,4 @@ export function cache(duration) { next(); }; -} \ No newline at end of file +} \ No newline at end of file diff --git a/src/utils/client.js b/src/utils/client.js index 76c4b44..282ab3b 100644 --- a/src/utils/client.js +++ b/src/utils/client.js @@ -6,7 +6,7 @@ export const client = axios.create({ 'Accept': 'application/json, text/plain, */*', 'Accept-Language': 'en-US,en;q=0.9' }, - timeout: 10000 + timeout: 60000 }); -export default client; \ No newline at end of file +export default client; \ No newline at end of file