diff --git a/anime/anicrush/source.js b/anime/anicrush/source.js new file mode 100644 index 0000000..3eead70 --- /dev/null +++ b/anime/anicrush/source.js @@ -0,0 +1,368 @@ +class AniCrush { + constructor() { + this.type = "anime-board"; + this.version = "1.0.0"; + this.baseUrl = "https://anicrush.to"; + this.apiBase = "https://api.anicrush.to"; + } + + getSettings() { + return { episodeServers: ["Southcloud-1", "Southcloud-2", "Southcloud-3"], supportsDub: true }; + } + + _nativeFetch(url, method, headers, body) { + const raw = Native.fetch(String(url), method || "GET", JSON.stringify(headers || {}), body == null ? "" : String(body)); + try { return JSON.parse(raw || "{}"); } catch (e) { return { ok: false, status: 0, headers: {}, body: "" }; } + } + + _getText(url, headers) { + const res = this._nativeFetch(url, "GET", headers, ""); + return String(res.body || ""); + } + + _getJson(url, headers) { + const res = this._nativeFetch(url, "GET", headers, ""); + try { return JSON.parse(String(res.body || "{}")); } catch (e) { return {}; } + } + + _postJson(url, headers, obj) { + const res = this._nativeFetch(url, "POST", headers, JSON.stringify(obj || {})); + try { return JSON.parse(String(res.body || "{}")); } catch (e) { return {}; } + } + + _safeStr(v) { + return typeof v === "string" ? v : (v == null ? "" : String(v)); + } + + _normalizeTitle(t) { + return this._safeStr(t) + .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, ""); + } + + _normalizeDate(dateStr) { + const s = this._safeStr(dateStr); + if (!s) return null; + const months = { + Jan: "01", Feb: "02", Mar: "03", Apr: "04", + May: "05", Jun: "06", Jul: "07", Aug: "08", + Sep: "09", 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 mm = months[m[1]]; + if (!mm) return null; + return { year: parseInt(m[2], 10), month: parseInt(mm, 10) }; + } + + _levSim(a, b) { + a = this._safeStr(a); + b = this._safeStr(b); + const lenA = a.length; + const lenB = b.length; + if (!lenA && !lenB) return 1; + if (!lenA || !lenB) return 0; + + const dp = []; + for (let i = 0; i <= lenA; i++) { + dp[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; + } + + _headers() { + return { + "User-Agent": "Mozilla/5.0", + "Accept": "application/json", + "Referer": this.baseUrl + "/", + "Origin": this.baseUrl, + "X-Site": "anicrush" + }; + } + + _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 q || {}; + } + + search(query) { + query = this._parseQuery(query); + + const q = this._safeStr(query.query).trim(); + if (!q) return []; + + const media = query.media || {}; + const start = (media.startDate || {}); + const wantYear = start.year || 0; + const wantMonth = start.month || 0; + + const targetNormJP = this._normalizeTitle(media.romajiTitle); + const targetNorm = media.englishTitle ? this._normalizeTitle(media.englishTitle) : targetNormJP; + + const url = `${this.apiBase}/shared/v2/movie/list?keyword=${encodeURIComponent(q)}&limit=48&page=1`; + const json = this._getJson(url, this._headers()); + const list = (((json || {}).result || {}).movies) || []; + if (!Array.isArray(list) || !list.length) return []; + + let matches = list.map((movie) => { + const id = movie && movie.id != null ? String(movie.id) : ""; + const slug = movie && movie.slug ? String(movie.slug) : ""; + if (!id || !slug) return null; + + const titleJP = movie && movie.name ? String(movie.name) : ""; + const titleEN = movie && movie.name_english ? String(movie.name_english) : ""; + const title = titleEN || titleJP || "Unknown"; + + return { + id, + slug, + title, + titleJP, + normTitle: this._normalizeTitle(title), + normTitleJP: this._normalizeTitle(titleJP), + dub: !!(movie && movie.has_dub), + startDate: this._normalizeDate(movie && movie.aired_from ? String(movie.aired_from) : "") + }; + }).filter(Boolean); + + if (query.dub) matches = matches.filter(m => m.dub); + + let filtered = matches; + + if (wantYear) { + filtered = matches.filter(m => { + const tMatch = (m.normTitle === targetNorm) || (m.normTitleJP === targetNormJP); + const d = m.startDate || {}; + const dMatch = wantMonth ? ((d.year === wantYear) && (d.month === wantMonth)) : (d.year === wantYear); + return tMatch && dMatch; + }); + + if (!filtered.length) { + filtered = matches.filter(m => { + const a = m.normTitle; + const b = targetNorm; + const aj = m.normTitleJP; + const bj = targetNormJP; + + const fuzzy = + (a && b && (a.includes(b) || b.includes(a) || this._levSim(a, b) > 0.72)) || + (aj && bj && (aj.includes(bj) || bj.includes(aj) || this._levSim(aj, bj) > 0.72)); + + const d = m.startDate || {}; + const dMatch = wantMonth ? ((d.year === wantYear) && (d.month === wantMonth)) : (d.year === wantYear); + return fuzzy && dMatch; + }); + } + } else { + const qn = this._normalizeTitle(q); + filtered = matches.filter(m => { + const a = this._normalizeTitle(m.title); + const aj = this._normalizeTitle(m.titleJP); + return (a === qn) || (aj === qn) || a.includes(qn) || aj.includes(qn) || qn.includes(a) || qn.includes(aj); + }); + + filtered.sort((x, y) => { + const A = this._normalizeTitle(x.title); + const B = this._normalizeTitle(y.title); + if (A.length !== B.length) return A.length - B.length; + return A.localeCompare(B); + }); + } + + const subOrDub = query.dub ? "dub" : "sub"; + return filtered.map(m => ({ + id: `${m.id}/${subOrDub}`, + title: m.title, + url: `${this.baseUrl}/detail/${m.slug}.${m.id}`, + subOrDub + })); + } + + findEpisodes(Id) { + const parts = String(Id || "").split("/"); + const id = parts[0]; + const subOrDub = parts[1] || "sub"; + if (!id) throw new Error("Missing id"); + + const url = `${this.apiBase}/shared/v2/episode/list?_movieId=${encodeURIComponent(id)}`; + const epJson = this._getJson(url, this._headers()); + const groups = (epJson && epJson.result) ? epJson.result : {}; + const episodes = []; + + const keys = Object.keys(groups || {}); + for (let i = 0; i < keys.length; i++) { + const group = groups[keys[i]]; + if (!Array.isArray(group)) continue; + + for (let j = 0; j < group.length; j++) { + const ep = group[j] || {}; + const num = Number(ep.number); + if (!Number.isFinite(num)) continue; + + episodes.push({ + id: `${id}/${subOrDub}`, + number: num, + title: ep.name_english ? String(ep.name_english) : (ep.name ? String(ep.name) : `Episode ${num}`), + url: "" + }); + } + } + + episodes.sort((a, b) => (a.number || 0) - (b.number || 0)); + return episodes; + } + + findEpisodeServer(episodeOrId, _server) { + let ep = episodeOrId; + if (typeof ep === "string") { try { ep = JSON.parse(ep); } catch (e) {} } + ep = ep || {}; + + const parts = String(ep.id || "").split("/"); + const id = parts[0]; + const subOrDub = parts[1] || "sub"; + if (!id) throw new Error("Missing id"); + + const num = Number(ep.number); + if (!Number.isFinite(num)) throw new Error("Missing episode number"); + + let server = String(_server || "").trim(); + if (!server || server === "default") server = "Southcloud-1"; + if (server === "HD-1") server = "Southcloud-1"; + if (server === "HD-2") server = "Southcloud-2"; + if (server === "HD-3") server = "Southcloud-3"; + + const serverMap = { "Southcloud-1": 4, "Southcloud-2": 1, "Southcloud-3": 6 }; + const sv = serverMap[server] != null ? serverMap[server] : 4; + + const linkUrl = + `${this.apiBase}/shared/v2/episode/sources?_movieId=${encodeURIComponent(id)}` + + `&ep=${encodeURIComponent(String(num))}&sv=${encodeURIComponent(String(sv))}&sc=${encodeURIComponent(subOrDub)}`; + + const json = this._getJson(linkUrl, this._headers()); + const encryptedIframe = (((json || {}).result || {}).link) ? String(json.result.link) : ""; + if (!encryptedIframe) throw new Error("Missing encrypted iframe link"); + + let decryptData = null; + let requiredHeaders = null; + + try { + decryptData = this.extractMegaCloudSync(encryptedIframe); + requiredHeaders = decryptData && decryptData.headersProvided ? decryptData.headersProvided : null; + } catch (e) {} + + if (!decryptData) { + decryptData = this._getJson( + `https://ac-api.ofchaos.com/api/anime/embed/convert/v2?embedUrl=${encodeURIComponent(encryptedIframe)}`, + {} + ); + 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" + }; + } + + if (!decryptData) throw new Error("No video sources"); + + const sources = decryptData.sources || []; + const streamSource = + sources.find((s) => s && s.type === "hls") || + sources.find((s) => s && s.type === "mp4"); + + if (!streamSource || !streamSource.file) throw new Error("No valid stream file found"); + + const tracks = decryptData.tracks || []; + const subtitles = tracks + .filter((t) => t && t.kind === "captions" && t.file) + .map((track, index) => ({ + id: `sub-${index}`, + language: track.label || "Unknown", + url: track.file, + isDefault: !!track.default + })); + + const st = String(streamSource.type || ""); + const outType = (st === "hls" || st === "m3u8") ? "m3u8" : "mp4"; + + return { + server: server, + headers: requiredHeaders || {}, + videoSources: [{ + url: streamSource.file, + type: outType, + quality: "auto", + subtitles: subtitles + }] + }; + } + + 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 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 = 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"); + 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) throw new Error("nonce not found"); + + const sourcesJson = this._getJson(`${baseDomain}embed-2/v3/e-1/getSources?id=${fileId}&_k=${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 = AniCrush;