class HiAnime { constructor() { this.type = "anime-streaming"; this.version = "1.0.1"; this.baseUrl = "https://hianime.to"; } getSettings() { return { episodeServers: ["HD-1", "HD-2", "HD-3"], supportsSub: true, supportsDub: true, supportsHls: true }; } async _getText(url, headers) { const res = await fetch(String(url), { method: "GET", headers: headers || {} }); return String(await res.text()); } async _getJson(url, headers) { const res = await fetch(String(url), { method: "GET", headers: headers || {} }); try { return await res.json(); } catch (e) { return {}; } } _decodeHtml(s) { return String(s || "") .replace(/\\u0026/g, "&") .replace(/&#(\d+);?/g, (m, d) => { try { return String.fromCharCode(parseInt(d, 10)); } catch (e) { return m; } }) .replace(/"/g, '"') .replace(/'/g, "'") .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">"); } _normalizeTitle(s) { return String(s || "") .toLowerCase() .replace(/(season|cour|part|uncensored)/g, " ") .replace(/\d+(st|nd|rd|th)\b/g, (m) => m.replace(/st|nd|rd|th/g, "")) .replace(/[^a-z0-9\s]+/g, " ") .replace(/\s+/g, " ") .trim(); } _levSim(a, b) { a = String(a || ""); b = String(b || ""); if (!a.length || !b.length) return 0; const la = a.length, lb = b.length; const dp = []; for (let i = 0; i <= la; i++) { dp[i] = new Array(lb + 1); dp[i][0] = i; } for (let j = 0; j <= lb; j++) dp[0][j] = j; for (let i = 1; i <= la; i++) { for (let j = 1; j <= lb; j++) { if (a[i - 1] === b[j - 1]) dp[i][j] = dp[i - 1][j - 1]; else { const x = dp[i - 1][j] + 1; const y = dp[i][j - 1] + 1; const z = dp[i - 1][j - 1] + 1; dp[i][j] = Math.min(x, y, z); } } } const dist = dp[la][lb]; const maxLen = Math.max(la, lb) || 1; return 1 - dist / maxLen; } _parseStartDate(dateStr) { const s = String(dateStr || "").trim(); const m = s.match(/([A-Za-z]+)\s+(\d{1,2}),\s*(\d{4})/); if (!m) return { year: 0, month: 0, day: 0 }; const monthMap = { Jan: 1, Feb: 2, Mar: 3, Apr: 4, May: 5, Jun: 6, Jul: 7, Aug: 8, Sep: 9, Oct: 10, Nov: 11, Dec: 12 }; const mm = monthMap[m[1]] || 0; const dd = parseInt(m[2], 10) || 0; const yy = parseInt(m[3], 10) || 0; return { year: yy, month: mm, day: dd }; } _scoreCandidate(candidateTitle, candidateJp, targetEn, targetRo, targetYear) { const cand = this._normalizeTitle(this._decodeHtml(candidateTitle)); const candJ = this._normalizeTitle(this._decodeHtml(candidateJp)); const tEn = this._normalizeTitle(targetEn); const tRo = this._normalizeTitle(targetRo); let best = 0; const pairs = [ [cand, tEn], [cand, tRo], [candJ, tRo], [candJ, tEn] ]; for (let i = 0; i < pairs.length; i++) { const a = pairs[i][0], b = pairs[i][1]; if (!a || !b) continue; if (a === b) best = Math.max(best, 1000); if (a.includes(b) || b.includes(a)) best = Math.max(best, 700); const sim = this._levSim(a, b); best = Math.max(best, Math.floor(sim * 650)); } if (targetYear && targetYear > 0) { best += 0; } return best; } async search(query) { if (typeof query === "string") { query = { query, media: { startDate: { year: 0, month: 0, day: 0 } } }; } const q = query && query.query ? String(query.query) : ""; const media = (query && query.media) || {}; const start = (media && media.startDate) || { year: 0, month: 0, day: 0 }; const targetYear = (start && start.year) ? (start.year | 0) : 0; const targetEn = media.englishTitle || media.english || media.titleEnglish || ""; const targetRo = media.romajiTitle || media.romaji || media.titleRomaji || ""; const subOrDub = query && query.dub ? "dub" : "sub"; let candidates = []; try { const url = `${this.baseUrl}/ajax/search/suggest?keyword=${encodeURIComponent(q)}`; const reply = await this._getJson(url, { "X-Requested-With": "XMLHttpRequest" }); const html = String((reply && reply.html) || ""); const regex = /]+href="\/([^"]+)"[^>]*class="nav-item"[^>]*>[\s\S]*?]*class="film-name"[^>]*data-jname="([^"]*)"[^>]*>([^<]*)<\/h3>[\s\S]*?]*class="film-infor"[^>]*>[\s\S]*?]*>([^<]*)<\/span>/gi; const imgRegex = /]+(?:data-src|src)="([^"]+)"[^>]*>/i; const matches = [...html.matchAll(regex)]; for (let i = 0; i < matches.length; i++) { const pageUrlRaw = matches[i][1] || ""; if (!pageUrlRaw || pageUrlRaw.startsWith("search?")) continue; const jname = matches[i][2] || ""; const title = matches[i][3] || ""; const dateStr = matches[i][4] || ""; const startDate = this._parseStartDate(dateStr); const pageUrl = pageUrlRaw.startsWith("watch/") ? pageUrlRaw : pageUrlRaw; const idMatch = String(pageUrl).match(/-(\d+)$/); const id = idMatch ? idMatch[1] : pageUrl; const blockStart = html.indexOf(matches[i][0]); const slice = blockStart >= 0 ? html.slice(blockStart, blockStart + 800) : matches[i][0]; const im = slice.match(imgRegex); const image = im ? im[1] : null; candidates.push({ id, pageUrl, title: this._decodeHtml(title), jname: this._decodeHtml(jname), image, startDate }); } } catch (e) { } if (!candidates.length) { const url = `${this.baseUrl}/search?keyword=${encodeURIComponent(q)}`; const html = await this._getText(url, {}); const regex = /]+title="([^"]+)"[^>]+data-id="(\d+)"/g; const matches = [...html.matchAll(regex)]; for (let i = 0; i < matches.length; i++) { const pageUrl = matches[i][1]; const title = matches[i][2]; const id = matches[i][3]; const imageRegex = new RegExp( `]+(?:data-src|src)="([^"]+)"`, "i" ); const imageMatch = html.match(imageRegex); const image = imageMatch ? imageMatch[1] : null; candidates.push({ id, pageUrl: "watch/" + pageUrl, title: this._decodeHtml(title), jname: "", image, startDate: { year: 0, month: 0, day: 0 } }); } } if (!candidates.length) return []; // Score + filter by year when available const scored = candidates.map((c) => { const score = this._scoreCandidate(c.title, c.jname, targetEn, targetRo, targetYear); let yearBonus = 0; if (targetYear > 0 && c.startDate && c.startDate.year > 0) { if (c.startDate.year === targetYear) yearBonus = 140; else if (Math.abs(c.startDate.year - targetYear) === 1) yearBonus = 40; else yearBonus = -80; } return { c, score: score + yearBonus }; }); scored.sort((a, b) => b.score - a.score); const top = scored.slice(0, 30).map((x) => x.c); return top.map((m) => { const id = String(m.id || ""); const pageUrl = String(m.pageUrl || ""); const url = pageUrl.startsWith("http") ? pageUrl : (pageUrl.startsWith("watch/") ? `${this.baseUrl}/${pageUrl}` : `${this.baseUrl}/${pageUrl}`); return { id: `${id}/${subOrDub}`, title: m.title || "", image: m.image || null, url, subOrDub }; }); } async findEpisodes(animeId) { const parts = String(animeId || "").split("/"); const id = parts[0]; const subOrDub = parts[1] || "sub"; const json = await this._getJson( `${this.baseUrl}/ajax/v2/episode/list/${id}`, { "X-Requested-With": "XMLHttpRequest" } ); const html = String((json && 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: this._decodeHtml(match[4] || "") }); } return episodes; } async findEpisodeServer(episode, _server) { if (typeof episode === "string") { try { episode = JSON.parse(episode); } catch (e) {} } const parts = String((episode && episode.id) || "").split("/"); const epId = parts[0]; const subOrDub = parts[1] || "sub"; let serverName = _server && _server !== "default" ? String(_server) : "HD-1"; if (serverName === "HD-4") return null; const serverJson = await this._getJson( `${this.baseUrl}/ajax/v2/episode/servers?episodeId=${encodeURIComponent(epId)}`, { "X-Requested-With": "XMLHttpRequest" } ); const serverHtml = String((serverJson && serverJson.html) || ""); const regex = new RegExp( `]*class="item server-item"[^>]*data-type="${subOrDub}"[^>]*data-id="(\\\\d+)"[^>]*>\\\\s*]*>\\\\s*${serverName}\\\\s*`, "i" ); const match = regex.exec(serverHtml); if (!match) throw new Error(`Server "${serverName}" (${subOrDub}) not found`); const serverId = match[1]; const sourcesJson = await this._getJson( `${this.baseUrl}/ajax/v2/episode/sources?id=${encodeURIComponent(serverId)}`, { "X-Requested-With": "XMLHttpRequest" } ); const embed = (sourcesJson && sourcesJson.link) ? String(sourcesJson.link) : ""; if (!embed) throw new Error("No embed link returned"); let decryptData = null; let requiredHeaders = {}; try { decryptData = await this.extractMegaCloud(embed); requiredHeaders = (decryptData && decryptData.headersProvided) ? decryptData.headersProvided : {}; } catch (e) { decryptData = null; } if (!decryptData) { decryptData = await this._getJson( `https://ac-api.ofchaos.com/api/anime/embed/convert/v2?embedUrl=${encodeURIComponent(embed)}`, {} ); 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 sources = (decryptData && decryptData.sources) ? decryptData.sources : []; const streamSource = sources.find((s) => s && s.type === "hls" && s.file) || sources.find((s) => s && s.type === "mp4" && s.file) || sources.find((s) => s && s.file); if (!streamSource || !streamSource.file) throw new Error("No valid stream file found"); const tracks = (decryptData && (decryptData.tracks || decryptData.track || decryptData.subtitle)) || []; const subtitles = (tracks || []) .filter((t) => t && String(t.kind || "").toLowerCase() === "captions" && t.file) .map((track, index) => ({ id: `sub-${index}`, language: String(track.label || "Unknown"), url: String(track.file), isDefault: !!track.default })); return { server: serverName, headers: requiredHeaders || {}, videoSources: [ { url: String(streamSource.file), type: String(streamSource.type || "").toLowerCase() === "hls" ? "m3u8" : "mp4", quality: "auto", subtitles } ] }; } async extractMegaCloud(embedUrl) { const u = new URL(String(embedUrl)); const protocol = String(u.protocol || "https:").replace(":", ""); const host = String(u.host || ""); const baseDomain = `${protocol}://${host}/`; const headers = { Accept: "*/*", "X-Requested-With": "XMLHttpRequest", Referer: baseDomain, Origin: `${protocol}://${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 this._getText(embedUrl, headers); 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]; if (!nonce) { 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 this._getJson( `${baseDomain}embed-2/v3/e-1/getSources?id=${encodeURIComponent(fileId)}&_k=${encodeURIComponent(nonce)}`, headers ); return { sources: sourcesJson.sources || [], tracks: sourcesJson.tracks || [], intro: sourcesJson.intro || null, outro: sourcesJson.outro || null, server: sourcesJson.server || null, headersProvided: headers }; } } module.exports = new HiAnime();