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;