This commit is contained in:
shafat-96
2026-02-13 20:46:15 +06:00
parent fefba45a43
commit f7bf4d6cab
8 changed files with 477 additions and 401 deletions

View File

@@ -14,7 +14,7 @@ export const ANILIST_QUERY = `
} }
} }
`; `;
export const HIANIME_URL = 'https://hianimez.to'; export const HIANIME_URL = 'https://hianime.to';
export const ANIZIP_URL = 'https://api.ani.zip/mappings'; export const ANIZIP_URL = 'https://api.ani.zip/mappings';
export default { export default {

173
src/extractors/kwik.js Normal file
View File

@@ -0,0 +1,173 @@
import axios from 'axios';
const kwikUserAgent = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Mobile Safari/537.36";
export async function extractKwik(kwikUrl, referer) {
if (!kwikUrl) {
throw new Error("missing kwik URL");
}
try {
const urlObj = new URL(kwikUrl);
// Always use the origin of the kwik URL as Referer, regardless of passed-in value
// mimicking: if u, err := url.Parse(kwikURL); err == nil { referer = u.Scheme + "://" + u.Host + "/" }
const refinedReferer = `${urlObj.protocol}//${urlObj.host}/`;
const response = await axios.get(kwikUrl, {
headers: {
'User-Agent': kwikUserAgent,
'Referer': refinedReferer,
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8'
}
});
const html = response.data;
// Find the packed eval JS - look for eval(...) containing m3u8
const jsMatch = html.match(/;(eval\(function\(p,a,c,k,e,d\).*?m3u8.*?\)\))/);
if (!jsMatch || jsMatch.length < 2) {
throw new Error("could not find eval JS pattern in Kwik page");
}
const jsCode = jsMatch[1];
const lastBraceIdx = jsCode.lastIndexOf("}(");
if (lastBraceIdx === -1) {
throw new Error("could not find argument start marker '}('");
}
const endIdx = jsCode.lastIndexOf("))");
if (endIdx === -1 || endIdx <= lastBraceIdx) {
throw new Error("could not find argument end marker '))'");
}
const stripped = jsCode.substring(lastBraceIdx + 2, endIdx);
const parts = parsePackedArgs(stripped);
if (parts.length < 4) {
throw new Error(`invalid packed data: expected at least 4 parts, got ${parts.length}`);
}
const p = parts[0];
const a = parseInt(parts[1], 10);
const c = parseInt(parts[2], 10);
let kStr = parts[3];
kStr = kStr.replace(/\.split\(['"]\|['"]\)$/, "");
const k = kStr.split("|");
let decoded = unpackKwik(p, a, c, k);
decoded = decoded.replace(/\\/g, "");
decoded = decoded.replace("https.split(://", "https://");
decoded = decoded.replace("http.split(://", "http://");
const srcMatch = decoded.match(/source=(https?:\/\/[^;]+)/);
if (!srcMatch || srcMatch.length < 2) {
throw new Error("could not find video URL in unpacked code");
}
const videoURL = cleanKwikURL(srcMatch[1]);
return {
url: videoURL,
isM3U8: videoURL.includes(".m3u8"),
};
} catch (error) {
throw error;
}
}
function unpackKwik(p, a, c, k) {
const digits = "0123456789abcdefghijklmnopqrstuvwxyz";
const dict = {};
function baseEncode(n) {
const rem = n % a;
let digit;
if (rem > 35) {
digit = String.fromCharCode(rem + 29);
} else {
digit = digits[rem];
}
if (n < a) {
return digit;
}
return baseEncode(Math.floor(n / a)) + digit;
}
for (let i = c - 1; i >= 0; i--) {
const key = baseEncode(i);
if (i < k.length && k[i] !== "") {
dict[key] = k[i];
} else {
dict[key] = key;
}
}
// Use regex to replace words
return p.replace(/\b\w+\b/g, (w) => {
if (Object.prototype.hasOwnProperty.call(dict, w)) {
return dict[w];
}
return w;
});
}
function parsePackedArgs(input) {
const result = [];
let inQuote = false;
let quoteChar = null;
let depth = 0;
let current = "";
for (let i = 0; i < input.length; i++) {
const r = input[i];
if (!inQuote) {
if (r === '\'' || r === '"') {
inQuote = true;
quoteChar = r;
// Don't add quote to current, mimicking Go logic 'continue'
continue;
}
if (r === ',' && depth === 0) {
result.push(current.trim());
current = "";
continue;
}
if (r === '(' || r === '[' || r === '{') {
depth++;
} else if (r === ')' || r === ']' || r === '}') {
if (depth > 0) {
depth--;
}
}
} else {
if (r === quoteChar) {
inQuote = false;
// Don't add quote to current
continue;
}
}
current += r;
}
if (current !== "") {
result.push(current.trim());
}
return result;
}
function cleanKwikURL(u) {
u = u.replace(/\\\//g, "/");
u = u.replace(/^["']|["']$/g, ''); // Trim quotes
u = u.replace(/[\n\r\t ]/g, ''); // Trim whitespace chars
// Remove semicolon and anything after it
const idx = u.indexOf(";");
if (idx !== -1) {
u = u.substring(0, idx);
}
return u;
}

124
src/extractors/megacloud.js Normal file
View File

@@ -0,0 +1,124 @@
import axios from 'axios';
import { client } from '../utils/client.js';
class MegaCloudExtractor {
constructor() {
this.mainUrl = "https://megacloud.blog";
this.scriptUrl = "https://script.google.com/macros/s/AKfycbxHbYHbrGMXYD2-bC-C43D3njIbU-wGiYQuJL61H4vyy6YVXkybMNNEPJNPPuZrD1gRVA/exec";
this.keysUrl = "https://raw.githubusercontent.com/yogesh-hacker/MegacloudKeys/refs/heads/main/keys.json";
}
async extract(videoUrl) {
try {
const embedUrl = new URL(videoUrl);
const headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:140.0) Gecko/20100101 Firefox/140.0",
"Accept": "*/*",
"Accept-Language": "en-US,en;q=0.5",
"Origin": this.mainUrl,
"Referer": `${this.mainUrl}/`,
};
// 1. Fetch Embed Page
const { data: html } = await client.get(videoUrl, { headers });
// 2. Extract Nonce
let nonce = null;
const match1 = html.match(/\b[a-zA-Z0-9]{48}\b/);
if (match1) {
nonce = match1[0];
} else {
const match2 = html.match(/\b([a-zA-Z0-9]{16})\b.*?\b([a-zA-Z0-9]{16})\b.*?\b([a-zA-Z0-9]{16})\b/);
if (match2) {
nonce = match2[1] + match2[2] + match2[3];
}
}
if (!nonce) throw new Error("Nonce not found");
// 3. Get Sources
// e.g. https://megacloud.blog/embed-2/e-1/VJq4nDSaJyzH?k=1 -> ID: VJq4nDSaJyzH
const id = embedUrl.pathname.split('/').pop();
const apiUrl = `${this.mainUrl}/embed-2/v3/e-1/getSources?id=${id}&_k=${nonce}`;
const { data: response } = await client.get(apiUrl, {
headers: {
...headers,
"X-Requested-With": "XMLHttpRequest",
"Referer": this.mainUrl
}
});
if (!response.sources || response.sources.length === 0) {
throw new Error("No sources found");
}
const encodedFile = response.sources[0].file;
let m3u8Url = "";
if (encodedFile.includes(".m3u8")) {
m3u8Url = encodedFile;
} else {
// 4. Decrypt via Google Script
const { data: keyData } = await axios.get(this.keysUrl);
const secret = keyData.mega;
const params = new URLSearchParams();
params.append("encrypted_data", encodedFile);
params.append("nonce", nonce);
params.append("secret", secret);
const decryptUrl = `${this.scriptUrl}?${params.toString()}`;
// Fetch text response
const { data: decryptedResponse } = await axios.get(decryptUrl, { responseType: 'text' });
// Kotlin Regex: "\"file\":\"(.*?)\""
// Handling potentially weird JSON structure or escaped strings
const textContent = typeof decryptedResponse === 'string' ? decryptedResponse : JSON.stringify(decryptedResponse);
const fileMatch = textContent.match(/"file":"(.*?)"/);
if (fileMatch && fileMatch[1]) {
// Clean up URL if needed (remove escape slashes)
m3u8Url = fileMatch[1].replace(/\\/g, '');
} else {
throw new Error("Video URL not found in decrypted response");
}
}
// 5. Build Result
const tracks = [];
if (response.tracks) {
response.tracks.forEach(track => {
if (track.kind === "captions" || track.kind === "subtitles") {
tracks.push({
url: track.file,
lang: track.label || track.kind,
label: track.label
});
}
});
}
return {
sources: [{
url: m3u8Url,
isM3U8: true
}],
tracks: tracks,
intro: response.intro || { start: 0, end: 0 },
outro: response.outro || { start: 0, end: 0 },
headers: {
Referer: this.mainUrl,
"User-Agent": headers["User-Agent"]
}
};
} catch (error) {
console.error("MegaCloud extraction failed:", error.message);
throw error;
}
}
}
export const megaCloudExtractor = new MegaCloudExtractor();

View File

@@ -1,6 +1,6 @@
import express from 'express'; import express from 'express';
import { ANIME } from '@consumet/extensions';
import { mapAnilistToAnimePahe, mapAnilistToHiAnime, mapAnilistToAnimeKai } from './mappers/index.js'; import { mapAnilistToAnimePahe, mapAnilistToHiAnime, mapAnilistToAnimeKai } from './mappers/index.js';
import { AnimePahe } from './providers/animepahe.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, getEpisodeSources } from './providers/hianime-servers.js'; import { getEpisodeServers, getEpisodeSources } from './providers/hianime-servers.js';
@@ -34,11 +34,11 @@ app.get('/', (req, res) => {
app.get('/animepahe/map/:anilistId', cache('5 minutes'), async (req, res) => { app.get('/animepahe/map/:anilistId', cache('5 minutes'), async (req, res) => {
try { try {
const { anilistId } = req.params; const { anilistId } = req.params;
if (!anilistId) { if (!anilistId) {
return res.status(400).json({ error: 'AniList ID is required' }); return res.status(400).json({ error: 'AniList ID is required' });
} }
const mappingResult = await mapAnilistToAnimePahe(anilistId); const mappingResult = await mapAnilistToAnimePahe(anilistId);
return res.json(mappingResult); return res.json(mappingResult);
} catch (error) { } catch (error) {
@@ -51,11 +51,11 @@ app.get('/animepahe/map/:anilistId', cache('5 minutes'), async (req, res) => {
app.get('/hianime/:anilistId', cache('5 minutes'), async (req, res) => { app.get('/hianime/:anilistId', cache('5 minutes'), async (req, res) => {
try { try {
const { anilistId } = req.params; const { anilistId } = req.params;
if (!anilistId) { if (!anilistId) {
return res.status(400).json({ error: 'AniList ID is required' }); return res.status(400).json({ error: 'AniList ID is required' });
} }
const episodes = await mapAnilistToHiAnime(anilistId); const episodes = await mapAnilistToHiAnime(anilistId);
return res.json(episodes); return res.json(episodes);
} catch (error) { } catch (error) {
@@ -69,14 +69,14 @@ app.get('/hianime/servers/:animeId', cache('15 minutes'), async (req, res) => {
try { try {
const { animeId } = req.params; const { animeId } = req.params;
const { ep } = req.query; const { ep } = req.query;
if (!animeId) { if (!animeId) {
return res.status(400).json({ error: 'Anime ID is required' }); return res.status(400).json({ error: 'Anime ID is required' });
} }
// Combine animeId and ep to form the expected episodeId format // Combine animeId and ep to form the expected episodeId format
const episodeId = ep ? `${animeId}?ep=${ep}` : animeId; const episodeId = ep ? `${animeId}?ep=${ep}` : animeId;
const servers = await getEpisodeServers(episodeId); const servers = await getEpisodeServers(episodeId);
return res.json(servers); return res.json(servers);
} catch (error) { } catch (error) {
@@ -90,26 +90,26 @@ app.get('/hianime/sources/:animeId', cache('15 minutes'), async (req, res) => {
try { try {
const { animeId } = req.params; const { animeId } = req.params;
const { ep, server = 'vidstreaming', category = 'sub' } = req.query; const { ep, server = 'vidstreaming', category = 'sub' } = req.query;
if (!animeId || !ep) { if (!animeId || !ep) {
return res.status(400).json({ error: 'Both anime ID and episode number (ep) are required' }); return res.status(400).json({ error: 'Both anime ID and episode number (ep) are required' });
} }
// 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 our local extractor which supports MegaCloud // Use our local extractor which supports MegaCloud
const sources = await getEpisodeSources(episodeId, server, category); const sources = await getEpisodeSources(episodeId, server, category);
return res.json({ return res.json({
success: true, success: true,
data: sources data: sources
}); });
} catch (error) { } catch (error) {
console.error('HiAnime sources error:', error.message); console.error('HiAnime sources error:', error.message);
return res.status(500).json({ return res.status(500).json({
success: false, success: false,
error: error.message error: error.message
}); });
} }
}); });
@@ -118,11 +118,11 @@ app.get('/hianime/sources/:animeId', cache('15 minutes'), async (req, res) => {
app.get('/animekai/map/:anilistId', cache('5 minutes'), async (req, res) => { app.get('/animekai/map/:anilistId', cache('5 minutes'), async (req, res) => {
try { try {
const { anilistId } = req.params; const { anilistId } = req.params;
if (!anilistId) { if (!anilistId) {
return res.status(400).json({ error: 'AniList ID is required' }); return res.status(400).json({ error: 'AniList ID is required' });
} }
const mappingResult = await mapAnilistToAnimeKai(anilistId); const mappingResult = await mapAnilistToAnimeKai(anilistId);
return res.json(mappingResult); return res.json(mappingResult);
} catch (error) { } catch (error) {
@@ -136,11 +136,11 @@ app.get('/animekai/sources/:episodeId', cache('15 minutes'), async (req, res) =>
try { try {
const { episodeId } = req.params; const { episodeId } = req.params;
const { server, dub } = req.query; const { server, dub } = req.query;
if (!episodeId) { if (!episodeId) {
return res.status(400).json({ error: 'Episode ID is required' }); return res.status(400).json({ error: 'Episode ID is required' });
} }
const animeKai = new AnimeKai(); const animeKai = new AnimeKai();
const isDub = dub === 'true' || dub === '1'; const isDub = dub === 'true' || dub === '1';
const sources = await animeKai.fetchEpisodeSources(episodeId, server, isDub); const sources = await animeKai.fetchEpisodeSources(episodeId, server, isDub);
@@ -156,20 +156,20 @@ app.get('/animepahe/sources/:session/:episodeId', cache('15 minutes'), async (re
try { try {
const { session, episodeId } = req.params; const { session, episodeId } = req.params;
const fullEpisodeId = `${session}/${episodeId}`; const fullEpisodeId = `${session}/${episodeId}`;
// Initialize a new AnimePahe instance each time // Initialize a new AnimePahe instance each time
const consumetAnimePahe = new ANIME.AnimePahe(); const animePahe = new AnimePahe();
// Directly fetch and return the sources without modification // Directly fetch and return the sources without modification
const sources = await consumetAnimePahe.fetchEpisodeSources(fullEpisodeId); const sources = await animePahe.fetchEpisodeSources(fullEpisodeId);
// Simply return the sources directly as provided by Consumet // Simply return the sources directly as provided by Consumet
return res.status(200).json(sources); return res.status(200).json(sources);
} catch (error) { } catch (error) {
console.error('Error fetching episode sources:', error.message); console.error('Error fetching episode sources:', error.message);
// Keep error handling simple // Keep error handling simple
return res.status(500).json({ return res.status(500).json({
error: error.message, error: error.message,
message: 'Failed to fetch episode sources. If you receive a 403 error when accessing streaming URLs, add a Referer: "https://kwik.cx/" header to your requests.' message: 'Failed to fetch episode sources. If you receive a 403 error when accessing streaming URLs, add a Referer: "https://kwik.cx/" header to your requests.'
}); });
@@ -180,24 +180,24 @@ app.get('/animepahe/sources/:session/:episodeId', cache('15 minutes'), async (re
app.get('/animepahe/sources/:id', cache('15 minutes'), async (req, res) => { app.get('/animepahe/sources/:id', cache('15 minutes'), async (req, res) => {
try { try {
const episodeId = req.params.id; const episodeId = req.params.id;
if (!episodeId) { if (!episodeId) {
return res.status(400).json({ error: 'Episode ID is required' }); return res.status(400).json({ error: 'Episode ID is required' });
} }
// Initialize a new AnimePahe instance each time // Initialize a new AnimePahe instance each time
const consumetAnimePahe = new ANIME.AnimePahe(); const animePahe = new AnimePahe();
// Directly fetch and return the sources without modification // Directly fetch and return the sources without modification
const sources = await consumetAnimePahe.fetchEpisodeSources(episodeId); const sources = await animePahe.fetchEpisodeSources(episodeId);
// Simply return the sources directly as provided by Consumet // Simply return the sources directly as provided by Consumet
return res.status(200).json(sources); return res.status(200).json(sources);
} catch (error) { } catch (error) {
console.error('Error fetching episode sources:', error.message); console.error('Error fetching episode sources:', error.message);
// Keep error handling simple // Keep error handling simple
return res.status(500).json({ return res.status(500).json({
error: error.message, error: error.message,
message: 'Failed to fetch episode sources. If you receive a 403 error when accessing streaming URLs, add a Referer: "https://kwik.cx/" header to your requests.' message: 'Failed to fetch episode sources. If you receive a 403 error when accessing streaming URLs, add a Referer: "https://kwik.cx/" header to your requests.'
}); });
@@ -208,18 +208,18 @@ app.get('/animepahe/sources/:id', cache('15 minutes'), async (req, res) => {
app.get('/animepahe/hls/:anilistId/:episode', cache('15 minutes'), async (req, res) => { app.get('/animepahe/hls/:anilistId/:episode', cache('15 minutes'), async (req, res) => {
try { try {
const { anilistId, episode } = req.params; const { anilistId, episode } = req.params;
if (!anilistId || !episode) { if (!anilistId || !episode) {
return res.status(400).json({ error: 'Both AniList ID and episode number are required' }); return res.status(400).json({ error: 'Both AniList ID and episode number are required' });
} }
// First, get the mapping from Anilist to AnimePahe // First, get the mapping from Anilist to AnimePahe
const mappingResult = await mapAnilistToAnimePahe(anilistId); const mappingResult = await mapAnilistToAnimePahe(anilistId);
if (!mappingResult.animepahe || !mappingResult.animepahe.episodes || mappingResult.animepahe.episodes.length === 0) { if (!mappingResult.animepahe || !mappingResult.animepahe.episodes || mappingResult.animepahe.episodes.length === 0) {
return res.status(404).json({ error: 'No episodes found for this anime on AnimePahe' }); return res.status(404).json({ error: 'No episodes found for this anime on AnimePahe' });
} }
// Try to find the episode with the exact number first (e.g., AnimePahe episode numbers) // Try to find the episode with the exact number first (e.g., AnimePahe episode numbers)
let targetEpisode = mappingResult.animepahe.episodes.find( let targetEpisode = mappingResult.animepahe.episodes.find(
ep => ep.number === parseInt(episode, 10) ep => ep.number === parseInt(episode, 10)
@@ -237,11 +237,11 @@ app.get('/animepahe/hls/:anilistId/:episode', cache('15 minutes'), async (req, r
if (!targetEpisode) { if (!targetEpisode) {
return res.status(404).json({ error: `Episode ${episode} not found on AnimePahe` }); return res.status(404).json({ error: `Episode ${episode} not found on AnimePahe` });
} }
// Now fetch the sources for this episode // Now fetch the sources for this episode
const consumetAnimePahe = new ANIME.AnimePahe(); const animePahe = new AnimePahe();
const sources = await consumetAnimePahe.fetchEpisodeSources(targetEpisode.episodeId); const sources = await animePahe.fetchEpisodeSources(targetEpisode.episodeId);
// Return the sources directly // Return the sources directly
return res.status(200).json({ return res.status(200).json({
sources: sources, sources: sources,
@@ -249,7 +249,7 @@ app.get('/animepahe/hls/:anilistId/:episode', cache('15 minutes'), async (req, r
}); });
} catch (error) { } catch (error) {
console.error('Error fetching HLS sources:', error.message); console.error('Error fetching HLS sources:', error.message);
return res.status(500).json({ return res.status(500).json({
error: error.message, error: error.message,
message: 'Failed to fetch HLS sources. If you receive a 403 error when accessing streaming URLs, add a Referer: "https://kwik.cx/" header to your requests.' message: 'Failed to fetch HLS sources. If you receive a 403 error when accessing streaming URLs, add a Referer: "https://kwik.cx/" header to your requests.'
}); });

View File

@@ -1,44 +1,27 @@
import { AniList } from '../providers/anilist.js'; import { AniList } from '../providers/anilist.js';
import { AnimeKai } from '../providers/animekai.js'; import { AnimeKai } from '../providers/animekai.js';
/**
* Maps an Anilist anime to AnimeKai
* @param {string|number} anilistId - The AniList ID to map
* @returns {Promise<Object>} The mapping result with episodes
*/
export async function mapAnilistToAnimeKai(anilistId) { export async function mapAnilistToAnimeKai(anilistId) {
const mapper = new AnimeKaiMapper(); const mapper = new AnimeKaiMapper();
return await mapper.mapAnilistToAnimeKai(anilistId); return await mapper.mapAnilistToAnimeKai(anilistId);
} }
/**
* Mapper class that provides mapping between Anilist and AnimeKai
*/
export class AnimeKaiMapper { export class AnimeKaiMapper {
constructor() { constructor() {
this.anilist = new AniList(); this.anilist = new AniList();
this.animeKai = new AnimeKai(); this.animeKai = new AnimeKai();
} }
/**
* Maps an Anilist anime to AnimeKai content
* @param {string|number} anilistId - The AniList ID to map
*/
async mapAnilistToAnimeKai(anilistId) { async mapAnilistToAnimeKai(anilistId) {
try { try {
// Get anime info from AniList
const animeInfo = await this.anilist.getAnimeInfo(parseInt(anilistId)); const animeInfo = await this.anilist.getAnimeInfo(parseInt(anilistId));
if (!animeInfo) { if (!animeInfo) {
throw new Error(`Anime with id ${anilistId} not found on AniList`); throw new Error(`Anime with id ${anilistId} not found on AniList`);
} }
// Search for the anime on AnimeKai using the title
const searchTitle = animeInfo.title.english || animeInfo.title.romaji || animeInfo.title.userPreferred; const searchTitle = animeInfo.title.english || animeInfo.title.romaji || animeInfo.title.userPreferred;
if (!searchTitle) { if (!searchTitle) {
throw new Error('No title available for the anime'); throw new Error('No title available for the anime');
} }
const searchResults = await this.animeKai.search(searchTitle); const searchResults = await this.animeKai.search(searchTitle);
if (!searchResults || !searchResults.results || searchResults.results.length === 0) { if (!searchResults || !searchResults.results || searchResults.results.length === 0) {
return { return {
@@ -47,8 +30,6 @@ export class AnimeKaiMapper {
animekai: null animekai: null
}; };
} }
// Find the best match from search results
const bestMatch = this.findBestMatch(searchTitle, animeInfo, searchResults.results); const bestMatch = this.findBestMatch(searchTitle, animeInfo, searchResults.results);
if (!bestMatch) { if (!bestMatch) {
return { return {
@@ -57,10 +38,8 @@ export class AnimeKaiMapper {
animekai: null animekai: null
}; };
} }
// Get detailed info for the best match
const animeDetails = await this.animeKai.fetchAnimeInfo(bestMatch.id); const animeDetails = await this.animeKai.fetchAnimeInfo(bestMatch.id);
return { return {
id: animeInfo.id, id: animeInfo.id,
title: searchTitle, title: searchTitle,
@@ -86,57 +65,37 @@ export class AnimeKaiMapper {
throw error; throw error;
} }
} }
/**
* Find the best match from search results
* @param {string} searchTitle - The search title
* @param {Object} animeInfo - The AniList anime info
* @param {Array} results - The search results
* @returns {Object|null} The best match or null if no good match found
*/
findBestMatch(searchTitle, animeInfo, results) { findBestMatch(searchTitle, animeInfo, results) {
if (!results || results.length === 0) return null; if (!results || results.length === 0) return null;
// Normalize titles for comparison
const normalizeTitle = title => title.toLowerCase().replace(/[^\w\s]/g, '').replace(/\s+/g, ' ').trim(); const normalizeTitle = title => title.toLowerCase().replace(/[^\w\s]/g, '').replace(/\s+/g, ' ').trim();
const normalizedSearch = normalizeTitle(searchTitle); const normalizedSearch = normalizeTitle(searchTitle);
// Extract year from AniList title if present
let year = null; let year = null;
if (animeInfo.startDate && animeInfo.startDate.year) { if (animeInfo.startDate && animeInfo.startDate.year) {
year = animeInfo.startDate.year; year = animeInfo.startDate.year;
} else if (animeInfo.seasonYear) { } else if (animeInfo.seasonYear) {
year = animeInfo.seasonYear; year = animeInfo.seasonYear;
} }
// First try: find exact title match
for (const result of results) { for (const result of results) {
const resultTitle = normalizeTitle(result.title); const resultTitle = normalizeTitle(result.title);
const japaneseTitle = result.japaneseTitle ? normalizeTitle(result.japaneseTitle) : ''; const japaneseTitle = result.japaneseTitle ? normalizeTitle(result.japaneseTitle) : '';
if (resultTitle === normalizedSearch || japaneseTitle === normalizedSearch) { if (resultTitle === normalizedSearch || japaneseTitle === normalizedSearch) {
return result; return result;
} }
} }
// Second try: find partial match with proper episode count match
const expectedEpisodes = animeInfo.episodes || 0; const expectedEpisodes = animeInfo.episodes || 0;
for (const result of results) { for (const result of results) {
const resultTitle = normalizeTitle(result.title); const resultTitle = normalizeTitle(result.title);
const japaneseTitle = result.japaneseTitle ? normalizeTitle(result.japaneseTitle) : ''; const japaneseTitle = result.japaneseTitle ? normalizeTitle(result.japaneseTitle) : '';
// Check if this is likely the right anime by comparing episode count
if (result.episodes === expectedEpisodes && expectedEpisodes > 0) { if (result.episodes === expectedEpisodes && expectedEpisodes > 0) {
if (resultTitle.includes(normalizedSearch) || if (resultTitle.includes(normalizedSearch) ||
normalizedSearch.includes(resultTitle) || normalizedSearch.includes(resultTitle) ||
japaneseTitle.includes(normalizedSearch) || japaneseTitle.includes(normalizedSearch) ||
normalizedSearch.includes(japaneseTitle)) { normalizedSearch.includes(japaneseTitle)) {
return result; return result;
} }
} }
} }
// Final fallback: just return the first result
return results[0]; return results[0];
} }
} }

View File

@@ -1,49 +1,36 @@
import { AniList } from '../providers/anilist.js'; import { AniList } from '../providers/anilist.js';
import { AnimePahe } from '../providers/animepahe.js'; import { AnimePahe } from '../providers/animepahe.js';
/**
* Maps an Anilist anime to AnimePahe content
*/
export async function mapAnilistToAnimePahe(anilistId) { export async function mapAnilistToAnimePahe(anilistId) {
const mapper = new AnimepaheMapper(); const mapper = new AnimepaheMapper();
return await mapper.mapAnilistToAnimePahe(anilistId); return await mapper.mapAnilistToAnimePahe(anilistId);
} }
/**
* Mapper class that provides mapping between Anilist and AnimePahe
*/
export class AnimepaheMapper { export class AnimepaheMapper {
constructor() { constructor() {
this.anilist = new AniList(); this.anilist = new AniList();
this.animePahe = new AnimePahe(); this.animePahe = new AnimePahe();
} }
/**
* Maps an Anilist anime to AnimePahe content
*/
async mapAnilistToAnimePahe(anilistId) { async mapAnilistToAnimePahe(anilistId) {
try { try {
// Get anime info from AniList
const animeInfo = await this.anilist.getAnimeInfo(parseInt(anilistId)); const animeInfo = await this.anilist.getAnimeInfo(parseInt(anilistId));
if (!animeInfo) { if (!animeInfo) {
throw new Error(`Anime with id ${anilistId} not found on AniList`); throw new Error(`Anime with id ${anilistId} not found on AniList`);
} }
// Try to find matching content on AnimePahe
const bestMatch = await this.findAnimePaheMatch(animeInfo); const bestMatch = await this.findAnimePaheMatch(animeInfo);
if (!bestMatch) { if (!bestMatch) {
return { return {
id: animeInfo.id, id: animeInfo.id,
animepahe: null animepahe: null
}; };
} }
// Get episode data for the matched anime
const episodeData = await this.getAnimePaheEpisodes(bestMatch); const episodeData = await this.getAnimePaheEpisodes(bestMatch);
// Return the mapped result
return { return {
id: animeInfo.id, id: animeInfo.id,
animepahe: { animepahe: {
@@ -65,20 +52,12 @@ export class AnimepaheMapper {
} }
} }
/**
* Finds the matching AnimePahe content for an AniList anime
*/
async findAnimePaheMatch(animeInfo) { async findAnimePaheMatch(animeInfo) {
// Only use one primary title to reduce API calls
let bestTitle = animeInfo.title.romaji || animeInfo.title.english || animeInfo.title.userPreferred; let bestTitle = animeInfo.title.romaji || animeInfo.title.english || animeInfo.title.userPreferred;
const titleType = animeInfo.title.romaji ? 'romaji' : (animeInfo.title.english ? 'english' : 'userPreferred');
// First search attempt
const searchResults = await this.animePahe.scrapeSearchResults(bestTitle); const searchResults = await this.animePahe.scrapeSearchResults(bestTitle);
// Process results if we found any
if (searchResults && searchResults.length > 0) { if (searchResults && searchResults.length > 0) {
// First try direct ID match (fastest path)
const rawId = animeInfo.id.toString(); const rawId = animeInfo.id.toString();
for (const result of searchResults) { for (const result of searchResults) {
const resultId = (result.id || '').split('-')[0]; const resultId = (result.id || '').split('-')[0];
@@ -86,52 +65,40 @@ export class AnimepaheMapper {
return result; return result;
} }
} }
// If no direct ID match, find the best match with our algorithm
return this.findBestMatchFromResults(animeInfo, searchResults); return this.findBestMatchFromResults(animeInfo, searchResults);
} }
// If no results found, try a fallback search with a more generic title
const genericTitle = this.getGenericTitle(animeInfo); const genericTitle = this.getGenericTitle(animeInfo);
if (genericTitle && genericTitle !== bestTitle) { if (genericTitle && genericTitle !== bestTitle) {
const fallbackResults = await this.animePahe.scrapeSearchResults(genericTitle); const fallbackResults = await this.animePahe.scrapeSearchResults(genericTitle);
if (fallbackResults && fallbackResults.length > 0) { if (fallbackResults && fallbackResults.length > 0) {
return this.findBestMatchFromResults(animeInfo, fallbackResults); return this.findBestMatchFromResults(animeInfo, fallbackResults);
} }
} }
return null; return null;
} }
/**
* Find the best match from available search results
*/
findBestMatchFromResults(animeInfo, results) { findBestMatchFromResults(animeInfo, results) {
if (!results || results.length === 0) return null; if (!results || results.length === 0) return null;
// Normalize titles just once to avoid repeating work
const normalizeTitle = t => t.toLowerCase().replace(/[^\w\s]/g, '').replace(/\s+/g, ' ').trim(); const normalizeTitle = t => t.toLowerCase().replace(/[^\w\s]/g, '').replace(/\s+/g, ' ').trim();
const anilistTitles = [ const anilistTitles = [
animeInfo.title.romaji, animeInfo.title.romaji,
animeInfo.title.english, animeInfo.title.english,
animeInfo.title.userPreferred animeInfo.title.userPreferred
].filter(Boolean).map(normalizeTitle); ].filter(Boolean).map(normalizeTitle);
// Prepare year information const anilistYear =
const anilistYear = (animeInfo.startDate && animeInfo.startDate.year) ?
(animeInfo.startDate && animeInfo.startDate.year) ? animeInfo.startDate.year : animeInfo.seasonYear;
animeInfo.startDate.year : animeInfo.seasonYear;
const animeYear = anilistYear || this.extractYearFromTitle(animeInfo); const animeYear = anilistYear || this.extractYearFromTitle(animeInfo);
// Process matches sequentially with early returns
let bestMatch = null; let bestMatch = null;
// Try exact title match with year (highest priority)
if (animeYear) { if (animeYear) {
// Find matches with exact year
const yearMatches = []; const yearMatches = [];
for (const result of results) { for (const result of results) {
const resultYear = result.year ? parseInt(result.year) : this.extractYearFromTitle(result); const resultYear = result.year ? parseInt(result.year) : this.extractYearFromTitle(result);
@@ -139,80 +106,69 @@ export class AnimepaheMapper {
yearMatches.push(result); yearMatches.push(result);
} }
} }
// If we have year matches, try to find the best title match among them
if (yearMatches.length > 0) { if (yearMatches.length > 0) {
for (const match of yearMatches) { for (const match of yearMatches) {
const resultTitle = normalizeTitle(match.title || match.name); const resultTitle = normalizeTitle(match.title || match.name);
// First try: exact title match with year
for (const title of anilistTitles) { for (const title of anilistTitles) {
if (!title) continue; if (!title) continue;
if (resultTitle === title || if (resultTitle === title ||
(resultTitle.includes(title) && title.length > 7) || (resultTitle.includes(title) && title.length > 7) ||
(title.includes(resultTitle) && resultTitle.length > 7)) { (title.includes(resultTitle) && resultTitle.length > 7)) {
return match; // Early return for best match return match;
} }
} }
// Second try: high similarity title match with year
for (const title of anilistTitles) { for (const title of anilistTitles) {
if (!title) continue; if (!title) continue;
const similarity = this.calculateTitleSimilarity(title, resultTitle); const similarity = this.calculateTitleSimilarity(title, resultTitle);
if (similarity > 0.5) { if (similarity > 0.5) {
bestMatch = match; bestMatch = match;
break; break;
} }
} }
if (bestMatch) break; if (bestMatch) break;
} }
// If we found a title similarity match with year, return it
if (bestMatch) return bestMatch; if (bestMatch) return bestMatch;
// Otherwise use the first year match as a fallback
return yearMatches[0]; return yearMatches[0];
} }
} }
// Try exact title match
for (const result of results) { for (const result of results) {
const resultTitle = normalizeTitle(result.title || result.name); const resultTitle = normalizeTitle(result.title || result.name);
for (const title of anilistTitles) { for (const title of anilistTitles) {
if (!title) continue; if (!title) continue;
if (resultTitle === title) { if (resultTitle === title) {
return result; // Early return for exact title match return result;
} }
} }
} }
// Try high similarity title match
bestMatch = this.findBestSimilarityMatch(anilistTitles, results); bestMatch = this.findBestSimilarityMatch(anilistTitles, results);
if (bestMatch) return bestMatch; if (bestMatch) return bestMatch;
// Just use the first result as a fallback
return results[0]; return results[0];
} }
/**
* Find the best match based on title similarity
*/
findBestSimilarityMatch(titles, results) { findBestSimilarityMatch(titles, results) {
const normalizeTitle = t => t.toLowerCase().replace(/[^\w\s]/g, '').replace(/\s+/g, ' ').trim(); const normalizeTitle = t => t.toLowerCase().replace(/[^\w\s]/g, '').replace(/\s+/g, ' ').trim();
let bestMatch = null; let bestMatch = null;
let highestSimilarity = 0; let highestSimilarity = 0;
for (const result of results) { for (const result of results) {
const resultTitle = normalizeTitle(result.title || result.name); const resultTitle = normalizeTitle(result.title || result.name);
for (const title of titles) { for (const title of titles) {
if (!title) continue; if (!title) continue;
const similarity = this.calculateTitleSimilarity(title, resultTitle); const similarity = this.calculateTitleSimilarity(title, resultTitle);
if (similarity > highestSimilarity) { if (similarity > highestSimilarity) {
highestSimilarity = similarity; highestSimilarity = similarity;
@@ -220,14 +176,10 @@ export class AnimepaheMapper {
} }
} }
} }
// Only return if we have a reasonably good match
return highestSimilarity > 0.6 ? bestMatch : null; return highestSimilarity > 0.6 ? bestMatch : null;
} }
/**
* Get the AnimePahe episodes for a match
*/
async getAnimePaheEpisodes(match) { async getAnimePaheEpisodes(match) {
try { try {
const episodeData = await this.animePahe.scrapeEpisodes(match.id); const episodeData = await this.animePahe.scrapeEpisodes(match.id);
@@ -240,82 +192,62 @@ export class AnimepaheMapper {
return { totalEpisodes: 0, episodes: [] }; return { totalEpisodes: 0, episodes: [] };
} }
} }
/**
* Calculate similarity between two titles
*/
calculateTitleSimilarity(title1, title2) { calculateTitleSimilarity(title1, title2) {
if (!title1 || !title2) return 0; if (!title1 || !title2) return 0;
// Normalize both titles
const norm1 = title1.toLowerCase().replace(/[^\w\s]/g, '').replace(/\s+/g, ' ').trim(); const norm1 = title1.toLowerCase().replace(/[^\w\s]/g, '').replace(/\s+/g, ' ').trim();
const norm2 = title2.toLowerCase().replace(/[^\w\s]/g, '').replace(/\s+/g, ' ').trim(); const norm2 = title2.toLowerCase().replace(/[^\w\s]/g, '').replace(/\s+/g, ' ').trim();
// Exact match is best
if (norm1 === norm2) return 1; if (norm1 === norm2) return 1;
// Split into words
const words1 = norm1.split(' ').filter(Boolean); const words1 = norm1.split(' ').filter(Boolean);
const words2 = norm2.split(' ').filter(Boolean); const words2 = norm2.split(' ').filter(Boolean);
// Count common words
const commonCount = words1.filter(w => words2.includes(w)).length; const commonCount = words1.filter(w => words2.includes(w)).length;
// Weight by percentage of common words
return commonCount * 2 / (words1.length + words2.length); return commonCount * 2 / (words1.length + words2.length);
} }
/**
* Extract year from title (e.g., "JoJo's Bizarre Adventure (2012)" -> 2012)
*/
extractYearFromTitle(item) { extractYearFromTitle(item) {
if (!item) return null; if (!item) return null;
// Extract the title string based on the input type
let titleStr = ''; let titleStr = '';
if (typeof item === 'string') { if (typeof item === 'string') {
titleStr = item; titleStr = item;
} else if (typeof item === 'object') { } else if (typeof item === 'object') {
// Handle both anime objects and result objects
if (item.title) { if (item.title) {
if (typeof item.title === 'string') { if (typeof item.title === 'string') {
titleStr = item.title; titleStr = item.title;
} else if (typeof item.title === 'object') { } else if (typeof item.title === 'object') {
// AniList title object
titleStr = item.title.userPreferred || item.title.english || item.title.romaji || ''; titleStr = item.title.userPreferred || item.title.english || item.title.romaji || '';
} }
} else if (item.name) { } else if (item.name) {
titleStr = item.name; titleStr = item.name;
} }
} }
if (!titleStr) return null; if (!titleStr) return null;
// Look for year pattern in parentheses or brackets
const yearMatches = titleStr.match(/[\(\[](\d{4})[\)\]]/); const yearMatches = titleStr.match(/[\(\[](\d{4})[\)\]]/);
if (yearMatches && yearMatches[1]) { if (yearMatches && yearMatches[1]) {
const year = parseInt(yearMatches[1]); const year = parseInt(yearMatches[1]);
if (!isNaN(year) && year > 1950 && year <= new Date().getFullYear()) { if (!isNaN(year) && year > 1950 && year <= new Date().getFullYear()) {
return year; return year;
} }
} }
return null; return null;
} }
/**
* Get a generic title by removing year information and other specific identifiers
*/
getGenericTitle(animeInfo) { getGenericTitle(animeInfo) {
if (!animeInfo || !animeInfo.title) return null; if (!animeInfo || !animeInfo.title) return null;
const title = animeInfo.title.english || animeInfo.title.romaji || animeInfo.title.userPreferred; const title = animeInfo.title.english || animeInfo.title.romaji || animeInfo.title.userPreferred;
if (!title) return null; if (!title) return null;
// Remove year information and common specifiers
return title.replace(/\([^)]*\d{4}[^)]*\)/g, '').replace(/\[[^\]]*\d{4}[^\]]*\]/g, '').trim(); return title.replace(/\([^)]*\d{4}[^)]*\)/g, '').replace(/\[[^\]]*\d{4}[^\]]*\]/g, '').trim();
} }
} }
export default mapAnilistToAnimePahe; export default mapAnilistToAnimePahe;

View File

@@ -1,9 +1,10 @@
import axios from 'axios'; import axios from 'axios';
import * as cheerio from 'cheerio'; import * as cheerio from 'cheerio';
import { extractKwik } from '../extractors/kwik.js';
export class AnimePahe { export class AnimePahe {
constructor() { constructor() {
this.baseUrl = "https://animepahe.ru"; this.baseUrl = "https://animepahe.si";
this.sourceName = 'AnimePahe'; this.sourceName = 'AnimePahe';
this.isMulti = false; this.isMulti = false;
} }
@@ -15,7 +16,7 @@ export class AnimePahe {
'Cookie': "__ddg1_=;__ddg2_=;", 'Cookie': "__ddg1_=;__ddg2_=;",
} }
}); });
const jsonResult = response.data; const jsonResult = response.data;
const searchResults = []; const searchResults = [];
@@ -36,9 +37,9 @@ export class AnimePahe {
score: item.score || 0, score: item.score || 0,
poster: item.poster, poster: item.poster,
session: item.session, session: item.session,
episodes: { episodes: {
sub: item.episodes || null, sub: item.episodes || null,
dub: '??' dub: '??'
} }
}); });
} }
@@ -54,16 +55,16 @@ export class AnimePahe {
try { try {
const title = url.split('-')[1]; const title = url.split('-')[1];
const id = url.split('-')[0]; const id = url.split('-')[0];
const session = await this._getSession(title, id); const session = await this._getSession(title, id);
const epUrl = `${this.baseUrl}/api?m=release&id=${session}&sort=episode_desc&page=1`; const epUrl = `${this.baseUrl}/api?m=release&id=${session}&sort=episode_desc&page=1`;
const response = await axios.get(epUrl, { const response = await axios.get(epUrl, {
headers: { headers: {
'Cookie': "__ddg1_=;__ddg2_=;", 'Cookie': "__ddg1_=;__ddg2_=;",
} }
}); });
return await this._recursiveFetchEpisodes(epUrl, JSON.stringify(response.data), session); return await this._recursiveFetchEpisodes(epUrl, JSON.stringify(response.data), session);
} catch (error) { } catch (error) {
console.error('Error fetching episodes:', error.message); console.error('Error fetching episodes:', error.message);
@@ -102,38 +103,34 @@ export class AnimePahe {
'Cookie': "__ddg1_=;__ddg2_=;", 'Cookie': "__ddg1_=;__ddg2_=;",
} }
}); });
const moreEpisodes = await this._recursiveFetchEpisodes(newUrl, JSON.stringify(newResponse.data), session); const moreEpisodes = await this._recursiveFetchEpisodes(newUrl, JSON.stringify(newResponse.data), session);
episodes = [...episodes, ...moreEpisodes.episodes]; episodes = [...episodes, ...moreEpisodes.episodes];
animeTitle = moreEpisodes.title; animeTitle = moreEpisodes.title;
animeDetails = moreEpisodes.details || animeDetails; animeDetails = moreEpisodes.details || animeDetails;
} else { } else {
const detailUrl = `https://animepahe.ru/a/${jsonResult.data[0].anime_id}`; const detailUrl = `https://animepahe.si/a/${jsonResult.data[0].anime_id}`;
const newResponse = await axios.get(detailUrl, { const newResponse = await axios.get(detailUrl, {
headers: { headers: {
'Cookie': "__ddg1_=;__ddg2_=;", 'Cookie': "__ddg1_=;__ddg2_=;",
} }
}); });
if (newResponse.status === 200) { if (newResponse.status === 200) {
const $ = cheerio.load(newResponse.data); const $ = cheerio.load(newResponse.data);
animeTitle = $('.title-wrapper span').text().trim() || 'Could not fetch title'; animeTitle = $('.title-wrapper span').text().trim() || 'Could not fetch title';
// Try to extract additional information
try { try {
// Parse type
const typeText = $('.col-sm-4.anime-info p:contains("Type")').text(); const typeText = $('.col-sm-4.anime-info p:contains("Type")').text();
if (typeText) { if (typeText) {
animeDetails.type = typeText.replace('Type:', '').trim(); animeDetails.type = typeText.replace('Type:', '').trim();
} }
// Parse status
const statusText = $('.col-sm-4.anime-info p:contains("Status")').text(); const statusText = $('.col-sm-4.anime-info p:contains("Status")').text();
if (statusText) { if (statusText) {
animeDetails.status = statusText.replace('Status:', '').trim(); animeDetails.status = statusText.replace('Status:', '').trim();
} }
// Parse season and year
const seasonText = $('.col-sm-4.anime-info p:contains("Season")').text(); const seasonText = $('.col-sm-4.anime-info p:contains("Season")').text();
if (seasonText) { if (seasonText) {
const seasonMatch = seasonText.match(/Season:\s+(\w+)\s+(\d{4})/); const seasonMatch = seasonText.match(/Season:\s+(\w+)\s+(\d{4})/);
@@ -142,8 +139,7 @@ export class AnimePahe {
animeDetails.year = parseInt(seasonMatch[2]); animeDetails.year = parseInt(seasonMatch[2]);
} }
} }
// Parse score
const scoreText = $('.col-sm-4.anime-info p:contains("Score")').text(); const scoreText = $('.col-sm-4.anime-info p:contains("Score")').text();
if (scoreText) { if (scoreText) {
const scoreMatch = scoreText.match(/Score:\s+([\d.]+)/); const scoreMatch = scoreText.match(/Score:\s+([\d.]+)/);
@@ -157,7 +153,6 @@ export class AnimePahe {
} }
} }
// Always sort episodes by number in ascending order, regardless of how the API returns them
const sortedEpisodes = [...episodes].sort((a, b) => a.number - b.number); const sortedEpisodes = [...episodes].sort((a, b) => a.number - b.number);
return { return {
@@ -165,7 +160,7 @@ export class AnimePahe {
session: session, session: session,
totalEpisodes: jsonResult.total, totalEpisodes: jsonResult.total,
details: animeDetails, details: animeDetails,
episodes: sortedEpisodes, // Return sorted episodes, always in ascending order episodes: sortedEpisodes,
}; };
} catch (error) { } catch (error) {
console.error('Error recursively fetching episodes:', error.message); console.error('Error recursively fetching episodes:', error.message);
@@ -173,14 +168,18 @@ export class AnimePahe {
} }
} }
async scrapeEpisodesSrcs(episodeUrl, { category, lang } = {}) { async fetchEpisodeSources(episodeId, options = {}) {
return this.scrapeEpisodesSrcs(episodeId, options);
}
async scrapeEpisodesSrcs(episodeId, { category, lang } = {}) {
try { try {
const response = await axios.get(`${this.baseUrl}/play/${episodeUrl}`, { const response = await axios.get(`${this.baseUrl}/play/${episodeId}`, {
headers: { headers: {
'Cookie': "__ddg1_=;__ddg2_=;", 'Cookie': "__ddg1_=;__ddg2_=;",
} }
}); });
const $ = cheerio.load(response.data); const $ = cheerio.load(response.data);
const buttons = $('#resolutionMenu > button'); const buttons = $('#resolutionMenu > button');
const videoLinks = []; const videoLinks = [];
@@ -189,21 +188,27 @@ export class AnimePahe {
const btn = buttons[i]; const btn = buttons[i];
const kwikLink = $(btn).attr('data-src'); const kwikLink = $(btn).attr('data-src');
const quality = $(btn).text(); const quality = $(btn).text();
// Instead of extracting, just return the link directly try {
videoLinks.push({ const extraction = await extractKwik(kwikLink, response.config.url);
quality: quality, if (extraction && extraction.url) {
url: kwikLink, videoLinks.push({
referer: "https://kwik.cx", quality: quality,
}); url: extraction.url,
isM3U8: extraction.isM3U8,
});
}
} catch (e) {
console.error(`Error extracting Kwik for ${quality}:`, e.message);
}
} }
const result = { return {
sources: videoLinks.length > 0 ? [{ url: videoLinks[0].url }] : [], headers: {
multiSrc: videoLinks, Referer: "https://kwik.cx/"
},
sources: videoLinks
}; };
return result;
} catch (error) { } catch (error) {
console.error('Error fetching episode sources:', error.message); console.error('Error fetching episode sources:', error.message);
throw new Error('Failed to fetch episode sources'); throw new Error('Failed to fetch episode sources');
@@ -217,62 +222,55 @@ export class AnimePahe {
'Cookie': "__ddg1_=;__ddg2_=;", 'Cookie': "__ddg1_=;__ddg2_=;",
} }
}); });
const resBody = response.data; const resBody = response.data;
if (!resBody.data || resBody.data.length === 0) { if (!resBody.data || resBody.data.length === 0) {
throw new Error(`No results found for title: ${title}`); throw new Error(`No results found for title: ${title}`);
} }
// First try: Direct ID match if provided and valid
if (animeId) { if (animeId) {
const animeIdMatch = resBody.data.find(anime => String(anime.id) === String(animeId)); const animeIdMatch = resBody.data.find(anime => String(anime.id) === String(animeId));
if (animeIdMatch) { if (animeIdMatch) {
return animeIdMatch.session; return animeIdMatch.session;
} }
} }
// Second try: Normalize titles and find best match
const normalizeTitle = t => t.toLowerCase().replace(/[^\w\s]/g, '').replace(/\s+/g, ' ').trim(); const normalizeTitle = t => t.toLowerCase().replace(/[^\w\s]/g, '').replace(/\s+/g, ' ').trim();
const normalizedSearchTitle = normalizeTitle(title); const normalizedSearchTitle = normalizeTitle(title);
let bestMatch = null; let bestMatch = null;
let highestSimilarity = 0; let highestSimilarity = 0;
for (const anime of resBody.data) { for (const anime of resBody.data) {
const normalizedAnimeTitle = normalizeTitle(anime.title); const normalizedAnimeTitle = normalizeTitle(anime.title);
// Calculate simple similarity (more sophisticated than exact match)
let similarity = 0; let similarity = 0;
// Exact match
if (normalizedAnimeTitle === normalizedSearchTitle) { if (normalizedAnimeTitle === normalizedSearchTitle) {
similarity = 1; similarity = 1;
} }
// Contains match else if (normalizedAnimeTitle.includes(normalizedSearchTitle) ||
else if (normalizedAnimeTitle.includes(normalizedSearchTitle) || normalizedSearchTitle.includes(normalizedAnimeTitle)) {
normalizedSearchTitle.includes(normalizedAnimeTitle)) { const lengthRatio = Math.min(normalizedAnimeTitle.length, normalizedSearchTitle.length) /
const lengthRatio = Math.min(normalizedAnimeTitle.length, normalizedSearchTitle.length) / Math.max(normalizedAnimeTitle.length, normalizedSearchTitle.length);
Math.max(normalizedAnimeTitle.length, normalizedSearchTitle.length);
similarity = 0.8 * lengthRatio; similarity = 0.8 * lengthRatio;
} }
// Word match
else { else {
const searchWords = normalizedSearchTitle.split(' '); const searchWords = normalizedSearchTitle.split(' ');
const animeWords = normalizedAnimeTitle.split(' '); const animeWords = normalizedAnimeTitle.split(' ');
const commonWords = searchWords.filter(word => animeWords.includes(word)); const commonWords = searchWords.filter(word => animeWords.includes(word));
similarity = commonWords.length / Math.max(searchWords.length, animeWords.length); similarity = commonWords.length / Math.max(searchWords.length, animeWords.length);
} }
if (similarity > highestSimilarity) { if (similarity > highestSimilarity) {
highestSimilarity = similarity; highestSimilarity = similarity;
bestMatch = anime; bestMatch = anime;
} }
} }
if (bestMatch && highestSimilarity > 0.5) { if (bestMatch && highestSimilarity > 0.5) {
return bestMatch.session; return bestMatch.session;
} }
// Default to first result if no good match found
return resBody.data[0].session; return resBody.data[0].session;
} catch (error) { } catch (error) {
console.error('Error getting session:', error.message); console.error('Error getting session:', error.message);
@@ -281,4 +279,4 @@ export class AnimePahe {
} }
} }
export default AnimePahe; export default AnimePahe;

View File

@@ -1,12 +1,9 @@
import { load } from 'cheerio'; import { load } from 'cheerio';
import { client } from '../utils/client.js'; import { client } from '../utils/client.js';
import { HIANIME_URL } from '../constants/api-constants.js'; import { HIANIME_URL } from '../constants/api-constants.js';
import { megaCloudExtractor } from '../extractors/megacloud.js';
/**
* Get all available servers for a HiAnime episode
* @param {string} episodeId - Episode ID in format "anime-title-123?ep=456"
* @returns {Promise<Object>} Object containing sub, dub, and raw server lists
*/
export async function getEpisodeServers(episodeId) { export async function getEpisodeServers(episodeId) {
const result = { const result = {
sub: [], sub: [],
@@ -23,7 +20,7 @@ export async function getEpisodeServers(episodeId) {
const epId = episodeId.split("?ep=")[1]; const epId = episodeId.split("?ep=")[1];
const ajaxUrl = `${HIANIME_URL}/ajax/v2/episode/servers?episodeId=${epId}`; const ajaxUrl = `${HIANIME_URL}/ajax/v2/episode/servers?episodeId=${epId}`;
const { data } = await client.get(ajaxUrl, { const { data } = await client.get(ajaxUrl, {
headers: { headers: {
"X-Requested-With": "XMLHttpRequest", "X-Requested-With": "XMLHttpRequest",
@@ -71,14 +68,6 @@ export async function getEpisodeServers(episodeId) {
throw error; throw error;
} }
} }
/**
* Get streaming sources for a HiAnime episode
* @param {string} episodeId - Episode ID in format "anime-title-123?ep=456"
* @param {string} serverName - Name of the server to get sources from
* @param {string} category - Type of episode: 'sub', 'dub', or 'raw'
* @returns {Promise<Object>} Object containing sources and related metadata
*/
export async function getEpisodeSources(episodeId, serverName = 'vidstreaming', category = 'sub') { export async function getEpisodeSources(episodeId, serverName = 'vidstreaming', category = 'sub') {
try { try {
if (!episodeId || episodeId.trim() === "" || episodeId.indexOf("?ep=") === -1) { if (!episodeId || episodeId.trim() === "" || episodeId.indexOf("?ep=") === -1) {
@@ -87,18 +76,18 @@ export async function getEpisodeSources(episodeId, serverName = 'vidstreaming',
// First get available servers // First get available servers
const servers = await getEpisodeServers(episodeId); const servers = await getEpisodeServers(episodeId);
// Find the requested server // Find the requested server
const serverList = servers[category] || []; const serverList = servers[category] || [];
const server = serverList.find(s => s.serverName.toLowerCase() === serverName.toLowerCase()); const server = serverList.find(s => s.serverName.toLowerCase() === serverName.toLowerCase());
if (!server) { if (!server) {
throw new Error(`Server '${serverName}' not found for category '${category}'`); throw new Error(`Server '${serverName}' not found for category '${category}'`);
} }
const epId = episodeId.split("?ep=")[1]; const epId = episodeId.split("?ep=")[1];
const serverId = server.serverId; const serverId = server.serverId;
// Fetch the source URL // Fetch the source URL
const { data } = await client.get( const { data } = await client.get(
`${HIANIME_URL}/ajax/v2/episode/sources?id=${epId}&server=${serverId}`, `${HIANIME_URL}/ajax/v2/episode/sources?id=${epId}&server=${serverId}`,
@@ -112,15 +101,18 @@ export async function getEpisodeSources(episodeId, serverName = 'vidstreaming',
// If the target is a MegaCloud embed, extract the direct source URL // If the target is a MegaCloud embed, extract the direct source URL
if (data?.link && /megacloud\./i.test(data.link)) { if (data?.link && /megacloud\./i.test(data.link)) {
const extracted = await extractFromMegaCloud(data.link); try {
return extracted; const extracted = await megaCloudExtractor.extract(data.link);
return extracted;
} catch (e) {
console.warn(`MegaCloud extraction failed for ${data.link}:`, e.message);
// Fallback to returning the link as is
}
} }
// Return sources format similar to the AniWatch package for other hosts
return { return {
headers: { headers: {
Referer: data.link, Referer: data.link,
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.102 Safari/537.36"
}, },
sources: [ sources: [
{ {
@@ -136,108 +128,6 @@ 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