Files
WaifuBoard-Extensions/anime/OppaiStream.js
2026-01-13 17:26:06 +01:00

568 lines
21 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.2";
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,
};
}
getGenreOptions() {
return [
'4k','ahegao','anal','armpitmasturbation','bdsm','beach','bigboobs',
'blackhair','blondehair','blowjob','bluehair','bondage','boobjob',
'brownhair','censored','comedy','cosplay','cowgirl','creampie',
'darkskin','demon','doggy','dominantgirl','doublepenetration','elf',
'facial','fantasy','filmed','footjob','futanari','gangbang',
'girlsonly','glasses','greenhair','gyaru','hd','handjob','harem',
'horror','incest','inflation','invertednipples','lactation','loli',
'maid','masturbation','milf','mindbreak','mindcontrol','missionary',
'monster','ntr','nekomimi','nurse','old','orgy','pov','pinkhair',
'plot','pregnant','publicsex','purplehair','rape','redhair',
'reversegangbang','reverserape','rimjob','scat','schoolgirl',
'shorthair','shota','smallboobs','softcore','succubus','swimsuit',
'teacher','tentacle','threesome','toys','trap','tripplepenetration',
'tsundere','uglybastard','uncensored','vampire','vanilla','virgin',
'watersports','whitehair','x-ray','yaoi','yuri'
].map(g => ({ value: g, label: g }));
}
getStudioOptions() {
return [
"44℃ Baidoku","AT-X","AXsiZ","Alice Soft","Antechinus","An♥Tekinus",
"BOOTLEG","BREAKBOTTLE","Bomb! Cute! Bomb!","Breakbottle","Bunny Walker",
"ChuChu","Collaboration Works","Cotton Doll","Digital Works",
"Global Solutions","HiLLS","Himajin Planning","JapanAnime","Jumondou",
"Kitty Media","Lune Pictures","MS Pictures","Magic Bus","Magin Label",
"Majin Petit","Majin petit","Majin","Mary Jane","Mediabank",
"Milky Animation Label","Mirai Koujou",
"NBCUniversal Entertainment Japan","Natural High","NewGeneration",
"Nippon Columbia","Nur","Office Nobu","Pashima","Pashmina","Passione",
"Peak Hunt","Pink Pineapple","PoRO petit","PoRO","Queen Bee",
"Rabbit Gate","Seven","Shion","Show-Ten","Shueisha","Studio 1st",
"Studio Gokumi","Studio Houkiboshi","Suzuki Mirano","T-Rex",
"TEATRO Nishi Tokyo Studio","TNK","Toranoana","WHITE BEAR","Y.O.U.C",
"YTV","Yomiuri TV Enterprise","ZIZ Entertainment","erozuki"
].map(s => ({ value: s, label: s }));
}
getFilters() {
return {
order: {
label: 'Sort By',
type: 'select',
options: [
{ value: 'az', label: 'A-Z' },
{ value: 'za', label: 'Z-A' },
{ value: 'recent', label: 'Recently Released' },
{ value: 'old', label: 'Oldest Releases' },
{ value: 'views', label: 'Most Views' },
{ value: 'rating', label: 'Highest Rated' },
{ value: 'uploaded', label: 'Recently Uploaded' },
{ value: 'random', label: 'Randomize' },
]
},
// TRI-STATE SIMULADO CON MULTISELECT
genre_include: {
label: 'Genre (Include)',
type: 'multiselect',
options: this.getGenreOptions()
},
genre_exclude: {
label: 'Genre (Exclude)',
type: 'multiselect',
options: this.getGenreOptions()
},
studio: {
label: 'Studio',
type: 'multiselect',
options: this.getStudioOptions()
}
};
}
async search({ query = "", filters }) {
let tempquery = query || "";
// 👉 si no hay texto pero sí filtros, hacemos una sola búsqueda
const hasFilters = filters && Object.keys(filters).length > 0;
let firstRun = true;
while (firstRun || tempquery !== "") {
firstRun = false;
try {
const params = new URLSearchParams();
// SOLO ponemos text si existe
if (tempquery) params.set("text", tempquery);
if (filters) {
if (filters.order) params.set("order", filters.order);
if (filters.genre_include) {
const inc = String(filters.genre_include).split(',').map(x => x.trim()).filter(Boolean);
if (inc.length) params.set("genres", inc.join(','));
}
if (filters.genre_exclude) {
const exc = String(filters.genre_exclude).split(',').map(x => x.trim()).filter(Boolean);
if (exc.length) params.set("blacklist", exc.join(','));
}
if (filters.studio) {
const studios = String(filters.studio).split(',').map(x => x.trim()).filter(Boolean);
if (studios.length) params.set("studio", studios.join(','));
}
}
params.set("page", "1");
params.set("limit", "35");
const url = `${this.baseUrl}/actions/search.php?${params.toString()}`;
const html = await this.GETText(url);
const $ = this.cheerio.load(html);
const movies = $("div.in-grid.episode-shown");
// 👉 si no hay resultados:
if (movies.length <= 0) {
// si hay filtros, no hacemos fallback por palabras
if (hasFilters || !tempquery) return [];
// fallback normal cuando hay texto
if (tempquery.includes(" ")) {
tempquery = tempquery.split(/[\s:']+/).slice(0, -1).join(" ");
continue;
}
return [];
}
const results = [];
movies.each((_, el) => {
const elObj = $(el);
const title = elObj.find(".title-ep .title").text().trim();
const href = elObj.find("a").attr("href");
const rawUrl = href ? href.replace("&for=search", "") : "";
const image = elObj.find(".cover-img-in").attr("src")
|| elObj.find(".cover-img-in").attr("original");
if (title && rawUrl) {
results.push({
id: encodeURIComponent(rawUrl),
title,
url: rawUrl,
image,
subOrDub: "sub",
});
}
});
// 👉 si hay query usamos tu sistema de score
if (query) {
const best = this.findBestTitle(
results.map(r => ({ Title: r.title, Url: r.url, Image: r.image })),
query
);
if (!best) return [];
return [{
id: encodeURIComponent(best.Url),
title: best.Title,
url: best.Url,
image: best.Image,
subOrDub: "sub",
}];
}
// 👉 si NO hay query, devolvemos todo (modo catálogo)
return results;
} 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);
console.log(decodedUrl)
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;