437 lines
16 KiB
JavaScript
437 lines
16 KiB
JavaScript
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 = /<track\s+[^>]*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 = "<script type='text/javascript'>";
|
|
const closeTag = "</script>";
|
|
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; |