Files
WaifuBoard-Extensions/anime/hentaila.js
2026-01-13 17:26:06 +01:00

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;