diff --git a/anime/hianime/source.js b/anime/hianime/source.js index ecf1a25..2a39a59 100644 --- a/anime/hianime/source.js +++ b/anime/hianime/source.js @@ -1,7 +1,7 @@ class HiAnime { constructor() { this.type = "anime-streaming"; - this.version = "1.0.6"; + this.version = "1.0.2"; this.baseUrl = "https://hianime.to"; } @@ -42,60 +42,82 @@ class HiAnime { } } - _safeStr(v) { - return typeof v === "string" ? v : (v == null ? "" : String(v)); - } - - _headersHtml() { - return { - "User-Agent": "Mozilla/5.0", - "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" - }; + safeString(str) { + return typeof str === "string" ? str : (str == null ? "" : String(str)); } _decodeHtml(s) { - const str = this._safeStr(s); - if (!str) return ""; - return str - .replace(/\\u0026/g, "&") + const t = this.safeString(s); + if (!t) return ""; + return t .replace(/&/g, "&") - .replace(/"/g, '"') + .replace(/"/g, "\"") .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) : ""; + try { return String.fromCharCode(parseInt(n, 10)); } catch (e) { return ""; } }); } - _parseArg(a1) { - if (typeof a1 === "string") { - const s = a1.trim(); + normalizeSeasonParts(title) { + const s = this.safeString(title); + return s.toLowerCase() + .replace(/\d+(st|nd|rd|th)/g, (m) => m.replace(/st|nd|rd|th/, "")) + .replace(/season|cour|part/g, "") + .replace(/[^a-z0-9]+/g, ""); + } + + _levSim(a, b) { + a = this.safeString(a); + b = this.safeString(b); + if (!a && !b) return 1; + if (!a || !b) return 0; + + const la = a.length, lb = b.length; + const dp = new Array(la + 1); + for (let i = 0; i <= la; i++) dp[i] = new Array(lb + 1).fill(0); + for (let i = 0; i <= la; i++) 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++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + dp[i][j] = Math.min( + dp[i - 1][j] + 1, + dp[i][j - 1] + 1, + dp[i - 1][j - 1] + cost + ); + } + } + const dist = dp[la][lb]; + const maxLen = Math.max(la, lb) || 1; + return 1 - (dist / maxLen); + } + + _titleOk(candidateNorm, targetNorm, targetNormJP) { + if (!candidateNorm) return false; + if (candidateNorm === targetNorm || candidateNorm === targetNormJP) return true; + if (candidateNorm.includes(targetNorm) || targetNorm.includes(candidateNorm)) return true; + if (candidateNorm.includes(targetNormJP) || targetNormJP.includes(candidateNorm)) return true; + return (this._levSim(candidateNorm, targetNorm) >= 0.72) || (this._levSim(candidateNorm, targetNormJP) >= 0.72); + } + + _parseQuery(q) { + if (typeof q === "string") { + const s = q.trim(); if (s.startsWith("{") || s.startsWith("[")) { try { return JSON.parse(s); } catch (e) { return { query: s }; } } return { query: s }; } - return a1 || {}; + return q || {}; } - _getSubOrDubFromAny(obj) { - const e = obj || {}; + _wantTrackFromEpisode(e) { + if (!e) return "sub"; + if (typeof e.dub === "boolean") return e.dub ? "dub" : "sub"; const sod = String(e.subOrDub || "").toLowerCase(); @@ -112,290 +134,116 @@ class HiAnime { return "sub"; } - _normalizeTitle(title) { - return this._safeStr(title) - .toLowerCase() - .replace(/(season|cour|part|uncensored)/g, "") - .replace(/\d+(st|nd|rd|th)/g, (m) => m.replace(/st|nd|rd|th/, "")) - .replace(/[^a-z0-9]+/g, ""); - } - - _levSim(a, b) { - a = this._safeStr(a); - b = this._safeStr(b); - const lenA = a.length, lenB = b.length; - if (!lenA && !lenB) return 1; - if (!lenA || !lenB) return 0; - - const dp = new Array(lenA + 1); - for (let i = 0; i <= lenA; i++) dp[i] = new Array(lenB + 1).fill(0); - - for (let i = 0; i <= lenA; i++) dp[i][0] = i; - for (let j = 0; j <= lenB; j++) dp[0][j] = j; - - for (let i = 1; i <= lenA; i++) { - for (let j = 1; j <= lenB; j++) { - if (a[i - 1] === b[j - 1]) dp[i][j] = dp[i - 1][j - 1]; - else dp[i][j] = 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]); - } - } - - const dist = dp[lenA][lenB]; - const maxLen = Math.max(lenA, lenB); - return 1 - dist / maxLen; - } - - _isSingleWordStrictTarget(title) { - const n = this._safeStr(title).trim(); - if (!n) return false; - const parts = n.split(/\s+/).filter(Boolean); - if (parts.length !== 1) return false; - const t = this._normalizeTitle(n); - return t.length >= 3 && t.length <= 10; - } - - _bestTitleFromSuggestItem(x) { - if (!x) return ""; - const t = x.title; - if (typeof t === "string") return t; - if (t && typeof t === "object") { - return String(t.english || t.romaji || t.native || t.userPreferred || ""); - } - return String(x.name || x.english || x.romaji || ""); - } - - _parseStartDate(dateStr) { - const s = this._safeStr(dateStr).trim(); // "Jul 4, 2025" - if (!s) return null; - - 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 m = s.match(/([A-Za-z]+)\s+(\d{1,2}),\s*(\d{4})/); - if (!m) return null; - - const mon = monthMap[m[1]]; - const day = parseInt(m[2], 10); - const year = parseInt(m[3], 10); - - if (!mon || !isFinite(day) || !isFinite(year)) return null; - return { year, month: mon, day }; - } - - _extractWatchIdFromUrl(url) { - const s = String(url || ""); - const m = s.match(/\/watch\/[^\/]+-(\d+)/i); - return m ? m[1] : ""; - } - - _extractImageFromHtmlChunk(chunk) { - const c = String(chunk || ""); - const m = c.match(/]+src="([^"]+)"|]+data-src="([^"]+)"/i); - return m ? (m[1] || m[2] || "") : ""; - } - - _fetchSuggestMatches(q) { - const url = `${this.baseUrl}/ajax/search/suggest?keyword=${encodeURIComponent(q)}`; - const j = this._getJson(url, this._headersJson()); - - if (j && Array.isArray(j.results)) { - return j.results.map((x) => { - const title = this._decodeHtml(this._bestTitleFromSuggestItem(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); - const image = x.image ? String(x.image) : ""; - const dateStr = x.released ? String(x.released) : ""; - const startDate = this._parseStartDate(dateStr); - return { id, title, url: full, image, startDate }; - }).filter((x) => x.id && x.url); - } - - const html = this._decodeHtml(String(j.html || j.result || "")); - if (!html) return []; - - const re = /([\s\S]*?)<\/a>/g; - const out = []; - let m; - while ((m = re.exec(html)) !== null) { - const pageUrl = String(m[1] || "").trim(); // e.g. "monster-2004-12345" - if (!pageUrl || pageUrl.startsWith("search?")) continue; - - const chunk = String(m[2] || ""); - const titleM = chunk.match(/]*class="film-name"[^>]*>([^<]+)<\/h3>/i); - const title = this._decodeHtml(titleM ? titleM[1] : ""); - - const dateM = chunk.match(/
\s*([^<]+)<\/span>/i); - const startDate = this._parseStartDate(dateM ? dateM[1] : ""); - - const fullUrl = `${this.baseUrl}/${pageUrl}`; - const idMatch = pageUrl.match(/-(\d+)$/); - const id = idMatch ? idMatch[1] : this._extractWatchIdFromUrl(fullUrl); - - const image = this._extractImageFromHtmlChunk(chunk); - - if (!id) continue; - out.push({ id, title: title || q, url: fullUrl, image, startDate }); - } - return out; - } - - _fetchFallbackSearchMatches(q) { - const url2 = `${this.baseUrl}/search?keyword=${encodeURIComponent(q)}`; - const html = this._getText(url2, this._headersHtml()); - - const out = []; - const re = /= 20) break; - } - return out; - } - search(a1) { - const arg = this._parseArg(a1); - const q = this._safeStr(arg.query || arg.q || "").trim(); + const query = this._parseQuery(a1); + const media = query.media || {}; + const start = media.startDate || {}; + const q = this.safeString(query.query || "").trim(); if (!q) return "[]"; - const subOrDub = this._getSubOrDubFromAny(arg); + const targetEn = this.safeString(media.englishTitle || media.english || media.titleEnglish || ""); + const targetRo = this.safeString(media.romajiTitle || media.romaji || media.titleRomaji || ""); + const targetNorm = this.normalizeSeasonParts(this._decodeHtml(targetEn || targetRo || q)); + const targetNormJP = this.normalizeSeasonParts(this._decodeHtml(targetRo || q)); - const media = arg.media || {}; - const start = media.startDate || {}; - const targetYear = parseInt(start.year, 10); - const targetMonth = parseInt(start.month, 10); + const url = + `${this.baseUrl}/search?keyword=${encodeURIComponent(q)}` + + `&sy=${encodeURIComponent(String(start.year || ""))}` + + `&sm=${encodeURIComponent(String(start.month || ""))}` + + `&sort=default`; - const en = this._safeStr(media.englishTitle || media.english || "").trim(); - const ro = this._safeStr(media.romajiTitle || media.romaji || "").trim(); + const html = this._getText(url, { + "User-Agent": "Mozilla/5.0", + "Accept": "text/html", + "Referer": this.baseUrl + "/", + "Origin": this.baseUrl + }); - const targetNormJP = this._normalizeTitle(ro); - const targetNormEN = this._normalizeTitle(en); - const targetNorm = targetNormEN || targetNormJP || this._normalizeTitle(q); + const re = /]+title="([^"]+)"[^>]+data-id="(\d+)"/g; + const matches = []; + let m; + while ((m = re.exec(html)) !== null) { + const id = String(m[3] || ""); + const pageUrl = String(m[1] || ""); + const title = this._decodeHtml(String(m[2] || "")); - const strictTargetRaw = (this._isSingleWordStrictTarget(en) ? en : (this._isSingleWordStrictTarget(ro) ? ro : "")); - const strictNorm = strictTargetRaw ? this._normalizeTitle(strictTargetRaw) : ""; + const jnameRe = new RegExp( + `

