class OppaiStream { constructor() { this.baseUrl = "https://oppai.stream"; this.searchBaseUrl = "https://oppai.stream/actions/search.php?order=recent&page=1&limit=35&genres=&blacklist=&studio=&ibt=0&swa=1&text="; this.type = "anime-board"; this.version = "1.0"; this.userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36"; this.ScoreWeight = { Title: 3.6, MaxScore: 10 }; } getSettings() { return { episodeServers: ["oppai.stream"], supportsDub: false, }; } async search(queryObj) { let tempquery = queryObj.query; while (tempquery !== "") { try { const url = this.searchBaseUrl + encodeURIComponent(tempquery); const html = await this.GETText(url); const $ = this.cheerio.load(html); const movies = $("div.in-grid.episode-shown"); if (movies.length <= 0) { if (tempquery.includes(" ")) { tempquery = tempquery.split(/[\s:']+/).slice(0, -1).join(" "); continue; } else { break; } } const movieList = []; movies.each((_, el) => { const title = $(el).find(".title-ep").text().trim(); const href = $(el).find("a").attr("href"); const rawUrl = href ? href.replace("&for=search", "") : ""; if (title && rawUrl) { movieList.push({ Title: title, Url: rawUrl }); } }); const bestMovie = this.findBestTitle(movieList, queryObj.query); if (!bestMovie) return []; return [{ // Codificamos la URL para que sea un ID seguro para la URL de la app id: encodeURIComponent(bestMovie.Url), title: bestMovie.Title, url: bestMovie.Url, subOrDub: queryObj.dub ? "dub" : "sub", }]; } catch (e) { console.error(e); return []; } } return []; } async getMetadata(id) { try { // Decodificamos el ID para obtener la URL real de OppaiStream const decodedUrl = decodeURIComponent(id); const html = await this.GETText(decodedUrl); const $ = this.cheerio.load(html); const title = $("meta[property='og:title']").attr("content") || $("h1").text().trim(); const image = $("meta[property='og:image']").attr("content") || ""; const summary = $("meta[property='og:description']").attr("content") || $(".desc").text().trim(); return { title: title, summary: summary, episodes: $("div.other-episodes.more-same-eps div.in-grid").length || 0, image: image, genres: [], status: "Unknown" }; } catch (e) { console.error(e); throw new Error("Failed to get metadata"); } } async findEpisodes(id) { if (!id) return []; try { // Decodificamos el ID para obtener la URL real const decodedUrl = decodeURIComponent(id); const html = await this.GETText(decodedUrl); const $ = this.cheerio.load(html); const episodeDetails = []; const eps = $("div.other-episodes.more-same-eps div.in-grid.episode-shown"); eps.each((_, el) => { const elObj = $(el); const idgt = elObj.attr("idgt"); if (idgt) { const href = elObj.find("a").attr("href"); const rawEpUrl = href ? href.replace("&for=episode-more", "") : ""; const title = elObj.find("h5 .title").text().trim(); const epNum = parseInt(elObj.find("h5 .ep").text().trim(), 10); episodeDetails.push({ // TambiƩn codificamos el ID del episodio por seguridad id: encodeURIComponent(rawEpUrl), number: isNaN(epNum) ? 0 : epNum, title: title || `Episode ${epNum}`, url: rawEpUrl, }); } }); return episodeDetails; } catch (e) { console.error(e); return []; } } async findEpisodeServer(episode, serverStr) { // Decodificamos el ID del episodio (que es la URL) const serverUrl = decodeURIComponent(episode.id); const videoSources = []; if (serverUrl) { const result = await this.HandleServerUrl(serverUrl); if (Array.isArray(result)) { videoSources.push(...result); } else if (result) { videoSources.push(result); } } return { server: serverStr || "oppai.stream", headers: { "Referer": this.baseUrl, "User-Agent": this.userAgent }, videoSources: videoSources }; } async HandleServerUrl(serverUrl) { try { const html = await this.GETText(serverUrl); let unpacked = ""; const scriptContents = this.extractScripts(html); for (const c of scriptContents) { let c2 = c; for (let j = 0; j < c.length; j += 900) { c2 = c2.substring(0, j) + "\n" + c2.substring(j); } if (c.includes("eval(function(p,a,c,k,e,d)")) { console.log("Packed script found."); const fullRegex = /eval\(function\([^)]*\)\{[\s\S]*?\}\(\s*'([\s\S]*?)'\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*'([\s\S]*?)'\.split\('\|'\)/; const match = c2.match(fullRegex); if (match) { const packed = match[1]; const base = parseInt(match[2], 10); const count = parseInt(match[3], 10); const dict = match[4].split('|'); unpacked = this.unpack(packed, base, count, dict); unpacked = this.decodeUnpacked(unpacked); } } } const m3u8Videos = await this.findMediaUrls("m3u8", html, serverUrl, unpacked); if (m3u8Videos) return m3u8Videos; const mp4Videos = await this.findMediaUrls("mp4", html, serverUrl, unpacked); if (mp4Videos) return mp4Videos; return []; } catch (e) { console.error("Error handling server URL:", e); return []; } } async findMediaUrls(type, html, serverUrl, unpacked) { const regex = new RegExp('https?:\\/\\/[^\'"]+\\.' + type + '(?:\\?[^\\s\'"]*)?(?:#[^\\s\'"]*)?', 'g'); const quotedRegex = new RegExp(`"([^"]+\\.${type})"`, "g"); let VideoMatch = html.match(regex) || (unpacked && unpacked.match(regex)) || html.match(quotedRegex) || (unpacked && unpacked.match(quotedRegex)); if (VideoMatch) { VideoMatch = VideoMatch.map(url => { let clean = url.replace(/"/g, ""); if (!clean.startsWith("http")) { const domain = serverUrl.split("/").slice(0, 3).join("/"); return `${domain}${clean}`; } return clean; }); VideoMatch = [...new Set(VideoMatch)]; const mainUrl = VideoMatch[0]; console.log(`Found ${type} URL:`, mainUrl); if (mainUrl.includes(`master.${type}`)) { try { const reqHtml = await this.GETText(mainUrl); const videos = []; let qual = ""; let url = ""; if (reqHtml.includes("#EXTM3U")) { const lines = reqHtml.split("\n"); for (let line of lines) { if (line.startsWith("#EXT-X-STREAM-INF")) { qual = line.split("RESOLUTION=")[1]?.split(",")[0] || "unknown"; const h = parseInt(qual.split("x")[1]) || 0; if (h >= 1080) qual = "1080p"; else if (h >= 720) qual = "720p"; else if (h >= 480) qual = "480p"; else if (h >= 360) qual = "360p"; } else if (line.trim().startsWith("http") || line.trim().endsWith(".m3u8")) { url = line.trim(); if (!url.startsWith("http")) { const baseUrl = mainUrl.substring(0, mainUrl.lastIndexOf('/') + 1); url = baseUrl + url; } } if (url && qual) { videos.push({ url: url, type: type, quality: qual, subtitles: [] }); url = ""; qual = ""; } } } if (videos.length > 0) { const subtitles = await this.findSubtitles(html, serverUrl, unpacked); videos.forEach(v => v.subtitles = subtitles); return videos; } } catch (e) { console.warn("Failed to parse master playlist", e); } } const resolutionRegex = /\/(\d{3,4})\//; const resolutionMatch = mainUrl.match(resolutionRegex); const quality = resolutionMatch ? `${resolutionMatch[1]}p` : "unknown"; return { url: mainUrl, quality: quality, type: type, subtitles: await this.findSubtitles(html, serverUrl, unpacked) }; } return undefined; } async findSubtitles(html, serverUrl, unpacked) { let subtitles = []; const subtitleRegex = /]*src=["']([^"']+\.vtt(?:\?[^"']*)?)["'][^>]*>/gi; const extract = (text) => { const matches = text.matchAll(subtitleRegex); for (const match of matches) { const src = match[1]; let url = src.startsWith("http") ? src : `${serverUrl.split("/").slice(0, 3).join("/")}${src}`; const langMatch = match[0].match(/(?:label|srclang)=["']?([a-zA-Z\-]{2,})["']?/i); const lang = langMatch?.[1]?.toLowerCase() || "unknown"; subtitles.push({ url, language: lang, type: "vtt" }); } }; if (html) extract(html); if (subtitles.length === 0) { const rawRegex = /https?:\/\/[^\s'"]+\.vtt(?:\?[^'"\s]*)?/g; const matches = (html.match(rawRegex) || []).concat(unpacked ? (unpacked.match(rawRegex) || []) : []); matches.forEach((url, idx) => { if (!subtitles.some(s => s.url === url)) { subtitles.push({ url: url, language: "Unknown " + (idx + 1), type: "vtt" }); } }); } return subtitles; } extractScripts(str) { const results = []; const openTag = ""; let pos = 0; while (pos < str.length) { const start = str.indexOf(openTag, pos); if (start === -1) break; const end = str.indexOf(closeTag, start); if (end === -1) break; results.push(str.substring(start + openTag.length, end)); pos = end + closeTag.length; } return results; } unpack(p, a, c, k) { while (c--) if (k[c]) p = p.replace(new RegExp('\\b' + c.toString(a) + '\\b', 'g'), k[c]); return p; } decodeUnpacked(str) { return str.replace(/\\u([\d\w]{4})/gi, (_, grp) => String.fromCharCode(parseInt(grp, 16))) .replace(/%3C/g, '<').replace(/%3E/g, '>') .replace(/%3F/g, '?').replace(/%3A/g, ':') .replace(/%2C/g, ',').replace(/%2F/g, '/') .replace(/%2B/g, '+').replace(/%20/g, ' ') .replace(/%21/g, '!').replace(/%22/g, '"') .replace(/%27/g, "'").replace(/%28/g, '(') .replace(/%29/g, ')').replace(/%3B/g, ';'); } findBestTitle(movies, query) { let bestScore = 0; let bestMovie = undefined; for (const movie of movies) { let score = this.scoreStringMatch(2, movie.Title, query); console.log(`Movie: ${movie.Title} - Score: ${score}`); if (score > bestScore) { bestScore = score; bestMovie = movie; } } return bestMovie; } scoreStringMatch(weight, text, query) { if (!text || !query) return 0; text = text.toLowerCase(); query = query.toLowerCase(); if (text === query) return this.ScoreWeight.MaxScore * weight; const textWords = text.split(" "); const queryWords = query.split(" "); let score = 0; for (const word of queryWords) { if (textWords.includes(word)) { score += this.ScoreWeight.MaxScore / textWords.length; } else { const similarity = this.getWordSimilarity(word, textWords); score -= similarity * this.ScoreWeight.MaxScore / textWords.length; } } return score * weight; } getWordSimilarity(word1, words) { const word1Vector = this.getWordVector(word1); let maxSimilarity = 0; for (const word2 of words) { const word2Vector = this.getWordVector(word2); const similarity = this.cosineSimilarity(word1Vector, word2Vector); maxSimilarity = Math.max(maxSimilarity, similarity); } return maxSimilarity; } getWordVector(word) { return Array.from(word).map(char => char.charCodeAt(0)); } cosineSimilarity(vec1, vec2) { const dotProduct = vec1.reduce((sum, val, i) => sum + val * (vec2[i] || 0), 0); const magnitude1 = Math.sqrt(vec1.reduce((sum, val) => sum + val * val, 0)); const magnitude2 = Math.sqrt(vec2.reduce((sum, val) => sum + val * val, 0)); return (magnitude1 && magnitude2) ? dotProduct / (magnitude1 * magnitude2) : 0; } async GETText(url) { const response = await fetch(url, { headers: { "User-Agent": this.userAgent } }); if (!response.ok) throw new Error(`GETText failed: ${response.status}`); return await response.text(); } } module.exports = OppaiStream;