diff --git a/anime/hianime/source.js b/anime/hianime/source.js index e69de29..06abbde 100644 --- a/anime/hianime/source.js +++ b/anime/hianime/source.js @@ -0,0 +1,244 @@ +class HiAnime { + constructor() { + this.type = "anime-streaming"; + this.version = "1.0.0"; + this.baseUrl = "https://hianime.to"; + } + + getSettings() { + return { + episodeServers: ["HD-1", "HD-2", "HD-3", "HD-4"], + supportsDub: true + }; + } + + async search(query) { + const normalize = (str) => + this.safeString(str).toLowerCase().replace(/[^a-z0-9]+/g, ""); + + const start = query.media.startDate; + + const fetchMatches = async (url) => { + const html = await fetch(url).then((res) => res.text()); + const regex = /]+title="([^"]+)"[^>]+data-id="(\d+)"/g; + + return [...html.matchAll(regex)].map((m) => { + const id = m[3]; + const pageUrl = m[1]; + const title = m[2]; + + const jnameRegex = new RegExp( + `

[\\s\\S]*?]+href="\\/${pageUrl}[^"]*"[^>]+data-jname="([^"]+)"`, + "i" + ); + const jnameMatch = html.match(jnameRegex); + const jname = jnameMatch ? jnameMatch[1] : null; + + const imageRegex = new RegExp( + `]+data-src="([^"]+)"`, + "i" + ); + const imageMatch = html.match(imageRegex); + const image = imageMatch ? imageMatch[1] : null; + + return { + id, + pageUrl, + title, + image, + normTitleJP: normalize(this.normalizeSeasonParts(jname)), + normTitle: normalize(this.normalizeSeasonParts(title)) + }; + }); + }; + + const url = + `${this.baseUrl}/search?keyword=${encodeURIComponent(query.query)}` + + `&sy=${start.year}&sm=${start.month}&sort=default`; + + const matches = await fetchMatches(url); + if (matches.length === 0) return []; + + return matches.map((m) => ({ + id: `${m.id}/${query.dub ? "dub" : "sub"}`, + title: m.title, + image: m.image, + url: `${this.baseUrl}/${m.pageUrl}`, + subOrDub: query.dub ? "dub" : "sub" + })); + } + + async findEpisodes(animeId) { + const [id, subOrDub] = animeId.split("/"); + const res = await fetch(`${this.baseUrl}/ajax/v2/episode/list/${id}`, { + headers: { "X-Requested-With": "XMLHttpRequest" } + }); + const json = await res.json(); + const html = json.html; + + const episodes = []; + const regex = + /]*class="[^"]*\bep-item\b[^"]*"[^>]*data-number="(\d+)"[^>]*data-id="(\d+)"[^>]*href="([^"]+)"[\s\S]*?
]*title="([^"]+)"/g; + + let match; + while ((match = regex.exec(html)) !== null) { + episodes.push({ + id: `${match[2]}/${subOrDub}`, + number: parseInt(match[1], 10), + url: this.baseUrl + match[3], + title: match[4] + }); + } + + return episodes; + } + + async findEpisodeServer(episode, _server) { + const [id, subOrDub] = episode.id.split("/"); + const serverName = _server !== "default" ? _server : "HD-1"; + + if (_server === "HD-1" || _server === "HD-2" || _server === "HD-3" || _server === "default") { + const serverJson = await fetch( + `${this.baseUrl}/ajax/v2/episode/servers?episodeId=${id}`, + { headers: { "X-Requested-With": "XMLHttpRequest" } } + ).then((res) => res.json()); + + const serverHtml = serverJson.html; + const regex = new RegExp( + `]*class="item server-item"[^>]*data-type="${subOrDub}"[^>]*data-id="(\\d+)"[^>]*>\\s*]*>\\s*${serverName}\\s*`, + "i" + ); + + const m = regex.exec(serverHtml); + if (!m) throw new Error(`Server "${serverName}" (${subOrDub}) not found`); + + const serverId = m[1]; + + const sourcesJson = await fetch( + `${this.baseUrl}/ajax/v2/episode/sources?id=${serverId}`, + { headers: { "X-Requested-With": "XMLHttpRequest" } } + ).then((res) => res.json()); + + let decryptData = null; + let requiredHeaders = {}; + + try { + decryptData = await this.extractMegaCloud(sourcesJson.link, true); + if (decryptData && decryptData.headersProvided) requiredHeaders = decryptData.headersProvided; + } catch (err) { + // ignore + } + + if (!decryptData) { + const fallbackRes = await fetch( + `https://ac-api.ofchaos.com/api/anime/embed/convert/v2?embedUrl=${encodeURIComponent( + sourcesJson.link + )}` + ); + decryptData = await fallbackRes.json(); + + requiredHeaders = { + "Referer": "https://megacloud.club/", + "Origin": "https://megacloud.club", + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36", + "X-Requested-With": "XMLHttpRequest" + }; + } + + const streamSource = + (decryptData.sources || []).find((s) => s.type === "hls") || + (decryptData.sources || []).find((s) => s.type === "mp4"); + + if (!streamSource?.file) throw new Error("No valid stream file found"); + + const subtitles = (decryptData.tracks || []) + .filter((t) => t.kind === "captions") + .map((track, index) => ({ + id: `sub-${index}`, + language: track.label || "Unknown", + url: track.file, + isDefault: !!track.default + })); + + return { + server: serverName, + headers: requiredHeaders, + videoSources: [ + { + url: streamSource.file, + type: streamSource.type === "hls" ? "m3u8" : "mp4", + quality: "auto", + subtitles + } + ] + }; + } + + if (_server === "HD-4") { + return null; + } + + return null; + } + + safeString(str) { + return typeof str === "string" ? str : ""; + } + + normalizeSeasonParts(title) { + const s = this.safeString(title); + return s + .toLowerCase() + .replace(/[^a-z0-9]+/g, "") + .replace(/\d+(st|nd|rd|th)/g, (m) => m.replace(/st|nd|rd|th/, "")) + .replace(/season|cour|part/g, ""); + } + + async extractMegaCloud(embedUrl, returnHeaders = false) { + const url = new URL(embedUrl); + const baseDomain = `${url.protocol}//${url.host}/`; + + const headers = { + "Accept": "*/*", + "X-Requested-With": "XMLHttpRequest", + "Referer": baseDomain, + "Origin": `${url.protocol}//${url.host}`, + "User-Agent": + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36" + }; + + const html = await fetch(embedUrl, { headers }).then((r) => r.text()); + + const fileIdMatch = html.match(/\s*File\s+#([a-zA-Z0-9]+)\s*-/i); + if (!fileIdMatch) throw new Error("file_id not found in embed page"); + const fileId = fileIdMatch[1]; + + let nonce = null; + const match48 = html.match(/\b[a-zA-Z0-9]{48}\b/); + if (match48) nonce = match48[0]; + else { + const match3x16 = [...html.matchAll(/["']([A-Za-z0-9]{16})["']/g)]; + if (match3x16.length >= 3) { + nonce = match3x16[0][1] + match3x16[1][1] + match3x16[2][1]; + } + } + if (!nonce) throw new Error("nonce not found"); + + const sourcesJson = await fetch( + `${baseDomain}embed-2/v3/e-1/getSources?id=${fileId}&_k=${nonce}`, + { headers } + ).then((r) => r.json()); + + return { + sources: sourcesJson.sources, + tracks: sourcesJson.tracks || [], + intro: sourcesJson.intro || null, + outro: sourcesJson.outro || null, + server: sourcesJson.server || null, + headersProvided: returnHeaders ? headers : undefined + }; + } +} + +module.exports = HiAnime;