Hunterdark99/PBstremio-addon
0
1const { addonBuilder, getRouter } = require("stremio-addon-sdk");2const express = require("express");3const fetch = require("node-fetch");4const cheerio = require("cheerio");5const { HttpsProxyAgent } = require("https-proxy-agent");6 7const PORT = process.env.PORT || 7860;8const BASE_URL = process.env.PB_BASE_URL || "https://pimpbunny.com";9const PUBLIC_BASE_URL = process.env.SPACE_URL || process.env.PUBLIC_URL || "";10 11const ENABLE_BROWSER_1080P = process.env.ENABLE_BROWSER_1080P === "1";12 13const BROWSER_1080P_TIMEOUT_MS = Number(process.env.BROWSER_1080P_TIMEOUT_MS || 12000);14const BROWSER_1080P_CACHE_MS = Number(process.env.BROWSER_1080P_CACHE_MS || 180 * 1000);15const BROWSER_IDLE_TTL_MS = Number(process.env.BROWSER_IDLE_TTL_MS || 45 * 1000);16const BROWSER_MIN_INTERVAL_MS = Number(process.env.BROWSER_MIN_INTERVAL_MS || 8000);17const BROWSER_MAX_JOBS_BEFORE_RESTART = Number(process.env.BROWSER_MAX_JOBS_BEFORE_RESTART || 12);18 19const MAX_RESOLVE_CANDIDATES = Number(process.env.MAX_RESOLVE_CANDIDATES || 2);20const RETRY_404 = process.env.RETRY_404 === "1";21const ENABLE_FRESH_REFETCH = process.env.ENABLE_FRESH_REFETCH === "1";22const ENABLE_ST_DIRECT_1080P = process.env.ENABLE_ST_DIRECT_1080P === "1";23 24const browser1080pCache = new Map();25 26let sharedBrowser = null;27let sharedBrowserLaunchPromise = null;28let browserIdleTimer = null;29let browserJobQueue = Promise.resolve();30let browserLastRunAt = 0;31let browserJobsSinceRestart = 0;32 33const GENRE_TAG_SLUGS = {34 "OnlyFans": ["onlyfans"],35 "Amateur": ["amateur"],36 "Milf": ["milf"],37 "Teen": ["teen"],38 "Anal": ["anal"],39 "Blowjob": ["blowjob"],40 "Lesbian": ["lesbian"],41 "Interracial": ["interracial"],42 "Solo": ["solo"],43 "BDSM": ["bdsm"],44};45 46const PROXY_HOST = process.env.OUTBOUND_PROXY_HOST || "";47const PROXY_PORT_ENV = process.env.OUTBOUND_PROXY_PORT || "";48const PROXY_USER = process.env.OUTBOUND_PROXY_USERNAME || "";49const PROXY_PASS = process.env.OUTBOUND_PROXY_PASSWORD || "";50const PROXY_URL = process.env.OUTBOUND_PROXY_URL || (PROXY_HOST && PROXY_PORT_ENV51 ? `http://${PROXY_USER ? encodeURIComponent(PROXY_USER) : ""}${PROXY_PASS ? ':' + encodeURIComponent(PROXY_PASS) : ""}${PROXY_USER || PROXY_PASS ? '@' : ''}${PROXY_HOST}:${PROXY_PORT_ENV}`52 : "");53const proxyAgent = PROXY_URL ? new HttpsProxyAgent(PROXY_URL) : null;54 55// TS/AAC/M4S segments are NOT proxied (bandwidth). Everything else goes through proxy.56const SEGMENT_RE = /\.(ts|aac|m4s|woff2)(\?|$)/i;57 58/**59 * Central fetch wrapper.60 * useProxy=true → route through proxyAgent (HTML pages, images, API calls, get_file)61 * useProxy=false → direct (only raw media segments)62 */63async function doFetch(url, opts = {}, useProxy = true) {64 const options = { redirect: "follow", ...opts };65 if (useProxy && proxyAgent) options.agent = proxyAgent;66 return fetch(url, options);67}68 69const manifest = {70 id: "community.pimpbunny.vod",71 version: "1.0.0",72 name: "[18+] PimpBunny",73 description: "18+ adult videos scraped from pimpbunny.com.",74 logo: "https://pimpbunny.com/favicon.ico",75 types: ["movie"],76 resources: ["catalog", { name: "meta", types: ["movie"] }, { name: "stream", types: ["movie"] }],77 idPrefixes: ["pb:"],78 catalogs: [79 {80 type: "movie",81 id: "latest",82 name: "PB Latest Videos",83 extra: [84 {85 name: "genre",86 isRequired: false,87 options: ["OnlyFans", "Amateur", "Milf", "Teen", "Anal", "Blowjob", "Lesbian", "Interracial", "Solo", "BDSM"]88 },89 { name: "skip", isRequired: false },90 { name: "search", isRequired: false }91 ],92 behaviorHints: { adult: true, configurable: false, configurationRequired: false }93 }94 ]95};96 97const builder = new addonBuilder(manifest);98const metaCache = new Map();99 100 101setInterval(() => {102 const cutoff = Date.now() - 60 * 60 * 1000;103 for (const [key, val] of metaCache.entries()) {104 if (val.updatedAt < cutoff) metaCache.delete(key);105 }106}, 15 * 60 * 1000);107 108const HEADERS = {109 "User-Agent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Mobile Safari/537.36",110 "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",111 "Accept-Language": "en-US,en;q=0.9",112 "Cache-Control": "no-cache",113 "Pragma": "no-cache",114 "Upgrade-Insecure-Requests": "1",115 "Referer": BASE_URL + "/"116};117 118// Headers for fetching video files — matches a browser watching the video119const VIDEO_HEADERS = {120 "User-Agent": "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Mobile Safari/537.36",121 "Accept": "video/webm,video/ogg,video/*;q=0.9,application/ogg;q=0.7,audio/*;q=0.6,*/*;q=0.5",122 "Accept-Language": "en-US,en;q=0.9",123 "Referer": BASE_URL + "/",124 "Origin": BASE_URL,125};126 127function absoluteUrl(url) {128 if (!url) return null;129 try { return new URL(url, BASE_URL).toString(); } catch { return null; }130}131 132async function fetchHtml(url) {133 console.log(`[fetchHtml] GET ${url} (proxy=${!!proxyAgent})`);134 const res = await doFetch(url, { headers: HEADERS }, true);135 if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);136 return await res.text();137}138 139function makeIdFromPath(pathname) {140 return `pb:${pathname.replace(/^\/+|\/+$/g, "")}`;141}142 143function decodeId(id) {144 return id.replace(/^pb:/, "");145}146 147// ── Taxonomy prefix probe ─────────────────────────────────────────────────────148let _taxonomyCache = null;149async function getTaxonomyPrefix(testSlug) {150 if (_taxonomyCache) return _taxonomyCache.prefix;151 const prefixes = ["tag", "category", "tags", "categories", "genre", "niche"];152 for (const prefix of prefixes) {153 const url = `${BASE_URL}/${prefix}/${testSlug}/`;154 try {155 const res = await doFetch(url, { headers: HEADERS }, true);156 console.log(`[taxonomy] probe /${prefix}/${testSlug}/ → HTTP ${res.status}`);157 if (res.ok) {158 console.log(`[taxonomy] ✅ resolved prefix: /${prefix}/`);159 _taxonomyCache = { prefix };160 return prefix;161 }162 } catch (e) {163 console.log(`[taxonomy] probe error /${prefix}/${testSlug}/: ${e.message}`);164 }165 }166 console.warn(`[taxonomy] ⚠️ all prefixes failed for "${testSlug}", defaulting to "tag"`);167 _taxonomyCache = { prefix: "tag" };168 return "tag";169}170 171// ── Post card extractor ───────────────────────────────────────────────────────172function extractPostCards(html, baseUrl) {173 const $ = cheerio.load(html);174 const siteBase = baseUrl || BASE_URL;175 const siteHostname = (() => { try { return new URL(siteBase).hostname; } catch { return ""; } })();176 177 const results = [];178 const seenHref = new Set();179 180 $("a[href]").each((_, el) => {181 const a = $(el);182 const rawHref = a.attr("href");183 if (!rawHref) return;184 185 let href;186 try { href = new URL(rawHref, siteBase).toString(); } catch { return; }187 188 let u;189 try {190 u = new URL(href);191 if (u.hostname !== siteHostname) return;192 } catch { return; }193 194 if (/^\/(page|tag|tags|category|categories|author|search|wp-|feed|#|genre|niche)/i.test(u.pathname)) return;195 if (/\/page\/\d+/i.test(u.pathname)) return;196 if (u.pathname === "/" || u.pathname === "") return;197 if (!/-/.test(u.pathname)) return;198 const segments = u.pathname.replace(/^\/|\/$/g, "").split("/");199 if (segments.length === 1 && segments[0].length < 5) return;200 201 if (seenHref.has(href)) return;202 seenHref.add(href);203 204 const container = a.closest(205 "article, .post, [class*='post'], [class*='item'], [class*='card'], [class*='thumb'], [class*='entry'], li, div"206 );207 208 const title =209 a.attr("title") ||210 container.find("h1, h2, h3, h4, [class*='title'], [class*='name']").first().text().trim() ||211 a.find("h1, h2, h3, h4, [class*='title']").first().text().trim() ||212 a.text().trim() ||213 segments[segments.length - 1].replace(/-/g, " ");214 215 if (!title || title.length < 3) return;216 if (/^(home|about|contact|blog|videos|models|categories|tags|search|login|register|privacy|terms|dmca|sitemap)$/i.test(title.trim())) return;217 218 const imgNode = container.find("img").first();219 const rawImg = absoluteUrl(220 imgNode.attr("data-src") ||221 imgNode.attr("data-lazy-src") ||222 imgNode.attr("data-original") ||223 imgNode.attr("data-thumb") ||224 (() => { const ss = imgNode.attr("srcset"); if (!ss) return null; const m = ss.match(/https?:\/\/[^ ,]+/); return m ? m[0] : null; })() ||225 imgNode.attr("src")226 );227 228 const imgOk = rawImg && !/(placeholder|avatar|logo|icon|blank|spacer|pixel|\.gif)/i.test(rawImg);229 const img = imgOk230 ? (PUBLIC_BASE_URL ? `${PUBLIC_BASE_URL}/imgproxy?url=${encodeURIComponent(rawImg)}` : rawImg)231 : undefined;232 233 const description = container234 .find("[class*='desc'], [class*='excerpt'], [class*='summary'], p")235 .first().text().trim().substring(0, 200);236 237 const date =238 container.find("time").attr("datetime") ||239 container.find("[class*='date'], [class*='time']").first().text().trim();240 241 results.push({242 id: makeIdFromPath(u.pathname),243 type: "movie",244 name: title,245 poster: img,246 posterShape: "landscape",247 background: img,248 description: [date, description].filter(Boolean).join(" • "),249 website: href250 });251 });252 253 const seen = new Set();254 const deduped = results.filter(x => x.id && !seen.has(x.id) && (seen.add(x.id), true));255 console.log(`[catalog] extractPostCards found ${deduped.length} items`);256 return deduped.slice(0, 24);257}258 259// ── Catalog fetcher ───────────────────────────────────────────────────────────260async function fetchCatalogPage(catalogId, skip = 0, search = "", genre = "") {261 const page = Math.floor((Number(skip) || 0) / 24) + 1;262 263 if (search) {264 const searchUrls = [265 `${BASE_URL}/?s=${encodeURIComponent(search)}${page > 1 ? `&paged=${page}` : ""}`,266 `${BASE_URL}/search/${encodeURIComponent(search)}${page > 1 ? `/page/${page}/` : "/"}`,267 ];268 for (const url of searchUrls) {269 try {270 const html = await fetchHtml(url);271 const metas = extractPostCards(html);272 if (metas.length > 0) return metas;273 } catch (err) {274 console.warn(`Search fetch failed: ${url} -> ${err.message}`);275 }276 }277 throw new Error("No search results could be fetched");278 }279 280 if (genre && GENRE_TAG_SLUGS[genre]) {281 const prefix = await getTaxonomyPrefix(GENRE_TAG_SLUGS[genre][0]);282 const allMetas = [];283 const seen = new Set();284 285 for (const slug of GENRE_TAG_SLUGS[genre]) {286 const urls = [287 `${BASE_URL}/${prefix}/${slug}/${page > 1 ? `page/${page}/` : ""}`,288 `${BASE_URL}/${prefix}/${slug}${page > 1 ? `/?paged=${page}` : "/"}`,289 ];290 for (const genreUrl of urls) {291 try {292 const html = await fetchHtml(genreUrl);293 const metas = extractPostCards(html);294 for (const m of metas) {295 if (!seen.has(m.id)) { seen.add(m.id); allMetas.push(m); }296 }297 if (allMetas.length > 0) break;298 } catch (err) {299 console.warn(`Genre "${slug}" @ ${genreUrl} failed: ${err.message}`);300 }301 }302 }303 if (allMetas.length > 0) return allMetas;304 }305 306 // Default: try several home/listing URL patterns307 const candidates = [308 `${BASE_URL}/${page > 1 ? `?paged=${page}` : ""}`,309 `${BASE_URL}/${page > 1 ? `page/${page}` : ""}`,310 `${BASE_URL}/videos${page > 1 ? `?paged=${page}` : ""}`,311 `${BASE_URL}/videos/${page > 1 ? `page/${page}/` : ""}`,312 ];313 let lastError = null;314 for (const url of candidates) {315 try {316 const html = await fetchHtml(url);317 const metas = extractPostCards(html);318 if (metas.length > 0) return metas;319 } catch (err) {320 lastError = err;321 console.warn(`Catalog candidate failed: ${url} -> ${err.message}`);322 }323 }324 throw lastError || new Error("No catalog pages could be fetched");325}326 327function extractVideoIdFromHtml(html) {328 const patterns = [329 /\/embed\/(\d+)/i,330 /video_id["']?\s*[:=]\s*["']?(\d+)/i,331 /videoId["']?\s*[:=]\s*["']?(\d+)/i,332 /video-id=["']?(\d+)/i,333 /\/videos_screenshots\/\d+\/(\d+)\//i,334 /\/get_file\/\d+\/[^/]+\/\d+\/(\d+)\//i335 ];336 337 for (const re of patterns) {338 const m = html.match(re);339 if (m?.[1]) return m[1];340 }341 342 return null;343}344 345function urlBelongsToVideo(url, videoId) {346 if (!videoId) return true;347 348 let decoded = String(url || "");349 try {350 decoded = decodeURIComponent(decoded);351 } catch {}352 353 return (354 decoded.includes(`/${videoId}/`) ||355 decoded.includes(`_${videoId}_`) ||356 decoded.includes(`/embed/${videoId}`) ||357 decoded.includes(`${videoId}_pb_`) ||358 decoded.includes(`${videoId}_preview`) ||359 decoded.includes(`/${videoId}_`)360 );361}362 363function isPreviewOrThumbMp4(url) {364 let decoded = String(url || "");365 try {366 decoded = decodeURIComponent(decoded);367 } catch {}368 369 return /(preview_pb|_preview\.mp4|videos_screenshots|thumb|poster|listing|webp)/i.test(decoded);370}371 372function decodeEscapedMediaString(value) {373 return String(value || "")374 .replace(/&/g, "&")375 .replace(/\\u0026/gi, "&")376 .replace(/\\u003d/gi, "=")377 .replace(/\\u003f/gi, "?")378 .replace(/\\u002f/gi, "/")379 .replace(/\\\//g, "/")380 .replace(/\\\\\//g, "/")381 .replace(/\\"/g, '"')382 .replace(///g, "/")383 .replace(///g, "/")384 .trim();385}386 387function unwrapKvsFunctionUrl(value) {388 let v = decodeEscapedMediaString(value);389 390 // KVS player format:391 // function/0/https://site/get_file/.../video_1080p.mp4/392 const m = v.match(/^function\/\d+\/(https?:\/\/.+)$/i);393 if (m) v = m[1];394 395 v = v.replace(/^[`'"]+|[`'"]+$/g, "");396 v = v.replace(/[),;]+$/g, "");397 398 return v;399}400 401function getQualityFromUrlOrText(url, text = "") {402 const s = `${decodeEscapedMediaString(url)} ${decodeEscapedMediaString(text)}`;403 404 if (/_1080p\.mp4/i.test(s) || /\b1080p\b/i.test(s)) return "1080p";405 if (/_720p\.mp4/i.test(s) || /\b720p\b/i.test(s)) return "720p";406 if (/_480p\.mp4/i.test(s) || /\b480p\b/i.test(s)) return "480p";407 if (/_360p\.mp4/i.test(s) || /\b360p\b/i.test(s)) return "360p";408 409 if (/\.mp4\/?(?:[?#]|$)/i.test(s)) return "480p";410 411 return "HD";412}413 414function qualityRank(q) {415 return {416 "1080p": 0,417 "720p": 1,418 "480p": 2,419 "360p": 3,420 "HD": 4421 }[q] ?? 99;422}423 424function extractKvsPlayerSources(html, videoId = null) {425 const text = decodeEscapedMediaString(html);426 const out = [];427 const seen = new Set();428 429 const urlRe = /\b(video_url|video_alt_url\d*)\s*:\s*(['"`])((?:\\.|(?!\2).)*?)\2/gi;430 431 for (const m of text.matchAll(urlRe)) {432 const key = m[1];433 const rawValue = m[3];434 const unwrapped = unwrapKvsFunctionUrl(rawValue);435 436 if (!/^https?:\/\//i.test(unwrapped)) continue;437 if (!/\/get_file\//i.test(unwrapped)) continue;438 if (!/\.mp4\/?(?:[?#]|$)/i.test(unwrapped)) continue;439 if (isPreviewOrThumbMp4(unwrapped)) continue;440 if (!urlBelongsToVideo(unwrapped, videoId)) continue;441 442 const textKey = `${key}_text`;443 const textRe = new RegExp(`\\b${textKey}\\s*:\\s*(['"\`])((?:\\\\.|(?!\\1).)*?)\\1`, "i");444 const textMatch = text.match(textRe);445 const qualityText = textMatch ? textMatch[2] : "";446 447 const quality = getQualityFromUrlOrText(unwrapped, qualityText);448 const dedupeKey = `${quality}:${unwrapped}`;449 450 if (seen.has(dedupeKey)) continue;451 seen.add(dedupeKey);452 453 out.push({454 key,455 quality,456 url: unwrapped457 });458 }459 460 out.sort((a, b) => qualityRank(a.quality) - qualityRank(b.quality));461 462 return out;463}464 465function getGetFileHash(url) {466 const m = String(url || "").match(/\/get_file\/\d+\/([^/]+)\//i);467 return m ? m[1] : "";468}469 470function collectAllGetFileCandidates(text, videoId = null) {471 const normalized = decodeEscapedMediaString(text);472 const out = [];473 const seen = new Set();474 475 const patterns = [476 /https?:\/\/[^"'\\\s<>]+\/get_file\/[^"'\\\s<>]+\.mp4\/?(?:[?#][^"'\\\s<>]*)?/gi,477 /function\/\d+\/https?:\/\/[^"'\\\s<>]+\/get_file\/[^"'\\\s<>]+\.mp4\/?/gi478 ];479 480 const add = (raw, source) => {481 let u = unwrapKvsFunctionUrl(raw);482 u = decodeEscapedMediaString(u).replace(/[),;'"<>]+$/g, "");483 484 if (!/^https?:\/\//i.test(u)) return;485 if (!/\/get_file\//i.test(u)) return;486 if (!/\.mp4\/?(?:[?#]|$)/i.test(u)) return;487 if (isPreviewOrThumbMp4(u)) return;488 if (!urlBelongsToVideo(u, videoId)) return;489 490 const quality = getQualityFromUrlOrText(u);491 const hash = getGetFileHash(u);492 const key = `${quality}:${hash}:${u}`;493 494 if (seen.has(key)) return;495 seen.add(key);496 497 out.push({ quality, hash, source, url: u });498 };499 500 for (const re of patterns) {501 for (const m of normalized.matchAll(re)) {502 add(m[0], "raw-html");503 }504 }505 506 for (const src of extractKvsPlayerSources(normalized, videoId)) {507 add(src.url, `kvs:${src.key}`);508 }509 510 out.sort((a, b) => {511 const q = qualityRank(a.quality) - qualityRank(b.quality);512 if (q !== 0) return q;513 return a.hash.localeCompare(b.hash);514 });515 516 return out;517}518 519async function collectCandidatesFromPageResources(html, pageUrl, videoId, cookieStr) {520 const $ = cheerio.load(html);521 const resources = new Set();522 523 const fullScan = process.env.FULL_RESOURCE_SCAN === "1";524 525 const addResource = (raw) => {526 if (!raw) return;527 528 try {529 const abs = new URL(raw, pageUrl).toString();530 const u = new URL(abs);531 532 if (u.origin !== BASE_URL) return;533 534 // Default: only scan resources that have actually been useful.535 if (/\/embed\/\d+/i.test(u.pathname)) {536 resources.add(abs);537 return;538 }539 540 if (/\/player\/kt_player\.js/i.test(u.pathname)) {541 resources.add(abs);542 return;543 }544 545 // Optional debug mode only.546 if (fullScan && /\.js(?:$|\?)/i.test(u.pathname + u.search)) {547 resources.add(abs);548 }549 } catch {}550 };551 552 $("script[src], iframe[src]").each((_, el) => {553 addResource($(el).attr("src"));554 });555 556 if (videoId) {557 resources.add(`${BASE_URL}/embed/${videoId}`);558 }559 560 const all = [];561 562 for (const resourceUrl of resources) {563 try {564 console.log(`[resource-scan] fetching ${resourceUrl}`);565 566 const res = await doFetch(resourceUrl, {567 headers: {568 ...HEADERS,569 Cookie: cookieStr,570 Referer: pageUrl,571 Origin: BASE_URL572 }573 }, true);574 575 console.log(`[resource-scan] ${resourceUrl} -> HTTP ${res.status}`);576 577 if (!res.ok) continue;578 579 const body = await res.text();580 const candidates = collectAllGetFileCandidates(body, videoId);581 582 for (const c of candidates) {583 all.push({584 ...c,585 source: `resource:${resourceUrl}`586 });587 588 console.log(589 `[resource-scan] candidate ${c.quality} hash=${c.hash} source=${resourceUrl} url=${c.url}`590 );591 }592 } catch (e) {593 console.log(`[resource-scan] error ${resourceUrl}: ${e.message}`);594 }595 }596 597 const seen = new Set();598 599 return all.filter(c => {600 const key = `${c.quality}:${c.hash}:${c.url}`;601 if (seen.has(key)) return false;602 seen.add(key);603 return true;604 });605}606 607// ── Video URL extraction ──────────────────────────────────────────────────────608function findVideoUrls(html, forcedVideoId = null) {609 const found = new Set();610 const log = (msg) => console.log(`[extract] ${msg}`);611 612 const currentVideoId = forcedVideoId || extractVideoIdFromHtml(html);613 log(`currentVideoId=${currentVideoId || "unknown"}`);614 615 const normalizeCandidateUrl = (u) => {616 return String(u || "")617 .replace(/&/g, "&")618 .replace(/\\u0026/gi, "&")619 .replace(/\\u003d/gi, "=")620 .replace(/\\u003f/gi, "?")621 .replace(/\\u002f/gi, "/")622 .replace(/\\\//g, "/")623 .replace(/\\\\\//g, "/")624 .trim()625 .replace(/[),;]+$/g, "");626 };627 628 const add = (u, why = "") => {629 if (!u) return;630 631 let v = normalizeCandidateUrl(u);632 633 if (v.startsWith("//")) {634 v = "https:" + v;635 }636 637 if (!v) return;638 639 // Critical: ignore related videos / random page previews.640 if (!urlBelongsToVideo(v, currentVideoId)) return;641 642 if (found.has(v)) return;643 644 log(`candidate (${why}) ${v}`);645 found.add(v);646 };647 648 const scanText = (text, label) => {649 if (!text) return;650 651 const normalizedText = normalizeCandidateUrl(text);652 653 normalizedText654 .match(/(?:https?:)?\/\/[^"'\\\s<>]+\/remote_control\.php\?[^"'\\\s<>]+/gi)655 ?.forEach(u => add(u, `${label}:remote_control`));656 657 normalizedText658 .match(/https?:\/\/[^"'\\\s<>]+\.(?:m3u8|mp4)(?:[?#][^"'\\\s<>]*)?/gi)659 ?.forEach(u => add(u, `${label}:direct`));660 661 normalizedText662 .match(/<iframe[^>]+src=["']([^"']+)["']/gi)663 ?.forEach(tag => {664 const m = tag.match(/src=["']([^"']+)["']/i);665 if (m) add(absoluteUrl(m[1]), `${label}:iframe`);666 });667 };668 669 log(`html length=${html.length}`);670 671 const $ = cheerio.load(html);672 673 scanText(html, "full-html");674 675 $("script").each((i, el) => {676 const text = $(el).html() || $(el).text() || "";677 if (text.length < 50) return;678 scanText(text, `script[${i}]`);679 });680 681 $("[src], [data-src], [data-url], [data-file], iframe").each((_, el) => {682 const tag = el.tagName.toLowerCase();683 const attrs = el.attribs || {};684 685 for (const k of ["src", "data-src", "data-url", "data-file"]) {686 if (attrs[k]) add(attrs[k], `${tag}:${k}`);687 }688 });689 690 log(`total candidates=${found.size}`);691 692 const remoteControl = [];693 const getFileMp4 = [];694 const directM3u8 = [];695 const embeds = [];696 697 for (const u of found) {698 let decoded = u;699 try {700 decoded = decodeURIComponent(u);701 } catch {}702 703 const isRemoteControlMp4 =704 /\/remote_control\.php\?/i.test(u) &&705 /(?:[?&])file=/i.test(u) &&706 /\.mp4/i.test(decoded);707 708 if (isRemoteControlMp4) {709 remoteControl.push(u);710 } else if (/\/get_file\//i.test(u) && /\.mp4(?:[?#]|$)/i.test(u)) {711 // Keep only real current-video files, not previews.712 if (!isPreviewOrThumbMp4(u)) {713 getFileMp4.push(u);714 }715 } else if (/\.m3u8(?:[?#]|$)/i.test(u)) {716 directM3u8.push(u);717 } else if (/\/embed\/\d+/i.test(u)) {718 embeds.push(u);719 }720 }721 722 log(`classified: remote=${remoteControl.length}, get_file=${getFileMp4.length}, m3u8=${directM3u8.length}, embeds=${embeds.length}`);723 724 const sortByQuality = (urls) => {725 const qualityOrder = ["_1080p", "_720p", "_480p", "_360p"];726 727 const sorted = qualityOrder728 .map(q => urls.find(u => {729 try {730 return decodeURIComponent(u).includes(q);731 } catch {732 return u.includes(q);733 }734 }))735 .filter(Boolean);736 737 for (const u of urls) {738 if (!sorted.includes(u)) sorted.push(u);739 }740 741 return sorted;742 };743 744 if (remoteControl.length) {745 const sorted = sortByQuality(remoteControl);746 log(`✅ REMOTE list: ${sorted.join(", ")}`);747 return sorted;748}749 750 // Critical: prefer embed over get_file.751 // get_file URLs are showing as 404, but the embed/player may generate remote_control.php.752 if (embeds.length) {753 const best = embeds.find(u => /\/embed\/\d+/i.test(u)) || embeds[0];754 log(`✅ embed: ${best}`);755 return [best];756 }757 758 if (directM3u8.length) {759 const best = directM3u8.sort((a, b) => b.length - a.length)[0];760 log(`✅ M3U8: ${best}`);761 return [best];762 }763 764 // Do NOT return get_file as a stream candidate anymore.765 // Keep this only as a debug log.766 if (getFileMp4.length) {767 log(`⚠️ ignoring ${getFileMp4.length} get_file URL(s); they are not stable playable streams`);768}769 770 log(`❌ No playable media found`);771 return [];772}773 774function getVideoIdFromAnyUrl(url) {775 const s = String(url || "");776 777 const patterns = [778 /\/embed\/(\d+)/i,779 /\/videos_screenshots\/\d+\/(\d+)\//i,780 /\/get_file\/\d+\/[^/]+\/\d+\/(\d+)\//i,781 /file=%2Fvideos%2F\d+%2F(\d+)%2F/i,782 /file=\/videos\/\d+\/(\d+)\//i,783 /video_id=(\d+)/i784 ];785 786 for (const re of patterns) {787 const m = s.match(re);788 if (m?.[1]) return m[1];789 }790 791 return null;792}793 794function extractRemoteControlUrlsFromText(text, videoId = null) {795 const out = new Set();796 797 const normalize = (s) =>798 String(s || "")799 .replace(/&/g, "&")800 .replace(/\\u0026/gi, "&")801 .replace(/\\u003d/gi, "=")802 .replace(/\\u003f/gi, "?")803 .replace(/\\u002f/gi, "/")804 .replace(/\\\//g, "/")805 .replace(/\\\\\//g, "/")806 .replace(/\\"/g, '"')807 .trim();808 809 const t = normalize(text);810 811 const patterns = [812 /https?:\/\/[^"'\\\s<>]+\/remote_control\.php\?[^"'\\\s<>]+/gi,813 /\/\/[^"'\\\s<>]+\/remote_control\.php\?[^"'\\\s<>]+/gi814 ];815 816 for (const re of patterns) {817 for (const m of t.matchAll(re)) {818 let u = normalize(m[0]).replace(/[),;]+$/g, "");819 820 if (u.startsWith("//")) {821 u = "https:" + u;822 }823 824 let decoded = u;825 try {826 decoded = decodeURIComponent(u);827 } catch {}828 829 if (videoId && !decoded.includes(`/${videoId}/`) && !decoded.includes(`%2F${videoId}%2F`)) {830 continue;831 }832 833 if (/\/remote_control\.php\?/i.test(u) && /\.mp4/i.test(decoded)) {834 out.add(u);835 }836 }837 }838 839 return [...out];840}841 842async function resolveEmbedToRemoteControlUrls(embedUrl, pageUrl, videoId) {843 try {844 const fullEmbedUrl = absoluteUrl(embedUrl);845 if (!fullEmbedUrl) return [];846 847 console.log(`[embed-resolve] fetching embed: ${fullEmbedUrl}`);848 849 const embedRes = await doFetch(fullEmbedUrl, {850 headers: {851 ...HEADERS,852 Referer: pageUrl,853 Origin: BASE_URL854 }855 }, true);856 857 if (!embedRes.ok) {858 console.log(`[embed-resolve] embed HTTP ${embedRes.status}`);859 return [];860 }861 862 const embedHtml = await embedRes.text();863 864 let found = extractRemoteControlUrlsFromText(embedHtml, videoId);865 866 if (found.length) {867 console.log(`[embed-resolve] ✅ remote_control in embed html: ${found.length}`);868 return found;869 }870 871 const $ = cheerio.load(embedHtml);872 873 const inlineScripts = [];874 $("script:not([src])").each((_, el) => {875 const txt = $(el).html() || "";876 if (txt.trim()) inlineScripts.push(txt);877 });878 879 for (let i = 0; i < inlineScripts.length; i++) {880 found = extractRemoteControlUrlsFromText(inlineScripts[i], videoId);881 if (found.length) {882 console.log(`[embed-resolve] ✅ remote_control in inline script ${i}: ${found.length}`);883 return found;884 }885 }886 887 const externalScripts = [];888 $("script[src]").each((_, el) => {889 const src = $(el).attr("src");890 if (!src) return;891 892 try {893 const abs = new URL(src, fullEmbedUrl).toString();894 if (!externalScripts.includes(abs)) externalScripts.push(abs);895 } catch {}896 });897 898 for (const scriptUrl of externalScripts) {899 try {900 console.log(`[embed-resolve] scanning script: ${scriptUrl}`);901 902 const sres = await doFetch(scriptUrl, {903 headers: {904 ...HEADERS,905 Referer: fullEmbedUrl,906 Origin: BASE_URL907 }908 }, true);909 910 if (!sres.ok) continue;911 912 const js = await sres.text();913 found = extractRemoteControlUrlsFromText(js, videoId);914 915 if (found.length) {916 console.log(`[embed-resolve] ✅ remote_control in external script: ${found.length}`);917 return found;918 }919 } catch (e) {920 console.log(`[embed-resolve] script error ${scriptUrl}: ${e.message}`);921 }922 }923 924 console.log(`[embed-resolve] ❌ no remote_control found in embed resources`);925 return [];926 } catch (err) {927 console.log(`[embed-resolve] error: ${err.message}`);928 return [];929 }930}931 932async function resolveGetFileToPlayableUrl(getFileUrl, pageUrl) {933 try {934 console.log(`[resolve] trying get_file redirect: ${getFileUrl}`);935 936 const headers = {937 ...VIDEO_HEADERS,938 "Referer": pageUrl,939 "Origin": BASE_URL,940 "Accept": "*/*"941 };942 943 // Do NOT use Range here. We want the redirect target, not media bytes.944 const res = await doFetch(getFileUrl, {945 headers,946 redirect: "manual"947 }, true);948 949 const location = res.headers.get("location");950 951 if (location) {952 const resolved = new URL(location, getFileUrl).toString();953 console.log(`[resolve] location: ${resolved}`);954 955 if (/\/remote_control\.php\?/i.test(resolved) || /\.mp4(?:[?#]|$)/i.test(resolved)) {956 return resolved;957 }958 }959 960 const contentType = res.headers.get("content-type") || "";961 962 if (res.ok && /video|octet-stream/i.test(contentType)) {963 console.log(`[resolve] get_file itself appears playable`);964 return getFileUrl;965 }966 967 const body = await res.text().catch(() => "");968 console.log(`[resolve] failed status=${res.status} body=${body.substring(0, 200)}`);969 970 return null;971 } catch (err) {972 console.log(`[resolve] error: ${err.message}`);973 return null;974 }975}976 977// ── Embed resolver ────────────────────────────────────────────────────────────978async function tryEmbedApiEndpoints(embedPageUrl, token, scanBlock) {979 const embedOrigin = (() => { try { return new URL(embedPageUrl).origin; } catch { return ""; } })();980 const endpoints = [981 `${embedOrigin}/api/source`, `${embedOrigin}/api/stream`, `${embedOrigin}/api/video`,982 `${embedOrigin}/api/player`, `${embedOrigin}/api/get`, `${embedOrigin}/source`, `${embedOrigin}/stream`,983 ];984 const bodies = [985 token ? JSON.stringify({ token }) : null,986 token ? `token=${encodeURIComponent(token)}` : null,987 ].filter(Boolean);988 989 for (const endpoint of endpoints) {990 for (const body of bodies) {991 try {992 const isJson = body.startsWith("{");993 const res = await doFetch(endpoint, {994 method: "POST",995 headers: { ...HEADERS, Referer: embedPageUrl, Origin: embedOrigin, "Content-Type": isJson ? "application/json" : "application/x-www-form-urlencoded" },996 body,997 }, true);998 if (!res.ok) continue;999 const found = scanBlock(await res.text(), `api-endpoint:${endpoint}`);1000 if (found) return found;1001 } catch (e) { console.log(`[embed] api error ${endpoint}: ${e.message}`); }1002 }1003 }1004 return null;1005}1006 1007async function resolveEmbedUrl(embedUrl) {1008 try {1009 const fullUrl = absoluteUrl(embedUrl);1010 if (!fullUrl) return null;1011 console.log(`[embed] resolving: ${fullUrl}`);1012 1013 const normalize = (s) =>1014 String(s || "")1015 .replace(/&/g, "&").replace(/\\\\\//g, "/").replace(/\\\\/g, "\\")1016 .replace(/\\"/g, '"').replace(/\\u002F/gi, "/").replace(/\\u003A/gi, ":")1017 .replace(///g, "/").replace(///g, "/").trim();1018 1019 const isValidMedia = (u) =>1020 u && /(?:master\.txt|\.m3u8|\.mp4)(?:\?|$)/i.test(u) && !/\.php(?:\?|$)/i.test(u);1021 1022 const pickUrl = (text, label) => {1023 if (!text) return null;1024 const t = normalize(text);1025 const patterns = [1026 /(?:file|src|url|source|hls|playlist)\s*:\s*["'`]([^"'`\s]{10,})["'`]/gi,1027 /["'](?:file|src|url|source|hls|playlist)["']\s*:\s*["'`]([^"'`\s]{10,})["'`]/gi,1028 /https?:\/\/[^\s"'<>\\]+(?:master\.txt|\.m3u8|\.mp4)(?:\?[^\s"'<>\\]*)?/gi,1029 ];1030 for (const re of patterns) {1031 for (const m of [...t.matchAll(re)]) {1032 const raw = normalize(m[1] || m[0]);1033 if (isValidMedia(raw)) { console.log(`[embed] pickUrl (${label}): ${raw}`); return raw; }1034 }1035 }1036 return null;1037 };1038 1039 const scanBlock = (text, label) => {1040 if (!text) return null;1041 const direct = pickUrl(text, label);1042 if (direct) return direct;1043 const t = normalize(text);1044 const blocks = [1045 ...t.matchAll(/sources\s*:\s*\[([\s\S]{0,20000}?)\]/gi),1046 ...t.matchAll(/playlist\s*:\s*\[([\s\S]{0,20000}?)\]/gi),1047 ...t.matchAll(/setup\s*\(\s*\{([\s\S]{0,20000}?)\}\s*\)/gi),1048 ...t.matchAll(/jwplayer\s*\([^)]*\)\s*\.setup\s*\(\s*\{([\s\S]{0,20000}?)\}\s*\)/gi),1049 ...t.matchAll(/new\s+Player\s*\(\s*\{([\s\S]{0,20000}?)\}\s*\)/gi),1050 ...t.matchAll(/playerConfig\s*=\s*\{([\s\S]{0,20000}?)\}/gi),1051 ...t.matchAll(/var\s+\w+\s*=\s*\{([\s\S]{0,20000}?)\}/gi),1052 ];1053 for (const m of blocks) {1054 const found = pickUrl(m[1], `${label}:block`);1055 if (found) return found;1056 }1057 return null;1058 };1059 1060 const tokenMatch = fullUrl.match(/[?&]token=([^&\s]+)/i);1061 const tokenVal = tokenMatch ? tokenMatch[1] : null;1062 if (tokenVal) {1063 try {1064 const decoded = Buffer.from(decodeURIComponent(tokenVal), "base64").toString("utf8");1065 const found = scanBlock(decoded, "decoded token");1066 if (found) return found;1067 } catch (e) { console.log(`[embed] token decode error: ${e.message}`); }1068 }1069 1070 const idMatch = fullUrl.match(/[?&]id=(\d+)/i);1071 if (idMatch) {1072 try {1073 const res = await doFetch(`https://cdn.jwplayer.com/players/${idMatch[1]}-IDzF9Zmk.js`, { headers: HEADERS }, true);1074 if (res.ok) { const found = scanBlock(await res.text(), "jw-embed-js"); if (found) return found; }1075 } catch (e) { console.log(`[embed] JW error: ${e.message}`); }1076 }1077 1078 const res = await doFetch(fullUrl, { headers: { ...HEADERS, Referer: BASE_URL + "/", Origin: BASE_URL } }, true);1079 if (!res.ok) { console.log(`[embed] HTTP ${res.status}`); return null; }1080 const html = await res.text();1081 1082 if (html.trimStart().startsWith('#EXTM3U')) return fullUrl;1083 1084 const foundHtml = scanBlock(html, "raw html");1085 if (foundHtml) return foundHtml;1086 1087 const $ = cheerio.load(html);1088 1089 const fileMatch = html.match(/file\s*:\s*["'`]((?:\\.|[^"'`])+?)["'`]/i);1090 if (fileMatch) {1091 const raw = normalize(fileMatch[1]);1092 const embedOrigin = (() => { try { return new URL(fullUrl).origin; } catch { return ""; } })();1093 const resolved = raw.startsWith("http") ? raw : `${embedOrigin}${raw.startsWith("/") ? "" : "/"}${raw}`;1094 if (resolved.startsWith("http")) return resolved;1095 }1096 1097 for (const sel of ["video", "source", "[data-file]", "[data-src]", "[data-url]", "[data-hls]", "[data-m3u8]", "[data-playlist]", "[data-source]"]) {1098 let matched = null;1099 $(sel).each((_, el) => {1100 if (matched) return;1101 for (const a of ["data-file", "data-src", "data-url", "data-hls", "data-m3u8", "data-playlist", "data-source", "src"]) {1102 const v = $(el).attr(a);1103 const abs = v && absoluteUrl(v);1104 if (isValidMedia(abs)) { matched = abs; return; }1105 if (isValidMedia(v)) { matched = v; return; }1106 }1107 });1108 if (matched) return matched;1109 }1110 1111 const scriptBlocks = [];1112 $("script:not([src])").each((_, el) => { const t = $(el).html() || ""; if (t.trim()) scriptBlocks.push(t); });1113 for (let i = 0; i < scriptBlocks.length; i++) {1114 const found = scanBlock(scriptBlocks[i], `inline-script[${i}]`);1115 if (found) return found;1116 }1117 1118 const extScripts = [];1119 $("script[src]").each((_, el) => {1120 const src = $(el).attr("src");1121 if (!src) return;1122 try { const abs = new URL(src, fullUrl).toString(); if (!extScripts.includes(abs)) extScripts.push(abs); } catch {}1123 });1124 for (const scriptSrc of extScripts) {1125 try {1126 const sres = await doFetch(scriptSrc, { headers: { ...HEADERS, Referer: fullUrl } }, true);1127 if (!sres.ok) continue;1128 const found = scanBlock(await sres.text(), `ext-script:${scriptSrc}`);1129 if (found) return found;1130 } catch (e) { console.log(`[embed] ext script error: ${scriptSrc} -> ${e.message}`); }1131 }1132 1133 return await tryEmbedApiEndpoints(fullUrl, tokenVal, scanBlock);1134 } catch (err) {1135 console.error(`[embed] failed: ${err.message}`);1136 return null;1137 }1138}1139 1140// ── NEW helpers for st-server direct access ───────────────────────────────────1141function getStServerFromRemoteControlUrl(rcUrl) {1142 try {1143 return new URL(rcUrl).hostname;1144 } catch {1145 return null;1146 }1147}1148 1149function buildStGetFileUrl(stHost, getFileUrl, quality = "1080p") {1150 try {1151 const u = new URL(getFileUrl);1152 const newPath = u.pathname1153 .replace(/_(1080|720|480|360)p\.mp4/i, `_${quality}.mp4`)1154 .replace(/\.mp4\/?$/, `_${quality}.mp4`);1155 return `https://${stHost}${newPath}?rnd=${Date.now()}`;1156 } catch {1157 return null;1158 }1159}1160 1161// ── Browser/player runtime resolver ───────────────────────────────────────────1162async function sleep(ms) {1163 return new Promise(resolve => setTimeout(resolve, ms));1164}1165 1166function getCachedBrowser1080p(cacheKey) {1167 const cached = browser1080pCache.get(cacheKey);1168 1169 if (cached && cached.expiresAt > Date.now()) {1170 console.log(`[browser-1080p] cache hit for ${cacheKey}`);1171 return cached.value;1172 }1173 1174 if (cached) {1175 browser1080pCache.delete(cacheKey);1176 }1177 1178 return null;1179}1180 1181function setCachedBrowser1080p(cacheKey, value) {1182 browser1080pCache.set(cacheKey, {1183 value,1184 expiresAt: Date.now() + BROWSER_1080P_CACHE_MS1185 });1186}1187 1188async function closeSharedBrowser(reason = "idle") {1189 if (browserIdleTimer) {1190 clearTimeout(browserIdleTimer);1191 browserIdleTimer = null;1192 }1193 1194 const browser = sharedBrowser;1195 sharedBrowser = null;1196 sharedBrowserLaunchPromise = null;1197 browserJobsSinceRestart = 0;1198 1199 if (browser) {1200 console.log(`[browser-1080p] closing shared browser: ${reason}`);