diff --git a/anime/hianime/source.js b/anime/hianime/source.js index ace3776..007163e 100644 --- a/anime/hianime/source.js +++ b/anime/hianime/source.js @@ -1,14 +1,16 @@ class HiAnime { constructor() { this.type = "anime-streaming"; - this.version = "1.0.4"; + this.version = "1.0.5"; this.baseUrl = "https://hianime.to"; } getSettings() { return { episodeServers: ["HD-1", "HD-2", "HD-3", "HD-4"], - supportsDub: true + supportsSub: true, + supportsDub: true, + supportsHls: true }; } @@ -33,52 +35,52 @@ class HiAnime { _getJson(url, headers) { const res = this._nativeFetch(url, "GET", headers, ""); - const body = String(res.body || ""); try { - return JSON.parse(body || "{}"); + return JSON.parse(String(res.body || "{}")); } catch (e) { - return { _raw: body }; + return {}; } } - safeString(str) { - return (typeof str === "string" ? str : ""); + _safeStr(v) { + return typeof v === "string" ? v : (v == null ? "" : String(v)); } - 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, ""); - } - - _decodeHtml(str) { - return String(str || "") - .replace(/\\u0026/g, "&") - .replace(/&#(\d+);?/g, (m, dec) => { - const n = parseInt(dec, 10); - if (!isFinite(n)) return m; - return String.fromCharCode(n); - }) - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/&/g, "&") - .replace(/</g, "<") - .replace(/>/g, ">") - .replace(///g, "/"); - } - - _headers() { + _headersHtml() { return { "User-Agent": "Mozilla/5.0", - "Accept": "*/*", + "Accept": "text/html", "Referer": this.baseUrl + "/", "Origin": this.baseUrl, "X-Requested-With": "XMLHttpRequest" }; } + _headersJson() { + return { + "User-Agent": "Mozilla/5.0", + "Accept": "application/json", + "Referer": this.baseUrl + "/", + "Origin": this.baseUrl, + "X-Requested-With": "XMLHttpRequest" + }; + } + + _decodeHtml(s) { + const str = this._safeStr(s); + if (!str) return ""; + return str + .replace(/&/g, "&") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/&#(\d+);/g, (_, n) => { + const code = parseInt(n, 10); + return isFinite(code) ? String.fromCharCode(code) : ""; + }); + } + _parseQuery(q) { if (typeof q === "string") { const s = q.trim(); @@ -90,8 +92,8 @@ class HiAnime { return q || {}; } - _wantTrackFromEpisode(ep) { - const e = ep || {}; + _getSubOrDubFromAny(obj) { + const e = obj || {}; if (typeof e.dub === "boolean") return e.dub ? "dub" : "sub"; const sod = String(e.subOrDub || "").toLowerCase(); @@ -114,255 +116,240 @@ class HiAnime { return m ? m[1] : ""; } + _bestTitle(obj) { + if (!obj) return ""; + const t = obj.title; + if (typeof t === "string") return t; + if (t && typeof t === "object") { + return String(t.english || t.romaji || t.native || t.userPreferred || ""); + } + return String(obj.name || obj.english || obj.romaji || ""); + } + search(a1) { - const query = this._parseQuery(a1); + const arg = this._parseQuery(a1); - const normalize = (str) => - this.safeString(str).toLowerCase().replace(/[^a-z0-9]+/g, ""); - - const media = query.media || {}; - const start = media.startDate || {}; - const q = this.safeString(query.query || "").trim(); + const q = this._safeStr(arg.query || arg.q || "").trim(); if (!q) return "[]"; - const url = `${this.baseUrl}/search?keyword=${encodeURIComponent(q)}&sy=${encodeURIComponent(String(start.year || ""))}&sm=${encodeURIComponent(String(start.month || ""))}&sort=default`; - const html = this._getText(url, { - "User-Agent": "Mozilla/5.0", - "Accept": "text/html", - "Referer": this.baseUrl + "/", - "Origin": this.baseUrl - }); + const subOrDub = this._getSubOrDubFromAny(arg); - const regex = /]+title="([^"]+)"[^>]+data-id="(\d+)"/g; + const url = `${this.baseUrl}/ajax/search/suggest?keyword=${encodeURIComponent(q)}`; + const j = this._getJson(url, this._headersJson()); - const matches = []; - let m; - while ((m = regex.exec(html)) !== null) { - const id = m[3]; - const pageUrl = m[1]; - const title = this._decodeHtml(m[2]); - - const jnameRegex = new RegExp( - `

[\\s\\S]*?]+href="\\/${pageUrl}[^"]*"[^>]+data-jname="([^"]+)"`, - "i" - ); - const jnameMatch = html.match(jnameRegex); - const jname = jnameMatch ? this._decodeHtml(jnameMatch[1]) : ""; - - const imageRegex = new RegExp( - `]+data-src="([^"]+)"`, - "i" - ); - const imageMatch = html.match(imageRegex); - const image = imageMatch ? String(imageMatch[1]) : ""; - - matches.push({ - id, - pageUrl, - title, - image, - normTitleJP: normalize(this.normalizeSeasonParts(jname)), - normTitle: normalize(this.normalizeSeasonParts(title)) - }); + let results = []; + if (j && Array.isArray(j.results)) { + results = j.results + .map((x) => { + const title = this._bestTitle(x) || String(x.name || ""); + const u = x.url ? String(x.url) : ""; + const full = u.startsWith("http") ? u : (u ? (this.baseUrl + u) : ""); + const id = x.id ? String(x.id) : this._extractWatchIdFromUrl(full); + return { id, title, url: full }; + }) + .filter((x) => x.id && x.url); } - if (matches.length === 0) return "[]"; + if (!results.length) { + const url2 = `${this.baseUrl}/search?keyword=${encodeURIComponent(q)}`; + const html = this._getText(url2, this._headersHtml()); - const wantTrack = query.dub ? "dub" : "sub"; + const out = []; + const re = /href="(\/watch\/[^"]+)"/gi; + const seen = {}; + let m; + while ((m = re.exec(html)) !== null) { + const href = String(m[1] || ""); + const full = href.startsWith("http") ? href : (this.baseUrl + href); + const id = this._extractWatchIdFromUrl(full); + if (!id || seen[id]) continue; + seen[id] = true; + out.push({ id, title: q, url: full }); + if (out.length >= 20) break; + } + results = out; + } - const out = matches.map(x => ({ - id: String(x.id), - title: String(x.title || ""), - image: String(x.image || ""), - url: `${this.baseUrl}/watch/${x.pageUrl}`, - subOrDub: wantTrack + const mapped = results.map((r) => ({ + id: `${r.id}/${subOrDub}`, + title: this._decodeHtml(r.title || ""), + url: r.url, + subOrDub })); - return JSON.stringify(out); + return JSON.stringify(mapped); } findEpisodes(animeId) { - const raw = String(animeId || "").trim(); - if (!raw) return "[]"; - - const parts = raw.split("/"); + const parts = String(animeId || "").split("/"); const id = parts[0]; - const subOrDub = (parts[1] && (parts[1] === "dub" || parts[1] === "sub")) ? parts[1] : "sub"; + const subOrDub = parts[1] || "sub"; + if (!id) return "[]"; - const json = this._getJson(`${this.baseUrl}/ajax/v2/episode/list/${encodeURIComponent(id)}`, { - "X-Requested-With": "XMLHttpRequest" - }); + const j = this._getJson( + `${this.baseUrl}/ajax/v2/episode/list/${encodeURIComponent(id)}`, + this._headersJson() + ); - const html = this._decodeHtml(String(json.html || json.result || "")); + const html = this._decodeHtml(String(j.html || j.result || "")); 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) { + const re = /data-number="([^"]+)"[^>]*data-id="([^"]+)"/gi; + let m; + while ((m = re.exec(html)) !== null) { + const numRaw = String(m[1] || "").trim(); + const epId = String(m[2] || "").trim(); + const num = parseFloat(numRaw); + if (!epId || !isFinite(num)) continue; episodes.push({ - id: `${match[2]}/${subOrDub}`, - number: parseInt(match[1], 10), - url: this.baseUrl + match[3], - title: this._decodeHtml(match[4] || "") + id: `${epId}/${subOrDub}`, + number: num, + title: "", + url: "" }); } + episodes.sort((a, b) => (a.number || 0) - (b.number || 0)); return JSON.stringify(episodes); } - findEpisodeServer(episodeObj, _server) { - let episode = episodeObj; - if (typeof episode === "string") { - try { episode = JSON.parse(episode); } catch (e) { episode = {}; } - } - episode = episode || {}; - - const idParts = String(episode.id || "").split("/"); - const episodeId = idParts[0]; - if (!episodeId) throw new Error("Missing episode id"); - - const wantTrack = this._wantTrackFromEpisode(episode); - - let serverName = String(_server || "").trim(); - if (!serverName || serverName === "default") serverName = "HD-1"; - - if (serverName === "HD-4") { - throw new Error("HD-4 not implemented"); + findEpisodeServer(episodeObj, serverName) { + let ep = episodeObj; + if (typeof ep === "string") { + try { ep = JSON.parse(ep); } catch (e) { ep = {}; } } - const serverJson = this._getJson( - `${this.baseUrl}/ajax/v2/episode/servers?episodeId=${encodeURIComponent(episodeId)}`, - { "X-Requested-With": "XMLHttpRequest" } + const epIdRaw = this._safeStr(ep && ep.id ? ep.id : "").trim(); + if (!epIdRaw) throw new Error("Missing episode id"); + + const parts = epIdRaw.split("/"); + const epId = parts[0]; + const subOrDub = (parts[1] || "sub").toLowerCase(); + + const server = String(serverName || "").trim() || "HD-1"; + if (server === "HD-4") return "null"; + + const j = this._getJson( + `${this.baseUrl}/ajax/v2/episode/servers?episodeId=${encodeURIComponent(epId)}`, + this._headersJson() ); - const serverHtml = this._decodeHtml(String(serverJson.html || serverJson.result || "")); - if (!serverHtml) throw new Error("Empty server list"); + const html = this._decodeHtml(String(j.html || j.result || "")); - const esc = (s) => String(s || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const strictRe = new RegExp( - `]*class="item\\s+server-item"[^>]*data-type="${esc(wantTrack)}"[^>]*data-id="(\\d+)"[^>]*>[\\s\\S]*?]*>[\\s\\S]*?${esc(serverName)}[\\s\\S]*?<\\/a>`, + const escapedServer = server.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const reExact = new RegExp( + `class="item server-item"[^>]*data-type="${subOrDub}"[^>]*data-id="([^"]+)"[^>]*>[\\s\\S]*?>\\s*${escapedServer}\\s*<`, "i" ); + const mmExact = reExact.exec(html); - let m = strictRe.exec(serverHtml); - let serverId = m ? String(m[1] || "").trim() : ""; + let serverId = mmExact ? String(mmExact[1] || "").trim() : ""; if (!serverId) { - const trackAnyRe = new RegExp( - `]*class="item\\s+server-item"[^>]*data-type="${esc(wantTrack)}"[^>]*data-id="(\\d+)"`, - "i" - ); - m = trackAnyRe.exec(serverHtml); - serverId = m ? String(m[1] || "").trim() : ""; + const reFirst = new RegExp(`class="item server-item"[^>]*data-type="${subOrDub}"[^>]*data-id="([^"]+)"`, "i"); + const mmFirst = reFirst.exec(html); + if (mmFirst) serverId = String(mmFirst[1] || "").trim(); } - if (!serverId) { - const anyRe = /data-id="(\d+)"/i; - const mm = anyRe.exec(serverHtml); - serverId = mm ? String(mm[1] || "").trim() : ""; - } - - if (!serverId) throw new Error(`Server id not found (track=${wantTrack} server=${serverName})`); + if (!serverId) throw new Error(`No server id found for track=${subOrDub}`); const sourcesJson = this._getJson( `${this.baseUrl}/ajax/v2/episode/sources?id=${encodeURIComponent(serverId)}`, - { "X-Requested-With": "XMLHttpRequest" } + this._headersJson() ); - const embedUrl = sourcesJson && sourcesJson.link ? String(sourcesJson.link) : ""; - if (!embedUrl) throw new Error("No embed link returned"); + const embed = (sourcesJson && sourcesJson.link) ? String(sourcesJson.link) : ""; + if (!embed) throw new Error("No embed link returned"); let decryptData = null; let requiredHeaders = {}; try { - decryptData = this.extractMegaCloudSync(embedUrl); + decryptData = this.extractMegaCloudSync(embed); requiredHeaders = (decryptData && decryptData.headersProvided) ? decryptData.headersProvided : {}; } catch (e) { decryptData = null; } if (!decryptData) { - decryptData = this._getJson( - `https://ac-api.ofchaos.com/api/anime/embed/convert/v2?embedUrl=${encodeURIComponent(embedUrl)}`, - {} + const fb = this._getJson( + `https://ac-api.ofchaos.com/api/anime/embed/convert/v2?embedUrl=${encodeURIComponent(embed)}`, + { "User-Agent": "Mozilla/5.0", "Accept": "application/json" } ); + decryptData = fb || {}; 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", + "User-Agent": "Mozilla/5.0", "X-Requested-With": "XMLHttpRequest" }; } - if (!decryptData || !decryptData.sources) throw new Error("No video sources from any decrypter"); + const sources = Array.isArray(decryptData.sources) ? decryptData.sources : []; + const tracks = Array.isArray(decryptData.tracks) ? decryptData.tracks : []; - const sources = decryptData.sources || []; const streamSource = - sources.find((s) => s && String(s.type || "").toLowerCase() === "hls" && s.file) || - sources.find((s) => s && String(s.type || "").toLowerCase() === "mp4" && s.file) || + 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 subtitles = (decryptData.tracks || []) + const subtitles = tracks .filter((t) => t && String(t.kind || "").toLowerCase() === "captions" && t.file) - .map((track, index) => ({ + .map((t, index) => ({ id: `sub-${index}`, - language: String(track.label || "Unknown"), - url: String(track.file), - isDefault: !!track.default + language: String(t.label || "Unknown"), + url: String(t.file), + isDefault: !!t.default })); - const outType = (String(streamSource.type || "").toLowerCase() === "hls") ? "m3u8" : "mp4"; + const out = { + server: server, + headers: requiredHeaders, + videoSources: [ + { + url: String(streamSource.file), + type: String(streamSource.type || "").toLowerCase() === "hls" ? "m3u8" : "mp4", + quality: "auto", + subtitles: subtitles + } + ] + }; - return JSON.stringify({ - server: serverName, - headers: requiredHeaders || {}, - _debug: { scUsed: wantTrack, serverId: serverId }, - videoSources: [{ - url: String(streamSource.file), - type: outType, - quality: "auto", - subtitles - }] - }); + return JSON.stringify(out); } extractMegaCloudSync(embedUrl) { - const s = String(embedUrl || ""); - const mm = s.match(/^(https?):\/\/([^\/]+)(\/.*)?$/i); - if (!mm) throw new Error("Invalid embedUrl"); - - const protocol = mm[1].toLowerCase(); - const host = mm[2]; - const baseDomain = `${protocol}://${host}/`; + const url = new URL(String(embedUrl)); + const baseDomain = `${url.protocol}//${url.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" + "Origin": `${url.protocol}//${url.host}`, + "User-Agent": "Mozilla/5.0" }; - const html = this._getText(embedUrl, headers); + const html = this._getText(String(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]; - else { - const match3x16 = []; - const re = /["']([A-Za-z0-9]{16})["']/g; - let m; - while ((m = re.exec(html)) !== null) match3x16.push(m[1]); - if (match3x16.length >= 3) nonce = match3x16[0] + match3x16[1] + match3x16[2]; + + 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 = this._getJson( @@ -381,4 +368,4 @@ class HiAnime { } } -module.exports = new HiAnime(); +module.exports = HiAnime;