[\\s\\S]*?]+href="\\/${pageUrl}[^"]*"[^>]+data-jname="([^"]+)"`, + "i" + ); + const jnameMatch = html.match(jnameRe); + const jname = jnameMatch ? this._decodeHtml(jnameMatch[1]) : ""; + + const imageRe = new RegExp( + `]+data-src="([^"]+)"`, + "i" + ); + const imageMatch = html.match(imageRe); + const image = imageMatch ? String(imageMatch[1]) : ""; + + const normTitle = this.normalizeSeasonParts(title); + const normJP = this.normalizeSeasonParts(jname); + + matches.push({ + id, + pageUrl, + title, + image, + normTitle, + normJP + }); + } - let matches = this._fetchSuggestMatches(q); - if (!matches.length) matches = this._fetchFallbackSearchMatches(q); if (!matches.length) return "[]"; - const hasYear = isFinite(targetYear) && targetYear > 0; - const hasMonth = isFinite(targetMonth) && targetMonth > 0 && targetMonth <= 12; + const wantTrack = query.dub ? "dub" : "sub"; - const withNorm = matches.map((m) => { - const title = this._safeStr(m.title); - return { - id: String(m.id || ""), - title, - url: String(m.url || ""), - image: String(m.image || ""), - startDate: m.startDate || null, - normTitle: this._normalizeTitle(title) - }; - }).filter((m) => m.id && m.url); + let filtered = matches.filter(x => this._titleOk(x.normTitle, targetNorm, targetNormJP) || this._titleOk(x.normJP, targetNorm, targetNormJP)); - const exactTitleMatch = (m) => { - if (!m.normTitle) return false; - if (strictNorm) return m.normTitle === strictNorm; - return (m.normTitle === targetNorm) || (targetNormJP && m.normTitle === targetNormJP); - }; + if (!filtered.length) { + const qNorm = this.normalizeSeasonParts(q); + filtered = matches.filter(x => this._titleOk(x.normTitle, qNorm, qNorm) || this._titleOk(x.normJP, qNorm, qNorm)); + } - const fuzzyTitleMatch = (m) => { - if (!m.normTitle) return false; - if (strictNorm) return m.normTitle === strictNorm; - - const a = m.normTitle; - const b = targetNorm; - - if (a === b) return true; - if (a.includes(b) || b.includes(a)) return true; - - if (targetNormJP) { - if (a.includes(targetNormJP) || targetNormJP.includes(a)) return true; - if (this._levSim(a, targetNormJP) > 0.72) return true; - } - - return this._levSim(a, b) > 0.72; - }; - - const dateMatchYM = (m) => { - if (!hasYear) return true; - if (!m.startDate || !m.startDate.year) return false; - if (m.startDate.year !== targetYear) return false; - if (!hasMonth) return true; - return m.startDate.month === targetMonth; - }; - - const dateMatchY = (m) => { - if (!hasYear) return true; - if (!m.startDate || !m.startDate.year) return false; - return m.startDate.year === targetYear; - }; - - let filtered = withNorm.filter((m) => exactTitleMatch(m) && dateMatchYM(m)); - if (!filtered.length) filtered = withNorm.filter((m) => exactTitleMatch(m) && dateMatchY(m)); - if (!filtered.length) filtered = withNorm.filter((m) => fuzzyTitleMatch(m) && dateMatchYM(m)); - if (!filtered.length) filtered = withNorm.filter((m) => fuzzyTitleMatch(m) && dateMatchY(m)); - - if (!filtered.length && strictNorm) return "[]"; - - if (!filtered.length) filtered = withNorm.filter((m) => fuzzyTitleMatch(m)); if (!filtered.length) return "[]"; filtered.sort((a, b) => { - const A = a.normTitle.length; - const B = b.normTitle.length; - if (A !== B) return A - B; - return a.normTitle.localeCompare(b.normTitle); + const sa = Math.max(this._levSim(a.normTitle, targetNorm), this._levSim(a.normJP, targetNormJP)); + const sb = Math.max(this._levSim(b.normTitle, targetNorm), this._levSim(b.normJP, targetNormJP)); + return sb - sa; }); - const mapped = filtered.map((r) => ({ - id: `${r.id}/${subOrDub}`, - title: this._decodeHtml(r.title || ""), - image: r.image || "", - url: r.url, - subOrDub, - startDate: r.startDate || null - })); - - return JSON.stringify(mapped); + return JSON.stringify(filtered.map(x => ({ + id: `${x.id}/${wantTrack}`, + title: x.title, + image: x.image, + url: `${this.baseUrl}/watch/${x.pageUrl}`, + subOrDub: wantTrack + }))); } findEpisodes(animeId) { const parts = String(animeId || "").split("/"); const id = parts[0]; - const subOrDub = parts[1] || "sub"; - if (!id) return "[]"; + const subOrDub = (parts[1] || "sub").toLowerCase(); - const j = this._getJson( - `${this.baseUrl}/ajax/v2/episode/list/${encodeURIComponent(id)}`, - this._headersJson() - ); + const json = this._getJson(`${this.baseUrl}/ajax/v2/episode/list/${encodeURIComponent(id)}`, { + "X-Requested-With": "XMLHttpRequest" + }); - const html = this._decodeHtml(String(j.html || j.result || "")); + const html = this._decodeHtml(String(json.html || json.result || "")); const episodes = []; - 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; + 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: `${epId}/${subOrDub}`, - number: num, - title: "", - url: "" + id: `${match[2]}/${subOrDub}`, + number: parseInt(match[1], 10), + url: this.baseUrl + match[3], + title: this._decodeHtml(match[4] || "") }); } @@ -403,52 +251,55 @@ class HiAnime { return JSON.stringify(episodes); } - findEpisodeServer(episodeObj, serverName) { - let ep = episodeObj; - if (typeof ep === "string") { - try { ep = JSON.parse(ep); } catch (e) { ep = {}; } + findEpisodeServer(episodeObj, _server) { + let episode = episodeObj; + if (typeof episode === "string") { + try { episode = JSON.parse(episode); } catch (e) { episode = {}; } } + episode = episode || {}; - const epIdRaw = this._safeStr(ep && ep.id ? ep.id : "").trim(); - if (!epIdRaw) throw new Error("Missing episode id"); + const idParts = String(episode.id || "").split("/"); + const episodeId = idParts[0]; + if (!episodeId) throw new Error("Missing episode id"); - const parts = epIdRaw.split("/"); - const epId = parts[0]; - const subOrDub = (parts[1] || "sub").toLowerCase(); + const wantTrack = this._wantTrackFromEpisode(episode); - const server = String(serverName || "").trim() || "HD-1"; - if (server === "HD-4") return "null"; + let serverName = String(_server || "").trim(); + if (!serverName || serverName === "default") serverName = "HD-1"; + if (serverName === "HD-4") throw new Error("HD-4 not implemented"); - const j = this._getJson( - `${this.baseUrl}/ajax/v2/episode/servers?episodeId=${encodeURIComponent(epId)}`, - this._headersJson() + const serverJson = this._getJson( + `${this.baseUrl}/ajax/v2/episode/servers?episodeId=${encodeURIComponent(episodeId)}`, + { "X-Requested-With": "XMLHttpRequest" } ); - const html = this._decodeHtml(String(j.html || j.result || "")); + const serverHtml = this._decodeHtml(String(serverJson.html || serverJson.result || "")); + if (!serverHtml) throw new Error("Empty server list"); - const escapedServer = server.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const reExact = new RegExp( - `class="item server-item"[^>]*data-type="${subOrDub}"[^>]*data-id="([^"]+)"[^>]*>[\\s\\S]*?>\\s*${escapedServer}\\s*<`, + 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>`, "i" ); - const mmExact = reExact.exec(html); - let serverId = mmExact ? String(mmExact[1] || "").trim() : ""; + let mm = strictRe.exec(serverHtml); + let serverId = mm ? String(mm[1] || "").trim() : ""; if (!serverId) { - const reFirst = new RegExp( - `class="item server-item"[^>]*data-type="${subOrDub}"[^>]*data-id="([^"]+)"`, + const trackAnyRe = new RegExp( + `]*class="item\\s+server-item"[^>]*data-type="${esc(wantTrack)}"[^>]*data-id="(\\d+)"`, "i" ); - const mmFirst = reFirst.exec(html); - if (mmFirst) serverId = String(mmFirst[1] || "").trim(); + mm = trackAnyRe.exec(serverHtml); + serverId = mm ? String(mm[1] || "").trim() : ""; } - if (!serverId) throw new Error(`No server id found for track=${subOrDub}`); + if (!serverId) throw new Error(`No server id found for track=${wantTrack}`); const sourcesJson = this._getJson( `${this.baseUrl}/ajax/v2/episode/sources?id=${encodeURIComponent(serverId)}`, - this._headersJson() + { "X-Requested-With": "XMLHttpRequest" } ); const embed = (sourcesJson && sourcesJson.link) ? String(sourcesJson.link) : ""; @@ -481,7 +332,7 @@ class HiAnime { const sources = Array.isArray(decryptData.sources) ? decryptData.sources : []; const tracks = Array.isArray(decryptData.tracks) ? decryptData.tracks : []; - const streamSource = + let 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); @@ -490,27 +341,23 @@ class HiAnime { const subtitles = tracks .filter((t) => t && String(t.kind || "").toLowerCase() === "captions" && t.file) - .map((t, index) => ({ - id: `sub-${index}`, - language: String(t.label || "Unknown"), + .map((t, idx) => ({ + id: `sub-${idx}`, + language: this._decodeHtml(t.label || "Unknown"), url: String(t.file), isDefault: !!t.default })); - const out = { - server: server, + return JSON.stringify({ + server: serverName, headers: requiredHeaders, - videoSources: [ - { - url: String(streamSource.file), - type: String(streamSource.type || "").toLowerCase() === "hls" ? "m3u8" : "mp4", - quality: "auto", - subtitles: subtitles - } - ] - }; - - return JSON.stringify(out); + videoSources: [{ + url: String(streamSource.file), + type: (String(streamSource.type || "").toLowerCase() === "hls" || String(streamSource.file).includes(".m3u8")) ? "m3u8" : "mp4", + quality: "auto", + subtitles + }] + }); } extractMegaCloudSync(embedUrl) { @@ -522,10 +369,10 @@ class HiAnime { "X-Requested-With": "XMLHttpRequest", "Referer": baseDomain, "Origin": `${url.protocol}//${url.host}`, - "User-Agent": "Mozilla/5.0" + "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 = this._getText(String(embedUrl), headers); + const html = 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"); @@ -534,14 +381,12 @@ class HiAnime { 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)]; + else { + const match3x16 = Array.from(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( @@ -550,7 +395,7 @@ class HiAnime { ); return { - sources: sourcesJson.sources || [], + sources: sourcesJson.sources, tracks: sourcesJson.tracks || [], intro: sourcesJson.intro || null, outro: sourcesJson.outro || null, @@ -560,4 +405,4 @@ class HiAnime { } } -module.exports = HiAnime; +module.exports = new HiAnime();