diff --git a/anime/hianime/source.js b/anime/hianime/source.js index e7f4617..f2d6b65 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.1"; + this.version = "1.0.2"; this.baseUrl = "https://hianime.to"; } @@ -14,25 +14,38 @@ class HiAnime { }; } + async _fetch(url, opts) { + if (typeof fetch === "function") return fetch(String(url), opts || {}); + const raw = Native.fetch( + String(url), + (opts && opts.method) ? String(opts.method) : "GET", + JSON.stringify((opts && opts.headers) || {}), + (opts && opts.body != null) ? String(opts.body) : "" + ); + try { return JSON.parse(raw || "{}"); } catch (e) { return { ok: false, status: 0, headers: {}, body: "" }; } + } + async _getText(url, headers) { - const res = await fetch(String(url), { method: "GET", headers: headers || {} }); - return String(await res.text()); + const res = await this._fetch(String(url), { method: "GET", headers: headers || {} }); + if (res && typeof res.text === "function") return String(await res.text()); + return String(res.body || ""); } async _getJson(url, headers) { - const res = await fetch(String(url), { method: "GET", headers: headers || {} }); - try { - return await res.json(); - } catch (e) { - return {}; + const res = await this._fetch(String(url), { method: "GET", headers: headers || {} }); + if (res && typeof res.json === "function") { + try { return await res.json(); } catch (e) { return {}; } } + try { return JSON.parse(String(res.body || "{}")); } catch (e) { return {}; } } - _decodeHtml(s) { - return String(s || "") + _decodeHtml(str) { + return String(str || "") .replace(/\\u0026/g, "&") - .replace(/&#(\d+);?/g, (m, d) => { - try { return String.fromCharCode(parseInt(d, 10)); } catch (e) { return m; } + .replace(/&#(\d+);?/g, (m, dec) => { + const n = parseInt(dec, 10); + if (!isFinite(n)) return m; + return String.fromCharCode(n); }) .replace(/"/g, '"') .replace(/'/g, "'") @@ -41,10 +54,10 @@ class HiAnime { .replace(/>/g, ">"); } - _normalizeTitle(s) { + _norm(s) { return String(s || "") .toLowerCase() - .replace(/(season|cour|part|uncensored)/g, " ") + .replace(/(season|cour|part|uncensored|movie|ova|ona|special)/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, " ") @@ -54,32 +67,26 @@ class HiAnime { _levSim(a, b) { a = String(a || ""); b = String(b || ""); - if (!a.length || !b.length) return 0; + if (!a || !b) 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; - } + const dp = Array.from({ length: la + 1 }, () => 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++) { 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); - } + else dp[i][j] = 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]); } } + const dist = dp[la][lb]; const maxLen = Math.max(la, lb) || 1; return 1 - dist / maxLen; } - _parseStartDate(dateStr) { + _parseDate(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 }; @@ -87,41 +94,107 @@ class HiAnime { 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 }; + const month = monthMap[m[1]] || 0; + const day = parseInt(m[2], 10) || 0; + const year = parseInt(m[3], 10) || 0; + return { year, month, day }; } - _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); + async _fetchSuggestMatches(queryStr) { + const url = `${this.baseUrl}/ajax/search/suggest?keyword=${encodeURIComponent(queryStr)}`; + const reply = await this._getJson(url, { "X-Requested-With": "XMLHttpRequest" }); + const html = String((reply && reply.html) || ""); - let best = 0; - const pairs = [ - [cand, tEn], - [cand, tRo], - [candJ, tRo], - [candJ, tEn] - ]; + const regex = + /[\s\S]*?

]*data-jname="([^"]+)"[^>]*>([^<]+)<\/h3>[\s\S]*?
\s*([^<]+)<\/span>/g; - for (let i = 0; i < pairs.length; i++) { - const a = pairs[i][0], b = pairs[i][1]; - if (!a || !b) continue; + const matches = []; + const all = [...html.matchAll(regex)]; + for (let i = 0; i < all.length; i++) { + const pageUrl = String(all[i][1] || "").trim(); + if (!pageUrl || pageUrl.startsWith("search?")) continue; - if (a === b) best = Math.max(best, 1000); - if (a.includes(b) || b.includes(a)) best = Math.max(best, 700); + const jname = this._decodeHtml(String(all[i][2] || "").trim()); + const title = this._decodeHtml(String(all[i][3] || "").trim()); + const dateStr = String(all[i][4] || "").trim(); - const sim = this._levSim(a, b); - best = Math.max(best, Math.floor(sim * 650)); + const startDate = this._parseDate(dateStr); + + const idMatch = pageUrl.match(/-(\d+)$/); + const id = idMatch ? idMatch[1] : pageUrl; + + matches.push({ + id, + pageUrl, + title, + jname, + normTitle: this._norm(title), + normTitleJP: this._norm(jname), + startDate + }); } + return matches; + } - if (targetYear && targetYear > 0) { - best += 0; + async _fallbackSearchPageMatches(queryStr) { + const url = `${this.baseUrl}/search?keyword=${encodeURIComponent(queryStr)}`; + const html = await this._getText(url, {}); + const regex = /]+title="([^"]+)"[^>]+data-id="(\d+)"/g; + + const matches = []; + const all = [...html.matchAll(regex)]; + for (let i = 0; i < all.length; i++) { + const pageUrl = all[i][1]; + const title = this._decodeHtml(all[i][2]); + const id = all[i][3]; + + matches.push({ + id, + pageUrl: "watch/" + pageUrl, + title, + jname: "", + normTitle: this._norm(title), + normTitleJP: "", + startDate: { year: 0, month: 0, day: 0 } + }); } - return best; + return matches; + } + + _titleOk(m, targetNorm, targetNormJP) { + if (!m) return false; + const a = m.normTitle || ""; + const b = m.normTitleJP || ""; + if (!a && !b) return false; + + if (a === targetNorm || b === targetNormJP) return true; + + const inc = + a.includes(targetNorm) || + b.includes(targetNormJP) || + targetNorm.includes(a) || + targetNormJP.includes(b); + + if (inc) return true; + + const simA = this._levSim(a, targetNorm); + const simB = this._levSim(b, targetNormJP); + return (simA >= 0.72) || (simB >= 0.72); + } + + _dateOk(m, start, strictMonth) { + if (!start || !start.year) return true; + const y = (m.startDate && m.startDate.year) || 0; + const mo = (m.startDate && m.startDate.month) || 0; + + if (!y) return false; + if (y !== start.year) return false; + + if (strictMonth) { + if (!start.month) return true; + return mo === start.month; + } + return true; } async search(query) { @@ -129,118 +202,87 @@ class HiAnime { query = { query, media: { startDate: { year: 0, month: 0, day: 0 } } }; } - const q = query && query.query ? String(query.query) : ""; + const q = String((query && query.query) || "").trim(); + if (!q) return []; + 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 targetNormJP = this._norm(this._decodeHtml(targetRo)); + const targetNorm = this._norm(this._decodeHtml(targetEn || targetRo || q)); + 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) || ""); + let matches = await this._fetchSuggestMatches(q); + if (!matches.length) return []; - const regex = - /]+href="\/([^"]+)"[^>]*class="nav-item"[^>]*>[\s\S]*?]*class="film-name"[^>]*data-jname="([^"]*)"[^>]*>([^<]*)<\/h3>[\s\S]*?]*class="film-infor"[^>]*>[\s\S]*?]*>([^<]*)<\/span>/gi; + if (start && start.year) { + let filtered = matches.filter(m => this._titleOk(m, targetNorm, targetNormJP) && this._dateOk(m, start, true)); + if (!filtered.length) filtered = matches.filter(m => this._titleOk(m, targetNorm, targetNormJP) && this._dateOk(m, start, false)); - const imgRegex = /]+(?:data-src|src)="([^"]+)"[^>]*>/i; + if (!filtered.length) return []; - const matches = [...html.matchAll(regex)]; - for (let i = 0; i < matches.length; i++) { - const pageUrlRaw = matches[i][1] || ""; - if (!pageUrlRaw || pageUrlRaw.startsWith("search?")) continue; + filtered.sort((a, b) => { + const sa = Math.max(this._levSim(a.normTitle, targetNorm), this._levSim(a.normTitleJP, targetNormJP)); + const sb = Math.max(this._levSim(b.normTitle, targetNorm), this._levSim(b.normTitleJP, targetNormJP)); + if (sb !== sa) return sb - sa; - const jname = matches[i][2] || ""; - const title = matches[i][3] || ""; - const dateStr = matches[i][4] || ""; + const am = (a.startDate && a.startDate.month) || 0; + const bm = (b.startDate && b.startDate.month) || 0; + const tm = start.month || 0; + const da = tm ? Math.abs(am - tm) : 0; + const db = tm ? Math.abs(bm - tm) : 0; + return da - db; + }); - 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, + return filtered.map(m => ({ + id: `${m.id}/${subOrDub}`, + title: m.title, + url: `${this.baseUrl}/${m.pageUrl}`, subOrDub - }; - }); + })); + } + + const queryNorm = this._norm(q); + + let filtered = matches.filter(m => this._titleOk(m, queryNorm, queryNorm)); + if (!filtered.length) { + const pageMatches = await this._fallbackSearchPageMatches(q); + if (!pageMatches.length) return []; + + filtered = pageMatches.filter(m => { + const a = m.normTitle || ""; + if (!a) return false; + if (a === queryNorm) return true; + if (a.includes(queryNorm) || queryNorm.includes(a)) return true; + return this._levSim(a, queryNorm) >= 0.72; + }); + + if (!filtered.length) return []; + + filtered.sort((a, b) => { + const la = (a.normTitle || "").length; + const lb = (b.normTitle || "").length; + if (la !== lb) return la - lb; + return String(a.title || "").localeCompare(String(b.title || "")); + }); + } else { + filtered.sort((a, b) => { + const sa = Math.max(this._levSim(a.normTitle, queryNorm), this._levSim(a.normTitleJP, queryNorm)); + const sb = Math.max(this._levSim(b.normTitle, queryNorm), this._levSim(b.normTitleJP, queryNorm)); + return sb - sa; + }); + } + + return filtered.map(m => ({ + id: `${m.id}/${subOrDub}`, + title: m.title, + url: `${this.baseUrl}/${m.pageUrl}`, + subOrDub + })); } async findEpisodes(animeId) { @@ -249,7 +291,7 @@ class HiAnime { const subOrDub = parts[1] || "sub"; const json = await this._getJson( - `${this.baseUrl}/ajax/v2/episode/list/${id}`, + `${this.baseUrl}/ajax/v2/episode/list/${encodeURIComponent(id)}`, { "X-Requested-With": "XMLHttpRequest" } ); @@ -259,13 +301,13 @@ class HiAnime { 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) { + let m; + while ((m = 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] || "") + id: `${m[2]}/${subOrDub}`, + number: parseInt(m[1], 10), + url: this.baseUrl + m[3], + title: this._decodeHtml(m[4] || "") }); } @@ -278,14 +320,14 @@ class HiAnime { } const parts = String((episode && episode.id) || "").split("/"); - const epId = parts[0]; + const id = parts[0]; const subOrDub = parts[1] || "sub"; - let serverName = _server && _server !== "default" ? String(_server) : "HD-1"; + const 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)}`, + `${this.baseUrl}/ajax/v2/episode/servers?episodeId=${encodeURIComponent(id)}`, { "X-Requested-With": "XMLHttpRequest" } ); const serverHtml = String((serverJson && serverJson.html) || ""); @@ -294,9 +336,10 @@ class HiAnime { `]*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 mm = regex.exec(serverHtml); + if (!mm) throw new Error(`Server "${serverName}" (${subOrDub}) not found`); + const serverId = mm[1]; const sourcesJson = await this._getJson( `${this.baseUrl}/ajax/v2/episode/sources?id=${encodeURIComponent(serverId)}`, @@ -338,7 +381,7 @@ class HiAnime { if (!streamSource || !streamSource.file) throw new Error("No valid stream file found"); - const tracks = (decryptData && (decryptData.tracks || decryptData.track || decryptData.subtitle)) || []; + const tracks = (decryptData && decryptData.tracks) ? decryptData.tracks : []; const subtitles = (tracks || []) .filter((t) => t && String(t.kind || "").toLowerCase() === "captions" && t.file) .map((track, index) => ({