437 lines
16 KiB
JavaScript
437 lines
16 KiB
JavaScript
class Hentaila {
|
|
constructor() {
|
|
this.baseUrl = "https://hentaila.com";
|
|
this.cdnUrl = "https://cdn.hentaila.com";
|
|
this.type = "anime-board";
|
|
this.version = "1.0";
|
|
}
|
|
|
|
getFilters() {
|
|
return {
|
|
sort: {
|
|
label: "Ordenar por",
|
|
type: "select",
|
|
options: [
|
|
{ value: "latest_released", label: "Recientes" },
|
|
{ value: "popular", label: "Populares" }
|
|
],
|
|
default: "latest_released"
|
|
},
|
|
genres: {
|
|
label: "Géneros",
|
|
type: "select",
|
|
options: [
|
|
{ value: "3d", label: "3D" },
|
|
{ value: "ahegao", label: "Ahegao" },
|
|
{ value: "anal", label: "Anal" },
|
|
{ value: "casadas", label: "Casadas" },
|
|
{ value: "chikan", label: "Chikan" },
|
|
{ value: "ecchi", label: "Ecchi" },
|
|
{ value: "enfermeras", label: "Enfermeras" },
|
|
{ value: "futanari", label: "Futanari" },
|
|
{ value: "escolares", label: "Escolares" },
|
|
{ value: "gore", label: "Gore" },
|
|
{ value: "hardcore", label: "Hardcore" },
|
|
{ value: "harem", label: "Harem" },
|
|
{ value: "incesto", label: "Incesto" },
|
|
{ value: "juegos-sexuales", label: "Juegos Sexuales" },
|
|
{ value: "milfs", label: "Milfs" },
|
|
{ value: "maids", label: "Maids" },
|
|
{ value: "netorare", label: "Netorare" },
|
|
{ value: "ninfomania", label: "Ninfomanía" },
|
|
{ value: "ninjas", label: "Ninjas" },
|
|
{ value: "orgias", label: "Orgías" },
|
|
{ value: "romance", label: "Romance" },
|
|
{ value: "shota", label: "Shota" },
|
|
{ value: "softcore", label: "Softcore" },
|
|
{ value: "succubus", label: "Succubus" },
|
|
{ value: "teacher", label: "Teacher" },
|
|
{ value: "tentaculos", label: "Tentáculos" },
|
|
{ value: "tetonas", label: "Tetonas" },
|
|
{ value: "vanilla", label: "Vanilla" },
|
|
{ value: "violacion", label: "Violación" },
|
|
{ value: "virgenes", label: "Vírgenes" },
|
|
{ value: "yaoi", label: "Yaoi" },
|
|
{ value: "yuri", label: "Yuri" },
|
|
{ value: "bondage", label: "Bondage" },
|
|
{ value: "elfas", label: "Elfas" },
|
|
{ value: "petit", label: "Petit" },
|
|
{ value: "threesome", label: "Threesome" },
|
|
{ value: "paizuri", label: "Paizuri" },
|
|
{ value: "gal", label: "Gal" },
|
|
{ value: "oyakodon", label: "Oyakodon" }
|
|
]
|
|
},
|
|
status: {
|
|
label: "Estado",
|
|
type: "select",
|
|
options: [
|
|
{ value: "emision", label: "En Emisión" },
|
|
{ value: "finalizado", label: "Finalizado" }
|
|
]
|
|
},
|
|
uncensored: {
|
|
label: "Sin Censura",
|
|
type: "checkbox",
|
|
default: false
|
|
}
|
|
};
|
|
}
|
|
|
|
getSettings() {
|
|
return {
|
|
episodeServers: ["StreamWish", "VidHide"], //"VIP" works but the stream is blocked even with the headers.
|
|
supportsDub: false
|
|
};
|
|
}
|
|
|
|
_resolveRemixData(json) {
|
|
if (!json || !json.nodes) return [];
|
|
|
|
for (const node of json.nodes) {
|
|
if (node && node.uses && node.uses.search_params) {
|
|
const data = node.data;
|
|
if (!data || data.length === 0) continue;
|
|
|
|
const rootConfig = data[0];
|
|
|
|
if (!rootConfig || typeof rootConfig.results !== 'number') continue;
|
|
|
|
const resultsIndex = rootConfig.results;
|
|
|
|
const animePointers = data[resultsIndex];
|
|
|
|
if (!Array.isArray(animePointers)) continue;
|
|
|
|
return animePointers.map(pointer => {
|
|
const rawObj = data[pointer];
|
|
|
|
if (!rawObj) return null;
|
|
|
|
const realId = data[rawObj.id];
|
|
const title = data[rawObj.title];
|
|
const slug = data[rawObj.slug];
|
|
|
|
// Validación básica
|
|
if (!title || !slug) return null;
|
|
|
|
return {
|
|
id: slug,
|
|
title: title,
|
|
url: `${this.baseUrl}/media/${slug}`,
|
|
image: `${this.cdnUrl}/covers/${realId}.jpg`,
|
|
year: null
|
|
};
|
|
}).filter(Boolean);
|
|
}
|
|
}
|
|
return [];
|
|
}
|
|
|
|
async search(queryObj) {
|
|
const { query, filters, page } = queryObj;
|
|
const pageNum = page || 1;
|
|
|
|
let url = `${this.baseUrl}/catalogo/__data.json?page=${pageNum}`;
|
|
|
|
if (query && query.trim() !== "") {
|
|
url += `&search=${encodeURIComponent(query)}`;
|
|
} else {
|
|
if (filters.sort) url += `&order=${filters.sort}`;
|
|
else url += `&order=latest_released`;
|
|
|
|
if (filters.genres) url += `&genre=${filters.genres}`;
|
|
|
|
if (filters.status) url += `&status=${filters.status}`;
|
|
|
|
if (filters.uncensored) url += `&uncensored=`;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(url);
|
|
const json = await response.json();
|
|
return this._resolveRemixData(json);
|
|
} catch (error) {
|
|
console.error("Error searching Hentaila:", error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async getMetadata(id) {
|
|
const url = `${this.baseUrl}/media/${id}`;
|
|
|
|
try {
|
|
const response = await fetch(url);
|
|
const html = await response.text();
|
|
const $ = this.cheerio.load(html);
|
|
|
|
const title = $(".grid.items-start h1.text-lead").first().text().trim();
|
|
const image = $("img.object-cover.w-full.aspect-poster").first().attr("src");
|
|
const summary = $(".entry.text-lead.text-sm p").text().trim();
|
|
|
|
// Estado
|
|
const statusText = $("div.flex.flex-wrap.items-center.text-sm span").text();
|
|
const status = statusText.includes("En emisión") ? "En Emisión" : "Finalizado";
|
|
|
|
// Géneros
|
|
const genres = [];
|
|
$(".flex-wrap.items-center .btn.btn-xs.rounded-full").each((i, el) => {
|
|
const txt = $(el).text().trim();
|
|
if (txt) genres.push(txt);
|
|
});
|
|
|
|
const episodeCount = $("article.group\\/item").length;
|
|
|
|
return {
|
|
title: title,
|
|
summary: summary,
|
|
status: status,
|
|
genres: genres,
|
|
image: image,
|
|
episodes: episodeCount,
|
|
url: url
|
|
};
|
|
} catch (error) {
|
|
console.error("Error getting metadata:", error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async findEpisodes(id) {
|
|
const url = `${this.baseUrl}/media/${id}`;
|
|
|
|
try {
|
|
const response = await fetch(url);
|
|
const html = await response.text();
|
|
const $ = this.cheerio.load(html);
|
|
const episodes = [];
|
|
|
|
$("article.group\\/item").each((i, el) => {
|
|
const $el = $(el);
|
|
|
|
const numberText = $el.find("span.text-lead").text().trim();
|
|
const number = parseFloat(numberText);
|
|
|
|
const relativeUrl = $el.find("a").attr("href");
|
|
|
|
const image = $el.find("img").attr("src");
|
|
|
|
if (!isNaN(number) && relativeUrl) {
|
|
episodes.push({
|
|
id: JSON.stringify({ slug: id, number: number }),
|
|
number: number,
|
|
title: `Episodio ${number}`,
|
|
url: `${this.baseUrl}${relativeUrl}`,
|
|
image: image
|
|
});
|
|
}
|
|
});
|
|
|
|
return episodes;
|
|
|
|
} catch (error) {
|
|
console.error("Error finding episodes:", error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async findEpisodeServer(episodeOrId, _server, category = "sub") {
|
|
let slug, number;
|
|
|
|
const ep = typeof episodeOrId === "string"
|
|
? JSON.parse(episodeOrId)
|
|
: episodeOrId;
|
|
|
|
if (ep.id && typeof ep.id === "string" && ep.id.startsWith("{")) {
|
|
const p = JSON.parse(ep.id);
|
|
slug = p.slug;
|
|
number = p.number;
|
|
} else {
|
|
slug = ep.slug;
|
|
number = ep.number;
|
|
}
|
|
|
|
if (!slug || !number) throw new Error("No se pudo determinar episodio");
|
|
|
|
const url = `${this.baseUrl}/media/${slug}/${number}/__data.json`;
|
|
const json = await fetch(url).then(r => r.json());
|
|
|
|
let chosen = null;
|
|
const wanted = (_server || "VIP").toLowerCase();
|
|
|
|
if (json.nodes) {
|
|
for (const node of json.nodes) {
|
|
if (!node?.uses?.params?.includes("number")) continue;
|
|
|
|
const data = node.data;
|
|
const root = data?.[0];
|
|
if (!root || typeof root.embeds !== "number") continue;
|
|
|
|
const embeds = data[root.embeds];
|
|
const listIndex = category === "dub" ? embeds?.DUB : embeds?.SUB;
|
|
if (typeof listIndex !== "number") continue;
|
|
|
|
const list = data[listIndex];
|
|
if (!Array.isArray(list)) continue;
|
|
|
|
for (const i of list) {
|
|
const v = data[i];
|
|
const server = data[v.server];
|
|
const link = data[v.url];
|
|
if (!server || !link) continue;
|
|
|
|
if (server.toLowerCase() !== wanted) continue;
|
|
|
|
let finalUrl = link;
|
|
let type = "iframe";
|
|
|
|
// --- VIP → m3u8 directo ---
|
|
const serverName = server.toLowerCase();
|
|
|
|
// --- VIP ---
|
|
if (serverName === "vip") {
|
|
finalUrl = link.replace("/play/", "/m3u8/");
|
|
type = "m3u8";
|
|
}
|
|
|
|
// --- STREAMWISH ---
|
|
else if (serverName === "streamwish") {
|
|
const m3u8 = await this.extractPackedM3U8(link);
|
|
if (m3u8) {
|
|
finalUrl = m3u8;
|
|
type = "m3u8";
|
|
}
|
|
}
|
|
|
|
else if (serverName === "vidhide") {
|
|
const m3u8 = await this.extractPackedM3U8(link);
|
|
if (m3u8) {
|
|
finalUrl = m3u8;
|
|
type = "m3u8";
|
|
}
|
|
}
|
|
|
|
chosen = {
|
|
url: finalUrl,
|
|
type,
|
|
quality: server,
|
|
subtitles: [],
|
|
subOrDub: category
|
|
};
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!chosen) throw new Error(`No se encontró el server ${_server}`);
|
|
|
|
return {
|
|
server: _server || "VIP",
|
|
headers: {
|
|
Referer: "https://hentaila.com/",
|
|
Origin: "https://hentaila.com"
|
|
},
|
|
videoSources: [chosen]
|
|
};
|
|
}
|
|
|
|
async extractPackedM3U8(embedUrl) {
|
|
|
|
try {
|
|
const { result } = await this.scrape(embedUrl, async (page) => {
|
|
try {
|
|
await page.waitForSelector('script', { state: 'attached', timeout: 5000 });
|
|
} catch (e) {}
|
|
|
|
return await page.evaluate(() => {
|
|
function unpack(code) {
|
|
try {
|
|
|
|
const regex = /}\s*\('(.*?)',\s*(\d+),\s*(\d+),\s*'(.*?)'\.split\('\|'\)/;
|
|
const m = code.match(regex);
|
|
|
|
if (!m) return null;
|
|
|
|
let payload = m[1].replace(/\\'/g, "'");
|
|
const radix = parseInt(m[2]);
|
|
const count = parseInt(m[3]);
|
|
const dict = m[4].split('|');
|
|
|
|
const unbase = (val) => {
|
|
const chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
if (radix > 62) return parseInt(val, radix);
|
|
|
|
const alphabet = chars.slice(0, radix);
|
|
let ret = 0;
|
|
const reversed = val.split('').reverse().join('');
|
|
for (let i = 0; i < reversed.length; i++) {
|
|
const index = alphabet.indexOf(reversed[i]);
|
|
if (index === -1) continue;
|
|
ret += index * Math.pow(radix, i);
|
|
}
|
|
return ret;
|
|
};
|
|
|
|
return payload.replace(/\b\w+\b/g, (word) => {
|
|
const index = unbase(word);
|
|
if (dict[index]) return dict[index];
|
|
return word;
|
|
});
|
|
} catch (e) {
|
|
return "ERROR_IN_UNPACKER: " + e.message;
|
|
}
|
|
}
|
|
|
|
// --- BÚSQUEDA DEL SCRIPT ---
|
|
const scripts = Array.from(document.getElementsByTagName('script'));
|
|
|
|
for (const script of scripts) {
|
|
const content = script.textContent;
|
|
if (!content) continue;
|
|
|
|
// Buscamos la firma del packer
|
|
if (content.includes('eval(function(p,a,c,k,e,d)') || content.includes('eval(function(p,a,c')) {
|
|
// Intentamos desempaquetar
|
|
const unpacked = unpack(content);
|
|
|
|
// Si funcionó y parece contener HTML/JS válido
|
|
if (unpacked && unpacked.length > 20 && !unpacked.startsWith("ERROR")) {
|
|
return unpacked;
|
|
}
|
|
}
|
|
}
|
|
|
|
return "NO_PACKER_FOUND";
|
|
});
|
|
|
|
}, {
|
|
waitUntil: "domcontentloaded",
|
|
renderWaitTime: 2000
|
|
});
|
|
|
|
if (!result || result === "NO_PACKER_FOUND") {
|
|
return null;
|
|
}
|
|
|
|
if (result.startsWith("ERROR")) {
|
|
return null;
|
|
}
|
|
|
|
const m3u8Regex = /(https?:\/\/[^"']+\.m3u8[^"']*)/;
|
|
const match = result.match(m3u8Regex);
|
|
|
|
if (match) {
|
|
return match[1];
|
|
} else {
|
|
console.log("[DEBUG] ⚠️ Script desempaquetado pero SIN m3u8. Dump parcial:", result.substring(0, 100));
|
|
}
|
|
|
|
return null;
|
|
|
|
} catch (error) {
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
module.exports = Hentaila; |