From f8f2d8336ebad2bedf183eda99922ae815428b5b Mon Sep 17 00:00:00 2001 From: hzl <1803573449@qq.com> Date: Sat, 3 Oct 2026 19:26:57 +0800 Subject: [PATCH 1/6] Fix MissAV passive Cloudflare detection and bound playback stages --- CHANGELOG.md | 11 ++ README.md | 6 +- missav-mini-library-CloudFlare.js | 144 ++++++++++++++++------ missav-mini-library-download-working 6.js | 144 ++++++++++++++++------ missav-mini-library-download-working.js | 144 ++++++++++++++++------ missav-mini-library.js | 144 ++++++++++++++++------ tests/adapters.test.cjs | 72 ++++++++++- 7 files changed, 522 insertions(+), 143 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 552a6bf..6d3049d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # 2026-10-03 +## MissAV 1.5.11 定点修复 + +- 正常 HTTP 200 页面包含 `/cdn-cgi/challenge-platform/scripts/jsd/main.js` 时不再一律视为验证拦截;真正的验证标题、验证表单、`cf-mitigated: challenge` 和 HTTP 错误仍会拒绝。 +- 兼容 `statusCode` 响应。每次播放重新读取详情,避免使用旧 HTML 中的临时播放地址。 +- 播放详情 HTTP 6 秒、浏览器媒体捕获 12 秒、master 探测 5 秒均有脚本侧截止时间,保留 28 秒总上限。只使用一次媒体浏览器回退,跳过已识别的广告 iframe;不再为选画质遍历多域名或重复读取所有变体。 +- 已有 master 时,画质探测超时可使用页面已提供的画质或交给原生播放器处理 master;不再仅为补充画质打开浏览器。无地址时显示具体失败阶段,诊断不保留宿主原始异常或签名 URL。脚本截止时间不能强制取消宿主原生任务。 +- 三部实际详情页面均返回可解析的 HTTP 200 内容;桌面匿名 HTTP 的 CDN master 收到 Cloudflare 403,浏览器实时 master/1080P 清单为 200。二者不等于 DreamBy iPhone 原生播放成功;设备起播与切画质待确认。 +- 53 项媒体库回归、60 个普通脚本语法、39 个媒体库契约和兼容副本检查通过。MissAV 四个已发布文件路径、ID 和参数保持兼容。 + +以下为此前合并的批量优化记录。 + 基于 `main` 的 `8e43017532ec81e3338046ba6e32dff7655bd3dd` 修复审查中 O01–O25;保留所有已发布文件路径。 ## 加载与失败恢复 diff --git a/README.md b/README.md index 7754c84..222ac50 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,11 @@ | --- | --- | --- | | [eporner-mini-library 2.js](eporner-mini-library%202.js) | 1.0.9 | `eporner-mini-library.js` | | [xxxfollow-mini-library 5.js](xxxfollow-mini-library%205.js) | 1.0.1 | `xxxfollow-mini-library.js` | -| [missav-mini-library-download-working 6.js](missav-mini-library-download-working%206.js) | 1.5.10 | `missav-mini-library-download-working.js`、`missav-mini-library.js`、`missav-mini-library-CloudFlare.js` | +| [missav-mini-library-download-working 6.js](missav-mini-library-download-working%206.js) | 1.5.11 | `missav-mini-library-download-working.js`、`missav-mini-library.js`、`missav-mini-library-CloudFlare.js` | -Eporner、XXXFollow 的两份文件原本内容相同。MissAV 的两个旧 1.0.7 实现升级为 1.5.10 兼容副本,保留 `missav-mini-library` ID、旧参数名和 `missav://detail?` 历史条目格式。 +Eporner、XXXFollow 的两份文件原本内容相同。MissAV 的两个旧 1.0.7 实现升级为当前维护版的兼容副本,保留 `missav-mini-library` ID、旧参数名和 `missav://detail?` 历史条目格式。 + +MissAV 1.5.11 修正正常 HTTP 200 页面被误判成 Cloudflare 验证页的问题,并为播放阶段增加脚本侧请求截止时间。旧下载地址保持不变。桌面实际页面解析和离线回归已验证;iPhone 原生起播仍需设备确认,桌面匿名 HTTP 请求视频 CDN 的 403 与地址解析结果分别记录。 日常只修改左侧主维护文件,再运行: diff --git a/missav-mini-library-CloudFlare.js b/missav-mini-library-CloudFlare.js index 3324627..7646e27 100644 --- a/missav-mini-library-CloudFlare.js +++ b/missav-mini-library-CloudFlare.js @@ -12,7 +12,7 @@ const WidgetMetadata = { id: 'missav-mini-library', name: 'MissAV', title: 'MissAV', - version: '1.5.10', + version: '1.5.11', author: 'Alan huang', logo: MISSAV_LOGO, icon: MISSAV_LOGO, @@ -763,28 +763,22 @@ async function resolvePlaybackWithinBudget(ctx) { try { html = await fetchText(ctx, detailURL, entryURL(ctx)); } catch (error) { - // Playback has a separate, narrowly-scoped hidden browser media fallback. + recordPlaybackFailure(ctx, error); } let masterURL = extractPlayableURL(html); if (!masterURL) masterURL = await extractPlayableFromLinkedPlayers(ctx, html, detailURL); if (!masterURL) masterURL = await extractFromBrowser(detailURL, detailURL, ctx); let url = masterURL; if (masterURL) { - let qualities = await discoverAvailableQualities(ctx, masterURL, detailURL, html); - const knownQuality = qualities.some(function (quality) { return quality.height > 0; }); - const missingRequested = requestedQuality && !qualities.some(function (quality) { return String(quality.height) === String(requestedQuality); }); - if ((!knownQuality || missingRequested) && ctx.__playbackBudget.browserCalls === 0) { - const browserMasterURL = await extractFromBrowser(detailURL, detailURL, ctx); - if (browserMasterURL && browserMasterURL !== masterURL) { - const browserQualities = await discoverAvailableQualities(ctx, browserMasterURL, detailURL, html); - if (browserQualities.length > qualities.length) qualities = browserQualities; - } - } + const qualities = await discoverAvailableQualities(ctx, masterURL, detailURL, html); + // A valid master can let the native player choose a variant. Optional quality + // discovery must not spend a browser round or prevent that handoff. const selected = selectQuality(qualities, requestedQuality); if (selected && selected.url) url = selected.url; } if (!url) { - throw new Error('未能解析到 MissAV 播放地址。站点可能启用了 Cloudflare 或更换了播放器脚本,请尝试更新入口路径或在 App 侧启用浏览器请求。'); + const failures = ctx.__playbackBudget.failures.join(';'); + throw new Error('未能解析到 MissAV 播放地址;stage=media-discovery' + (failures ? ';' + failures : '') + '。请检查站点地址;若提示验证,请先手动完成验证。'); } return playbackResult(ctx, absoluteURL(ctx, url), detailURL); } @@ -906,14 +900,16 @@ async function onAction(ctx) { } async function extractPlayableFromLinkedPlayers(ctx, html, referer) { - const urls = extractPlayerURLs(ctx, html, referer); + const urls = extractPlayerURLs(ctx, html, referer).filter(function (url) { + return !/googletagmanager\.com|snaptrckr\.|myavlive\.com|mayzaent\.com|rallytrck\./i.test(originOf(url)); + }).slice(0, ctx && ctx.__playbackBudget ? 1 : 5); for (let index = 0; index < urls.length; index += 1) { try { const playerHTML = await fetchText(ctx, urls[index], referer); const playable = extractPlayableURL(playerHTML); if (playable) return playable; } catch (error) { - // Ignore broken auxiliary player pages and continue with browser fallback. + recordPlaybackFailure(ctx, error); } } return ''; @@ -1121,6 +1117,19 @@ async function fetchActressIndexText(ctx, url, referer) { } async function fetchText(ctx, url, referer) { + if (ctx && ctx.__playbackBudget) { + // Refresh player authorization on each play and reserve the one browser + // request for media capture, rather than an HTML-only verification attempt. + return runPlaybackStage(ctx, 'detail-http', 6, async function () { + const response = await httpGet(url, requestOptions(ctx, referer || entryURL(ctx))); + const text = responseText(response); + const status = responseStatus(response); + if (!isUsableHTML(text, status, response && response.headers)) { + throw playbackStageError('detail-http', 'HTTP ' + (status || 'empty') + (isCloudflare(text, status, response && response.headers) ? ';需要验证或站点拒绝请求' : ';页面不可用')); + } + return text; + }); + } const cached = getCachedText(ctx, url); if (cached) return cached; @@ -1132,12 +1141,12 @@ async function fetchText(ctx, url, referer) { try { const response = await httpGet(currentURL, requestOptions(ctx, requestReferer)); const text = responseText(response); - if (isUsableHTML(text, response && response.status, response && response.headers)) { + if (isUsableHTML(text, responseStatus(response), response && response.headers)) { setCachedText(ctx, currentURL, text); if (currentURL !== url) setCachedText(ctx, url, text); return text; } - if (isCloudflare(text, response && response.status, response && response.headers)) { + if (isCloudflare(text, responseStatus(response), response && response.headers)) { const browserText = await browserHTML(ctx, currentURL, requestReferer); if (isUsableHTML(browserText)) { setCachedText(ctx, currentURL, browserText); @@ -1239,6 +1248,10 @@ function responseText(response) { return String(response.data || response.body || ''); } +function responseStatus(response) { + return Number(response && (response.statusCode || response.status)) || 0; +} + async function browserHTML(ctx, url, referer, forceVisible) { if (!boolParam(ctx, 'enableBrowserFallback', true)) return ''; if (forceVisible !== true && !boolParam(ctx, 'automaticBrowserFallback', true)) return ''; @@ -1297,12 +1310,20 @@ function clearBrowserFailure(url) { async function extractFromBrowser(url, referer, ctx) { if (ctx && ctx.__playbackBudget && ctx.__playbackBudget.browserCalls >= 1) return ''; - if (typeof Widget === 'undefined' || !Widget.browser || typeof Widget.browser.fetch !== 'function') return ''; + if (!boolParam(ctx, 'enableBrowserFallback', true) || !boolParam(ctx, 'automaticBrowserFallback', true)) { + recordPlaybackFailure(ctx, new Error('stage=browser-media;浏览器回退已关闭')); + return ''; + } + if (typeof Widget === 'undefined' || !Widget.browser || typeof Widget.browser.fetch !== 'function') { + recordPlaybackFailure(ctx, new Error('stage=browser-media;宿主未提供浏览器接口')); + return ''; + } try { - const result = await Widget.browser.fetch(url, { + const timeout = playbackBrowserSeconds(ctx, 12, 1); + const result = await runPlaybackStage(ctx, 'browser-media', timeout, function () { return Widget.browser.fetch(url, { visible: false, - timeout: playbackBrowserSeconds(ctx, 12, 1), - timeoutSeconds: remainingPlaybackSeconds(ctx, 12), + timeout: timeout, + timeoutSeconds: timeout, waitAfterLoad: 3, waitForMediaSource: true, waitForAny: true, @@ -1312,13 +1333,18 @@ async function extractFromBrowser(url, referer, ctx) { 'User-Agent': MISSAV_UA, Referer: referer || url } - }); - return firstNonEmpty( + }); }); + const playable = firstNonEmpty( playableFromBrowserResult(result), extractPlayableURL(responseText(result)), extractPlayableURL(result && result.html) ); + if (!playable) { + recordPlaybackFailure(ctx, new Error('stage=browser-media;未返回媒体地址;keys=' + Object.keys(result || {}).slice(0, 12).join(','))); + } + return playable; } catch (error) { + recordPlaybackFailure(ctx, error); return ''; } } @@ -2195,7 +2221,9 @@ async function discoverAvailableQualities(ctx, playlistURL, referer, html) { }); } - const urls = unique(candidates.map(function (item) { return item.url; })).slice(0, 8); + const urls = ctx && ctx.__playbackBudget + ? [playlistURL] + : unique(candidates.map(function (item) { return item.url; })).slice(0, 8); let next = 0; await Promise.all(Array.from({ length: Math.min(2, urls.length) }, async function () { while (next < urls.length) { @@ -2275,17 +2303,24 @@ function normalizeQualities(qualities) { async function discoverQualities(ctx, playlistURL, referer) { if (!isPlayableURL(playlistURL) || containerOf(playlistURL) !== 'm3u8') return []; try { - const response = await httpGet(playlistURL, { + const budget = ctx && ctx.__playbackBudget; + const remaining = budget ? (budget.endAt - Date.now()) / 1000 : 5.5; + if (remaining <= 0.5) return []; + // Leave room to return an already discovered URL before the outer deadline. + const timeout = remainingPlaybackSeconds(ctx, Math.min(5, remaining - 0.5)); + const response = await runPlaybackStage(ctx, 'master-http', timeout, function () { return httpGet(playlistURL, { headers: playbackHeaders(ctx || {}, referer || playlistURL), - timeout: remainingPlaybackSeconds(ctx, 5), - timeoutSeconds: remainingPlaybackSeconds(ctx, 5), + timeout: timeout, + timeoutSeconds: timeout, useBrowserCookie: true, attachBrowserCookie: true - }); + }); }); + if (responseStatus(response) >= 400) throw playbackStageError('master-http', 'HTTP ' + responseStatus(response)); const playlist = responseText(response); if (playlist.indexOf('#EXT-X-STREAM-INF') < 0) return []; return parseMasterQualities(playlist, playlistURL); } catch (error) { + recordPlaybackFailure(ctx, error); return []; } } @@ -2910,14 +2945,20 @@ function isDetailPageURL(url) { } function isCloudflare(html, status, headers) { - const text = String(html || '') + ' ' + JSON.stringify(headers || {}); + const text = String(html || ''); + const title = cleanText(firstMatch(text, /]*>([\s\S]*?)<\/title>/i)); + const mitigated = Object.keys(headers || {}).some(function (key) { + return key.toLowerCase() === 'cf-mitigated' && /challenge/i.test(String(headers[key])); + }); + // Cloudflare's passive /challenge-platform/scripts/jsd/main.js also occurs + // on successful media pages. Only explicit blocking-page evidence counts. return Number(status) === 403 || Number(status) === 429 || Number(status) === 503 || - /\s*Just a moment/i.test(text) || - /Checking your browser|Verifying you are human|Verify you are human|Ray ID/i.test(text) || - /Enable JavaScript and cookies to continue/i.test(text) || - /cf-mitigated|cf-browser-verification|cf-chl-|challenge-platform|turnstile/i.test(text); + mitigated || + /^(?:Just a moment|Attention Required|Checking your browser|Verifying you are human|Verify you are human)/i.test(title) || + /\bid\s*=\s*["'](?:challenge-form|cf-challenge-running|cf-browser-verification)["']/i.test(text) || + /Enable JavaScript and cookies to continue/i.test(stripTags(text)); } function decodeEscapes(value) { @@ -3068,17 +3109,50 @@ async function withPlaybackBudget(input, operation) { let ctx = input; if (typeof ctx === 'string') { try { ctx = JSON.parse(ctx); } catch (_) { ctx = {}; } } ctx = Object.assign({}, ctx && typeof ctx === 'object' ? ctx : {}); - const budget = { endAt: Date.now() + 28000, expired: false, browserCalls: 0 }; + const budget = { endAt: Date.now() + 28000, expired: false, browserCalls: 0, stage: 'start', failures: [] }; ctx.__playbackBudget = budget; let timer; try { const timeout = new Promise(function (_, reject) { - timer = setTimeout(function () { budget.expired = true; reject(new Error('播放解析超时;stage=total-deadline;请稍后重试')); }, 28000); + timer = setTimeout(function () { budget.expired = true; reject(new Error('播放解析超时;stage=total-deadline;lastStage=' + budget.stage + ';请稍后重试')); }, 28000); }); return await Promise.race([Promise.resolve().then(function () { return operation(ctx); }), timeout]); } finally { clearTimeout(timer); budget.expired = true; } } +function recordPlaybackFailure(ctx, error) { + const budget = ctx && ctx.__playbackBudget; + if (!budget) return; + // No cookies, signed media URLs, or raw host exception text in diagnostics. + const raw = String(error && error.message || ''); + const message = /stage=(?:detail-http|browser-media|master-http|total-deadline)/.test(raw) + ? raw.replace(/https?:\/\/[^\s;]+/gi, '[URL]').slice(0, 200) + : '请求失败;stage=' + budget.stage; + if (budget.failures.indexOf(message) < 0) budget.failures.push(message); +} + +async function runPlaybackStage(ctx, stage, limit, operation) { + const timeout = remainingPlaybackSeconds(ctx, limit); + const budget = ctx && ctx.__playbackBudget; + if (budget) budget.stage = stage; + let timer; + try { + return await Promise.race([ + Promise.resolve().then(function () { remainingPlaybackSeconds(ctx, limit); return operation(); }), + new Promise(function (_, reject) { timer = setTimeout(function () { reject(playbackStageError(stage, '请求超时')); }, timeout * 1000); }) + ]); + } catch (error) { + if (error && error.missavPlaybackStage === true) throw error; + throw playbackStageError(stage, '请求失败'); + } finally { clearTimeout(timer); } +} + +function playbackStageError(stage, reason) { + const error = new Error(reason + ';stage=' + stage); + error.missavPlaybackStage = true; + return error; +} + function remainingPlaybackSeconds(ctx, limit) { const budget = ctx && ctx.__playbackBudget; if (!budget) return limit; diff --git a/missav-mini-library-download-working 6.js b/missav-mini-library-download-working 6.js index 3324627..7646e27 100644 --- a/missav-mini-library-download-working 6.js +++ b/missav-mini-library-download-working 6.js @@ -12,7 +12,7 @@ const WidgetMetadata = { id: 'missav-mini-library', name: 'MissAV', title: 'MissAV', - version: '1.5.10', + version: '1.5.11', author: 'Alan huang', logo: MISSAV_LOGO, icon: MISSAV_LOGO, @@ -763,28 +763,22 @@ async function resolvePlaybackWithinBudget(ctx) { try { html = await fetchText(ctx, detailURL, entryURL(ctx)); } catch (error) { - // Playback has a separate, narrowly-scoped hidden browser media fallback. + recordPlaybackFailure(ctx, error); } let masterURL = extractPlayableURL(html); if (!masterURL) masterURL = await extractPlayableFromLinkedPlayers(ctx, html, detailURL); if (!masterURL) masterURL = await extractFromBrowser(detailURL, detailURL, ctx); let url = masterURL; if (masterURL) { - let qualities = await discoverAvailableQualities(ctx, masterURL, detailURL, html); - const knownQuality = qualities.some(function (quality) { return quality.height > 0; }); - const missingRequested = requestedQuality && !qualities.some(function (quality) { return String(quality.height) === String(requestedQuality); }); - if ((!knownQuality || missingRequested) && ctx.__playbackBudget.browserCalls === 0) { - const browserMasterURL = await extractFromBrowser(detailURL, detailURL, ctx); - if (browserMasterURL && browserMasterURL !== masterURL) { - const browserQualities = await discoverAvailableQualities(ctx, browserMasterURL, detailURL, html); - if (browserQualities.length > qualities.length) qualities = browserQualities; - } - } + const qualities = await discoverAvailableQualities(ctx, masterURL, detailURL, html); + // A valid master can let the native player choose a variant. Optional quality + // discovery must not spend a browser round or prevent that handoff. const selected = selectQuality(qualities, requestedQuality); if (selected && selected.url) url = selected.url; } if (!url) { - throw new Error('未能解析到 MissAV 播放地址。站点可能启用了 Cloudflare 或更换了播放器脚本,请尝试更新入口路径或在 App 侧启用浏览器请求。'); + const failures = ctx.__playbackBudget.failures.join(';'); + throw new Error('未能解析到 MissAV 播放地址;stage=media-discovery' + (failures ? ';' + failures : '') + '。请检查站点地址;若提示验证,请先手动完成验证。'); } return playbackResult(ctx, absoluteURL(ctx, url), detailURL); } @@ -906,14 +900,16 @@ async function onAction(ctx) { } async function extractPlayableFromLinkedPlayers(ctx, html, referer) { - const urls = extractPlayerURLs(ctx, html, referer); + const urls = extractPlayerURLs(ctx, html, referer).filter(function (url) { + return !/googletagmanager\.com|snaptrckr\.|myavlive\.com|mayzaent\.com|rallytrck\./i.test(originOf(url)); + }).slice(0, ctx && ctx.__playbackBudget ? 1 : 5); for (let index = 0; index < urls.length; index += 1) { try { const playerHTML = await fetchText(ctx, urls[index], referer); const playable = extractPlayableURL(playerHTML); if (playable) return playable; } catch (error) { - // Ignore broken auxiliary player pages and continue with browser fallback. + recordPlaybackFailure(ctx, error); } } return ''; @@ -1121,6 +1117,19 @@ async function fetchActressIndexText(ctx, url, referer) { } async function fetchText(ctx, url, referer) { + if (ctx && ctx.__playbackBudget) { + // Refresh player authorization on each play and reserve the one browser + // request for media capture, rather than an HTML-only verification attempt. + return runPlaybackStage(ctx, 'detail-http', 6, async function () { + const response = await httpGet(url, requestOptions(ctx, referer || entryURL(ctx))); + const text = responseText(response); + const status = responseStatus(response); + if (!isUsableHTML(text, status, response && response.headers)) { + throw playbackStageError('detail-http', 'HTTP ' + (status || 'empty') + (isCloudflare(text, status, response && response.headers) ? ';需要验证或站点拒绝请求' : ';页面不可用')); + } + return text; + }); + } const cached = getCachedText(ctx, url); if (cached) return cached; @@ -1132,12 +1141,12 @@ async function fetchText(ctx, url, referer) { try { const response = await httpGet(currentURL, requestOptions(ctx, requestReferer)); const text = responseText(response); - if (isUsableHTML(text, response && response.status, response && response.headers)) { + if (isUsableHTML(text, responseStatus(response), response && response.headers)) { setCachedText(ctx, currentURL, text); if (currentURL !== url) setCachedText(ctx, url, text); return text; } - if (isCloudflare(text, response && response.status, response && response.headers)) { + if (isCloudflare(text, responseStatus(response), response && response.headers)) { const browserText = await browserHTML(ctx, currentURL, requestReferer); if (isUsableHTML(browserText)) { setCachedText(ctx, currentURL, browserText); @@ -1239,6 +1248,10 @@ function responseText(response) { return String(response.data || response.body || ''); } +function responseStatus(response) { + return Number(response && (response.statusCode || response.status)) || 0; +} + async function browserHTML(ctx, url, referer, forceVisible) { if (!boolParam(ctx, 'enableBrowserFallback', true)) return ''; if (forceVisible !== true && !boolParam(ctx, 'automaticBrowserFallback', true)) return ''; @@ -1297,12 +1310,20 @@ function clearBrowserFailure(url) { async function extractFromBrowser(url, referer, ctx) { if (ctx && ctx.__playbackBudget && ctx.__playbackBudget.browserCalls >= 1) return ''; - if (typeof Widget === 'undefined' || !Widget.browser || typeof Widget.browser.fetch !== 'function') return ''; + if (!boolParam(ctx, 'enableBrowserFallback', true) || !boolParam(ctx, 'automaticBrowserFallback', true)) { + recordPlaybackFailure(ctx, new Error('stage=browser-media;浏览器回退已关闭')); + return ''; + } + if (typeof Widget === 'undefined' || !Widget.browser || typeof Widget.browser.fetch !== 'function') { + recordPlaybackFailure(ctx, new Error('stage=browser-media;宿主未提供浏览器接口')); + return ''; + } try { - const result = await Widget.browser.fetch(url, { + const timeout = playbackBrowserSeconds(ctx, 12, 1); + const result = await runPlaybackStage(ctx, 'browser-media', timeout, function () { return Widget.browser.fetch(url, { visible: false, - timeout: playbackBrowserSeconds(ctx, 12, 1), - timeoutSeconds: remainingPlaybackSeconds(ctx, 12), + timeout: timeout, + timeoutSeconds: timeout, waitAfterLoad: 3, waitForMediaSource: true, waitForAny: true, @@ -1312,13 +1333,18 @@ async function extractFromBrowser(url, referer, ctx) { 'User-Agent': MISSAV_UA, Referer: referer || url } - }); - return firstNonEmpty( + }); }); + const playable = firstNonEmpty( playableFromBrowserResult(result), extractPlayableURL(responseText(result)), extractPlayableURL(result && result.html) ); + if (!playable) { + recordPlaybackFailure(ctx, new Error('stage=browser-media;未返回媒体地址;keys=' + Object.keys(result || {}).slice(0, 12).join(','))); + } + return playable; } catch (error) { + recordPlaybackFailure(ctx, error); return ''; } } @@ -2195,7 +2221,9 @@ async function discoverAvailableQualities(ctx, playlistURL, referer, html) { }); } - const urls = unique(candidates.map(function (item) { return item.url; })).slice(0, 8); + const urls = ctx && ctx.__playbackBudget + ? [playlistURL] + : unique(candidates.map(function (item) { return item.url; })).slice(0, 8); let next = 0; await Promise.all(Array.from({ length: Math.min(2, urls.length) }, async function () { while (next < urls.length) { @@ -2275,17 +2303,24 @@ function normalizeQualities(qualities) { async function discoverQualities(ctx, playlistURL, referer) { if (!isPlayableURL(playlistURL) || containerOf(playlistURL) !== 'm3u8') return []; try { - const response = await httpGet(playlistURL, { + const budget = ctx && ctx.__playbackBudget; + const remaining = budget ? (budget.endAt - Date.now()) / 1000 : 5.5; + if (remaining <= 0.5) return []; + // Leave room to return an already discovered URL before the outer deadline. + const timeout = remainingPlaybackSeconds(ctx, Math.min(5, remaining - 0.5)); + const response = await runPlaybackStage(ctx, 'master-http', timeout, function () { return httpGet(playlistURL, { headers: playbackHeaders(ctx || {}, referer || playlistURL), - timeout: remainingPlaybackSeconds(ctx, 5), - timeoutSeconds: remainingPlaybackSeconds(ctx, 5), + timeout: timeout, + timeoutSeconds: timeout, useBrowserCookie: true, attachBrowserCookie: true - }); + }); }); + if (responseStatus(response) >= 400) throw playbackStageError('master-http', 'HTTP ' + responseStatus(response)); const playlist = responseText(response); if (playlist.indexOf('#EXT-X-STREAM-INF') < 0) return []; return parseMasterQualities(playlist, playlistURL); } catch (error) { + recordPlaybackFailure(ctx, error); return []; } } @@ -2910,14 +2945,20 @@ function isDetailPageURL(url) { } function isCloudflare(html, status, headers) { - const text = String(html || '') + ' ' + JSON.stringify(headers || {}); + const text = String(html || ''); + const title = cleanText(firstMatch(text, /<title\b[^>]*>([\s\S]*?)<\/title>/i)); + const mitigated = Object.keys(headers || {}).some(function (key) { + return key.toLowerCase() === 'cf-mitigated' && /challenge/i.test(String(headers[key])); + }); + // Cloudflare's passive /challenge-platform/scripts/jsd/main.js also occurs + // on successful media pages. Only explicit blocking-page evidence counts. return Number(status) === 403 || Number(status) === 429 || Number(status) === 503 || - /<title>\s*Just a moment/i.test(text) || - /Checking your browser|Verifying you are human|Verify you are human|Ray ID/i.test(text) || - /Enable JavaScript and cookies to continue/i.test(text) || - /cf-mitigated|cf-browser-verification|cf-chl-|challenge-platform|turnstile/i.test(text); + mitigated || + /^(?:Just a moment|Attention Required|Checking your browser|Verifying you are human|Verify you are human)/i.test(title) || + /\bid\s*=\s*["'](?:challenge-form|cf-challenge-running|cf-browser-verification)["']/i.test(text) || + /Enable JavaScript and cookies to continue/i.test(stripTags(text)); } function decodeEscapes(value) { @@ -3068,17 +3109,50 @@ async function withPlaybackBudget(input, operation) { let ctx = input; if (typeof ctx === 'string') { try { ctx = JSON.parse(ctx); } catch (_) { ctx = {}; } } ctx = Object.assign({}, ctx && typeof ctx === 'object' ? ctx : {}); - const budget = { endAt: Date.now() + 28000, expired: false, browserCalls: 0 }; + const budget = { endAt: Date.now() + 28000, expired: false, browserCalls: 0, stage: 'start', failures: [] }; ctx.__playbackBudget = budget; let timer; try { const timeout = new Promise(function (_, reject) { - timer = setTimeout(function () { budget.expired = true; reject(new Error('播放解析超时;stage=total-deadline;请稍后重试')); }, 28000); + timer = setTimeout(function () { budget.expired = true; reject(new Error('播放解析超时;stage=total-deadline;lastStage=' + budget.stage + ';请稍后重试')); }, 28000); }); return await Promise.race([Promise.resolve().then(function () { return operation(ctx); }), timeout]); } finally { clearTimeout(timer); budget.expired = true; } } +function recordPlaybackFailure(ctx, error) { + const budget = ctx && ctx.__playbackBudget; + if (!budget) return; + // No cookies, signed media URLs, or raw host exception text in diagnostics. + const raw = String(error && error.message || ''); + const message = /stage=(?:detail-http|browser-media|master-http|total-deadline)/.test(raw) + ? raw.replace(/https?:\/\/[^\s;]+/gi, '[URL]').slice(0, 200) + : '请求失败;stage=' + budget.stage; + if (budget.failures.indexOf(message) < 0) budget.failures.push(message); +} + +async function runPlaybackStage(ctx, stage, limit, operation) { + const timeout = remainingPlaybackSeconds(ctx, limit); + const budget = ctx && ctx.__playbackBudget; + if (budget) budget.stage = stage; + let timer; + try { + return await Promise.race([ + Promise.resolve().then(function () { remainingPlaybackSeconds(ctx, limit); return operation(); }), + new Promise(function (_, reject) { timer = setTimeout(function () { reject(playbackStageError(stage, '请求超时')); }, timeout * 1000); }) + ]); + } catch (error) { + if (error && error.missavPlaybackStage === true) throw error; + throw playbackStageError(stage, '请求失败'); + } finally { clearTimeout(timer); } +} + +function playbackStageError(stage, reason) { + const error = new Error(reason + ';stage=' + stage); + error.missavPlaybackStage = true; + return error; +} + function remainingPlaybackSeconds(ctx, limit) { const budget = ctx && ctx.__playbackBudget; if (!budget) return limit; diff --git a/missav-mini-library-download-working.js b/missav-mini-library-download-working.js index 3324627..7646e27 100644 --- a/missav-mini-library-download-working.js +++ b/missav-mini-library-download-working.js @@ -12,7 +12,7 @@ const WidgetMetadata = { id: 'missav-mini-library', name: 'MissAV', title: 'MissAV', - version: '1.5.10', + version: '1.5.11', author: 'Alan huang', logo: MISSAV_LOGO, icon: MISSAV_LOGO, @@ -763,28 +763,22 @@ async function resolvePlaybackWithinBudget(ctx) { try { html = await fetchText(ctx, detailURL, entryURL(ctx)); } catch (error) { - // Playback has a separate, narrowly-scoped hidden browser media fallback. + recordPlaybackFailure(ctx, error); } let masterURL = extractPlayableURL(html); if (!masterURL) masterURL = await extractPlayableFromLinkedPlayers(ctx, html, detailURL); if (!masterURL) masterURL = await extractFromBrowser(detailURL, detailURL, ctx); let url = masterURL; if (masterURL) { - let qualities = await discoverAvailableQualities(ctx, masterURL, detailURL, html); - const knownQuality = qualities.some(function (quality) { return quality.height > 0; }); - const missingRequested = requestedQuality && !qualities.some(function (quality) { return String(quality.height) === String(requestedQuality); }); - if ((!knownQuality || missingRequested) && ctx.__playbackBudget.browserCalls === 0) { - const browserMasterURL = await extractFromBrowser(detailURL, detailURL, ctx); - if (browserMasterURL && browserMasterURL !== masterURL) { - const browserQualities = await discoverAvailableQualities(ctx, browserMasterURL, detailURL, html); - if (browserQualities.length > qualities.length) qualities = browserQualities; - } - } + const qualities = await discoverAvailableQualities(ctx, masterURL, detailURL, html); + // A valid master can let the native player choose a variant. Optional quality + // discovery must not spend a browser round or prevent that handoff. const selected = selectQuality(qualities, requestedQuality); if (selected && selected.url) url = selected.url; } if (!url) { - throw new Error('未能解析到 MissAV 播放地址。站点可能启用了 Cloudflare 或更换了播放器脚本,请尝试更新入口路径或在 App 侧启用浏览器请求。'); + const failures = ctx.__playbackBudget.failures.join(';'); + throw new Error('未能解析到 MissAV 播放地址;stage=media-discovery' + (failures ? ';' + failures : '') + '。请检查站点地址;若提示验证,请先手动完成验证。'); } return playbackResult(ctx, absoluteURL(ctx, url), detailURL); } @@ -906,14 +900,16 @@ async function onAction(ctx) { } async function extractPlayableFromLinkedPlayers(ctx, html, referer) { - const urls = extractPlayerURLs(ctx, html, referer); + const urls = extractPlayerURLs(ctx, html, referer).filter(function (url) { + return !/googletagmanager\.com|snaptrckr\.|myavlive\.com|mayzaent\.com|rallytrck\./i.test(originOf(url)); + }).slice(0, ctx && ctx.__playbackBudget ? 1 : 5); for (let index = 0; index < urls.length; index += 1) { try { const playerHTML = await fetchText(ctx, urls[index], referer); const playable = extractPlayableURL(playerHTML); if (playable) return playable; } catch (error) { - // Ignore broken auxiliary player pages and continue with browser fallback. + recordPlaybackFailure(ctx, error); } } return ''; @@ -1121,6 +1117,19 @@ async function fetchActressIndexText(ctx, url, referer) { } async function fetchText(ctx, url, referer) { + if (ctx && ctx.__playbackBudget) { + // Refresh player authorization on each play and reserve the one browser + // request for media capture, rather than an HTML-only verification attempt. + return runPlaybackStage(ctx, 'detail-http', 6, async function () { + const response = await httpGet(url, requestOptions(ctx, referer || entryURL(ctx))); + const text = responseText(response); + const status = responseStatus(response); + if (!isUsableHTML(text, status, response && response.headers)) { + throw playbackStageError('detail-http', 'HTTP ' + (status || 'empty') + (isCloudflare(text, status, response && response.headers) ? ';需要验证或站点拒绝请求' : ';页面不可用')); + } + return text; + }); + } const cached = getCachedText(ctx, url); if (cached) return cached; @@ -1132,12 +1141,12 @@ async function fetchText(ctx, url, referer) { try { const response = await httpGet(currentURL, requestOptions(ctx, requestReferer)); const text = responseText(response); - if (isUsableHTML(text, response && response.status, response && response.headers)) { + if (isUsableHTML(text, responseStatus(response), response && response.headers)) { setCachedText(ctx, currentURL, text); if (currentURL !== url) setCachedText(ctx, url, text); return text; } - if (isCloudflare(text, response && response.status, response && response.headers)) { + if (isCloudflare(text, responseStatus(response), response && response.headers)) { const browserText = await browserHTML(ctx, currentURL, requestReferer); if (isUsableHTML(browserText)) { setCachedText(ctx, currentURL, browserText); @@ -1239,6 +1248,10 @@ function responseText(response) { return String(response.data || response.body || ''); } +function responseStatus(response) { + return Number(response && (response.statusCode || response.status)) || 0; +} + async function browserHTML(ctx, url, referer, forceVisible) { if (!boolParam(ctx, 'enableBrowserFallback', true)) return ''; if (forceVisible !== true && !boolParam(ctx, 'automaticBrowserFallback', true)) return ''; @@ -1297,12 +1310,20 @@ function clearBrowserFailure(url) { async function extractFromBrowser(url, referer, ctx) { if (ctx && ctx.__playbackBudget && ctx.__playbackBudget.browserCalls >= 1) return ''; - if (typeof Widget === 'undefined' || !Widget.browser || typeof Widget.browser.fetch !== 'function') return ''; + if (!boolParam(ctx, 'enableBrowserFallback', true) || !boolParam(ctx, 'automaticBrowserFallback', true)) { + recordPlaybackFailure(ctx, new Error('stage=browser-media;浏览器回退已关闭')); + return ''; + } + if (typeof Widget === 'undefined' || !Widget.browser || typeof Widget.browser.fetch !== 'function') { + recordPlaybackFailure(ctx, new Error('stage=browser-media;宿主未提供浏览器接口')); + return ''; + } try { - const result = await Widget.browser.fetch(url, { + const timeout = playbackBrowserSeconds(ctx, 12, 1); + const result = await runPlaybackStage(ctx, 'browser-media', timeout, function () { return Widget.browser.fetch(url, { visible: false, - timeout: playbackBrowserSeconds(ctx, 12, 1), - timeoutSeconds: remainingPlaybackSeconds(ctx, 12), + timeout: timeout, + timeoutSeconds: timeout, waitAfterLoad: 3, waitForMediaSource: true, waitForAny: true, @@ -1312,13 +1333,18 @@ async function extractFromBrowser(url, referer, ctx) { 'User-Agent': MISSAV_UA, Referer: referer || url } - }); - return firstNonEmpty( + }); }); + const playable = firstNonEmpty( playableFromBrowserResult(result), extractPlayableURL(responseText(result)), extractPlayableURL(result && result.html) ); + if (!playable) { + recordPlaybackFailure(ctx, new Error('stage=browser-media;未返回媒体地址;keys=' + Object.keys(result || {}).slice(0, 12).join(','))); + } + return playable; } catch (error) { + recordPlaybackFailure(ctx, error); return ''; } } @@ -2195,7 +2221,9 @@ async function discoverAvailableQualities(ctx, playlistURL, referer, html) { }); } - const urls = unique(candidates.map(function (item) { return item.url; })).slice(0, 8); + const urls = ctx && ctx.__playbackBudget + ? [playlistURL] + : unique(candidates.map(function (item) { return item.url; })).slice(0, 8); let next = 0; await Promise.all(Array.from({ length: Math.min(2, urls.length) }, async function () { while (next < urls.length) { @@ -2275,17 +2303,24 @@ function normalizeQualities(qualities) { async function discoverQualities(ctx, playlistURL, referer) { if (!isPlayableURL(playlistURL) || containerOf(playlistURL) !== 'm3u8') return []; try { - const response = await httpGet(playlistURL, { + const budget = ctx && ctx.__playbackBudget; + const remaining = budget ? (budget.endAt - Date.now()) / 1000 : 5.5; + if (remaining <= 0.5) return []; + // Leave room to return an already discovered URL before the outer deadline. + const timeout = remainingPlaybackSeconds(ctx, Math.min(5, remaining - 0.5)); + const response = await runPlaybackStage(ctx, 'master-http', timeout, function () { return httpGet(playlistURL, { headers: playbackHeaders(ctx || {}, referer || playlistURL), - timeout: remainingPlaybackSeconds(ctx, 5), - timeoutSeconds: remainingPlaybackSeconds(ctx, 5), + timeout: timeout, + timeoutSeconds: timeout, useBrowserCookie: true, attachBrowserCookie: true - }); + }); }); + if (responseStatus(response) >= 400) throw playbackStageError('master-http', 'HTTP ' + responseStatus(response)); const playlist = responseText(response); if (playlist.indexOf('#EXT-X-STREAM-INF') < 0) return []; return parseMasterQualities(playlist, playlistURL); } catch (error) { + recordPlaybackFailure(ctx, error); return []; } } @@ -2910,14 +2945,20 @@ function isDetailPageURL(url) { } function isCloudflare(html, status, headers) { - const text = String(html || '') + ' ' + JSON.stringify(headers || {}); + const text = String(html || ''); + const title = cleanText(firstMatch(text, /<title\b[^>]*>([\s\S]*?)<\/title>/i)); + const mitigated = Object.keys(headers || {}).some(function (key) { + return key.toLowerCase() === 'cf-mitigated' && /challenge/i.test(String(headers[key])); + }); + // Cloudflare's passive /challenge-platform/scripts/jsd/main.js also occurs + // on successful media pages. Only explicit blocking-page evidence counts. return Number(status) === 403 || Number(status) === 429 || Number(status) === 503 || - /<title>\s*Just a moment/i.test(text) || - /Checking your browser|Verifying you are human|Verify you are human|Ray ID/i.test(text) || - /Enable JavaScript and cookies to continue/i.test(text) || - /cf-mitigated|cf-browser-verification|cf-chl-|challenge-platform|turnstile/i.test(text); + mitigated || + /^(?:Just a moment|Attention Required|Checking your browser|Verifying you are human|Verify you are human)/i.test(title) || + /\bid\s*=\s*["'](?:challenge-form|cf-challenge-running|cf-browser-verification)["']/i.test(text) || + /Enable JavaScript and cookies to continue/i.test(stripTags(text)); } function decodeEscapes(value) { @@ -3068,17 +3109,50 @@ async function withPlaybackBudget(input, operation) { let ctx = input; if (typeof ctx === 'string') { try { ctx = JSON.parse(ctx); } catch (_) { ctx = {}; } } ctx = Object.assign({}, ctx && typeof ctx === 'object' ? ctx : {}); - const budget = { endAt: Date.now() + 28000, expired: false, browserCalls: 0 }; + const budget = { endAt: Date.now() + 28000, expired: false, browserCalls: 0, stage: 'start', failures: [] }; ctx.__playbackBudget = budget; let timer; try { const timeout = new Promise(function (_, reject) { - timer = setTimeout(function () { budget.expired = true; reject(new Error('播放解析超时;stage=total-deadline;请稍后重试')); }, 28000); + timer = setTimeout(function () { budget.expired = true; reject(new Error('播放解析超时;stage=total-deadline;lastStage=' + budget.stage + ';请稍后重试')); }, 28000); }); return await Promise.race([Promise.resolve().then(function () { return operation(ctx); }), timeout]); } finally { clearTimeout(timer); budget.expired = true; } } +function recordPlaybackFailure(ctx, error) { + const budget = ctx && ctx.__playbackBudget; + if (!budget) return; + // No cookies, signed media URLs, or raw host exception text in diagnostics. + const raw = String(error && error.message || ''); + const message = /stage=(?:detail-http|browser-media|master-http|total-deadline)/.test(raw) + ? raw.replace(/https?:\/\/[^\s;]+/gi, '[URL]').slice(0, 200) + : '请求失败;stage=' + budget.stage; + if (budget.failures.indexOf(message) < 0) budget.failures.push(message); +} + +async function runPlaybackStage(ctx, stage, limit, operation) { + const timeout = remainingPlaybackSeconds(ctx, limit); + const budget = ctx && ctx.__playbackBudget; + if (budget) budget.stage = stage; + let timer; + try { + return await Promise.race([ + Promise.resolve().then(function () { remainingPlaybackSeconds(ctx, limit); return operation(); }), + new Promise(function (_, reject) { timer = setTimeout(function () { reject(playbackStageError(stage, '请求超时')); }, timeout * 1000); }) + ]); + } catch (error) { + if (error && error.missavPlaybackStage === true) throw error; + throw playbackStageError(stage, '请求失败'); + } finally { clearTimeout(timer); } +} + +function playbackStageError(stage, reason) { + const error = new Error(reason + ';stage=' + stage); + error.missavPlaybackStage = true; + return error; +} + function remainingPlaybackSeconds(ctx, limit) { const budget = ctx && ctx.__playbackBudget; if (!budget) return limit; diff --git a/missav-mini-library.js b/missav-mini-library.js index 3324627..7646e27 100644 --- a/missav-mini-library.js +++ b/missav-mini-library.js @@ -12,7 +12,7 @@ const WidgetMetadata = { id: 'missav-mini-library', name: 'MissAV', title: 'MissAV', - version: '1.5.10', + version: '1.5.11', author: 'Alan huang', logo: MISSAV_LOGO, icon: MISSAV_LOGO, @@ -763,28 +763,22 @@ async function resolvePlaybackWithinBudget(ctx) { try { html = await fetchText(ctx, detailURL, entryURL(ctx)); } catch (error) { - // Playback has a separate, narrowly-scoped hidden browser media fallback. + recordPlaybackFailure(ctx, error); } let masterURL = extractPlayableURL(html); if (!masterURL) masterURL = await extractPlayableFromLinkedPlayers(ctx, html, detailURL); if (!masterURL) masterURL = await extractFromBrowser(detailURL, detailURL, ctx); let url = masterURL; if (masterURL) { - let qualities = await discoverAvailableQualities(ctx, masterURL, detailURL, html); - const knownQuality = qualities.some(function (quality) { return quality.height > 0; }); - const missingRequested = requestedQuality && !qualities.some(function (quality) { return String(quality.height) === String(requestedQuality); }); - if ((!knownQuality || missingRequested) && ctx.__playbackBudget.browserCalls === 0) { - const browserMasterURL = await extractFromBrowser(detailURL, detailURL, ctx); - if (browserMasterURL && browserMasterURL !== masterURL) { - const browserQualities = await discoverAvailableQualities(ctx, browserMasterURL, detailURL, html); - if (browserQualities.length > qualities.length) qualities = browserQualities; - } - } + const qualities = await discoverAvailableQualities(ctx, masterURL, detailURL, html); + // A valid master can let the native player choose a variant. Optional quality + // discovery must not spend a browser round or prevent that handoff. const selected = selectQuality(qualities, requestedQuality); if (selected && selected.url) url = selected.url; } if (!url) { - throw new Error('未能解析到 MissAV 播放地址。站点可能启用了 Cloudflare 或更换了播放器脚本,请尝试更新入口路径或在 App 侧启用浏览器请求。'); + const failures = ctx.__playbackBudget.failures.join(';'); + throw new Error('未能解析到 MissAV 播放地址;stage=media-discovery' + (failures ? ';' + failures : '') + '。请检查站点地址;若提示验证,请先手动完成验证。'); } return playbackResult(ctx, absoluteURL(ctx, url), detailURL); } @@ -906,14 +900,16 @@ async function onAction(ctx) { } async function extractPlayableFromLinkedPlayers(ctx, html, referer) { - const urls = extractPlayerURLs(ctx, html, referer); + const urls = extractPlayerURLs(ctx, html, referer).filter(function (url) { + return !/googletagmanager\.com|snaptrckr\.|myavlive\.com|mayzaent\.com|rallytrck\./i.test(originOf(url)); + }).slice(0, ctx && ctx.__playbackBudget ? 1 : 5); for (let index = 0; index < urls.length; index += 1) { try { const playerHTML = await fetchText(ctx, urls[index], referer); const playable = extractPlayableURL(playerHTML); if (playable) return playable; } catch (error) { - // Ignore broken auxiliary player pages and continue with browser fallback. + recordPlaybackFailure(ctx, error); } } return ''; @@ -1121,6 +1117,19 @@ async function fetchActressIndexText(ctx, url, referer) { } async function fetchText(ctx, url, referer) { + if (ctx && ctx.__playbackBudget) { + // Refresh player authorization on each play and reserve the one browser + // request for media capture, rather than an HTML-only verification attempt. + return runPlaybackStage(ctx, 'detail-http', 6, async function () { + const response = await httpGet(url, requestOptions(ctx, referer || entryURL(ctx))); + const text = responseText(response); + const status = responseStatus(response); + if (!isUsableHTML(text, status, response && response.headers)) { + throw playbackStageError('detail-http', 'HTTP ' + (status || 'empty') + (isCloudflare(text, status, response && response.headers) ? ';需要验证或站点拒绝请求' : ';页面不可用')); + } + return text; + }); + } const cached = getCachedText(ctx, url); if (cached) return cached; @@ -1132,12 +1141,12 @@ async function fetchText(ctx, url, referer) { try { const response = await httpGet(currentURL, requestOptions(ctx, requestReferer)); const text = responseText(response); - if (isUsableHTML(text, response && response.status, response && response.headers)) { + if (isUsableHTML(text, responseStatus(response), response && response.headers)) { setCachedText(ctx, currentURL, text); if (currentURL !== url) setCachedText(ctx, url, text); return text; } - if (isCloudflare(text, response && response.status, response && response.headers)) { + if (isCloudflare(text, responseStatus(response), response && response.headers)) { const browserText = await browserHTML(ctx, currentURL, requestReferer); if (isUsableHTML(browserText)) { setCachedText(ctx, currentURL, browserText); @@ -1239,6 +1248,10 @@ function responseText(response) { return String(response.data || response.body || ''); } +function responseStatus(response) { + return Number(response && (response.statusCode || response.status)) || 0; +} + async function browserHTML(ctx, url, referer, forceVisible) { if (!boolParam(ctx, 'enableBrowserFallback', true)) return ''; if (forceVisible !== true && !boolParam(ctx, 'automaticBrowserFallback', true)) return ''; @@ -1297,12 +1310,20 @@ function clearBrowserFailure(url) { async function extractFromBrowser(url, referer, ctx) { if (ctx && ctx.__playbackBudget && ctx.__playbackBudget.browserCalls >= 1) return ''; - if (typeof Widget === 'undefined' || !Widget.browser || typeof Widget.browser.fetch !== 'function') return ''; + if (!boolParam(ctx, 'enableBrowserFallback', true) || !boolParam(ctx, 'automaticBrowserFallback', true)) { + recordPlaybackFailure(ctx, new Error('stage=browser-media;浏览器回退已关闭')); + return ''; + } + if (typeof Widget === 'undefined' || !Widget.browser || typeof Widget.browser.fetch !== 'function') { + recordPlaybackFailure(ctx, new Error('stage=browser-media;宿主未提供浏览器接口')); + return ''; + } try { - const result = await Widget.browser.fetch(url, { + const timeout = playbackBrowserSeconds(ctx, 12, 1); + const result = await runPlaybackStage(ctx, 'browser-media', timeout, function () { return Widget.browser.fetch(url, { visible: false, - timeout: playbackBrowserSeconds(ctx, 12, 1), - timeoutSeconds: remainingPlaybackSeconds(ctx, 12), + timeout: timeout, + timeoutSeconds: timeout, waitAfterLoad: 3, waitForMediaSource: true, waitForAny: true, @@ -1312,13 +1333,18 @@ async function extractFromBrowser(url, referer, ctx) { 'User-Agent': MISSAV_UA, Referer: referer || url } - }); - return firstNonEmpty( + }); }); + const playable = firstNonEmpty( playableFromBrowserResult(result), extractPlayableURL(responseText(result)), extractPlayableURL(result && result.html) ); + if (!playable) { + recordPlaybackFailure(ctx, new Error('stage=browser-media;未返回媒体地址;keys=' + Object.keys(result || {}).slice(0, 12).join(','))); + } + return playable; } catch (error) { + recordPlaybackFailure(ctx, error); return ''; } } @@ -2195,7 +2221,9 @@ async function discoverAvailableQualities(ctx, playlistURL, referer, html) { }); } - const urls = unique(candidates.map(function (item) { return item.url; })).slice(0, 8); + const urls = ctx && ctx.__playbackBudget + ? [playlistURL] + : unique(candidates.map(function (item) { return item.url; })).slice(0, 8); let next = 0; await Promise.all(Array.from({ length: Math.min(2, urls.length) }, async function () { while (next < urls.length) { @@ -2275,17 +2303,24 @@ function normalizeQualities(qualities) { async function discoverQualities(ctx, playlistURL, referer) { if (!isPlayableURL(playlistURL) || containerOf(playlistURL) !== 'm3u8') return []; try { - const response = await httpGet(playlistURL, { + const budget = ctx && ctx.__playbackBudget; + const remaining = budget ? (budget.endAt - Date.now()) / 1000 : 5.5; + if (remaining <= 0.5) return []; + // Leave room to return an already discovered URL before the outer deadline. + const timeout = remainingPlaybackSeconds(ctx, Math.min(5, remaining - 0.5)); + const response = await runPlaybackStage(ctx, 'master-http', timeout, function () { return httpGet(playlistURL, { headers: playbackHeaders(ctx || {}, referer || playlistURL), - timeout: remainingPlaybackSeconds(ctx, 5), - timeoutSeconds: remainingPlaybackSeconds(ctx, 5), + timeout: timeout, + timeoutSeconds: timeout, useBrowserCookie: true, attachBrowserCookie: true - }); + }); }); + if (responseStatus(response) >= 400) throw playbackStageError('master-http', 'HTTP ' + responseStatus(response)); const playlist = responseText(response); if (playlist.indexOf('#EXT-X-STREAM-INF') < 0) return []; return parseMasterQualities(playlist, playlistURL); } catch (error) { + recordPlaybackFailure(ctx, error); return []; } } @@ -2910,14 +2945,20 @@ function isDetailPageURL(url) { } function isCloudflare(html, status, headers) { - const text = String(html || '') + ' ' + JSON.stringify(headers || {}); + const text = String(html || ''); + const title = cleanText(firstMatch(text, /<title\b[^>]*>([\s\S]*?)<\/title>/i)); + const mitigated = Object.keys(headers || {}).some(function (key) { + return key.toLowerCase() === 'cf-mitigated' && /challenge/i.test(String(headers[key])); + }); + // Cloudflare's passive /challenge-platform/scripts/jsd/main.js also occurs + // on successful media pages. Only explicit blocking-page evidence counts. return Number(status) === 403 || Number(status) === 429 || Number(status) === 503 || - /<title>\s*Just a moment/i.test(text) || - /Checking your browser|Verifying you are human|Verify you are human|Ray ID/i.test(text) || - /Enable JavaScript and cookies to continue/i.test(text) || - /cf-mitigated|cf-browser-verification|cf-chl-|challenge-platform|turnstile/i.test(text); + mitigated || + /^(?:Just a moment|Attention Required|Checking your browser|Verifying you are human|Verify you are human)/i.test(title) || + /\bid\s*=\s*["'](?:challenge-form|cf-challenge-running|cf-browser-verification)["']/i.test(text) || + /Enable JavaScript and cookies to continue/i.test(stripTags(text)); } function decodeEscapes(value) { @@ -3068,17 +3109,50 @@ async function withPlaybackBudget(input, operation) { let ctx = input; if (typeof ctx === 'string') { try { ctx = JSON.parse(ctx); } catch (_) { ctx = {}; } } ctx = Object.assign({}, ctx && typeof ctx === 'object' ? ctx : {}); - const budget = { endAt: Date.now() + 28000, expired: false, browserCalls: 0 }; + const budget = { endAt: Date.now() + 28000, expired: false, browserCalls: 0, stage: 'start', failures: [] }; ctx.__playbackBudget = budget; let timer; try { const timeout = new Promise(function (_, reject) { - timer = setTimeout(function () { budget.expired = true; reject(new Error('播放解析超时;stage=total-deadline;请稍后重试')); }, 28000); + timer = setTimeout(function () { budget.expired = true; reject(new Error('播放解析超时;stage=total-deadline;lastStage=' + budget.stage + ';请稍后重试')); }, 28000); }); return await Promise.race([Promise.resolve().then(function () { return operation(ctx); }), timeout]); } finally { clearTimeout(timer); budget.expired = true; } } +function recordPlaybackFailure(ctx, error) { + const budget = ctx && ctx.__playbackBudget; + if (!budget) return; + // No cookies, signed media URLs, or raw host exception text in diagnostics. + const raw = String(error && error.message || ''); + const message = /stage=(?:detail-http|browser-media|master-http|total-deadline)/.test(raw) + ? raw.replace(/https?:\/\/[^\s;]+/gi, '[URL]').slice(0, 200) + : '请求失败;stage=' + budget.stage; + if (budget.failures.indexOf(message) < 0) budget.failures.push(message); +} + +async function runPlaybackStage(ctx, stage, limit, operation) { + const timeout = remainingPlaybackSeconds(ctx, limit); + const budget = ctx && ctx.__playbackBudget; + if (budget) budget.stage = stage; + let timer; + try { + return await Promise.race([ + Promise.resolve().then(function () { remainingPlaybackSeconds(ctx, limit); return operation(); }), + new Promise(function (_, reject) { timer = setTimeout(function () { reject(playbackStageError(stage, '请求超时')); }, timeout * 1000); }) + ]); + } catch (error) { + if (error && error.missavPlaybackStage === true) throw error; + throw playbackStageError(stage, '请求失败'); + } finally { clearTimeout(timer); } +} + +function playbackStageError(stage, reason) { + const error = new Error(reason + ';stage=' + stage); + error.missavPlaybackStage = true; + return error; +} + function remainingPlaybackSeconds(ctx, limit) { const budget = ctx && ctx.__playbackBudget; if (!budget) return limit; diff --git a/tests/adapters.test.cjs b/tests/adapters.test.cjs index eba6a24..3d35d5c 100644 --- a/tests/adapters.test.cjs +++ b/tests/adapters.test.cjs @@ -123,13 +123,83 @@ test('MissAV does not invoke player/browser fallbacks after a complete master di const result = await c.resolvePlayback({ itemId: c.makeItemId('https://missav.ws/cn/a', 'A', '') }); assert.equal(result.url, 'https://cdn.test/1080.m3u8'); assert.equal(linked, 0); assert.equal(browser, 0); }); -test('MissAV 1.5.10 accepts the original 1.0.7 detail payload and all legacy parameter names', () => { +test('MissAV accepts the original 1.0.7 detail payload and all legacy parameter names', () => { const c = load('missav-mini-library-download-working 6.js'); const old = 'missav://detail?url=https%3A%2F%2Fmissav.ws%2Fdm247%2Fcn%2Fsample&title=Old'; assert.equal(c.detailUrlFromContext({ itemId: old }), 'https://missav.ws/dm247/cn/sample'); const names = c.getManifest().parameters.map(p => p.name); for (const name of ['baseURL', 'entryPath', 'backupBaseURLs', 'enableBrowserFallback', 'browserVisible', 'requestTimeoutSeconds', 'cacheMinutes']) assert.ok(names.includes(name)); }); + +const missavFile = 'missav-mini-library-download-working 6.js'; +const missavPage = '<html><title>Sample - MissAV' + + '' + + ''; +function missavInput(c) { return { itemId: c.makeItemId('https://missav.ws/cn/sample-001', 'Sample', '') }; } +function acceleratedTimers() { return { setTimeout: (fn, ms) => setTimeout(fn, ms / 100) }; } + +test('MissAV HTTP 200 passive Cloudflare detection is not a blocking challenge', async () => { + const c = load(missavFile); const requests = []; let browser = 0; + c.Widget.http.get = async url => { requests.push(url); return { statusCode: 200, data: url.includes('.m3u8') ? master : missavPage }; }; + c.Widget.browser = { async fetch() { browser++; throw new Error('browser must not be needed'); } }; + const result = await c.resolvePlayback(missavInput(c)); + assert.equal(result.url, 'https://cdn.test/1080.m3u8'); + assert.equal(browser, 0); + assert.deepEqual(requests, ['https://missav.ws/cn/sample-001', 'https://cdn.test/playlist.m3u8']); + assert.equal(c.isCloudflare(missavPage, 200, { server: 'cloudflare' }), false); + for (const [html, status, headers] of [ + ['Just a moment...Verify you are human', 200, {}], + [missavPage, 200, { 'Cf-Mitigated': 'challenge' }], [missavPage, 403, {}] + ]) assert.equal(c.isUsableHTML(html, status, headers), false); +}); +test('MissAV uses its one browser call for media capture after a real challenge', async () => { + const c = load(missavFile); const browserCalls = [], requests = []; + c.Widget.http.get = async url => { requests.push(url); return { statusCode: 200, data: url.includes('.m3u8') ? master : 'Just a moment...Enable JavaScript and cookies to continue' }; }; + c.Widget.browser = { async fetch(url, options) { browserCalls.push({ url, options }); return { mediaSources: [{ url: 'https://cdn.test/playlist.m3u8' }] }; } }; + assert.equal((await c.resolvePlayback(missavInput(c))).url, 'https://cdn.test/1080.m3u8'); + assert.equal(browserCalls.length, 1); assert.equal(browserCalls[0].options.captureMedia, true); + assert.equal(browserCalls[0].options.visible, false); assert.equal(requests.length, 2); +}); +test('MissAV a host that ignores HTTP timeout still reaches media capture before the total deadline', async () => { + const c = load(missavFile, acceleratedTimers()); let browser = 0, requests = 0; + c.Widget.http.get = async url => { requests++; return url.includes('.m3u8') ? { status: 200, data: master } : new Promise(() => {}); }; + c.Widget.browser = { async fetch() { browser++; return { mediaSources: [{ url: 'https://cdn.test/playlist.m3u8' }] }; } }; + assert.equal((await c.resolvePlayback(missavInput(c))).url, 'https://cdn.test/1080.m3u8'); + assert.equal(browser, 1); assert.equal(requests, 2); +}); +test('MissAV a hung browser reports the failing media stage without retrying mirrors', async () => { + const c = load(missavFile, acceleratedTimers()); let browser = 0, requests = 0; + c.Widget.http.get = async () => { requests++; throw new Error('offline'); }; + c.Widget.browser = { async fetch() { browser++; return new Promise(() => {}); } }; + await assert.rejects(c.resolvePlayback(missavInput(c)), error => /stage=browser-media/.test(error.message) && !/total-deadline/.test(error.message)); + assert.equal(browser, 1); assert.equal(requests, 1); +}); +test('MissAV a hung optional master probe hands off the highest HTML quality without another browser', async () => { + const c = load(missavFile, acceleratedTimers()); let browser = 0; + c.Widget.http.get = async url => url.includes('.m3u8') ? new Promise(() => {}) : { status: 200, data: missavPage }; + c.Widget.browser = { async fetch() { browser++; return new Promise(() => {}); } }; + assert.equal((await c.resolvePlayback(missavInput(c))).url, 'https://cdn.test/1080p/video.m3u8'); + assert.equal(browser, 0); +}); +test('MissAV playback refreshes detail HTML instead of using cached signed source URLs', async () => { + const c = load(missavFile); let refreshes = 0; + c.setCachedText({}, 'https://missav.ws/cn/sample-001', missavPage); + c.Widget.http.get = async url => { if (url.includes('.m3u8')) return { status: 200, data: media }; refreshes++; return { status: 200, data: missavPage.replaceAll('https://cdn.test/', 'https://fresh.test/' + refreshes + '/') }; }; + assert.equal((await c.resolvePlayback(missavInput(c))).url, 'https://fresh.test/1/1080p/video.m3u8'); + assert.equal((await c.resolvePlayback(missavInput(c))).url, 'https://fresh.test/2/1080p/video.m3u8'); + assert.equal(refreshes, 2); +}); +test('MissAV optional master discovery reserves time to hand off an already discovered stream', async () => { + let offset = 0, masterTimeout; + class PlaybackClock extends Date { static now() { return Date.now() + offset; } } + const c = load(missavFile, { ...acceleratedTimers(), Date: PlaybackClock }); + c.Widget.http.get = async (url, options) => { + if (url.includes('.m3u8')) { masterTimeout = options.timeoutSeconds; return new Promise(() => {}); } + offset = 26500; return { status: 200, data: missavPage }; + }; + assert.equal((await c.resolvePlayback(missavInput(c))).url, 'https://cdn.test/1080p/video.m3u8'); + assert.ok(masterTimeout > 0 && masterTimeout <= 1); +}); test('MissAV resource discovery failure does not become a fake playable webpage', async () => { const c = load('missav-mini-library-download-working 6.js'); c.fetchText = async () => { throw new Error('503'); }; c.extractFromBrowser = async () => ''; From a393f687877e5190b33475f2fef0634f45c63968 Mon Sep 17 00:00:00 2001 From: hzl <1803573449@qq.com> Date: Sat, 3 Oct 2026 21:15:45 +0800 Subject: [PATCH 2/6] Preserve ASMRLIB embedding and allow manual playback verification --- CHANGELOG.md | 9 +++ README.md | 4 +- asmrlib-mini-library.js | 168 ++++++++++++++++++++++++---------------- tests/adapters.test.cjs | 68 ++++++++++++++++ 4 files changed, 181 insertions(+), 68 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 552a6bf..d9018f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # 2026-10-03 +## ASMRLIB 1.2.2 播放上下文修复稿 + +- 三个实际样本当前均提供 BI/AB。BI 嵌入页要求手动点击真人验证;AB 的公开脚本在 `top.location == self.location` 时跳转到托管站首页。原来的两次隐藏独立播放器请求不具备正确交互和嵌入上下文。 +- BI/AB 改为一个“验证后播放”资源入口,在原 ASMRLIB 详情页内保留 iframe 和线路选择。新增 `browserVisible` 参数,默认显示页面,验证和播放点击由用户完成;旧条目 ID 和旧 BI/AB 版本载荷仍能进入同一影片的详情页。 +- 仅使用一次 20 秒浏览器媒体等待,保留旧 UP 的最多 5 秒 API 尝试及一次页面回退。去掉未经当前宿主证实的 capture 标志,不再隐藏尝试主备各 18 秒。脚本侧截止时间不能强制取消原生浏览器任务。 +- 支持 `capturedRequests` 媒体返回;优先使用捕获媒体自己的请求头。若未返回媒体请求头,只保留 User-Agent,不把外层页面 Origin/Referer 推定为视频 CDN 的请求头。 +- 缺少媒体、blob-only、验证未完成、宿主异常和请求超时分别报告阶段及有限返回字段名;不回传原始异常、Cookie 或签名 URL 到错误信息。 +- 55 项媒体库回归通过(新增 9 项 ASMRLIB 流程检查);语法、39 个媒体库契约通过。未完成 DreamBy iPhone 可见验证、嵌套 iframe 媒体回传和原生起播;本稿不能据此宣称所有影片已恢复播放。 + 基于 `main` 的 `8e43017532ec81e3338046ba6e32dff7655bd3dd` 修复审查中 O01–O25;保留所有已发布文件路径。 ## 加载与失败恢复 diff --git a/README.md b/README.md index 7754c84..9fff2e7 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ GitHub Actions 会先生成兼容副本并验证;`main` 的检查通过后, | [591av-mini-library.js](591av-mini-library.js) | 1.3.1 | 1.3.2 | | [91porny_int.js](91porny_int.js) | 0.9.6 | 0.9.7 | | [MissAV 3.0.js](MissAV%203.0.js) | 3.0 | 3.0.1 | -| [asmrlib-mini-library.js](asmrlib-mini-library.js) | 1.2.0 | 1.2.1 | +| [asmrlib-mini-library.js](asmrlib-mini-library.js) | 1.2.0 | 1.2.2 | | [girigirilove-mini-library.js](girigirilove-mini-library.js) | 1.0.0 | 1.0.1 | | [jable.js](jable.js) | 1.3.0 | 1.3.1 | | [jable.media-library.js](jable.media-library.js) | 1.0.0 | 1.0.1 | @@ -59,6 +59,8 @@ GitHub Actions 会先生成兼容副本并验证;`main` 的检查通过后, 具体行为和设备验证限制见 [CHANGELOG.md](CHANGELOG.md)。 +ASMRLIB 1.2.2 的 BI/AB 使用“验证后播放”入口,在原详情页内选择线路并手动验证或点击播放。源设置中的“显示播放验证页面”默认开启。只有浏览器返回最终媒体地址才会交给原生播放器;若只返回网页或 blob,脚本会显示具体阶段。此流程的代码及模拟已验证,iPhone 嵌套媒体回传、自动交回和起播仍待设备确认。 + ## 本地验收 ```bash diff --git a/asmrlib-mini-library.js b/asmrlib-mini-library.js index 7537e6f..1ebe3d5 100644 --- a/asmrlib-mini-library.js +++ b/asmrlib-mini-library.js @@ -2,7 +2,7 @@ * ASMRLIB - Dreamby / baiPlay 自定义媒体库 * Source: https://asmrlib.com/ * @author Alan huang - * @version 1.2.0 + * @version 1.2.2 */ const ASMRLIB_BASE = 'https://asmrlib.com'; @@ -89,16 +89,17 @@ function asmrResponseText(response) { return ''; } -async function asmrHTTP(url, referer) { +async function asmrHTTP(url, referer, timeout) { const headers = asmrHeaders(referer); + timeout = timeout || 30; let response; if (typeof Widget !== 'undefined' && Widget.http) { - if (typeof Widget.http.get === 'function') response = await Widget.http.get(url, { headers: headers, timeout: 30 }); - else if (typeof Widget.http.request === 'function') response = await Widget.http.request({ url: url, method: 'GET', headers: headers, timeout: 30 }); + if (typeof Widget.http.get === 'function') response = await Widget.http.get(url, { headers: headers, timeout: timeout, timeoutSeconds: timeout }); + else if (typeof Widget.http.request === 'function') response = await Widget.http.request({ url: url, method: 'GET', headers: headers, timeout: timeout, timeoutSeconds: timeout }); } if (response === undefined && typeof $http !== 'undefined' && $http) { - if (typeof $http.get === 'function') response = await $http.get(url, { headers: headers, timeout: 30 }); - else if (typeof $http.request === 'function') response = await $http.request({ url: url, method: 'GET', headers: headers, timeout: 30 }); + if (typeof $http.get === 'function') response = await $http.get(url, { headers: headers, timeout: timeout, timeoutSeconds: timeout }); + else if (typeof $http.request === 'function') response = await $http.request({ url: url, method: 'GET', headers: headers, timeout: timeout, timeoutSeconds: timeout }); } if (response === undefined && typeof fetch === 'function') { const nativeResponse = await fetch(url, { headers: headers }); @@ -112,7 +113,7 @@ async function asmrHTTP(url, referer) { function asmrId(raw) { const value = asmrText(raw); - const match = value.match(/(?:asmrlib:\/\/post\/|\/posts\/)([a-f0-9]{32})/i); + const match = value.match(/(?:asmrlib(?:-page)?:\/\/post\/|\/posts\/)([a-f0-9]{32})/i); return match ? match[1] : (/^[a-f0-9]{32}$/i.test(value) ? value : ''); } function asmrPayload(id) { return 'asmrlib://post/' + asmrId(id); } @@ -191,12 +192,16 @@ function getManifest() { id: 'asmrlib', name: 'ASMRLIB', title: 'ASMRLIB', - version: '1.2.1', + version: '1.2.2', author: 'Alan huang', logo: ASMRLIB_LOGO, icon: ASMRLIB_LOGO, capabilities: { search: true, aggregation: true, playbackHistory: true }, - aggregation: { search: true, playbackHistory: true } + aggregation: { search: true, playbackHistory: true }, + parameters: [{ + name: 'browserVisible', title: '显示播放验证页面', type: 'boolean', defaultValue: true, + description: 'BI 需要手动验证;AB 必须在详情页内打开。请在页面里选择线路并点击验证或播放,捕获到媒体后返回播放器。' + }] }; } @@ -291,6 +296,21 @@ function asmrDetailData(html, id) { } function asmrResourceGroups(detail) { + const embedded = detail.versions.some(function (version) { + return /^https?:\/\/(?:bysetayico\.com|abyssplayer\.com)(?:\/|$)/i.test(version.playerUrl); + }); + if (embedded) { + // These players require the site's iframe/interaction context. Present one + // page entry; the user chooses BI/AB inside that page instead of promising + // that a standalone hidden request can select either embedded player. + return [{ id: 'online', title: '在线播放', versions: [{ + id: 'asmrlib-page://post/' + detail.id, + name: '验证后播放(BI / AB)', + subtitle: '在页面里选择 BI 或 AB,再点击验证或播放', + default: true, + action: { type: 'play', itemId: asmrPayload(detail.id), versionId: 'asmrlib-page://post/' + detail.id, title: detail.title } + }] }]; + } return [{ id: 'online', title: '在线播放', @@ -333,21 +353,38 @@ function asmrPlayable(value) { return /^https?:\/\//i.test(asmrText(value)) && /\.(?:m3u8|mp4|mpd|m4v|mov|webm)(?:[?#]|$)/i.test(asmrText(value)); } function asmrBrowserMedia(result) { - if (!result) return ''; + return asmrCapturedMedia(result).url; +} +function asmrCapturedMedia(result) { + if (!result) return { url: '', headers: {} }; + const candidates = []; + const add = function (value, headers) { + if (asmrPlayable(value) && !/\/(?:ads?|advert|preview|trailer)(?:[\/_.-]|$)/i.test(value)) candidates.push({ url: value, headers: asmrMediaHeaders(headers) }); + }; const directKeys = ['url', 'mediaURL', 'mediaUrl', 'videoURL', 'videoUrl', 'playURL', 'playUrl', 'src']; - for (let i = 0; i < directKeys.length; i += 1) if (asmrPlayable(result[directKeys[i]])) return result[directKeys[i]]; - const arrays = [result.mediaSources, result.mediaRequests, result.requests, result.responses, result.urls]; + for (let i = 0; i < directKeys.length; i += 1) add(result[directKeys[i]], result.mediaHeaders || result.requestHeaders); + const arrays = [result.mediaSources, result.mediaRequests, result.capturedRequests, result.requests, result.responses, result.urls]; for (let a = 0; a < arrays.length; a += 1) { if (!Array.isArray(arrays[a])) continue; for (let i = 0; i < arrays[a].length; i += 1) { const entry = arrays[a][i]; const value = typeof entry === 'string' ? entry : entry && (entry.url || entry.src || entry.responseURL); - if (asmrPlayable(value) && !/^blob:/i.test(value)) return value; + add(value, entry && (entry.requestHeaders || entry.headers)); } } + if (candidates.length) return candidates[0]; const text = asmrResponseText(result).replace(/\\\//g, '/'); const match = text.match(/https?:\/\/[^\s"'<>\\]+\.(?:m3u8|mp4|mpd|m4v|mov|webm)(?:\?[^\s"'<>\\]*)?/i); - return match && asmrPlayable(match[0]) ? match[0] : ''; + if (match) add(match[0], null); + return candidates[0] || { url: '', headers: {} }; +} +function asmrMediaHeaders(headers) { + const output = {}; + Object.keys(headers || {}).forEach(function (key) { + const canonical = { referer: 'Referer', origin: 'Origin', 'user-agent': 'User-Agent', cookie: 'Cookie' }[key.toLowerCase()]; + if (canonical && typeof headers[key] === 'string') output[canonical] = headers[key]; + }); + return output; } function asmrVersionPlayerURL(input) { const versionId = asmrText(asmrPick(input, ['versionId', 'resourceId', 'lineId'], '')); @@ -386,7 +423,7 @@ async function asmrResolveUP(playerUrl) { if (!code) return ''; const api = 'https://v.upn.one/api/v1/video?id=' + encodeURIComponent(code) + '&w=390&h=844&r=' + encodeURIComponent(ASMRLIB_BASE); - const encrypted = await asmrHTTP(api, ASMRLIB_BASE + '/'); + const encrypted = await asmrHTTP(api, ASMRLIB_BASE + '/', 5); const data = asmrDecryptUP(encrypted); const candidates = [data.source, data.cfNative]; if (data.hlsVideoTiktok && data.streamingConfig) { @@ -416,69 +453,66 @@ async function asmrResolveUP(playerUrl) { async function resolvePlayback(input) { const direct = asmrText(asmrPick(input, ['url', 'path', 'playUrl', 'videoUrl'], '')); if (asmrPlayable(direct)) return asmrPlaybackResult(direct, direct); - let playerUrl = asmrVersionPlayerURL(input); - if (!playerUrl) { - const detail = await getDetail(input); - const version = detail.resourceGroups[0] && detail.resourceGroups[0].versions[0]; - playerUrl = version ? asmrVersionPlayerURL(version.action) : ''; - } - if (!playerUrl) throw new Error('没有找到 ASMRLIB 播放线路'); + const playerUrl = asmrVersionPlayerURL(input); + const itemId = asmrId(asmrPick(input, ['itemId', 'id'], '')) || + asmrId(asmrPick(input, ['versionId'], '')) || asmrId(playerUrl); + if (!itemId && !asmrUPCode(playerUrl)) throw new Error('缺少 ASMRLIB 内容 ID;请重新打开影片详情'); if (typeof Widget === 'undefined' || !Widget.browser || typeof Widget.browser.fetch !== 'function') { throw new Error('当前 Dreamby 版本不支持设备内播放器解析'); } - const candidates = [playerUrl]; - const itemId = asmrId(asmrPick(input, ['itemId', 'id'], '')); - let media = ''; - let resolvedReferer = playerUrl; - for (let i = 0; i < 2 && !media; i += 1) { - if (!candidates[i] && i === 1 && itemId) { - try { - const detail = await getDetail({ itemId: asmrPayload(itemId) }); - const versions = detail.resourceGroups[0] ? detail.resourceGroups[0].versions : []; - versions.forEach(function (version) { - const alternate = asmrVersionPlayerURL(version.action); - if (alternate && candidates.indexOf(alternate) < 0) candidates.push(alternate); - }); - } catch (_) {} - } - const candidate = candidates[i]; - if (!candidate) continue; - if (asmrUPCode(candidate)) { - try { - media = await asmrResolveUP(candidate); - if (media) { - resolvedReferer = candidate; - break; - } - } catch (_) {} - continue; - } + if (asmrUPCode(playerUrl)) { try { - const result = await Widget.browser.fetch(candidate, { - visible: false, - timeout: 18, - timeoutSeconds: 18, - waitAfterLoad: 1.2, - waitForAny: true, - waitForMediaSource: true, - captureRequests: true, - captureMedia: true, - headers: asmrHeaders(ASMRLIB_BASE + '/') - }); - media = asmrBrowserMedia(result); - if (media) resolvedReferer = candidate; - } catch (_) {} + const media = await asmrBounded('up-api', 5, function () { return asmrResolveUP(playerUrl); }); + if (media) return asmrPlaybackResult(media, playerUrl); + } catch (error) { + if (!itemId) throw new Error('旧 UP 线路解析失败;stage=up-api;请重新打开影片详情选择当前线路'); + } } - if (!media) throw new Error('快速线路和备用线路均未捕获到媒体,请稍后重试'); - return asmrPlaybackResult(media, resolvedReferer); + const visibleValue = asmrPick(input, ['browserVisible'], true); + const visible = !/^(?:false|0|no|off)$/i.test(String(visibleValue)); + const pageURL = ASMRLIB_BASE + '/posts/' + itemId; + // BI needs a human action; AB redirects when top.location == self.location. + // Keep the original page and iframe instead of loading either player alone. + const result = await asmrBounded('page-media', 20, function () { + return Widget.browser.fetch(pageURL, { + visible: visible, timeout: 20, timeoutSeconds: 20, waitAfterLoad: 1.2, + waitForMediaSource: true, headers: asmrHeaders(ASMRLIB_BASE + '/') + }); + }); + const media = asmrCapturedMedia(result); + if (!media.url) { + const html = asmrResponseText(result); + const blobOnly = /blob:/i.test(JSON.stringify(result || {})); + const verification = /验证你是真人|verify.{0,20}human|captcha|turnstile/i.test(html); + const stage = verification || !visible ? 'verification-required' : 'page-media'; + throw new Error('ASMRLIB 未返回可交给播放器的媒体;stage=' + stage + + ';keys=' + Object.keys(result || {}).slice(0, 12).join(',') + ';blobOnly=' + blobOnly + + (stage === 'verification-required' ? ';请启用显示播放验证页面,并手动完成验证或点击播放' : ';若页面内已播放,宿主可能没有回传 iframe 媒体地址')); + } + // A captured media request supplies its own Referer/Origin. The outer ASMR + // page's origin is not necessarily the embedded player's media origin. + return asmrPlaybackResult(media.url, '', Object.assign({ 'User-Agent': ASMRLIB_UA }, media.headers)); +} + +async function asmrBounded(stage, seconds, operation) { + let timer; + try { + return await Promise.race([ + Promise.resolve().then(operation), + new Promise(function (_, reject) { timer = setTimeout(function () { reject(new Error('ASMRLIB 请求超时;stage=' + stage)); }, seconds * 1000); }) + ]); + } catch (error) { + if (String(error && error.message).indexOf('ASMRLIB 请求超时;stage=' + stage) === 0) throw error; + throw new Error('ASMRLIB 请求失败;stage=' + stage + ';请检查网络或宿主浏览器支持'); + } finally { clearTimeout(timer); } } -function asmrPlaybackResult(url, referer) { +function asmrPlaybackResult(url, referer, capturedHeaders) { const container = /\.m3u8(?:[?#]|$)/i.test(url) ? 'm3u8' : (/\.mpd(?:[?#]|$)/i.test(url) ? 'mpd' : 'mp4'); const originMatch = asmrText(referer).match(/^(https?:\/\/[^/]+)/i); return { url: url, container: container, - headers: { Referer: referer || ASMRLIB_BASE + '/', Origin: originMatch ? originMatch[1] : ASMRLIB_BASE, 'User-Agent': ASMRLIB_UA }, + headers: capturedHeaders || { Referer: referer || ASMRLIB_BASE + '/', Origin: originMatch ? originMatch[1] : ASMRLIB_BASE, 'User-Agent': ASMRLIB_UA }, startPositionSeconds: 0, isLive: false, streamKind: container === 'm3u8' ? 'hls' : 'file' }; } diff --git a/tests/adapters.test.cjs b/tests/adapters.test.cjs index eba6a24..d128b60 100644 --- a/tests/adapters.test.cjs +++ b/tests/adapters.test.cjs @@ -175,6 +175,74 @@ test('ASMRLIB category previews come from their own category and load after home const section = await c.getHomeSection({ sectionId: 'categories' }); assert.ok(requested.length >= 3); assert.ok(section.items.every(x => x.previewItems[0].id.startsWith(x.action.pageId + '-'))); }); +const asmrPost = 'becd2651e7d56ca656d27766d036dcee'; +const asmrCurrentPage = '

ASMR sample

'; +test('ASMRLIB current embedded players expose one interactive page action consistently', async () => { + const c = load('asmrlib-mini-library.js'); + c.Widget.http.get = async () => ({ status: 200, data: asmrCurrentPage }); + const input = { itemId: 'asmrlib://post/' + asmrPost }; + const detail = await c.getDetail(input), groups = await c.getResourceVersions(input); + assert.deepEqual(plain(groups), plain(detail.resourceGroups)); + const versions = groups[0].versions; + assert.equal(versions.length, 1); assert.equal(versions[0].default, true); + assert.equal(versions[0].action.itemId, input.itemId); + assert.equal(c.asmrId(versions[0].action.versionId), asmrPost); + assert.match(versions[0].subtitle, /验证或播放/); +}); +for (const player of ['https://bysetayico.com/e/old', 'https://abyssplayer.com/old']) { + test(`ASMRLIB legacy ${new URL(player).host} action preserves the original embedding page`, async () => { + const c = load('asmrlib-mini-library.js'); const calls = []; let http = 0; + c.Widget.http.get = async () => { http++; throw new Error('unnecessary detail refresh'); }; + c.Widget.browser = { async fetch(url, options) { calls.push({ url, options }); return { mediaSources: [{ url: 'https://cdn.test/main.m3u8', requestHeaders: { referer: player, origin: new URL(player).origin } }] }; } }; + const input = { itemId: 'asmrlib://post/' + asmrPost, versionId: 'asmrlib-line://old/' + encodeURIComponent(player) }; + const result = await c.resolvePlayback(JSON.stringify(input)); + assert.equal(result.url, 'https://cdn.test/main.m3u8'); assert.equal(result.container, 'm3u8'); + assert.equal(calls.length, 1); assert.equal(http, 0); + assert.equal(calls[0].url, 'https://asmrlib.com/posts/' + asmrPost); assert.equal(calls[0].options.visible, true); + assert.equal(calls[0].options.waitForMediaSource, true); + assert.equal(calls[0].options.captureRequests, undefined); assert.equal(calls[0].options.captureMedia, undefined); + assert.equal(result.headers.Referer, player); assert.equal(result.headers.Origin, new URL(player).origin); + }); +} +test('ASMRLIB page-version-only input uses captured media headers and not outer document headers', async () => { + const c = load('asmrlib-mini-library.js'); + c.Widget.browser = { async fetch() { return { headers: { Origin: 'https://outer.test' }, capturedRequests: [{ url: 'https://cdn.test/final.mp4', headers: { Referer: 'https://player.test/', Origin: 'https://player.test', Cookie: 'verified=sample', Connection: 'keep-alive' } }] }; } }; + const result = await c.resolvePlayback({ versionId: 'asmrlib-page://post/' + asmrPost }); + assert.equal(result.url, 'https://cdn.test/final.mp4'); assert.equal(result.headers.Origin, 'https://player.test'); + assert.equal(result.headers.Cookie, 'verified=sample'); assert.equal(result.headers.Connection, undefined); +}); +test('ASMRLIB refuses blob-only results and records capability evidence without retrying players', async () => { + const c = load('asmrlib-mini-library.js'); let calls = 0; + c.Widget.browser = { async fetch() { calls++; return { html: '', mediaSources: [{ url: 'blob:https://player.test/id' }] }; } }; + await assert.rejects(c.resolvePlayback({ itemId: asmrPost }), error => /stage=page-media/.test(error.message) && /blobOnly=true/.test(error.message) && /keys=html,mediaSources/.test(error.message)); + assert.equal(calls, 1); +}); +test('ASMRLIB honors hidden-page preference and reports manual verification requirements', async () => { + const c = load('asmrlib-mini-library.js'); let visible; + c.Widget.browser = { async fetch(url, options) { visible = options.visible; return { html: '

点击播放按钮以验证你是真人

' }; } }; + await assert.rejects(c.resolvePlayback({ itemId: asmrPost, params: { browserVisible: false } }), /stage=verification-required/); + assert.equal(visible, false); assert.equal(c.getManifest().parameters[0].defaultValue, true); +}); +test('ASMRLIB bounds a hung host browser and preserves the stage without exposing native exceptions', async () => { + const c = load('asmrlib-mini-library.js', { setTimeout: (fn, ms) => setTimeout(fn, ms / 100) }); let calls = 0; + c.Widget.browser = { async fetch() { calls++; return new Promise(() => {}); } }; + await assert.rejects(c.resolvePlayback({ itemId: asmrPost }), /stage=page-media/); assert.equal(calls, 1); + c.Widget.browser.fetch = async () => { throw new Error('Cookie=session-secret https://cdn.test/?token=secret'); }; + await assert.rejects(c.resolvePlayback({ itemId: asmrPost }), error => /stage=page-media/.test(error.message) && !/secret/.test(error.message)); +}); +test('ASMRLIB old UP failure falls back once to the embedded page instead of skipping browser capture', async () => { + const c = load('asmrlib-mini-library.js', { $crypto: { aesDecrypt() { throw new Error('unsupported AES'); } } }); let browser = 0, timeout; + c.Widget.http.get = async (url, options) => { timeout = options.timeout; return { data: 'abcdef123456' }; }; + c.Widget.browser = { async fetch(url) { browser++; assert.equal(url, 'https://asmrlib.com/posts/' + asmrPost); return { mediaURL: 'https://cdn.test/current.mp4' }; } }; + const versionId = 'asmrlib-line://UP/' + encodeURIComponent('https://v.upn.one/#legacycode'); + const result = await c.resolvePlayback({ itemId: asmrPost, versionId }); + assert.equal(result.url, 'https://cdn.test/current.mp4'); assert.equal(browser, 1); assert.equal(timeout, 5); + assert.equal(result.headers.Origin, undefined); assert.equal(result.headers.Referer, undefined); +}); +test('ASMRLIB retains direct media compatibility without requiring a browser', async () => { + const c = load('asmrlib-mini-library.js'); + assert.equal((await c.resolvePlayback({ url: 'https://cdn.test/legacy.mp4' })).url, 'https://cdn.test/legacy.mp4'); +}); test('Jable Forward adds sorting and page parameters once', async () => { const c = load('jable.js'); let captured; c.Widget.http.get = async url => { captured = new URL(url); throw new Error('fixture stop'); }; From 2c3359a374cffaae5c361aec4b2b7bec1f7180dd Mon Sep 17 00:00:00 2001 From: hzl <1803573449@qq.com> Date: Sat, 3 Oct 2026 21:32:23 +0800 Subject: [PATCH 3/6] Recover JAVGG packed media and bounded quality discovery --- CHANGELOG.md | 8 + README.md | 8 + javgg-mini-library.js | 338 +++++++++++++++++++++++++++------------- tests/adapters.test.cjs | 127 +++++++++++++++ 4 files changed, 373 insertions(+), 108 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 552a6bf..c94acc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +# JAVGG 1.0.2 — 2026-10-03 + +- 修复四条线路都被读为 `Server` 的问题;播放器容器编号与选项编号对应,避免遗漏 iframe 后错配线路及重复分组 ID。 +- 作为数据解包公开的 Dean Edwards 播放配置,不使用 `eval` 或执行网页代码。发现真实 HLS master 和画质,最高可用画质默认,旧载荷与原下载路径保留。 +- 资源发现限两路并发;失败线路独立降级。画质发现和播放均限 28 秒,播放器/清单 HTTP 各最多 4 秒;无静态可用线路时才进行一次浏览器捕获,不缓存失败或签名 URL。 +- 读取 `capturedRequests` 及其请求头;luluvdoo 使用实测的来源头,其他静态播放器不猜测 Origin/Referer。永久失效清单、验证页、blob-only 和宿主挂起分别给出阶段诊断。 +- 57 项媒体库离线回归通过,包含 11 项新检查。桌面匿名 HTTP 已验证两部影片的清单和范围分片;ATID-666 SW 实测 404。浏览器和匿名 HTTP 属诊断证据,iPhone/DreamBy 原生播放仍未验收。 + # 2026-10-03 基于 `main` 的 `8e43017532ec81e3338046ba6e32dff7655bd3dd` 修复审查中 O01–O25;保留所有已发布文件路径。 diff --git a/README.md b/README.md index 7754c84..e005a0d 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,14 @@ GitHub Actions 会先生成兼容副本并验证;`main` 的检查通过后, 具体行为和设备验证限制见 [CHANGELOG.md](CHANGELOG.md)。 +## JAVGG 1.0.2 播放修复稿 + +`javgg-mini-library.js` 保持原下载路径、媒体库 ID、`baseUrl` 参数和旧 `javgg://` 载荷。按实际 `data-nume` 对应 VH、playmate、luluvdoo、SW 线路,静态读取公开播放器的打包代码,不执行远程脚本。资源列表仅包含本次成功读取到的真实画质,最高画质默认;地址在每次播放时刷新。 + +画质发现与播放各有 28 秒总时限,播放器及清单 HTTP 阶段最多 4 秒;两路并发检查,某条线路失效不会抹掉其他可用线路。静态发现已有可用线路时直接返回;全部失败时最多一次 8 秒浏览器媒体捕获。已选线路播放的捕获最多 10 秒。404、验证页、非 HLS 响应会明确失败,临时发现失败可重新请求。脚本截止时间不能强制取消宿主已经启动的原生请求。 + +匿名桌面 HTTP 已验证 CAWB-046 的 luluvdoo 1080p、SW 1080p/720p/480p 和 MIRD-287 的 luluvdoo 720p,master、变体与分片可请求;ATID-666 的 SW 清单当时返回 404。首页/详情 HTTP 还可能遇到站点浏览器检查,浏览器正常打开不等于设备脚本能读取。DreamBy 浏览器回传、iPhone 原生起播与切画质仍待实测。 + ## 本地验收 ```bash diff --git a/javgg-mini-library.js b/javgg-mini-library.js index 98a770f..d78faf0 100644 --- a/javgg-mini-library.js +++ b/javgg-mini-library.js @@ -9,7 +9,7 @@ const WidgetMetadata = { id: 'javgg-mini-library', name: 'JAVGG', title: 'JAVGG', - version: '1.0.1', + version: '1.0.2', requiredVersion: '0.0.1', author: 'Alan huang', site: JAVGG_DEFAULT_BASE, @@ -218,21 +218,34 @@ async function getDetail(rawCtx) { } async function getResourceVersions(rawCtx) { + return withPlaybackBudget(rawCtx, discoverResourceVersions, '画质发现'); +} + +async function discoverResourceVersions(rawCtx) { const ctx = normalizeContext(rawCtx); const directPayload = decodePayload(firstNonEmpty(ctx.versionId, ctx.itemId, ctx.id)); const detailUrl = firstNonEmpty(directPayload.detailUrl, detailURLFromContext(ctx)); if (!detailUrl) return []; - const html = await fetchText(ctx, detailUrl); + const html = await fetchText(ctx, detailUrl, detailUrl, null, 4, 'detail-http'); const title = cleanText(firstNonEmpty(ctx.title, metaContent(html, 'property', 'og:title'), pageTitle(html))); const players = parsePlayers(html); - if (!players.length) throw new Error('源站详情页当前没有可用播放线路'); - const groups = []; - for (let index = 0; index < players.length; index += 1) { - const qualities = await discoverQualities(ctx, detailUrl, players[index]); - if (qualities.length) groups.push(qualityGroup(detailUrl, title, players[index], qualities)); + if (!players.length) throw stageError('detail-players', '源站详情页当前没有可用播放线路'); + const attempts = await inspectPlayerQualities(ctx, detailUrl, players); + const groups = attempts.filter(function (attempt) { return attempt.qualities.length; }) + .map(function (attempt) { return qualityGroup(detailUrl, title, attempt.player, attempt.qualities); }); + if (groups.length) return groups; + // Browser execution is one fallback only when static discovery produced no usable line. + const candidate = attempts.filter(function (attempt) { return !attempt.media; })[0]; + if (candidate && remainingPlaybackSeconds(ctx, 8) > 0.5) { + const captured = await extractFromBrowser(candidate.player.url, detailUrl, ctx, 8); + if (captured) { + const retry = await discoverQualities(ctx, detailUrl, candidate.player, false, captured); + if (retry.qualities.length) return [qualityGroup(detailUrl, title, retry.player, retry.qualities)]; + candidate.stage = retry.stage; + } } - if (!groups.length) throw new Error('暂时无法发现可用画质,请稍后重试'); - return groups; + const diagnostic = attempts.map(function (attempt) { return attempt.player.line + ':' + attempt.stage; }).join(', '); + throw stageError('quality-discovery', '未发现可用画质;' + diagnostic + ';请稍后重试'); } async function resolvePlaybackWithinBudget(rawCtx) { @@ -241,31 +254,44 @@ async function resolvePlaybackWithinBudget(rawCtx) { let playerUrl = firstNonEmpty(ctx.playerUrl, payload.playerUrl); let detailUrl = firstNonEmpty(payload.detailUrl, detailURLFromContext(ctx)); let line = firstNonEmpty(payload.line, ctx.line); + const requestedHeight = positiveInt(firstNonEmpty(payload.height, ctx.height), 0); + let media; if (!playerUrl && detailUrl) { - const players = parsePlayers(await fetchText(ctx, detailUrl)); - const selected = players.filter(function (item) { return !line || item.line === line; })[0] || players[0]; - playerUrl = selected && selected.url; - line = selected && selected.line; + const players = parsePlayers(await fetchText(ctx, detailUrl, detailUrl, null, 4, 'detail-http')); + const selected = players.filter(function (item) { return payload.lineId ? item.lineId === payload.lineId : (line && item.line === line); }); + const attempts = await inspectPlayerQualities(ctx, detailUrl, selected.length ? selected : players); + const usable = attempts.filter(function (attempt) { return attempt.qualities.length; }); + usable.sort(function (a, b) { + const aExact = a.qualities.some(function (q) { return q.height === requestedHeight && requestedHeight > 0; }); + const bExact = b.qualities.some(function (q) { return q.height === requestedHeight && requestedHeight > 0; }); + return Number(bExact) - Number(aExact) || (b.qualities[0].height || 0) - (a.qualities[0].height || 0); + }); + const chosen = usable[0]; + const player = chosen ? chosen.player : (selected[0] || players[0]); + playerUrl = player && player.url; + line = player && player.line; + media = chosen && chosen.media; } - if (!playerUrl) throw new Error('未找到 JAVGG 播放器地址'); - - let playable = await resolvePlayerMedia(ctx, playerUrl, detailUrl); - if (!playable) throw new Error('线路 ' + (line || '') + ' 暂时无法解析,请切换其他线路'); - const requestedHeight = positiveInt(firstNonEmpty(payload.height, ctx.height), 0); - if (requestedHeight && /\.m3u8(?:$|[?#])/i.test(playable)) { + if (!playerUrl) throw stageError('detail-players', '未找到 JAVGG 播放器地址'); + if (!media) media = await resolvePlayerMedia(ctx, playerUrl, detailUrl, true); + if (!media.url) throw stageError(media.stage || 'player-media', '线路 ' + (line || '') + ' 暂时无法解析,请切换其他线路'); + let playable = media.url; + if (/\.m3u8(?:$|[?#])/i.test(playable)) { try { - const playlist = await fetchText(ctx, playable, playerUrl); + const playlist = await fetchText(ctx, playable, playerUrl, media.headers, 4, 'manifest-http'); + if (!/#EXTM3U/i.test(playlist)) throw stageError('manifest-not-hls', '来源未返回 HLS 清单'); const variants = parseMasterPlaylist(playable, playlist); - const selected = variants.filter(function (item) { return item.height === requestedHeight; })[0]; + const selected = variants.filter(function (item) { return item.height === requestedHeight; })[0] || variants[0]; if (selected) playable = selected.url; } catch (error) { - // The master itself remains a valid highest/automatic fallback. + if (/-4\d\d$|verification$|not-hls$/.test(error.stage || '')) throw error; + // Preserve the fresh real master only when probing fails transiently. } } return { url: playable, container: /\.m3u8(?:$|[?#])/i.test(playable) ? 'm3u8' : 'mp4', - headers: playbackHeaders(playerUrl), + headers: media.headers, startPositionSeconds: 0, isLive: false, streamKind: /\.m3u8(?:$|[?#])/i.test(playable) ? 'hls' : 'file' @@ -350,32 +376,56 @@ function parseCards(ctx, html) { function parsePlayers(html) { const text = String(html || ''); - const results = []; - const iframeURLs = []; - const iframePattern = /]*(?:data-lazy-src|data-src|src)=["']([^"']+)["'][^>]*>/gi; + const options = {}; + const optionPattern = /]*)>([\s\S]*?)<\/li>/gi; let match; - while ((match = iframePattern.exec(text))) { - const url = htmlDecode(match[1]); - if (/earnvidjav|javggvideo|streamhgjav|\/embed\/|\/e\/|\/t\//i.test(url)) iframeURLs.push(url); + while ((match = optionPattern.exec(text))) { + if (!/\bdooplay_player_option\b/i.test(attributeValue(match[1], 'class'))) continue; + const number = attributeValue(match[1], 'data-nume'); + const server = firstMatch(match[2], /]*class=["'][^"']*\bserver\b[^"']*["'][^>]*>([\s\S]*?)<\/span>/i); + const title = firstMatch(match[2], /]*class=["'][^"']*\btitle\b[^"']*["'][^>]*>([\s\S]*?)<\/span>/i); + options[number] = cleanText(server || title) || ('线路 ' + number); } - const optionPattern = /]*class=["'][^"']*\bdooplay_player_option\b[^"']*["'][^>]*data-post=["']([^"']+)["'][^>]*data-nume=["']([^"']+)["'][^>]*>[\s\S]*?(?:]*class=["'][^"']*\btitle\b[^"']*["'][^>]*>([\s\S]*?)<\/span>|Server[\s\S]*?([A-Z]{1,5}))[\s\S]*?<\/li>/gi; - const labels = []; - while ((match = optionPattern.exec(text))) labels.push(cleanText(match[3] || match[4])); - iframeURLs.forEach(function (url, index) { - const line = labels[index] || ['VH', 'TB', 'SW'][index] || ('线路 ' + (index + 1)); - results.push({ line: line, url: url }); - }); - return uniqueBy(results, function (x) { return x.url; }); + const results = []; + // source-player-N matches data-nume=N, even if a frame is missing or reordered. + const sourcePattern = /]*\bid=["']source-player-\d+["'][^>]*)>([\s\S]*?]*>)/gi; + while ((match = sourcePattern.exec(text))) { + const id = attributeValue(match[1], 'id').match(/^source-player-(\d+)$/); + if (!id) continue; + const frame = firstMatch(match[2], /]*)>/i); + const url = iframeURL(frame); + if (url) results.push({ lineId: id[1], line: options[id[1]] || ('线路 ' + id[1]), url: url }); + } + if (!results.length) { + const frames = text.match(/]*>/gi) || []; + const numbers = Object.keys(options); + frames.forEach(function (frame) { + const url = iframeURL(frame); + if (!url) return; + const number = numbers[results.length] || String(results.length + 1); + results.push({ lineId: number, line: options[number] || ('线路 ' + number), url: url }); + }); + } + return uniqueBy(results, function (player) { return player.url; }); +} + +function attributeValue(tag, name) { + return htmlDecode(firstMatch(tag, new RegExp('\\b' + escapeRegExp(name) + '\\s*=\\s*["\']([^"\']*)', 'i'))); +} + +function iframeURL(tag) { + const url = firstNonEmpty(attributeValue(tag, 'data-lazy-src'), attributeValue(tag, 'data-src'), attributeValue(tag, 'src')); + return /^https?:\/\//i.test(url) && /earnvidjav|javggvideo|streamhgjav|\/embed\/|\/e\/|\/t\//i.test(url) ? url : ''; } function qualityGroup(detailUrl, title, player, qualities) { return { - id: 'line-' + player.line, + id: 'line-' + (player.lineId || player.line), title: player.line + ' 线路', versions: qualities.map(function (quality, index) { const payload = encodePayload({ kind: 'play', detailUrl: detailUrl, playerUrl: player.url, - line: player.line, height: quality.height || 0, title: title + line: player.line, lineId: player.lineId, height: quality.height || 0, title: title }); return { id: payload, @@ -392,34 +442,50 @@ function qualityGroup(detailUrl, title, player, qualities) { }; } -async function discoverQualities(ctx, detailUrl, player) { - const mediaURL = await resolvePlayerMedia(ctx, player.url, detailUrl); - if (!mediaURL) return []; - if (!/\.m3u8(?:$|[?#])/i.test(mediaURL)) return [{ name: 'MP4', height: 0 }]; - try { - const playlist = await fetchText(ctx, mediaURL, player.url); - const variants = parseMasterPlaylist(mediaURL, playlist); - if (variants.length) { - return variants.map(function (variant) { - return { name: variant.height ? variant.height + 'p' : 'HLS', height: variant.height || 0 }; - }); +async function inspectPlayerQualities(ctx, detailUrl, players) { + const attempts = new Array(players.length); + let next = 0; + async function worker() { + while (next < players.length) { + const index = next++; + attempts[index] = await discoverQualities(ctx, detailUrl, players[index], false); } - if (/#EXTINF/i.test(playlist)) return [{ name: 'HLS 原始画质', height: 0 }]; - } catch (error) { - return []; } - return []; + await Promise.all([worker(), worker()]); + return attempts; } -async function resolvePlayerMedia(ctx, playerUrl, detailUrl) { - let playable = ''; +async function discoverQualities(ctx, detailUrl, player, allowBrowser, captured) { + const media = captured || await resolvePlayerMedia(ctx, player.url, detailUrl, allowBrowser); + const result = { player: player, qualities: [], media: media.url ? media : null, stage: media.stage || 'player-media' }; + if (!media.url) return result; + if (!/\.m3u8(?:$|[?#])/i.test(media.url)) { + result.qualities = [{ name: 'MP4', height: 0 }]; + return result; + } try { - playable = extractPlayableURL(await fetchText(ctx, playerUrl, detailUrl || playerUrl)); - } catch (error) { - playable = ''; + const playlist = await fetchText(ctx, media.url, player.url, media.headers, 4, 'manifest-http'); + const variants = parseMasterPlaylist(media.url, playlist); + if (variants.length) result.qualities = variants.map(function (variant) { + return { name: variant.height ? variant.height + 'p' : 'HLS', height: variant.height || 0 }; + }); + else if (/#EXTINF/i.test(playlist)) result.qualities = [{ name: 'HLS 原始画质', height: 0 }]; + else result.stage = 'manifest-not-hls'; + } catch (error) { result.stage = error.stage || 'manifest-http'; } + return result; +} + +async function resolvePlayerMedia(ctx, playerUrl, detailUrl, allowBrowser) { + let stage = 'player-media'; + try { + const playable = extractPlayableURL(await fetchText(ctx, playerUrl, detailUrl || playerUrl, null, 4, 'player-http')); + if (playable) return { url: playable, headers: playbackHeaders(playerUrl) }; + } catch (error) { stage = error.stage || 'player-http'; } + if (allowBrowser) { + const captured = await extractFromBrowser(playerUrl, detailUrl, ctx, 10); + if (captured) return captured; } - if (!playable) playable = await extractFromBrowser(playerUrl, detailUrl, ctx); - return playable; + return { url: '', stage: stage }; } function parseMasterPlaylist(masterURL, text) { @@ -450,20 +516,21 @@ function resolveRelativeURL(base, value) { return String(base || '').replace(/[?#].*$/, '').replace(/\/[^/]*$/, '/') + value; } -async function fetchText(ctx, url, referer) { - const response = await httpGet(url, { - headers: requestHeaders(referer || url), - timeout: remainingPlaybackSeconds(ctx, 6), timeoutSeconds: remainingPlaybackSeconds(ctx, 6), - useBrowserCookie: false, - attachBrowserCookie: false, - useBrowserFallback: false, - browserFallback: false +async function fetchText(ctx, url, referer, headers, limit, stage) { + const seconds = remainingPlaybackSeconds(ctx, limit || 6); + const response = await runStage(ctx, stage || 'http', seconds, function () { + return httpGet(url, { + headers: headers || requestHeaders(referer || url), + timeout: seconds, timeoutSeconds: seconds, + useBrowserCookie: false, attachBrowserCookie: false, + useBrowserFallback: false, browserFallback: false + }); }); const text = responseText(response); - if (!text) throw new Error('请求失败: ' + url); - if (isVerificationPage(text, response && (response.status || response.statusCode))) { - throw new Error('源站返回了浏览器验证页'); - } + const status = Number(response && (response.status || response.statusCode)); + if (status >= 400) throw stageError((stage || 'http') + '-' + status, '来源请求失败(HTTP ' + status + ')'); + if (isVerificationPage(text, status)) throw stageError((stage || 'http') + '-verification', '源站要求浏览器验证'); + if (!text) throw stageError((stage || 'http') + '-empty', '来源返回空内容'); return text; } @@ -483,46 +550,54 @@ async function httpGet(url, options) { throw new Error('当前环境没有可用的 HTTP 客户端'); } -async function extractFromBrowser(url, referer, ctx) { - if (typeof Widget === 'undefined' || !Widget.browser || typeof Widget.browser.fetch !== 'function') return ''; +async function extractFromBrowser(url, referer, ctx, limit) { + if (typeof Widget === 'undefined' || !Widget.browser || typeof Widget.browser.fetch !== 'function') return null; + const seconds = playbackBrowserSeconds(ctx, limit || 10, 1); try { - const result = await Widget.browser.fetch(url, { - visible: false, - timeout: playbackBrowserSeconds(ctx, 12, 1), - timeoutSeconds: remainingPlaybackSeconds(ctx, 12), - waitAfterLoad: 4, - waitForAny: true, - waitForMediaSource: true, - headers: requestHeaders(referer || url) + const result = await runStage(ctx, 'browser-media', seconds, function () { + return Widget.browser.fetch(url, { + visible: false, timeout: seconds, timeoutSeconds: seconds, + waitAfterLoad: 1, waitForMediaSource: true, + headers: requestHeaders(referer || url) + }); }); - return firstPlayableInBrowserResult(result) || - extractPlayableURL(responseText(result)) || - extractPlayableURL(result && result.html); + const captured = firstPlayableInBrowserResult(result); + if (captured) return captured; + const link = extractPlayableURL(responseText(result)) || extractPlayableURL(result && result.html); + if (link) return { url: link, headers: { 'User-Agent': JAVGG_UA } }; + const keys = Object.keys(result || {}).slice(0, 12).join(','); + const blobOnly = /blob:https?:/.test(JSON.stringify((result && (result.mediaSources || result.capturedRequests || result.mediaRequests)) || [])); + throw stageError(blobOnly ? 'browser-blob-only' : 'browser-no-media', '浏览器未回传可用媒体;blobOnly=' + blobOnly + ';keys=' + keys); } catch (error) { - return ''; + throw stageError(error.stage || 'browser-media', error.stage ? error.message.replace(/;stage=.*$/, '') : '浏览器媒体捕获失败'); } } function firstPlayableInBrowserResult(result) { - if (!result) return ''; - const keys = ['url', 'mediaURL', 'mediaUrl', 'videoURL', 'videoUrl', 'playURL', 'playUrl', 'src']; - for (let i = 0; i < keys.length; i += 1) { - if (isPlayableURL(result[keys[i]])) return result[keys[i]]; - } - const arrays = [result.mediaSources, result.mediaRequests, result.requests, result.responses, result.urls]; + if (!result) return null; + const arrays = [result.capturedRequests, result.mediaRequests, result.requests, result.responses, result.mediaSources, result.urls, [result]]; for (let a = 0; a < arrays.length; a += 1) { if (!Array.isArray(arrays[a])) continue; for (let i = 0; i < arrays[a].length; i += 1) { const item = arrays[a][i]; - const value = typeof item === 'string' ? item : firstNonEmpty(item && item.url, item && item.src, item && item.responseURL); - if (isPlayableURL(value) && !/^blob:/i.test(value)) return value; + const url = typeof item === 'string' ? item : firstNonEmpty(item && item.url, item && item.src, item && item.responseURL, item && item.mediaURL, item && item.mediaUrl, item && item.videoURL, item && item.videoUrl, item && item.playURL, item && item.playUrl); + if (isPlayableURL(url)) return { url: url, headers: capturedHeaders(item && (item.requestHeaders || item.headers)) }; } } - return ''; + return null; +} + +function capturedHeaders(input) { + const headers = { 'User-Agent': JAVGG_UA }; + if (input && typeof input === 'object') Object.keys(input).forEach(function (key) { + const canonical = { 'user-agent': 'User-Agent', referer: 'Referer', origin: 'Origin', cookie: 'Cookie' }[key.toLowerCase()]; + if (canonical) headers[canonical] = String(input[key]); + }); + return headers; } function extractPlayableURL(value) { - let text = htmlDecode(responseText(value)).replace(/\\\//g, '/'); + const text = htmlDecode(responseText(value) + '\n' + unpackPlayerScripts(responseText(value)).join('\n')).replace(/\\\//g, '/'); const patterns = [ /(?:urlPlay|file|src)\s*[:=]\s*["'](https?:\/\/[^"']+\.(?:m3u8|mp4)(?:\?[^"']*)?)/i, /(https?:\/\/[^\s"'<>\\]+\.(?:m3u8|mp4)(?:\?[^\s"'<>\\]*)?)/i @@ -534,6 +609,35 @@ function extractPlayableURL(value) { return ''; } +// Decode the public Dean Edwards packing format as data; never execute source scripts. +function unpackPlayerScripts(value) { + const text = String(value || '').slice(0, 500000); + const pattern = /eval\(function\(p,a,c,k,e,[dr]\)[\s\S]*?\(\s*'((?:\\.|[^'\\])*)'\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*'((?:\\.|[^'\\])*)'\.split\('\|'\)/g; + const output = []; + let match; + while (output.length < 4 && (match = pattern.exec(text))) { + const radix = Number(match[2]), count = Number(match[3]); + if (radix < 2 || radix > 62 || count < 1 || count > 2000 || match[1].length > 100000) continue; + const words = decodePackedString(match[4]).split('|'); + const dictionary = Object.create(null); + for (let index = 0; index < count; index += 1) if (words[index]) dictionary[baseEncode(index, radix)] = words[index]; + output.push(decodePackedString(match[1]).replace(/\b[0-9a-zA-Z]+\b/g, function (token) { return dictionary[token] || token; })); + } + return output; +} + +function decodePackedString(text) { + return String(text || '').replace(/\\(['"\\])/g, '$1').replace(/\\x([0-9a-f]{2})|\\u([0-9a-f]{4})/gi, function (_, x, u) { return String.fromCharCode(parseInt(x || u, 16)); }); +} + +function baseEncode(number, radix) { + const alphabet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; + if (!number) return '0'; + let token = ''; + while (number > 0) { token = alphabet[number % radix] + token; number = Math.floor(number / radix); } + return token; +} + function requestHeaders(referer) { return { 'User-Agent': JAVGG_UA, @@ -547,11 +651,13 @@ function imageHeaders(referer) { } function playbackHeaders(playerUrl) { - return { - 'User-Agent': JAVGG_UA, - Referer: playerUrl, - Origin: originOf(playerUrl) - }; + const headers = { 'User-Agent': JAVGG_UA }; + // Observed on luluvdoo master, variant and segment requests (not the detail-page URL). + if (/^https:\/\/luluvdoo\.com(?:\/|$)/i.test(String(playerUrl))) { + headers.Referer = 'https://luluvdoo.com/'; + headers.Origin = 'https://luluvdoo.com'; + } + return headers; } function baseURL(ctx) { @@ -648,7 +754,7 @@ function responseText(response) { function isVerificationPage(html, status) { const text = String(html || '').slice(0, 30000); - return Number(status) === 403 || /Just a moment|Checking your browser|cf-browser-verification|cf-chl-/i.test(text); + return Number(status) === 403 || /Just a moment|Checking (?:your )?browser|cf-browser-verification|cf-chl-/i.test(text); } function absoluteURL(ctx, value) { @@ -808,16 +914,32 @@ if (typeof globalThis !== 'undefined') { } if (typeof module !== 'undefined' && module.exports) module.exports = exported; -async function withPlaybackBudget(input, operation) { - let ctx = input; - if (typeof ctx === 'string') { try { ctx = JSON.parse(ctx); } catch (_) { ctx = {}; } } - ctx = Object.assign({}, ctx && typeof ctx === 'object' ? ctx : {}); +function stageError(stage, message) { + const error = new Error(message + ';stage=' + stage); + error.stage = stage; + return error; +} + +async function runStage(ctx, stage, seconds, operation) { + const budget = ctx && ctx.__playbackBudget; + if (budget && budget.expired) throw stageError('total-deadline', '解析超时'); + let timer; + try { + const timeout = new Promise(function (_, reject) { + timer = setTimeout(function () { reject(stageError(stage + '-timeout', '来源请求超时')); }, seconds * 1000); + }); + return await Promise.race([Promise.resolve().then(operation), timeout]); + } finally { clearTimeout(timer); } +} + +async function withPlaybackBudget(input, operation, label) { + const ctx = Object.assign({}, normalizeContext(input)); const budget = { endAt: Date.now() + 28000, expired: false, browserCalls: 0 }; ctx.__playbackBudget = budget; let timer; try { const timeout = new Promise(function (_, reject) { - timer = setTimeout(function () { budget.expired = true; reject(new Error('播放解析超时;stage=total-deadline;请稍后重试')); }, 28000); + timer = setTimeout(function () { budget.expired = true; reject(stageError('total-deadline', (label || '播放解析') + '超时;请稍后重试')); }, 28000); }); return await Promise.race([Promise.resolve().then(function () { return operation(ctx); }), timeout]); } finally { clearTimeout(timer); budget.expired = true; } diff --git a/tests/adapters.test.cjs b/tests/adapters.test.cjs index eba6a24..a4f58f5 100644 --- a/tests/adapters.test.cjs +++ b/tests/adapters.test.cjs @@ -257,3 +257,130 @@ test('XVideos attaches the configured session token without printing it', async const options=await vm.runInContext('widgetAPI.getDefaultOptions()',c); assert.equal(options.headers.Cookie,'session_token='+token); assert.ok(logs.every(line=>!line.includes(token))); }); + +function javggDetail(lines = ['VH', 'playmate', 'luluvdoo', 'SW']) { + return '
    ' + lines.map((line, i) => + `
  • Server${line}
  • `).join('') + '
' + lines.map((line, i) => + `
`).join('') + '
'; +} +function packedJavgg(host = 'cdn', file = 'master', signature = 'fresh') { + return `eval(function(p,a,c,k,e,d){return p;}('0({1:[{2:"3://4.5/6.7?8=9"}]});',36,10,'setup|sources|file|https|${host}|test|${file}|m3u8|sign|${signature}'.split('|'),0,{}))`; +} + +test('JAVGG maps real server labels to numbered frames, despite missing/reordered frames', () => { + const c = load('javgg-mini-library.js'); + const source = javggDetail().replace(/
[\s\S]*?<\/div><\/div>/, ''); + const players = c.parsePlayers(source); + assert.deepEqual(plain(players.map(x => [x.lineId, x.line])), [['2','playmate'],['3','luluvdoo'],['4','SW']]); + assert.equal(players[0].url, 'https://player2.test/e/film'); + const groups = players.map(x => c.qualityGroup('https://javgg.net/jav/a/', 'A', x, [{name:'720p',height:720}])); + assert.equal(new Set(groups.map(x => x.id)).size, 3); +}); + +test('JAVGG discovers packed HLS qualities while hung and empty players fail independently', async () => { + const c = load('javgg-mini-library.js', { setTimeout: (fn, ms) => setTimeout(fn, ms / 100) }); + let active = 0, max = 0, browsers = 0; + c.Widget.browser = { async fetch() { browsers++; throw new Error('should not be needed'); } }; + c.Widget.http.get = async url => { + if (url.includes('/jav/')) return {statusCode:200,data:javggDetail()}; + if (url.includes('player1')) return new Promise(() => {}); + active++; max = Math.max(active, max); await Promise.resolve(); active--; + if (url.includes('player2')) return {statusCode:200,data:'dynamic player'}; + if (url.includes('player3')) return {statusCode:200,data:packedJavgg('lulu')}; + if (url.includes('player4')) return {statusCode:200,data:packedJavgg('sw')}; + return {statusCode:200,data:url.includes('lulu') ? master.split('#EXT-X-STREAM-INF:BANDWIDTH=900000')[0] : master}; + }; + const groups = await c.getResourceVersions(JSON.stringify({detailUrl:'https://javgg.net/jav/a/'})); + assert.deepEqual(plain(groups.map(x => x.title)), ['luluvdoo 线路','SW 线路']); + assert.deepEqual(plain(groups[1].versions.map(x => x.name)), ['1080p','720p']); + assert.equal(groups[1].versions.filter(x => x.default).length, 1); + assert.equal(browsers, 0); assert.ok(max <= 2); + const payload = c.decodePayload(groups[1].versions[1].id); + assert.equal(payload.height,720); assert.equal(payload.lineId,'4'); assert.equal(payload.playerUrl,'https://player4.test/e/film'); + assert.ok(!groups[1].versions[0].id.includes('sign')); +}); + +test('JAVGG refreshes signed packed URLs and selects highest / requested / missing quality', async () => { + const c = load('javgg-mini-library.js'); let refreshes = 0; + c.Widget.http.get = async url => url.includes('player.test') ? {status:200,data:packedJavgg('cdn','master','fresh'+(++refreshes))} : {status:200,data:master}; + for (const height of [0,720,2160,1080]) { + const result = await c.resolvePlayback({versionId:c.encodePayload({kind:'play',detailUrl:'https://javgg.net/jav/a/',playerUrl:'https://player.test/e/a',line:'Server',height})}); + assert.equal(result.url, 'https://cdn.test/' + (height === 720 ? '720' : '1080') + '.m3u8'); + } + assert.equal(refreshes,4); +}); + +test('JAVGG does not execute packed scripts or accept unbounded packing dictionaries', () => { + const c = load('javgg-mini-library.js'); + assert.equal(c.extractPlayableURL(packedJavgg()),'https://cdn.test/master.m3u8?sign=fresh'); + assert.equal(c.extractPlayableURL(packedJavgg().replace('return p;', 'globalThis.compromised=true;return p;')),'https://cdn.test/master.m3u8?sign=fresh'); + assert.equal(c.compromised,undefined); + assert.equal(c.extractPlayableURL(packedJavgg().replace(',36,10,', ',36,200000,')), ''); +}); + +test('JAVGG rejects expired 404 manifests, retries fresh discovery, and leaves detail resources empty', async () => { + const c = load('javgg-mini-library.js'); let available = false; + c.Widget.http.get = async url => url.includes('/jav/') ? {status:200,data:javggDetail(['SW'])} : url.includes('player1') ? {status:200,data:packedJavgg()} : {statusCode:available ? 200 : 404,data:available ? media : 'Not Found'}; + const ctx={detailUrl:'https://javgg.net/jav/a/'}; + assert.equal((await c.getDetail(ctx)).resourceGroups.length,0); + await assert.rejects(c.getResourceVersions(ctx), /manifest-http-404/); + available=true; + const groups=await c.getResourceVersions(ctx);assert.equal(groups[0].versions[0].name,'HLS 原始画质'); +}); + +test('JAVGG captures real browser request headers once, then resolves a lower quality', async () => { + const c=load('javgg-mini-library.js'); let browsers=0; const manifestHeaders=[]; + c.Widget.http.get=async (url, options)=>{ + if(url.includes('/jav/')) return {status:200,data:javggDetail(['playmate'])}; + if(url.includes('player1')) return {status:200,data:'dynamic'}; + manifestHeaders.push(options.headers);return {status:200,data:master}; + }; + c.Widget.browser={async fetch(url,options){browsers++;assert.equal(options.visible,false);assert.equal(options.waitForAny,undefined);assert.ok(options.timeout<=10);return {capturedRequests:[{url:'https://cdn.test/master.m3u8?fresh=1',requestHeaders:{referer:'https://actual-player.test/',origin:'https://actual-player.test','user-agent':'Actual Agent',cookie:'fixture-only','Authorization':'must-not-forward'}}]};}}; + const groups=await c.getResourceVersions({detailUrl:'https://javgg.net/jav/a/'}); + const result=await c.resolvePlayback(groups[0].versions[1].action); + assert.equal(result.url,'https://cdn.test/720.m3u8');assert.equal(browsers,2); + assert.deepEqual(plain(result.headers),{'User-Agent':'Actual Agent',Referer:'https://actual-player.test/',Origin:'https://actual-player.test',Cookie:'fixture-only'}); + assert.ok(manifestHeaders.every(h=>h.Referer==='https://actual-player.test/')); +}); + +test('JAVGG bounds a hung native browser and identifies capture failure without exposing tokens', async () => { + const c=load('javgg-mini-library.js',{setTimeout:(fn,ms)=>setTimeout(fn,ms/100)});let browsers=0; + c.Widget.http.get=async url=>({status:200,data:url.includes('/jav/')?javggDetail(): 'dynamic'}); + c.Widget.browser={async fetch(){browsers++;return new Promise(()=>{});}}; + const start=Date.now();await assert.rejects(c.getResourceVersions({detailUrl:'https://javgg.net/jav/a/'}),/browser-media-timeout/); + assert.equal(browsers,1);assert.ok(Date.now()-start<500); + c.Widget.browser.fetch=async()=>({mediaSources:['blob:https://player.test/id'],secret:'must-not-leak'}); + await assert.rejects(c.resolvePlayback({playerUrl:'https://player.test/e/a'}),e=>/browser-.*media|browser-blob/.test(e.message)&&!e.message.includes('must-not-leak')); +}); + +test('JAVGG quality discovery itself has an overall deadline', async () => { + const c=load('javgg-mini-library.js',{setTimeout:fn=>setTimeout(fn,15)}); + c.discoverResourceVersions=async()=>new Promise(()=>{}); + await assert.rejects(c.getResourceVersions({}),/画质发现超时.*total-deadline/); +}); + +test('JAVGG direct detail Play explores later lines and selects the highest real quality', async () => { + const c=load('javgg-mini-library.js'); + c.Widget.http.get=async url=>{ + if(url.includes('/jav/')) return {status:200,data:javggDetail(['luluvdoo','SW'])}; + if(url.includes('player1')) return {status:200,data:packedJavgg('low')}; + if(url.includes('player2')) return {status:200,data:packedJavgg('high')}; + return {status:200,data:url.includes('low')?master.replace(/1920x1080/g,'1280x720').replace(/1080.m3u8/g,'720.m3u8'):master}; + }; + const result=await c.resolvePlayback({itemId:c.encodePayload({kind:'detail',detailUrl:'https://javgg.net/jav/a/'})}); + assert.equal(result.url,'https://high.test/1080.m3u8'); +}); + +test('JAVGG playback fails explicitly when a fresh manifest is permanently unavailable', async () => { + const c=load('javgg-mini-library.js'); + c.Widget.http.get=async url=>url.includes('player.test')?{status:200,data:packedJavgg()}:{statusCode:404,data:'Not Found'}; + await assert.rejects(c.resolvePlayback({playerUrl:'https://player.test/e/a'}),/manifest-http-404/); +}); + +test('JAVGG uses observed luluvdoo headers and does not invent headers for other players', () => { + const c=load('javgg-mini-library.js'); + const known=c.playbackHeaders('https://luluvdoo.com/e/film'); + assert.equal(known.Referer,'https://luluvdoo.com/');assert.equal(known.Origin,'https://luluvdoo.com'); + const other=c.playbackHeaders('https://javstreamhq.xyz/e/film'); + assert.equal(other.Origin,undefined);assert.equal(other.Referer,undefined); +}); From 583e7670af0770a8baa4b14d5635351b9cf8655b Mon Sep 17 00:00:00 2001 From: hzl <1803573449@qq.com> Date: Sat, 3 Oct 2026 21:55:14 +0800 Subject: [PATCH 4/6] Bound SexBJCam playback and prefer static player configuration --- CHANGELOG.md | 8 + README.md | 8 + sexbjcam-mini-library.js | 280 ++++++++++++++++-------------- sexbjcam-network-recovery.test.js | 5 +- tests/adapters.test.cjs | 104 +++++++++++ 5 files changed, 271 insertions(+), 134 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 552a6bf..3448f45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +# SexBJCam 1.1.9 — 2026-10-03 + +- 播放器改为 HTTP 静态配置优先,必要时一次浏览器捕获;取消浏览器读取 HLS 正文及三轮画质重试。HTTP/正文和浏览器等待均有脚本侧截止时间,详情、资源发现与播放各限 28 秒。 +- 保留历史条目/画质 ID、媒体库 ID、`baseURL` 和下载路径。每次播放刷新地址,缺失请求画质时选择最高真实画质。 +- 原始 HLS 显示为原始画质,不缓存失败为假默认线路。此前成功的稳定分辨率元数据仍可在临时网络故障时复用;不保存签名 URL 或媒体请求凭证。永久失效、验证页和非 HLS 清单明确失败。 +- 支持 `capturedRequests` 与实际媒体请求头,取消跨来源推测 Origin/Referer 及从任意捕获变体猜造 master 地址。公开 CDN 的最小 User-Agent 流程已通过匿名清单与分片检查。 +- 修正详情标题读取上传者姓名的问题。58 项媒体库回归(新增 12 项 SexBJCam 检查)及网络恢复测试通过;设备浏览器回传和 iPhone 原生起播仍待验收。 + # 2026-10-03 基于 `main` 的 `8e43017532ec81e3338046ba6e32dff7655bd3dd` 修复审查中 O01–O25;保留所有已发布文件路径。 diff --git a/README.md b/README.md index 7754c84..bc681d5 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,14 @@ GitHub Actions 会先生成兼容副本并验证;`main` 的检查通过后, 具体行为和设备验证限制见 [CHANGELOG.md](CHANGELOG.md)。 +## SexBJCam 1.1.9 播放修复稿 + +保留 `sexbjcam-mini-library.js` 原下载路径、`sexbjcam-mini-library` ID、`baseURL` 参数、历史详情 URL 与 `quality:1080/720/480` ID。播放器改为先用 HTTP 读取公开打包配置,必要时只做一次 12 秒浏览器媒体捕获;HLS 正文使用最多 4 秒 HTTP 请求,不再用浏览器导航清单。详情、资源发现和播放各有 28 秒总时限;脚本截止时间不能强制取消宿主原生任务。 + +画质仅取真实清单或此前成功验证的分辨率元数据,签名地址在播放时刷新。只有原始 HLS 时明确显示“原始画质”,暂时失败不生成假默认线路;404、验证页、非 HLS 和捕获失败显示具体阶段。捕获媒体请求头仅用于本次播放,不写入画质缓存。详情标题优先读取影片标题,避免把嵌套上传者姓名当作标题。 + +匿名 HTTP 已验证 `recordplay.biz` 和 `playrecord.biz` 的真实 master、1080p/720p/480p 与范围分片;播放器请求也有超时,源站并非始终稳定。匿名主站详情出现过 Cloudflare 403,浏览器会话中可正常读取;不将桌面网页或 HTTP 成功视为 DreamBy/iPhone 原生播放验收。 + ## 本地验收 ```bash diff --git a/sexbjcam-mini-library.js b/sexbjcam-mini-library.js index 7738b12..8c3fd9c 100644 --- a/sexbjcam-mini-library.js +++ b/sexbjcam-mini-library.js @@ -9,7 +9,7 @@ const WidgetMetadata = { id: 'sexbjcam-mini-library', name: 'SexBJCam', title: 'SexBJCam', - version: '1.1.8', + version: '1.1.9', author: 'Alan huang', logo: SEXBJCAM_LOGO, icon: SEXBJCAM_LOGO, @@ -111,7 +111,7 @@ async function getCategory(rawCtx) { }; } -async function getDetail(rawCtx) { +async function getDetailWithinBudget(rawCtx) { const ctx = normalizeContext(rawCtx); const detailURL = detailURLFromContext(ctx); if (!detailURL) throw new Error('SexBJCam 详情参数无效'); @@ -119,17 +119,14 @@ async function getDetail(rawCtx) { const detail = parseDetailHtml(ctx, html, detailURL); const related = parseListHtml(ctx, html).filter(function (item) { return item.id !== detailURL; }).slice(0, 18); let qualityVersions = []; - let qualityDiscoveryFailed = false; if (detail.embedURL) { try { qualityVersions = await loadQualityVersions(ctx, detailURL, detail.title, detail.embedURL); - } catch (_) { - qualityDiscoveryFailed = true; - } + } catch (_) { /* Dynamic resource loading can retry; do not cache a fake default line. */ } } const resourceGroups = qualityVersions.length ? [{ id: 'quality', title: '画质', versions: qualityVersions }] - : (qualityDiscoveryFailed ? [] : resourceGroupsFor(ctx, detailURL, detail.title, detail.embedURL)); + : []; return { pageType: 'detail', id: detailURL, type: 'movie', title: detail.title, poster: detail.poster, backdrop: detail.poster, detailImageAspectRatio: '16:9', @@ -148,7 +145,7 @@ async function getDetail(rawCtx) { }; } -async function getResourceVersions(rawCtx) { +async function getResourceVersionsWithinBudget(rawCtx) { const ctx = normalizeContext(rawCtx); const detailURL = detailURLFromContext(ctx); if (!detailURL) return { itemId: '', groups: [] }; @@ -159,14 +156,11 @@ async function getResourceVersions(rawCtx) { embedURL = detail.embedURL; title = detail.title || title; } - let versions = []; - if (embedURL) { - versions = await loadQualityVersions(ctx, detailURL, title, embedURL); - } + if (!embedURL) throw stageError('detail-player', '没有解析到 SexBJCam 播放器地址'); + const versions = await loadQualityVersions(ctx, detailURL, title, embedURL); return { itemId: detailURL, - groups: versions.length ? [{ id: 'quality', title: '画质', versions: versions }] : - resourceGroupsFor(ctx, detailURL, title, embedURL) + groups: versions.length ? [{ id: 'quality', title: '画质', versions: versions }] : [] }; } @@ -182,8 +176,7 @@ async function resolvePlaybackWithinBudget(rawCtx) { const html = await fetchPlayerText(ctx, embedURL); const masterURL = extractMediaURL(html, embedURL); if (!masterURL) throw new Error('没有解析到 SexBJCam HLS 地址'); - const origin = urlOrigin(embedURL); - const headers = { Referer: embedURL, Origin: origin, 'User-Agent': SEXBJCAM_UA }; + const headers = ctx.__mediaHeaders || minimalMediaHeaders(); const quality = qualityFromContext(ctx); let mediaURL = masterURL; if (/\.m3u8(?:$|[?#])/i.test(masterURL)) { @@ -192,7 +185,10 @@ async function resolvePlaybackWithinBudget(rawCtx) { const variants = parseHlsVariants(manifest, masterURL); const selected = selectVariant(variants, quality); if (selected && selected.url) mediaURL = selected.url; - } catch (_) {} + } catch (error) { + if (/-4\d\d$|verification$|not-hls$|manifest-empty$/.test(error.stage || '')) throw error; + // A fresh real URL can still be handed off after a transient quality-probe failure. + } } return { url: mediaURL, container: mediaContainer(mediaURL), @@ -263,8 +259,8 @@ function parseListHtml(ctx, html) { function parseDetailHtml(ctx, html, detailURL) { const article = firstMatch(html, /(]*\bitemprop=["']video["'][^>]*>[\s\S]*?<\/article>)/i)[0] || String(html || ''); const title = cleanText(firstNonEmpty( - metaItemContent(article, 'name'), metaContent(html, 'property', 'og:title'), firstMatch(article, /]*>([\s\S]*?)<\/h1>/i)[0], + metaContent(html, 'property', 'og:title'), metaItemContent(article, 'name'), firstMatch(html, /]*>([\s\S]*?)<\/title>/i)[0] )).replace(/\s*[–|-]\s*sexbjcam\s*$/i, ''); const poster = absoluteURL(ctx, firstNonEmpty( @@ -292,52 +288,32 @@ function parseDetailHtml(ctx, html, detailURL) { }; } -function resourceGroupsFor(ctx, detailURL, title, embedURL) { - return [{ - id: 'online', title: '在线播放', - versions: [{ - id: detailURL, name: '默认线路', title: title || '默认线路', - subtitle: '动态解析 HLS', container: 'm3u8', default: true, - action: { - type: 'play', itemId: detailURL, versionId: detailURL, - embedURL: embedURL || undefined, title: title || '默认线路' - } - }] - }]; -} - async function loadQualityVersions(ctx, detailURL, title, embedURL) { let variants = []; - let lastError = null; - let manifestResolved = false; - let headers = { Referer: embedURL, Origin: urlOrigin(embedURL), 'User-Agent': SEXBJCAM_UA }; - for (let attempt = 0; attempt < 3 && !variants.length; attempt += 1) { - try { - const playerHTML = await fetchPlayerText(ctx, embedURL); - const masterURL = extractMediaURL(playerHTML, embedURL); - if (!masterURL) throw new Error('播放器没有返回 HLS 地址'); - const manifest = await fetchSignedManifestText(ctx, masterURL, headers); - if (!/^\s*#EXTM3U/i.test(manifest)) throw new Error('播放器没有返回有效 HLS 清单'); - manifestResolved = true; - variants = parseHlsVariants(manifest, masterURL); - } catch (error) { - lastError = error; - variants = []; - } - } - if (variants.length) { - writeQualityMetadata(embedURL, variants.map(function (variant) { + let original = false; + try { + const playerHTML = await fetchPlayerText(ctx, embedURL); + const masterURL = extractMediaURL(playerHTML, embedURL); + if (!masterURL) throw stageError('player-media', '播放器没有返回 HLS 地址'); + const headers = ctx.__mediaHeaders || minimalMediaHeaders(); + const manifest = await fetchSignedManifestText(ctx, masterURL, headers); + variants = parseHlsVariants(manifest, masterURL); + original = !variants.length && /#EXTINF|#EXT-X-STREAM-INF/i.test(manifest); + if (!variants.length && !original) throw stageError('manifest-empty', 'HLS 清单没有媒体条目'); + if (variants.length) writeQualityMetadata(embedURL, variants.map(function (variant) { return { height: variant.height, bandwidth: variant.bandwidth || 0 }; })); - } else { - variants = readQualityMetadata(embedURL); - } - if (!variants.length && !manifestResolved) { - const reason = lastError && lastError.message ? ':' + lastError.message : ''; - throw new Error('画质加载失败,请检查网络后重新打开资源列表' + reason); + } catch (error) { + // Previously proven resolution metadata is a fallback, never a cached signed URL. + if (!/-4\d\d$|verification$|not-hls$|manifest-empty$/.test(error.stage || '')) variants = readQualityMetadata(embedURL); + if (!variants.length) throw stageError(error.stage || 'quality-discovery', '画质加载失败,请检查网络后重新打开资源列表'); } - if (!variants.length) return []; - return buildQualityVersions(detailURL, title, embedURL, headers, variants); + if (original) return [{ + id: 'quality:original', name: 'HLS 原始画质', title: 'HLS 原始画质', + subtitle: '源站返回原始 HLS · 播放时刷新地址', container: 'm3u8', default: true, + action: { type: 'play', itemId: detailURL, versionId: 'quality:original', embedURL: embedURL, title: title } + }]; + return buildQualityVersions(detailURL, title, embedURL, variants); } function readQualityMetadata(embedURL) { @@ -407,7 +383,7 @@ function qualityCacheKey(embedURL) { return 'sexbjcam:qualities:v1:' + (hash >>> 0).toString(16); } -function buildQualityVersions(detailURL, title, embedURL, headers, variants) { +function buildQualityVersions(detailURL, title, embedURL, variants) { return variants.map(function (variant, index) { const qualityId = 'quality:' + variant.height; return { @@ -420,7 +396,6 @@ function buildQualityVersions(detailURL, title, embedURL, headers, variants) { quality: variant.height + 'P', container: 'm3u8', default: index === 0, - headers: headers, action: { type: 'play', itemId: detailURL, versionId: qualityId, quality: variant.height, embedURL: embedURL, title: title || variant.height + 'P' @@ -460,9 +435,7 @@ function selectVariant(variants, requestedHeight) { if (!variants || !variants.length) return null; if (!requestedHeight) return variants[0]; for (let i = 0; i < variants.length; i += 1) if (variants[i].height === requestedHeight) return variants[i]; - return variants.reduce(function (best, item) { - return Math.abs(item.height - requestedHeight) < Math.abs(best.height - requestedHeight) ? item : best; - }, variants[0]); + return variants[0]; } function qualityFromContext(ctx) { @@ -470,7 +443,7 @@ function qualityFromContext(ctx) { contextValue(ctx, 'quality'), contextValue(ctx, 'height'), contextValue(ctx, 'versionId'), contextValue(ctx, 'version') ); - const match = /(?:quality:)?(\d{3,4})p?/i.exec(raw); + const match = /^(?:quality:)?(\d{3,4})p?$/i.exec(raw); return match ? Number(match[1]) : 0; } @@ -500,68 +473,96 @@ async function loadSectionItems(ctx, section, page) { return parseListHtml(ctx, html); } -async function fetchText(ctx, url, headers) { - const response = await httpRequest(url, headers || requestHeaders(ctx, url), remainingPlaybackSeconds(ctx, 6)); - const text = unwrapText(response); - if (!text) throw new Error('请求失败: ' + url); - return text; +async function fetchText(ctx, url, headers, limit, stage) { + const seconds = remainingPlaybackSeconds(ctx, limit || 6); + return runStage(ctx, stage || 'http', seconds, async function () { + let response; + try { response = await httpRequest(url, headers || requestHeaders(ctx, url), seconds); } + catch (_) { throw stageError(stage || 'http', '来源请求失败'); } + const text = await unwrapText(response); + const status = Number(response && (response.status || response.statusCode)); + if (status >= 400) throw stageError((stage || 'http') + '-' + status, '来源请求失败(HTTP ' + status + ')'); + if (isVerificationPage(text)) throw stageError((stage || 'http') + '-verification', '源站要求浏览器验证'); + if (!text) throw stageError((stage || 'http') + '-empty', '来源返回空内容'); + return text; + }); } async function fetchPlayerText(ctx, url) { const referer = detailURLFromContext(ctx) || baseURL(ctx) + '/'; - const freshHeaders = { - Referer: referer, - 'User-Agent': SEXBJCAM_UA, + const headers = { + Referer: referer, 'User-Agent': SEXBJCAM_UA, Accept: 'text/html,application/xhtml+xml', - 'Cache-Control': 'no-cache, no-store, max-age=0', - Pragma: 'no-cache' + 'Cache-Control': 'no-cache, no-store, max-age=0', Pragma: 'no-cache' }; + let lastError; + try { + const text = await fetchText(ctx, cacheBustedURL(url), headers, 6, 'player-http'); + if (extractMediaURL(text, url)) { + ctx.__mediaHeaders = minimalMediaHeaders(); + return text; + } + lastError = stageError('player-media', '播放器页面未包含媒体配置'); + } catch (error) { lastError = error; } const widget = typeof Widget !== 'undefined' ? Widget : null; - if (widget && widget.browser && typeof widget.browser.fetch === 'function') { + if (!widget || !widget.browser || typeof widget.browser.fetch !== 'function') throw lastError; + const seconds = playbackBrowserSeconds(ctx, 12, 1); + return runStage(ctx, 'browser-media', seconds, async function () { + let result; try { - const result = await widget.browser.fetch(cacheBustedURL(url), { - visible: false, - timeout: playbackBrowserSeconds(ctx, 12, 2), - timeoutSeconds: remainingPlaybackSeconds(ctx, 12), - waitAfterLoad: 3, - waitForAny: true, - waitForMediaSource: true, - headers: freshHeaders + result = await widget.browser.fetch(cacheBustedURL(url), { + visible: false, timeout: seconds, timeoutSeconds: seconds, + waitAfterLoad: 1, waitForMediaSource: true, headers: headers }); - const browserText = unwrapText(result); - if (browserText && extractMediaURL(browserText, url)) return browserText; - const mediaURL = mediaURLFromBrowserResult(result); - if (mediaURL) return mediaURL; - } catch (_) {} - } - let normalError = null; - try { - const text = await fetchText(ctx, cacheBustedURL(url), freshHeaders); - if (extractMediaURL(text, url)) return text; - normalError = new Error('播放器页面未包含媒体配置'); - } catch (error) { - normalError = error; - } - throw normalError || new Error('播放器页面请求失败'); + } catch (_) { throw stageError('browser-media', '浏览器媒体捕获失败'); } + const browserText = await unwrapText(result); + const capturedURL = mediaURLFromBrowserResult(result); + const explicitURL = extractMediaURL(browserText, url); + const mediaURL = /\/master\.m3u8(?:$|[?#])/i.test(capturedURL) ? capturedURL : (explicitURL || capturedURL); + if (mediaURL) { + ctx.__mediaHeaders = capturedMediaHeaders(result, mediaURL) || minimalMediaHeaders(); + return mediaURL; + } + const keys = Object.keys(result || {}).slice(0, 12).join(','); + const blobOnly = /blob:https?:/.test(JSON.stringify(result && (result.mediaSources || result.capturedRequests || result.mediaRequests) || [])); + const stage = isVerificationPage(browserText) ? 'browser-verification' : (blobOnly ? 'browser-blob-only' : 'browser-no-media'); + throw stageError(stage, '浏览器未回传可用媒体;blobOnly=' + blobOnly + ';keys=' + keys); + }); } async function fetchSignedManifestText(ctx, url, headers) { - const widget = typeof Widget !== 'undefined' ? Widget : null; - if (widget && widget.browser && typeof widget.browser.fetch === 'function') { - try { - const result = await widget.browser.fetch(url, { - visible: false, - timeout: playbackBrowserSeconds(ctx, 6, 2), - timeoutSeconds: remainingPlaybackSeconds(ctx, 6), - waitAfterLoad: 1, - waitForAny: true, - headers: headers - }); - const browserText = unwrapText(result); - if (/^\s*#EXTM3U/i.test(browserText)) return browserText; - } catch (_) {} + // Navigating a browser to a signed playlist is not a reliable text fetch. + const text = await fetchText(ctx, url, headers, 4, 'manifest-http'); + if (!/^\s*#EXTM3U/i.test(text)) throw stageError('manifest-not-hls', '来源未返回 HLS 清单'); + if (!/#EXTINF|#EXT-X-STREAM-INF/i.test(text)) throw stageError('manifest-empty', 'HLS 清单没有媒体条目'); + return text; +} + +function capturedMediaHeaders(result, url) { + const arrays = [result && result.capturedRequests, result && result.mediaRequests, result && result.requests]; + for (let a = 0; a < arrays.length; a += 1) { + if (!Array.isArray(arrays[a])) continue; + const request = arrays[a].find(function (item) { return item && item.url === url; }); + const input = request && (request.requestHeaders || request.headers); + if (!input || typeof input !== 'object') continue; + const headers = { 'User-Agent': SEXBJCAM_UA }; + Object.keys(input).forEach(function (key) { + const canonical = { 'user-agent': 'User-Agent', referer: 'Referer', origin: 'Origin', cookie: 'Cookie' }[key.toLowerCase()]; + if (canonical) headers[canonical] = String(input[key]); + }); + return headers; } - return fetchText(ctx, url, headers); + return null; +} + +function minimalMediaHeaders() { + const headers = { 'User-Agent': SEXBJCAM_UA }; + // The verified static CDN flow works with this User-Agent; captured requests carry their own headers. + return headers; +} + +function isVerificationPage(text) { + return /Just a moment|Checking (?:your )?browser|cf-browser-verification|cf-chl-/i.test(String(text || '').slice(0, 30000)); } function mediaURLFromBrowserResult(result) { @@ -572,7 +573,7 @@ function mediaURLFromBrowserResult(result) { const direct = stringValue(result[directKeys[i]]); if (isMediaURL(direct) && !/^blob:/i.test(direct)) candidates.push(direct); } - const arrays = [result.mediaSources, result.mediaRequests, result.requests, result.responses, result.urls]; + const arrays = [result.capturedRequests, result.mediaSources, result.mediaRequests, result.requests, result.responses, result.urls]; for (let a = 0; a < arrays.length; a += 1) { const values = arrays[a]; if (!Array.isArray(values)) continue; @@ -586,19 +587,9 @@ function mediaURLFromBrowserResult(result) { } } for (let i = 0; i < candidates.length; i += 1) if (/\/master\.m3u8(?:$|[?#])/i.test(candidates[i])) return candidates[i]; - for (let i = 0; i < candidates.length; i += 1) { - const masterURL = masterURLFromVariant(candidates[i]); - if (masterURL) return masterURL; - } return candidates[0] || ''; } -function masterURLFromVariant(url) { - const value = stringValue(url); - if (!/\/index-[^/?#]+\.m3u8(?:$|[?#])/i.test(value)) return ''; - return value.replace(/\/index-[^/?#]+\.m3u8(?=[$?#])/i, '/master.m3u8'); -} - function cacheBustedURL(url) { SEXBJCAM_REQUEST_NONCE += 1; const separator = String(url || '').indexOf('?') >= 0 ? '&' : '?'; @@ -610,8 +601,9 @@ async function httpRequest(url, headers, timeout = 6) { const dollar = typeof $http !== 'undefined' ? $http : null; const client = (widget && widget.http) || dollar; if (!client) throw new Error('当前环境没有可用的 HTTP 客户端'); - if (typeof client.get === 'function') return client.get(url, { headers: headers, timeout, timeoutSeconds: timeout }); - if (typeof client.request === 'function') return client.request({ url: url, method: 'GET', headers: headers, timeout, timeoutSeconds: timeout }); + const options = { headers: headers, timeout: timeout, timeoutSeconds: timeout, useBrowserFallback: false, browserFallback: false, useBrowserCookie: true, attachBrowserCookie: true }; + if (typeof client.get === 'function') return client.get(url, options); + if (typeof client.request === 'function') return client.request(Object.assign({ url: url, method: 'GET' }, options)); throw new Error('当前 HTTP 客户端不支持 GET'); } @@ -820,16 +812,32 @@ const SEXBJCAM_API = { if (typeof globalThis !== 'undefined') Object.keys(SEXBJCAM_API).forEach(function (key) { globalThis[key] = SEXBJCAM_API[key]; }); if (typeof module !== 'undefined' && module.exports) module.exports = SEXBJCAM_API; -async function withPlaybackBudget(input, operation) { - let ctx = input; - if (typeof ctx === 'string') { try { ctx = JSON.parse(ctx); } catch (_) { ctx = {}; } } - ctx = Object.assign({}, ctx && typeof ctx === 'object' ? ctx : {}); +function stageError(stage, message) { + const error = new Error(message + ';stage=' + stage); + error.stage = stage; + return error; +} + +async function runStage(ctx, stage, seconds, operation) { + const budget = ctx && ctx.__playbackBudget; + if (budget && budget.expired) throw stageError('total-deadline', '解析超时'); + let timer; + try { + const timeout = new Promise(function (_, reject) { + timer = setTimeout(function () { reject(stageError(stage + '-timeout', '来源请求超时')); }, seconds * 1000); + }); + return await Promise.race([Promise.resolve().then(operation), timeout]); + } finally { clearTimeout(timer); } +} + +async function withPlaybackBudget(input, operation, label) { + const ctx = Object.assign({}, normalizeContext(input)); const budget = { endAt: Date.now() + 28000, expired: false, browserCalls: 0 }; ctx.__playbackBudget = budget; let timer; try { const timeout = new Promise(function (_, reject) { - timer = setTimeout(function () { budget.expired = true; reject(new Error('播放解析超时;stage=total-deadline;请稍后重试')); }, 28000); + timer = setTimeout(function () { budget.expired = true; reject(stageError('total-deadline', (label || '播放解析') + '超时;请稍后重试')); }, 28000); }); return await Promise.race([Promise.resolve().then(function () { return operation(ctx); }), timeout]); } finally { clearTimeout(timer); budget.expired = true; } @@ -854,3 +862,11 @@ function playbackBrowserSeconds(ctx, limit, maxCalls) { async function resolvePlayback(input) { return withPlaybackBudget(input, resolvePlaybackWithinBudget); } + +async function getDetail(input) { + return withPlaybackBudget(input, getDetailWithinBudget, '详情加载'); +} + +async function getResourceVersions(input) { + return withPlaybackBudget(input, getResourceVersionsWithinBudget, '画质发现'); +} diff --git a/sexbjcam-network-recovery.test.js b/sexbjcam-network-recovery.test.js index 49a9e58..79f30c7 100644 --- a/sexbjcam-network-recovery.test.js +++ b/sexbjcam-network-recovery.test.js @@ -27,14 +27,15 @@ global.Widget = { http: { get(url) { if (String(url).indexOf(detailURL) === 0) return detailHTML; + if (networkGood && String(url).indexOf(masterURL) === 0) return masterManifest; throw new Error('network unavailable'); } }, browser: { fetch(url) { if (!networkGood) throw new Error('network unavailable'); - if (String(url).indexOf(embedURL) === 0) return ''; - if (String(url).indexOf(masterURL) === 0) return masterManifest; + if (String(url).indexOf(embedURL) === 0) return { html: '', capturedRequests: [{ url: masterURL, requestHeaders: { Referer: embedURL, Origin: 'https://player.example', 'User-Agent': 'Fixture browser' } }] }; + if (String(url).indexOf(masterURL) === 0) throw new Error('不得用浏览器导航读取 HLS 正文'); throw new Error('unexpected browser URL: ' + url); } }, diff --git a/tests/adapters.test.cjs b/tests/adapters.test.cjs index eba6a24..ed79d68 100644 --- a/tests/adapters.test.cjs +++ b/tests/adapters.test.cjs @@ -257,3 +257,107 @@ test('XVideos attaches the configured session token without printing it', async const options=await vm.runInContext('widgetAPI.getDefaultOptions()',c); assert.equal(options.headers.Cookie,'session_token='+token); assert.ok(logs.every(line=>!line.includes(token))); }); + +const sexDetailURL='https://sexbjcam.com/2026/09/30/sample/'; +const sexEmbedURL='https://player.test/embed/sample'; +const sexDetailHTML='

Film title

'; +function packedSexPlayer(token='fresh') { + return `eval(function(p,a,c,k,e,d){return p;}('0="1://2.3/4.5?6=7";',36,8,'source|https|cdn|test|master|m3u8|token|${token}'.split('|'),0,{}))`; +} + +test('SexBJCam reads the film title rather than nested uploader metadata', () => { + const c=load('sexbjcam-mini-library.js'); + const detail=c.parseDetailHtml({},sexDetailHTML,sexDetailURL); + assert.equal(detail.title,'Film title');assert.equal(detail.embedURL,sexEmbedURL); +}); + +test('SexBJCam static packed playback avoids a hung browser and refreshes every selection', async () => { + const c=load('sexbjcam-mini-library.js');let browsers=0,players=0;const urls=[]; + c.Widget.browser={async fetch(){browsers++;return new Promise(()=>{});}}; + c.Widget.http.get=async (url,options)=>{urls.push(url);assert.equal(options.browserFallback,false);return {statusCode:200,data:url.includes('player.test')?packedSexPlayer('fresh'+(++players)):master};}; + for(const [id,expected] of [[sexDetailURL,1080],['quality:720',720],['quality:1080',1080],['quality:540',1080]]) { + const result=await c.resolvePlayback(JSON.stringify({itemId:sexDetailURL,embedURL:sexEmbedURL,versionId:id})); + assert.equal(result.url,'https://cdn.test/'+expected+'.m3u8'); + } + assert.equal(browsers,0);assert.equal(players,4); + assert.ok(urls.filter(x=>x.includes('cdn.test')).every(x=>!x.includes('_dreamby_refresh'))); +}); + +test('SexBJCam hung native HTTP falls back once and preserves captured media headers', async () => { + const c=load('sexbjcam-mini-library.js',{setTimeout:(fn,ms)=>setTimeout(fn,ms/100)});let browsers=0; + c.Widget.http.get=async (url,options)=>{ + if(url.includes('player.test'))return new Promise(()=>{}); + assert.equal(options.headers.Referer,'https://actual-player.test/');return {status:200,data:master}; + }; + c.Widget.browser={async fetch(url,options){browsers++;assert.ok(options.timeout<=12);assert.equal(options.waitForAny,undefined);return {capturedRequests:[{url:'https://cdn.test/master.m3u8?token=device',requestHeaders:{referer:'https://actual-player.test/',origin:'https://actual-player.test','user-agent':'Device Agent',cookie:'fixture-cookie',Authorization:'must-not-copy'}}]};}}; + const result=await c.resolvePlayback({itemId:sexDetailURL,embedURL:sexEmbedURL,versionId:'quality:720'}); + assert.equal(result.url,'https://cdn.test/720.m3u8');assert.equal(browsers,1); + assert.deepEqual(plain(result.headers),{'User-Agent':'Device Agent',Referer:'https://actual-player.test/',Origin:'https://actual-player.test',Cookie:'fixture-cookie'}); +}); + +test('SexBJCam hung native browser reports its own stage instead of the overall deadline', async () => { + const c=load('sexbjcam-mini-library.js',{setTimeout:(fn,ms)=>setTimeout(fn,ms/100)});let browsers=0; + c.Widget.http.get=async()=>({statusCode:503,data:'unavailable'}); + c.Widget.browser={async fetch(){browsers++;return new Promise(()=>{});}}; + const start=Date.now();await assert.rejects(c.resolvePlayback({embedURL:sexEmbedURL}),/browser-media-timeout/); + assert.equal(browsers,1);assert.ok(Date.now()-start<500); +}); + +test('SexBJCam stalled quality probe preserves a fresh media URL but creates no fake resource', async () => { + const c=load('sexbjcam-mini-library.js',{setTimeout:(fn,ms)=>setTimeout(fn,ms/100)}); + c.Widget.http.get=async url=>url.includes('/2026/')?{status:200,data:sexDetailHTML}:url.includes('player.test')?{status:200,data:packedSexPlayer()}:new Promise(()=>{}); + const result=await c.resolvePlayback({itemId:sexDetailURL,embedURL:sexEmbedURL}); + assert.equal(result.url,'https://cdn.test/master.m3u8?token=fresh'); + const detail=await c.getDetail({itemId:sexDetailURL});assert.equal(detail.resourceGroups.length,0); + await assert.rejects(c.getResourceVersions({itemId:sexDetailURL,embedURL:sexEmbedURL}),/manifest-http-timeout/); +}); + +test('SexBJCam variant-only capture keeps its URL and exposes honest original HLS quality', async () => { + const c=load('sexbjcam-mini-library.js');const variant='https://cdn.test/index-f3-v1-a1.m3u8?token=exact'; + c.Widget.http.get=async url=>url.includes('player.test')?{status:200,data:'dynamic player'}:{status:200,data:media}; + c.Widget.browser={async fetch(){return {capturedRequests:[{url:variant,requestHeaders:{Referer:'https://actual-player.test/'}}]};}}; + assert.equal(c.mediaURLFromBrowserResult({capturedRequests:[{url:variant}]}),variant); + const groups=await c.getResourceVersions({itemId:sexDetailURL,embedURL:sexEmbedURL}); + assert.equal(groups.groups[0].versions[0].name,'HLS 原始画质');assert.equal(groups.groups[0].versions[0].id,'quality:original'); + const result=await c.resolvePlayback(groups.groups[0].versions[0].action);assert.equal(result.url,variant); +}); + +test('SexBJCam metadata cache never stores signed URLs or media request credentials', async () => { + const c=load('sexbjcam-mini-library.js');const stored=[]; + c.Widget.storage={get(){return null},set(key,value){stored.push(value)}}; + c.Widget.http.get=async url=>url.includes('player.test')?{status:200,data:'dynamic player'}:{status:200,data:master}; + c.Widget.browser={async fetch(){return {capturedRequests:[{url:'https://cdn.test/master.m3u8?token=ephemeral',requestHeaders:{Cookie:'private-fixture',Origin:'https://actual-player.test'}}]};}}; + const groups=await c.getResourceVersions({itemId:sexDetailURL,embedURL:sexEmbedURL}); + assert.deepEqual(plain(groups.groups[0].versions.map(x=>x.name)),['1080P','720P']); + const serialized=JSON.stringify(stored)+JSON.stringify(groups); + assert.ok(!serialized.includes('ephemeral'));assert.ok(!serialized.includes('private-fixture')); +}); + +test('SexBJCam rejects permanent manifest errors and can retry the same item after recovery', async () => { + const c=load('sexbjcam-mini-library.js');let status=404; + c.Widget.http.get=async url=>url.includes('player.test')?{status:200,data:packedSexPlayer()}:{statusCode:status,data:status===200?master:'Not Found'}; + const ctx={itemId:sexDetailURL,embedURL:sexEmbedURL}; + await assert.rejects(c.resolvePlayback(ctx),/manifest-http-404/); + await assert.rejects(c.getResourceVersions(ctx),/manifest-http-404/); + status=200;assert.equal((await c.getResourceVersions(ctx)).groups[0].versions[0].name,'1080P'); +}); + +test('SexBJCam browser failures distinguish blob-only results without leaking response values', async () => { + const c=load('sexbjcam-mini-library.js');c.Widget.http.get=async()=>({status:200,data:'dynamic'}); + c.Widget.browser={async fetch(){return {mediaSources:['blob:https://player.test/id'],secret:'must-not-leak'};}}; + await assert.rejects(c.resolvePlayback({embedURL:sexEmbedURL}),error=>/browser-blob-only/.test(error.message)&&!error.message.includes('must-not-leak')); +}); + +for(const [entry,operation,label] of [['getDetail','getDetailWithinBudget','详情加载'],['getResourceVersions','getResourceVersionsWithinBudget','画质发现']]) { + test(`SexBJCam ${entry} has an overall deadline`,async()=>{ + const c=load('sexbjcam-mini-library.js',{setTimeout:fn=>setTimeout(fn,15)});c[operation]=async()=>new Promise(()=>{}); + await assert.rejects(c[entry]({}),new RegExp(label+'超时.*total-deadline')); + }); +} + +test('SexBJCam rejects an empty HLS response rather than returning a fake playable URL', async () => { + const c=load('sexbjcam-mini-library.js'); + c.Widget.http.get=async url=>({status:200,data:url.includes('player.test')?packedSexPlayer():'#EXTM3U\n'}); + await assert.rejects(c.resolvePlayback({embedURL:sexEmbedURL}),/manifest-empty/); + await assert.rejects(c.getResourceVersions({itemId:sexDetailURL,embedURL:sexEmbedURL}),/manifest-empty/); +}); From 60edba51ba081862a4ab5d395d7f4e0c29e46ec4 Mon Sep 17 00:00:00 2001 From: hzl <1803573449@qq.com> Date: Sat, 3 Oct 2026 22:32:39 +0800 Subject: [PATCH 5/6] Recover Madou streams and report source access limits --- CHANGELOG.md | 9 ++ README.md | 10 ++ kbjfan-mini-library.js | 60 +++++++--- madou8-mini-library 5.js | 229 ++++++++++++++++++++++++++------------- taolusm-mini-library.js | 113 ++++++++++++------- tests/adapters.test.cjs | 134 +++++++++++++++++++++++ 6 files changed, 423 insertions(+), 132 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 552a6bf..2d2f8ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +# 麻豆 / 套路 SM / KBJ fan — 2026-10-03 + +- 麻豆亚洲版 1.1.2 首屏减少为一次最近更新请求,分类预览限两个并发并随后加载。兼容 JSON 上下文/参数、嵌套 HTML 与异步响应正文;错误不再吞成成功空首页。缺少 `URL` 内置对象时仍能保留详情动作与相对 HLS 路径。 +- 普通 HLS 候选由前四条扩大到最多 12 条,限三个逻辑并发和 28 秒总预算;原生调用超时后无法由 JS 强制取消。读取并正常转交 API/公开播放器使用的 `Accept` 凭据,播放时刷新,资源版本不持有媒体凭据或签名 URL。网页专用线路不冒充原生 HLS;未知分辨率显示原始画质,全部失败时不返回假默认线路。 +- 桌面已验证五个分类的两页、搜索两页、三部详情/清单/分片。麻豆 IPX-559 返回有效原始 HLS,但该线路没有分辨率证据,1080P 仍未确认;其他 CDN 仍有 403。另一部影片完成 1080P → 720P → 1080P 的刷新验证。 +- 套路 SM 1.0.2 读取对应影片主播放器,不把下载/登录 HTML 当 MP4,不误取推荐区媒体。真实匿名样本均只提供预览,资源与说明明确标注;旧下载版本仍能识别,权限不足时报明原因。完整影片需要站点正常登录及权限,未宣称修复该限制。 +- KBJ fan 1.0.2 对连接、HTTP 状态、异常跳转、空响应及没有原站影片作明确诊断,HTTP 请求及正文限制为 8 秒。原域名当前连接异常/跳转其他站点,没有确认到替代站点,因此此项为诊断改进,内容未恢复。 +- 保留三个脚本原下载路径、媒体库 ID、原参数名及历史稳定标识。64 项独立媒体库回归(新增 18 项)与仓库 CI 对应检查通过;iPhone 实际显示和原生播放仍待验收。 + # 2026-10-03 基于 `main` 的 `8e43017532ec81e3338046ba6e32dff7655bd3dd` 修复审查中 O01–O25;保留所有已发布文件路径。 diff --git a/README.md b/README.md index 7754c84..d6429b8 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,16 @@ GitHub Actions 会先生成兼容副本并验证;`main` 的检查通过后, 具体行为和设备验证限制见 [CHANGELOG.md](CHANGELOG.md)。 +## 剩余故障修复稿 — 2026-10-03 + +- 麻豆亚洲版 `madou8-mini-library 5.js` 为 1.1.2:首屏只取最近更新,分类预览随后加载;HTTP 错误及验证失败明确报告。五个分类前两页各返回 12 条且没有跨页重复;桌面首屏 1.543 秒。原文件名、ID、`baseUrl` 和 `madou8://` 版本载荷保持兼容。 +- 播放检查最多 12 条实际普通 HLS 线路,使用三个逻辑并发任务和 28 秒预算,避免接口顺序变化时漏掉第五条以后的有效源。沿用网页 `xhrSetup` 的 `Accept` 凭据,每次从 API 刷新,不写入版本/缓存。专用 StreamPipe/service-worker 线路及明确 `native:false` 的线路不会伪装成原生地址。 +- 麻豆 IPX-559 已验证原始 HLS 及 MPEG-TS 分片;来源未标明该线路分辨率,没有宣称 1080P 恢复。STCV-497 返回 720P;另一部普通影片返回 1080P/720P/480P/360P,并通过 1080P → 720P → 1080P 的桌面请求。仍有 CDN 返回 403;未通过伪造权限或签名规避。 +- 套路 SM `taolusm-mini-library.js` 为 1.0.2:旧下载入口会跳到登录页,现从当前影片主播放器配置读取实际源。三部匿名 HTTP 样本仅提供公开预览,资源明确标注“公开预览(非完整影片)”;旧下载选择报告登录/观看权限,完整影片未宣称恢复。 +- KBJ fan `kbjfan-mini-library.js` 为 1.0.2:补充 HTTP 状态、响应正文、8 秒截止时间和域名跳转诊断,加载失败不再返回成功的空首页或假播放线路。当前原域名连接中断,另一次网页读取跳到其他站点;没有确认到官方替代地址,内容恢复仍待可用站点。 + +64 项媒体库回归(新增 18 项)及仓库检查通过。以上真实媒体结果来自桌面 HTTP;DreamBy/iPhone 的显示、原生起播及验证状态衔接仍需设备确认。用户报告的 IPX-559 所属媒体库尚待确认,本轮按麻豆亚洲版检查该影片。原下载路径全部保留;本分支为等待统一确认的修复稿。 + ## 本地验收 ```bash diff --git a/kbjfan-mini-library.js b/kbjfan-mini-library.js index 3d88485..84f93e0 100644 --- a/kbjfan-mini-library.js +++ b/kbjfan-mini-library.js @@ -9,7 +9,7 @@ const WidgetMetadata = { id: 'kbjfan-mini-library', name: 'KBJFan', title: 'KBJFan', - version: '1.0.1', + version: '1.0.2', author: 'Alan huang', logo: KBJFAN_LOGO, icon: KBJFAN_LOGO, @@ -61,12 +61,7 @@ function getManifest() { async function getHome(ctx) { ctx = normalizeRuntimeContext(ctx); - let hero = []; - try { - hero = (await loadSectionItems(ctx, KBJFAN_SECTIONS[0], 1)).slice(0, 8); - } catch (_) { - hero = []; - } + const hero = (await loadSectionItems(ctx, KBJFAN_SECTIONS[0], 1)).slice(0, 8); return { pageType: 'home', id: 'kbjfan-home', @@ -116,6 +111,8 @@ async function getCategory(ctx) { const section = findSection(ctx && (ctx.pageId || ctx.id || ctx.category || ctx.genreId)) || KBJFAN_SECTIONS[0]; const page = positiveInt(contextValue(ctx, 'page'), 1); const html = await fetchText(ctx, categoryURL(ctx, section, page)); + const items = parseListHtml(ctx, html); + if (!items.length && page === 1) throw new Error('KBJFan 当前页面没有原站影片,请确认可用站点地址'); return { pageType: 'category', id: section.id, @@ -124,7 +121,7 @@ async function getCategory(ctx) { itemAspectRatio: '16:9', page: page, hasMore: hasNextPage(html, page), - items: parseListHtml(ctx, html) + items: items }; } @@ -164,6 +161,7 @@ async function getResourceVersions(ctx) { videoURL = detail.videoURL; title = detail.title || title; } + if (!videoURL) throw new Error('KBJFan 当前详情没有可播放媒体,可能是站点已迁移或内容不可用'); return resourceGroupsFor(ctx, detailURL, title, videoURL); } @@ -269,6 +267,7 @@ function parseDetailHtml(ctx, html, detailURL) { } function resourceGroupsFor(ctx, detailURL, title, videoURL) { + if (!videoURL) return []; return [{ id: 'online', title: '在线播放', @@ -287,14 +286,49 @@ function resourceGroupsFor(ctx, detailURL, title, videoURL) { } async function loadSectionItems(ctx, section, page) { - return parseListHtml(ctx, await fetchText(ctx, categoryURL(ctx, section, page))); + const items = parseListHtml(ctx, await fetchText(ctx, categoryURL(ctx, section, page))); + if (!items.length && page === 1) throw new Error('KBJFan 当前页面没有原站影片;请确认站点地址是否仍提供 KBJFan 内容。'); + return items; } async function fetchText(ctx, url) { - const response = await Widget.http.get(url, { headers: requestHeaders(ctx, url) }); - const data = response && (response.data || response.body || response.text); - if (!data) throw new Error('请求失败: ' + url); - return String(data); + try { + const response = await kbjDeadline(async function () { + const options = { headers: requestHeaders(ctx, url), timeout: 8, timeoutSeconds: 8, browserFallback: false }; + let response; + if (typeof Widget !== 'undefined' && Widget.http && typeof Widget.http.get === 'function') response = await Widget.http.get(url, options); + else if (typeof Widget !== 'undefined' && Widget.http && typeof Widget.http.request === 'function') response = await Widget.http.request(Object.assign({ url: url, method: 'GET' }, options)); + else if (typeof $http !== 'undefined' && typeof $http.get === 'function') response = await $http.get(url, options); + else throw new Error('当前环境没有 HTTP 客户端'); + const final = response && (response.finalURL || response.urlEffective || response.responseURL || response.url); + if (final && kbjHost(final) && kbjHost(final) !== kbjHost(baseURL(ctx))) throw new Error('原域名已跳转到其他站点,请更新为已确认的 KBJFan 地址'); + const status = Number(response && (response.statusCode || response.status) || 200); + if (status >= 400) throw new Error('站点返回 HTTP ' + status); + const text = await kbjResponseText(response); + if (!text) throw new Error('站点返回空内容'); + if (/Just a moment|Checking (?:your )?browser|cf-chl-|cf-mitigated|Cloudflare Ray ID/i.test(text)) throw new Error('站点返回验证页,未收到影片内容'); + return text; + }, 8000); + return response; + } catch (error) { + throw new Error('KBJFan 加载失败:' + stringValue(error && error.message || error) + '。原下载路径仍保留;无法连接或站点已迁移时需确认可用站点地址。'); + } +} +function kbjHost(url) { const match = String(url || '').match(/^https?:\/\/([^/?#]+)/i); return match ? match[1].toLowerCase().replace(/^www\./, '') : ''; } +async function kbjResponseText(response) { + if (typeof response === 'string') return response; + if (!response) return ''; + if (typeof response.text === 'function') return String(await response.text()); + for (const value of [response.data, response.body, response.html, response.text]) { + if (typeof value === 'string') return value; + if (value && typeof value.html === 'string') return value.html; + } + return ''; +} +async function kbjDeadline(work, milliseconds) { + let timer; + try { return await Promise.race([Promise.resolve().then(work), new Promise(function (_, reject) { timer = setTimeout(function () { reject(new Error('HTTP 等待超时(8 秒)')); }, milliseconds); })]); } + finally { clearTimeout(timer); } } function requestHeaders(ctx, referer) { diff --git a/madou8-mini-library 5.js b/madou8-mini-library 5.js index b108309..0a2cf5c 100644 --- a/madou8-mini-library 5.js +++ b/madou8-mini-library 5.js @@ -8,7 +8,7 @@ const WidgetMetadata = { id: 'madou8-mini-library', name: '麻豆视频', title: '麻豆视频', - version: '1.1.1', + version: '1.1.2', requiredVersion: '0.0.1', author: 'Alan huang', site: MADOU8_DEFAULT_BASE, @@ -38,45 +38,38 @@ function getManifest() { } async function getHome(ctx) { - let homeHtml = ''; - let recent = []; - try { - homeHtml = await fetchText(ctx, entryURL(ctx)); - recent = parseHomeSectionCards(homeHtml, SECTIONS[0].title, ctx).slice(0, 18); - } catch (_) {} - if (!recent.length) { - try { recent = parseCards(await fetchText(ctx, listURL(ctx, SECTIONS[0], 1)), ctx).slice(0, 18); } catch (_) {} - } - const previewPairs = await Promise.all(SECTIONS.map(async function (section) { - if (section.id === 'recent' && recent.length) return { section: section, items: recent }; - const embedded = parseHomeSectionCards(homeHtml, section.sourceTitle || section.title, ctx).slice(0, 3); - if (embedded.length) return { section: section, items: embedded }; - try { - return { section: section, items: parseCards(await fetchText(ctx, listURL(ctx, section, 1)), ctx).slice(0, 3) }; - } catch (_) { return { section: section, items: [] }; } - })); - const categoryItems = previewPairs.map(function (pair) { - return categoryPreviewCard(pair.section, pair.items); - }).filter(Boolean); + ctx = madouContext(ctx, 12000); + const recent = parseCards(await fetchText(ctx, listURL(ctx, SECTIONS[0], 1)), ctx).slice(0, 18); + if (!recent.length) throw new Error('麻豆首页未收到影片列表;请检查站点地址或网络验证状态'); return { pageType: 'home', id: 'madou8-home', title: WidgetMetadata.title, heroAspectRatio: '16:9', hero: recent.slice(0, 6).map(wideItem), - sections: [{ id: 'madou8-categories', title: '分类浏览', style: 'discover.annualPosterStack', lazy: false, items: categoryItems }, + sections: [{ id: 'madou8-categories', title: '分类浏览', style: 'discover.annualPosterStack', lazy: true, + loadAction: { type: 'custom', id: 'madou8-categories', sectionId: 'madou8-categories' }, items: [] }, { id: 'recent', title: '最近更新', style: 'discover.ranked', lazy: false, moreAction: categoryAction(SECTIONS[0]), items: ranked(recent) } ].concat(SECTIONS.slice(1).map(sectionShell)) }; } async function getHomeSection(ctx) { + ctx = madouContext(ctx, 20000); + if ((ctx.sectionId || ctx.id) === 'madou8-categories') { + const pairs = await madouMap(SECTIONS, 2, async function (section) { + try { return categoryPreviewCard(section, parseCards(await fetchText(ctx, listURL(ctx, section, 1)), ctx)); } catch (_) { return null; } + }); + const items = pairs.filter(Boolean); + return { id: 'madou8-categories', title: '分类浏览', style: 'discover.annualPosterStack', lazy: false, items: items, error: items.length ? undefined : '分类预览加载失败,请稍后重试' }; + } const section = findSection(ctx && (ctx.sectionId || ctx.id || ctx.pageId)) || SECTIONS[0]; try { const items = parseCards(await fetchText(ctx, listURL(ctx, section, 1)), ctx).slice(0, 18); + if (!items.length) throw new Error('当前分类未返回影片条目'); return { id: section.id, title: section.title, style: section.style, lazy: false, moreAction: categoryAction(section), items: section.style === 'discover.ranked' ? ranked(items) : items }; } catch (error) { return emptySection(section, error); } } async function getCategory(ctx) { - ctx = normalizeContext(ctx); + ctx = madouContext(ctx); const page = positiveInt(contextValue(ctx, 'page'), 1); const section = findSection(ctx.pageId || ctx.id) || { id: String(ctx.pageId || ctx.id || 'recent'), title: ctx.title || '麻豆视频', path: ctx.path || 'videos/recent' }; try { @@ -88,7 +81,7 @@ async function getCategory(ctx) { } async function search(ctx) { - ctx = normalizeContext(ctx); + ctx = madouContext(ctx); const query = String(ctx.query || ctx.keyword || ctx.text || '').trim(); const page = positiveInt(contextValue(ctx, 'page'), 1); if (!query) return { pageType: 'search', title: '搜索', query: '', page: page, hasMore: false, items: [] }; @@ -98,7 +91,7 @@ async function search(ctx) { } async function getDetail(ctx) { - ctx = normalizeContext(ctx); + ctx = madouContext(ctx); const url = detailURL(ctx); if (!url) throw new Error('详情参数无效'); const html = await fetchText(ctx, url); @@ -122,7 +115,7 @@ async function getDetail(ctx) { } async function getResourceVersions(ctx) { - ctx = normalizeContext(ctx); + ctx = madouContext(ctx); const url = detailURL(ctx); if (!url) return { groups: [] }; const html = await fetchText(ctx, url); @@ -131,7 +124,7 @@ async function getResourceVersions(ctx) { } async function resolvePlayback(ctx) { - ctx = normalizeContext(ctx); + ctx = madouContext(ctx); const url = detailURL(ctx); if (!url) throw new Error('播放参数无效:缺少详情地址或视频 ID'); const decoded = decodeVersionId(ctx.versionId); @@ -145,16 +138,17 @@ async function resolvePlayback(ctx) { const requestedHeight = positiveInt(ctx.qualityId || decodeVersionId(ctx.versionId).height, 0); const chosen = await chooseStream(ctx, info.playlist || [], requestedHeight, url); if (!chosen || !chosen.url) throw new Error('播放解析失败:流接口未返回可播放的 M3U8'); - return { url: chosen.url, container: 'm3u8', headers: playbackHeaders(ctx, url), startPositionSeconds: 0, isLive: false, streamKind: 'vod' }; + return { url: chosen.url, container: 'm3u8', headers: chosen.headers || playbackHeaders(ctx, url), startPositionSeconds: 0, isLive: false, streamKind: 'hls' }; } async function buildPlaybackGroups(ctx, detailUrl, uid, title) { const info = await fetchStreamInfo(ctx, uid, detailUrl); const variants = await discoverVariants(ctx, info.playlist || [], detailUrl); - const qualities = variants.length ? variants : [{ height: 0, label: '自动' }]; + if (!variants.length) throw new Error('没有可验证的 HLS 线路;专用网页播放器线路不能作为原生播放地址'); + const qualities = variants; return [{ id: 'hls', title: '在线线路', versions: qualities.map(function (q, index) { const id = encodeVersionId({ url: detailUrl, uid: uid, height: q.height || 0 }); - return { id: id, name: q.height ? q.height + 'P' : q.label, subtitle: q.height ? '切换时请稍候' : '自动画质', container: 'm3u8', default: index === 0, headers: playbackHeaders(ctx, detailUrl), action: { type: 'play', itemId: videoSlug(detailUrl), detailUrl: detailUrl, videoUid: uid, qualityId: q.height || 0, versionId: id, title: title } }; + return { id: id, name: q.height ? q.height + 'P' : '原始画质', subtitle: q.height ? '切换时请稍候' : '源清单未标明分辨率', container: 'm3u8', default: index === 0, action: { type: 'play', itemId: videoSlug(detailUrl), detailUrl: detailUrl, videoUid: uid, qualityId: q.height || 0, versionId: id, title: title } }; }) }]; } @@ -166,64 +160,72 @@ async function fetchStreamInfo(ctx, uid, detailUrl) { return data; } +function nativePlaylist(playlist) { + return (playlist || []).filter(function (item) { + return item && /^https?:\/\//i.test(String(item.url || '')) && item.native !== false && + (!item.playMode || item.playMode === 'fetchm3u8') && !/\.enc(?:[/:]|$)/i.test(item.url); + }).slice(0, 12); +} +function streamHeaders(ctx, detailUrl, item) { + const headers = playbackHeaders(ctx, detailUrl); + // The public player's xhrSetup uses this exact Accept value for signed lines. + if (item && typeof item.urlSign === 'string' && item.urlSign) headers.Accept = '*/*;sign=' + encodeURIComponent(item.urlSign); + return headers; +} async function discoverVariants(ctx, playlist, detailUrl) { - const discovered = []; - for (let i = 0; i < Math.min(playlist.length, 4); i++) { - const url = playlist[i] && playlist[i].url; - if (!/^https?:\/\//i.test(String(url || ''))) continue; + const results = await madouMap(nativePlaylist(playlist), 3, async function (item) { try { - const body = await fetchHlsText(ctx, url, detailUrl); - const variants = parseMaster(body, url); - if (variants.length) { - discovered.push.apply(discovered, variants.slice(0, 6)); - } else if (/#EXTINF/i.test(body)) { - const inferred = inferQuality(url); - if (inferred) discovered.push(inferred); - } + const headers = streamHeaders(ctx, detailUrl, item); + const body = await fetchHlsText(ctx, item.url, detailUrl, headers); + const variants = parseMaster(body, item.url); + if (variants.length) return variants; + if (/#EXTINF/i.test(body)) return [inferQuality(item.url) || { height: 0, label: '原始画质' }]; } catch (_) {} - } - return uniqueBy(discovered, function (x) { return x.height; }).sort(function (a, b) { return b.height - a.height; }); + return []; + }); + return uniqueBy([].concat.apply([], results), function (x) { return x.height; }).sort(function (a, b) { return b.height - a.height; }); } async function chooseStream(ctx, playlist, requestedHeight, detailUrl) { - let fallback = null; - function remember(candidate) { if (!fallback || (candidate.height || 0) > (fallback.height || 0)) fallback = candidate; } - for (let i = 0; i < Math.min(playlist.length, 4); i++) { - const item = playlist[i]; - if (!item || !/^https?:\/\//i.test(String(item.url || ''))) continue; + const failures = []; + const results = await madouMap(nativePlaylist(playlist), 3, async function (item) { try { - const body = await fetchHlsText(ctx, item.url, detailUrl); + const headers = streamHeaders(ctx, detailUrl, item); + const body = await fetchHlsText(ctx, item.url, detailUrl, headers); const variants = parseMaster(body, item.url).sort(function (a, b) { return b.height - a.height; }); if (!variants.length) { - if (!/#EXTINF/i.test(body)) continue; - const inferred = inferQuality(item.url) || { url: item.url, height: 0 }; - inferred.url = item.url; remember(inferred); - if (requestedHeight && inferred.height === requestedHeight) return inferred; - } else { - const ordered = requestedHeight - ? variants.filter(function (x) { return x.height === requestedHeight; }).concat(variants.filter(function (x) { return x.height !== requestedHeight; })) : variants; - for (let j = 0; j < ordered.length; j++) { - if (!requestedHeight && fallback && ordered[j].height <= fallback.height) continue; - try { - if (!/#EXTINF/i.test(await fetchHlsText(ctx, ordered[j].url, detailUrl))) continue; - remember(ordered[j]); - if (requestedHeight && ordered[j].height === requestedHeight) return ordered[j]; - break; - } catch (_) {} - } + if (!/#EXTINF/i.test(body)) throw new Error('empty-media-playlist'); + return Object.assign(inferQuality(item.url) || { height: 0 }, { url: item.url, headers: headers }); } - } catch (_) {} - } - if (fallback) return fallback; - throw new Error('播放线路检查失败:所有线路均未返回有效的 HLS 媒体清单'); + const ordered = requestedHeight ? variants.filter(function (q) { return q.height === requestedHeight; }).concat(variants.filter(function (q) { return q.height !== requestedHeight; })) : variants; + for (const variant of ordered.slice(0, 6)) { + try { + if (/#EXTINF/i.test(await fetchHlsText(ctx, variant.url, detailUrl, headers))) return Object.assign(variant, { headers: headers }); + } catch (_) {} + } + throw new Error('variant-unavailable'); + } catch (error) { failures.push(errorMessage(error)); return null; } + }); + const candidates = results.filter(Boolean).sort(function (a, b) { return (b.height || 0) - (a.height || 0); }); + const selected = requestedHeight && candidates.find(function (q) { return q.height === requestedHeight; }); + if (selected || candidates.length) return selected || candidates[0]; + throw new Error('播放线路检查失败:未收到有效 HLS(' + unique(failures).slice(0, 3).join(';') + ');专用网页播放器线路不支持原生交回'); } -async function fetchHlsText(ctx, url, detailUrl) { - const body = await fetchText(ctx, url, playbackHeaders(ctx, detailUrl)); - if (!/^\s*#EXTM3U/i.test(body)) throw new Error('HLS 清单无效:' + url); +async function fetchHlsText(ctx, url, detailUrl, headers) { + const body = await fetchText(ctx, url, headers || playbackHeaders(ctx, detailUrl), true); + if (!/^\s*#EXTM3U/i.test(body)) throw new Error('HLS 正文无效'); return body; } +async function madouMap(items, concurrency, work) { + const results = new Array(items.length); let cursor = 0; + await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, async function () { + while (cursor < items.length) { const index = cursor++; results[index] = await work(items[index], index); } + })); + return results; +} + function inferQuality(url) { const value = String(url || ''); let match = value.match(/(?:^|\/)(\d{3,4})x(\d{3,4})(?:\/|$)/i); @@ -277,7 +279,15 @@ function parseHomeSectionCards(html, title, ctx) { } function listURL(ctx, section, page) { return entryURL(ctx) + '/' + String(section.path || 'videos/recent').replace(/^\/+/, '') + (page > 1 ? '/page/' + page : ''); } -function detailURL(ctx) { const direct = firstNonEmpty(ctx && ctx.detailUrl, ctx && ctx.url); if (/^https?:\/\//i.test(direct)) return direct; const id = firstNonEmpty(ctx && ctx.itemId, ctx && ctx.id, decodeVersionId(ctx && ctx.versionId).slug); return id ? entryURL(ctx) + '/video/cid/' + encodeURIComponent(String(id).replace(/^.*\//, '')) : ''; } +function detailURL(ctx) { + const explicit = firstNonEmpty(ctx && ctx.detailUrl, ctx && ctx.detailURL); + if (/^https?:\/\//i.test(explicit)) return explicit; + for (const value of [ctx && ctx.itemId, ctx && ctx.id, ctx && ctx.url]) { + if (/^https?:\/\//i.test(String(value || '')) && videoSlug(value)) return String(value); + } + const id = firstNonEmpty(ctx && ctx.itemId, ctx && ctx.id, decodeVersionId(ctx && ctx.versionId).slug); + return id && !/^https?:/i.test(id) ? entryURL(ctx) + '/video/cid/' + encodeURIComponent(String(id).replace(/^.*\//, '')) : ''; +} function entryURL(ctx) { return baseURL(ctx) + MADOU8_ENTRY; } function baseURL(ctx) { const raw = contextValue(normalizeContext(ctx), 'baseUrl') || MADOU8_DEFAULT_BASE; return String(raw).replace(/\/+$/, ''); } function categoryAction(s) { return { type: 'category', pageId: s.id, title: s.title, path: s.path, itemAspectRatio: '16:9' }; } @@ -308,20 +318,83 @@ function attribute(tag, name) { const m = String(tag).match(new RegExp('\\b' + n function imageHeaders(ctx, referer) { return { Referer: referer || entryURL(ctx) + '/', 'User-Agent': MADOU8_UA }; } function playbackHeaders(ctx, referer) { return { Referer: referer || entryURL(ctx) + '/', 'User-Agent': MADOU8_UA, Accept: '*/*' }; } -async function fetchText(ctx, url, extraHeaders) { const response = await httpGet(url, { headers: Object.assign({ 'User-Agent': MADOU8_UA, Accept: 'text/html,application/xhtml+xml,application/json,application/vnd.apple.mpegurl,*/*;q=0.8', Referer: entryURL(ctx) + '/' }, extraHeaders || {}) }); const text = unwrapResponse(response); if (!text) throw new Error('HTTP 返回空内容:' + url); if (/Just a moment|cf-mitigated|Cloudflare Ray ID/i.test(text)) throw new Error('站点返回 Cloudflare 验证页'); return text; } +async function fetchText(ctx, url, extraHeaders, mediaRequest) { + const headers = Object.assign({ 'User-Agent': MADOU8_UA, Accept: 'text/html,application/xhtml+xml,application/json,application/vnd.apple.mpegurl,*/*;q=0.8', Referer: entryURL(ctx) + '/', 'Cache-Control': 'no-cache' }, extraHeaders || {}); + const response = await madouStage(ctx, mediaRequest ? 'hls-http' : 'page-http', mediaRequest ? 4 : 6, async function () { + const r = await httpGet(url, { headers: headers, timeout: mediaRequest ? 4 : 6, timeoutSeconds: mediaRequest ? 4 : 6, browserFallback: false }); + return { status: Number(r && (r.statusCode || r.status) || 200), text: await unwrapResponse(r) }; + }); + let text = response.text; + const challenge = /Just a moment|Checking (?:your )?browser|cf-chl-|cf-mitigated|Cloudflare Ray ID/i.test(text); + if (challenge && !mediaRequest && !(ctx && ctx.__browserUsed) && typeof Widget !== 'undefined' && Widget.browser && typeof Widget.browser.fetch === 'function') { + ctx.__browserUsed = true; + text = await madouStage(ctx, 'page-browser', 8, async function () { + return await unwrapResponse(await Widget.browser.fetch(url, { visible: false, timeout: 8, timeoutSeconds: 8, waitAfterLoad: 1, headers: headers })); + }); + if (!text || /Just a moment|Checking (?:your )?browser|cf-chl-|cf-mitigated|Cloudflare Ray ID/i.test(text)) throw new Error('站点验证未返回影片内容,stage=page-browser'); + } else if (response.status >= 400) throw new Error('HTTP ' + response.status + ',stage=' + (mediaRequest ? 'hls-http' : 'page-http')); + else if (challenge) throw new Error('站点返回验证页,stage=' + (mediaRequest ? 'hls-http' : 'page-http')); + if (!text) throw new Error('HTTP 返回空内容'); + return text; +} +function madouContext(ctx, milliseconds) { return Object.assign({}, normalizeContext(ctx), { __deadline: Date.now() + (milliseconds || 28000), __browserUsed: false }); } +async function madouStage(ctx, stage, seconds, work) { + const limit = Math.min(seconds * 1000, ctx && ctx.__deadline ? ctx.__deadline - Date.now() : seconds * 1000); + if (limit <= 0) throw new Error('解析总时限已到,stage=total-deadline'); + let timer; + try { return await Promise.race([Promise.resolve().then(work), new Promise(function (_, reject) { timer = setTimeout(function () { reject(new Error('等待超时,stage=' + stage)); }, limit); })]); } + finally { clearTimeout(timer); } +} + async function httpGet(url, options) { if (typeof Widget !== 'undefined' && Widget && Widget.http) { if (typeof Widget.http.get === 'function') return Widget.http.get(url, options || {}); if (typeof Widget.http.request === 'function') return Widget.http.request(Object.assign({ url: url, method: 'GET' }, options || {})); } if (typeof $http !== 'undefined' && $http) { if (typeof $http.get === 'function') return $http.get(url, options || {}); if (typeof $http.request === 'function') return $http.request(Object.assign({ url: url, method: 'GET' }, options || {})); } if (typeof fetch === 'function') return fetch(url, options || {}); throw new Error('当前环境没有可用的 HTTP 客户端'); } -function unwrapResponse(value) { if (value == null) return ''; if (typeof value === 'string') return value; if (typeof value.text === 'function') return value.text(); if (typeof value.body === 'string') return value.body; if (typeof value.data === 'string') return value.data; if (value.data && typeof value.data.html === 'string') return value.data.html; if (typeof value.text === 'string') return value.text; return JSON.stringify(value.data || value.body || value); } -function normalizeContext(ctx) { if (typeof ctx === 'string') { try { return JSON.parse(ctx); } catch (_) { return {}; } } return ctx && typeof ctx === 'object' ? ctx : {}; } +async function unwrapResponse(value) { + if (value == null) return ''; + if (typeof value === 'string') return value; + if (typeof value.text === 'function') return String(await value.text()); + for (const candidate of [value.data, value.body, value.html, value.text]) { + if (typeof candidate === 'string') return candidate; + if (candidate && typeof candidate.html === 'string') return candidate.html; + } + return value.data && typeof value.data === 'object' ? JSON.stringify(value.data) : ''; +} +function normalizeContext(input) { + let ctx = input; + if (typeof ctx === 'string') { try { ctx = JSON.parse(ctx); } catch (_) { return {}; } } + if (!ctx || typeof ctx !== 'object' || Array.isArray(ctx)) return {}; + const nested = {}; + for (const name of ['params', 'config', 'settings', 'parameters', 'pagination', 'pageInfo']) { + let bag = ctx[name]; + if (typeof bag === 'string') { try { bag = JSON.parse(bag); } catch (_) { bag = null; } } + if (bag && typeof bag === 'object') Object.assign(nested, bag); + } + return Object.assign(nested, ctx); +} + function contextValue(ctx, key) { ctx = normalizeContext(ctx); const bags = [ctx, ctx.params, ctx.config, ctx.settings, ctx.parameters, ctx.pagination, ctx.pageInfo]; for (let i = 0; i < bags.length; i++) if (bags[i] && bags[i][key] != null && bags[i][key] !== '') return bags[i][key]; return ''; } function parseJSON(s) { try { return JSON.parse(String(s || '')); } catch (_) { return null; } } function encodeVersionId(x) { return 'madou8://' + encodeURIComponent(JSON.stringify({ slug: videoSlug(x.url), url: x.url, uid: x.uid, height: x.height || 0 })); } function decodeVersionId(v) { const s = String(v || ''); if (s.indexOf('madou8://') !== 0) return {}; try { return JSON.parse(decodeURIComponent(s.slice(9))); } catch (_) { return {}; } } -function resolveURL(value, base) { try { return new URL(value, base).toString(); } catch (_) { if (/^https?:\/\//i.test(value)) return value; return ''; } } +function resolveURL(value, base) { + const input = String(value || '').trim(); if (!input) return ''; + if (/^https?:\/\//i.test(input)) return input; + if (typeof URL === 'function') { try { return new URL(input, base).toString(); } catch (_) {} } + const origin = String(base || '').match(/^(https?:)\/\/([^/?#]+)/i); if (!origin) return ''; + if (input.indexOf('//') === 0) return origin[1] + input; + const root = origin[1] + '//' + origin[2]; + if (input.charAt(0) === '?') return String(base).split(/[?#]/)[0] + input; + const suffix = (input.match(/[?#][\s\S]*$/) || [''])[0]; + const relativePath = input.split(/[?#]/)[0]; + const path = relativePath.charAt(0) === '/' ? relativePath : String(base).slice(root.length).split(/[?#]/)[0].replace(/[^/]*$/, '') + relativePath; + const parts = []; + path.split('/').forEach(function (part) { if (part === '..') parts.pop(); else if (part && part !== '.') parts.push(part); }); + return root + '/' + parts.join('/') + (/\/$/.test(path) ? '/' : '') + suffix; +} + function absoluteURL(ctx, value) { return resolveURL(decodeHTML(value), baseURL(ctx) + '/'); } function firstMatch(s, re) { const m = String(s || '').match(re); return m ? m[1] || '' : ''; } function firstNonEmpty() { for (let i = 0; i < arguments.length; i++) if (arguments[i] != null && String(arguments[i]).trim()) return String(arguments[i]).trim(); return ''; } diff --git a/taolusm-mini-library.js b/taolusm-mini-library.js index 33a205f..9b14e1d 100644 --- a/taolusm-mini-library.js +++ b/taolusm-mini-library.js @@ -9,7 +9,7 @@ const WidgetMetadata = { id: 'taolusm-mini-library', name: '套路SM', title: '套路SM', - version: '1.0.1', + version: '1.0.2', author: 'Alan huang', logo: TAOLUSM_LOGO, icon: TAOLUSM_LOGO, @@ -176,6 +176,7 @@ async function getDetail(ctx) { .filter(function (item) { return item.id !== id; }) .slice(0, 18); const headers = mediaHeaders(ctx); + const source = parsePlaybackSource(html, id); return { id: id, @@ -187,8 +188,8 @@ async function getDetail(ctx) { posterHeaders: headers, backdropHeaders: headers, detailImageAspectRatio: '16:9', - overview: overview, - resourceGroups: resourceGroupsFor(ctx, id, title), + overview: [source.preview ? '公开预览;完整影片需要站点登录及观看权限。' : '', overview].filter(Boolean).join('\n'), + resourceGroups: resourceGroupsFor(ctx, id, title, source), recommendations: recommendations.length ? [ { id: 'related', @@ -200,26 +201,44 @@ async function getDetail(ctx) { }; } -function getResourceVersions(ctx) { +async function getResourceVersions(ctx) { ctx = normalizeRuntimeContext(ctx); const id = itemIdFromContext(ctx); if (!id) return []; - return resourceGroupsFor(ctx, id, stringValue(ctx && ctx.title) || '在线播放'); + const html = await fetchText(ctx, baseURL(ctx) + '/v/' + encodeURIComponent(id)); + const source = parsePlaybackSource(html, id); + if (!source.url) throw new Error('套路SM 当前页面未提供可播放的影片,可能需要登录及观看权限'); + return resourceGroupsFor(ctx, id, stringValue(ctx.title) || '在线播放', source); } -function resolvePlayback(ctx) { +async function resolvePlayback(ctx) { ctx = normalizeRuntimeContext(ctx); const id = itemIdFromContext(ctx); - const url = stringValue(ctx && ctx.url) || (id ? downloadURL(ctx, id) : ''); - if (!url) throw new Error('套路SM 播放参数无效'); + if (!id) throw new Error('套路SM 播放参数无效'); + const html = await fetchText(ctx, baseURL(ctx) + '/v/' + encodeURIComponent(id)); + const source = parsePlaybackSource(html, id); + if (!source.url) throw new Error('套路SM 当前页面未提供媒体地址;完整影片需站点登录及观看权限'); + if (source.preview && (/^download-/.test(stringValue(ctx.versionId)) || /\/download\//.test(stringValue(ctx.url)))) { + throw new Error('旧下载线路需要站点登录及观看权限;当前公开页面仅提供预览,请重新打开详情并选择“公开预览”'); + } + const hls = /\.m3u8(?:$|[?#])/i.test(source.url); + if (hls) { + const body = await fetchText(ctx, source.url); + if (!/^\s*#EXTM3U/i.test(body)) throw new Error('套路SM 当前播放线路没有返回有效 HLS'); + } + return { url: source.url, container: hls ? 'm3u8' : 'mp4', headers: mediaHeaders(ctx), startPositionSeconds: 0, isLive: false, streamKind: hls ? 'hls' : 'file' }; +} - return { - url: url, - container: 'mp4', - headers: mediaHeaders(ctx), - isLive: false, - streamKind: 'file' - }; +function parsePlaybackSource(html, id) { + const scripts = String(html || '').match(/]*>[\s\S]*?<\/script>/gi) || []; + for (const script of scripts) { + const owner = firstMatch(script, /const\s+video_id\s*=\s*['"]([^'"]+)['"]/); + if (owner !== String(id)) continue; + const url = decodeEntities(firstMatch(script, /const\s+source\s*=\s*['"]([^'"]+)['"]/)); + if (!/^https?:\/\//i.test(url) || !/\.(?:mp4|m3u8)(?:$|[?#])/i.test(url)) continue; + return { url: url, preview: /\/preview_mp4\//i.test(url) || /正在播放预览/.test(html) }; + } + return { url: '', preview: false }; } async function search(ctx) { @@ -301,25 +320,15 @@ function parseListHtml(ctx, html) { return items; } -function resourceGroupsFor(ctx, id, title) { - return [ - { - id: 'download', - title: '在线播放', - versions: [ - { - id: 'download-' + id, - name: '默认线路', - title: title || '默认线路', - subtitle: '站点下载直链', - url: downloadURL(ctx, id), - container: 'mp4', - default: true, - headers: mediaHeaders(ctx) - } - ] - } - ]; +function resourceGroupsFor(ctx, id, title, source) { + if (!source || !source.url) return []; + const versionId = (source.preview ? 'preview-' : 'source-') + id; + return [{ id: source.preview ? 'preview' : 'online', title: source.preview ? '公开预览' : '网页播放线路', versions: [{ + id: versionId, name: source.preview ? '公开预览(非完整影片)' : '当前网页线路', title: title, + subtitle: source.preview ? '完整影片需要站点登录及观看权限' : '播放时重新读取网页地址', + container: /\.m3u8(?:$|[?#])/i.test(source.url) ? 'm3u8' : 'mp4', default: true, + action: { type: 'play', itemId: id, versionId: versionId, title: title } + }] }]; } function categoryCard(ctx, section) { @@ -381,12 +390,28 @@ function toWideItem(item) { } async function fetchText(ctx, url) { - const response = await Widget.http.get(url, { - headers: mediaHeaders(ctx) - }); - const data = response && (response.data || response.body || response.text); - if (!data) throw new Error('请求失败: ' + url); - return String(data); + let timer; + try { + return await Promise.race([Promise.resolve().then(async function () { + const options = { headers: mediaHeaders(ctx), timeout: 8, timeoutSeconds: 8, browserFallback: false }; + let response; + if (typeof Widget !== 'undefined' && Widget.http && typeof Widget.http.get === 'function') response = await Widget.http.get(url, options); + else if (typeof Widget !== 'undefined' && Widget.http && typeof Widget.http.request === 'function') response = await Widget.http.request(Object.assign({ url: url, method: 'GET' }, options)); + else if (typeof $http !== 'undefined' && typeof $http.get === 'function') response = await $http.get(url, options); + else throw new Error('当前环境没有 HTTP 客户端'); + const status = Number(response && (response.statusCode || response.status) || 200); + if (status >= 400) throw new Error('套路SM HTTP ' + status); + const final = response && (response.finalURL || response.urlEffective || response.responseURL || response.url); + if (/\/login(?:[/?#]|$)/i.test(String(final || ''))) throw new Error('套路SM 此入口需要站点登录及观看权限'); + let text = ''; + if (typeof response === 'string') text = response; + else if (response && typeof response.text === 'function') text = await response.text(); + else if (response) for (const value of [response.data, response.body, response.html, response.text]) { if (typeof value === 'string') { text = value; break; } if (value && typeof value.html === 'string') { text = value.html; break; } } + if (!text) throw new Error('套路SM 返回空内容'); + if (/Just a moment|Checking (?:your )?browser|cf-chl-/i.test(text)) throw new Error('套路SM 返回验证页'); + return String(text); + }), new Promise(function (_, reject) { timer = setTimeout(function () { reject(new Error('套路SM HTTP 等待超时(8 秒)')); }, 8000); })]); + } finally { clearTimeout(timer); } } function mediaHeaders(ctx) { @@ -431,7 +456,13 @@ function normalizePageId(id) { } function itemIdFromContext(ctx) { - return stringValue(ctx && (ctx.itemId || ctx.id || ctx.link || ctx.vid || ctx.videoId)).replace(/\D+/g, ''); + for (const value of [ctx.itemId, ctx.id, ctx.link, ctx.vid, ctx.videoId, ctx.versionId, ctx.url]) { + const text = stringValue(value); + if (/^\d+$/.test(text)) return text; + const match = text.match(/^(?:download|preview|source)-(\d+)$/) || text.match(/\/(?:v|download)\/(\d+)(?:[/?#]|$)/); + if (match) return match[1]; + } + return ''; } function hasNextPage(html, page) { diff --git a/tests/adapters.test.cjs b/tests/adapters.test.cjs index eba6a24..8b9032a 100644 --- a/tests/adapters.test.cjs +++ b/tests/adapters.test.cjs @@ -257,3 +257,137 @@ test('XVideos attaches the configured session token without printing it', async const options=await vm.runInContext('widgetAPI.getDefaultOptions()',c); assert.equal(options.headers.Cookie,'session_token='+token); assert.ok(logs.every(line=>!line.includes(token))); }); + +const madouCard = id => `
${id}
`; +const taoluPage = (id, source, preview = true) => `

Film ${id}

${preview ? '正在播放预览,VIP可免费观看完整视频' : ''}`; + +test('KBJ unavailable home reports the source failure instead of successful empty media', async () => { + const c = load('kbjfan-mini-library.js'); c.Widget.http.get = async () => { throw new Error('connection closed'); }; + await assert.rejects(c.getHome({}), /KBJFan 加载失败.*connection closed/); + const section = await c.getHomeSection({ sectionId: 'dance' }); assert.equal(section.items.length, 0); assert.match(section.error, /connection closed/); +}); +test('KBJ rejects a repurposed domain redirect and does not invent a replacement', async () => { + const c = load('kbjfan-mini-library.js'); c.Widget.http.get = async () => ({ statusCode: 200, finalURL: 'https://other.test/', data: '

Other site

' }); + await assert.rejects(c.getHome({}), /跳转到其他站点/); + c.Widget.http.get = async () => ({ statusCode: 403, data: 'Forbidden' }); + await assert.rejects(c.getHome({}), /HTTP 403/); +}); +test('KBJ accepts request-only HTTP and nested response HTML while preserving old contexts', async () => { + const c = load('kbjfan-mini-library.js'); const calls = []; + c.Widget.http = { async request(options) { calls.push(options); return { statusCode: 200, body: { html: '

Film

' } }; } }; + const home = await c.getHome(JSON.stringify({ params: JSON.stringify({ baseURL: 'https://kbj.test' }) })); + assert.equal(home.hero[0].id, 'https://kbj.test/film/'); assert.equal(calls[0].url, 'https://kbj.test/koreanbjdance/'); assert.equal(calls[0].browserFallback, false); +}); +test('KBJ and Taolu bound a hung response body as well as their HTTP request', async () => { + for (const file of ['kbjfan-mini-library.js', 'taolusm-mini-library.js']) { + const c = load(file, { setTimeout: (fn, ms) => setTimeout(fn, Math.min(ms, 15)) }); + c.Widget.http.get = async () => ({ statusCode: 200, text: () => new Promise(() => {}) }); + await assert.rejects(c.fetchText({}, 'https://source.test/'), /超时/); + } +}); +test('Madou first screen loads only recent media; category artwork remains lazy', async () => { + const c = load('madou8-mini-library 5.js'); const calls = []; + c.Widget.http.get = async url => { calls.push(url); return { statusCode: 200, body: { html: madouCard('first') } }; }; + const home = await c.getHome(JSON.stringify({ params: JSON.stringify({ baseUrl: 'https://madou.test' }) })); + assert.deepEqual(calls, ['https://madou.test/asian/zh-CN/videos/recent']); assert.equal(home.hero[0].id, 'first'); + assert.equal(home.sections[0].lazy, true); assert.equal(home.sections[1].items[0].rank, 1); +}); +test('Madou page failures do not turn into successful empty home or fake qualities', async () => { + const c = load('madou8-mini-library 5.js'); c.Widget.http.get = async () => ({ statusCode: 403, data: '

Forbidden

' }); + await assert.rejects(c.getHome({}), /HTTP 403/); + c.Widget.http.get = async () => ({ statusCode: 200, data: 'No cards' }); + await assert.rejects(c.getHome({}), /未收到影片列表/); + c.fetchStreamInfo = async () => ({ playlist: [{ url: 'https://invalid.test/stream.m3u8' }] }); c.fetchHlsText = async () => { throw new Error('HTTP 403'); }; + await assert.rejects(c.buildPlaybackGroups({}, 'https://madou.test/detail', 'uid', 'Film'), /没有可验证/); +}); +test('Madou one challenged page can return native browser HTML without media capture or retry stacks', async () => { + const c = load('madou8-mini-library 5.js'); let browsers = 0; + c.Widget.http.get = async () => ({ statusCode: 403, data: 'Just a moment' }); + c.Widget.browser = { async fetch(url, options) { browsers++; assert.equal(options.visible, false); assert.equal(options.waitForMediaSource, undefined); return { html: madouCard('verified') }; } }; + const home = await c.getHome({}); assert.equal(home.hero[0].id, 'verified'); assert.equal(browsers, 1); + await assert.rejects(c.fetchHlsText({}, 'https://cdn.test/master.m3u8', 'https://madou.test/'), /HTTP 403/); assert.equal(browsers, 1); +}); +test('Madou relative cards and signed HLS paths round-trip without URL global', () => { + const c = load('madou8-mini-library 5.js', { URL: undefined }); + const items = c.parseCards(madouCard('film'), {}); + assert.equal(items[0].action.detailUrl, 'https://madou8.pw/asian/zh-CN/video/cid/film'); + assert.equal(c.resolveURL('../720/video.m3u8?sig=a/b#part', 'https://cdn.test/token/master.m3u8'), 'https://cdn.test/720/video.m3u8?sig=a/b#part'); +}); +test('Madou evaluates a working line beyond the old first-four limit and skips web-only schemes', async () => { + const c = load('madou8-mini-library 5.js'); const calls = []; + const playlist = Array.from({length: 5}, (_, i) => ({ url: `https://cdn.test/${i}/master.m3u8` })); + playlist.unshift({ url: 'https://enc.test/stream.m3u8', playMode: 'streampipe' }, { url: 'https://web.test/stream.m3u8', native: false }); + c.fetchHlsText = async (ctx, url) => { calls.push(url); if (url.includes('/4/')) return url.endsWith('master.m3u8') ? master : media; throw new Error('HTTP 403'); }; + const chosen = await c.chooseStream({}, playlist, 1080, 'https://madou.test/detail'); + assert.equal(chosen.height, 1080); assert.match(chosen.url, /\/4\/1080.m3u8/); assert.ok(!calls.some(url => /enc.test|web.test/.test(url))); +}); +test('Madou retains public player Accept-sign headers for master, variant and native playback', async () => { + const c = load('madou8-mini-library 5.js'); const calls = []; + c.Widget.http.get = async (url, options) => { calls.push({url, headers: options.headers}); return {statusCode:200,data:url.endsWith('master.m3u8') ? master : media}; }; + const chosen = await c.chooseStream({}, [{url:'https://cdn.test/master.m3u8',urlSign:'opaque normal value'}], 720, 'https://madou.test/detail'); + assert.equal(chosen.height, 720); assert.equal(chosen.headers.Accept, '*/*;sign=opaque%20normal%20value'); + assert.ok(calls.every(x => x.headers.Accept === chosen.headers.Accept)); assert.equal(chosen.headers.Origin, undefined); + const q = await c.discoverVariants({}, [{url:'https://cdn.test/master.m3u8',urlSign:'private-now'}], 'https://madou.test/detail'); + assert.ok(!JSON.stringify(q).includes('private-now')); +}); +test('Madou original media playlists expose honest original quality and refresh before every play', async () => { + const c = load('madou8-mini-library 5.js'); let api = 0; + c.Widget.http.get = async url => ({statusCode:200,data:url.includes('/api/video/stream') ? JSON.stringify({playlist:[{url:`https://cdn.test/stream.m3u8?fresh=${++api}`,urlSign:`now-${api}`} ]}) : media}); + const detail = 'https://madou8.pw/asian/zh-CN/video/cid/film'; + const groups = await c.buildPlaybackGroups(c.madouContext({}), detail, 'uid', 'Film'); + assert.equal(groups[0].versions[0].name, '原始画质'); assert.ok(!JSON.stringify(groups).includes('now-')); + const action = groups[0].versions[0].action; + for (const height of [0, 720, 0]) { + const result = await c.resolvePlayback(JSON.stringify({...action,qualityId:height,url:'https://expired.test/file.m3u8'})); + assert.match(result.url, new RegExp(`fresh=${api}$`)); assert.equal(result.headers.Accept, `*/*;sign=now-${api}`); + } + assert.equal(api, 4); +}); +test('Madou media discovery caps concurrency and the overall deadline when native requests hang', async () => { + const c = load('madou8-mini-library 5.js', {setTimeout:(fn,ms)=>setTimeout(fn,Math.min(ms,20))}); let active=0,max=0; + c.Widget.http.get = async () => { active++; max=Math.max(max,active); return new Promise(()=>{}); }; + const playlist=Array.from({length:9},(_,i)=>({url:`https://cdn.test/${i}/master.m3u8`})); + const start=Date.now(); await assert.rejects(c.chooseStream({__deadline:Date.now()+25},playlist,0,'https://madou.test/detail'), /stage=/); + assert.ok(Date.now()-start<150); assert.ok(max<=6); // timed-out native calls cannot be cancelled; logical workers remain three +}); +test('Taolu detail and versions identify public previews, not download/login pages or recommendations', async () => { + const c=load('taolusm-mini-library.js'); const calls=[]; + c.Widget.http.get=async url=>{calls.push(url);return {statusCode:200,data:taoluPage('121576','https://tl.test/preview_mp4/121576.mp4')};}; + const detail=await c.getDetail({itemId:'121576'}); const version=detail.resourceGroups[0].versions[0]; + assert.equal(version.name,'公开预览(非完整影片)'); assert.equal(version.url,undefined); assert.match(detail.overview,/完整影片需要/); + const result=await c.resolvePlayback(JSON.stringify(version.action)); assert.equal(result.url,'https://tl.test/preview_mp4/121576.mp4'); assert.equal(result.container,'mp4'); assert.equal(result.headers.Origin,undefined); + assert.ok(calls.every(url=>url.endsWith('/v/121576'))); assert.equal(c.parsePlaybackSource(taoluPage('other','https://tl.test/preview_mp4/other.mp4'),'121576').url,''); +}); +test('Taolu old complete/download choices report access requirements instead of silently substituting previews', async () => { + const c=load('taolusm-mini-library.js');c.Widget.http.get=async()=>({statusCode:200,data:taoluPage('121576','https://tl.test/preview_mp4/121576.mp4')}); + await assert.rejects(c.resolvePlayback({versionId:'download-121576'}),/旧下载线路需要/); + await assert.rejects(c.resolvePlayback({url:'https://taolusm.com/download/121576'}),/旧下载线路需要/); + assert.equal(c.itemIdFromContext({itemId:'https://taolusm.com/v/121576?year=2026'}),'121576'); +}); +test('Taolu missing source, HTTP errors and login redirects never become MP4 playback results', async () => { + const c=load('taolusm-mini-library.js'); + for(const response of [{statusCode:200,data:''},{statusCode:403,data:'Forbidden'},{statusCode:200,finalURL:'https://taolusm.com/login',data:'Login'}]) { + c.Widget.http.get=async()=>response; + await assert.rejects(c.resolvePlayback({itemId:'121576'}),/未提供|HTTP 403|需要站点登录/); + } +}); + +test('Madou history refresh ignores an old media URL when only stable item/version is present', async () => { + const c=load('madou8-mini-library 5.js');const version=c.encodeVersionId({url:'https://madou8.pw/asian/zh-CN/video/cid/film',uid:'uid',height:720}); + assert.equal(c.detailURL({itemId:'film',url:'https://expired.test/stream.m3u8'}),'https://madou8.pw/asian/zh-CN/video/cid/film'); + assert.equal(c.detailURL({versionId:version,url:'https://expired.test/stream.m3u8'}),'https://madou8.pw/asian/zh-CN/video/cid/film'); +}); +test('Madou native response body timeouts and logical media concurrency remain bounded', async () => { + const c=load('madou8-mini-library 5.js',{setTimeout:(fn,ms)=>setTimeout(fn,Math.min(ms,15))}); + c.Widget.http.get=async()=>({statusCode:200,text:()=>new Promise(()=>{})}); + await assert.rejects(c.getHome({}),/stage=page-http/); + let active=0,max=0; + const results=await c.madouMap([1,2,3,4,5,6,7],3,async value=>{active++;max=Math.max(max,active);await new Promise(resolve=>setTimeout(resolve,3));active--;return value;}); + assert.deepEqual(plain(results),[1,2,3,4,5,6,7]);assert.equal(max,3); +}); +test('KBJ unrelated HTML cannot create fake playable lines or successful first categories', async () => { + const c=load('kbjfan-mini-library.js');c.Widget.http.get=async()=>({statusCode:200,data:'Other site'}); + await assert.rejects(c.getCategory({pageId:'dance'}),/没有原站影片/); + const detail=await c.getDetail({itemId:'https://www.kbjfan.com/old-film/'});assert.equal(detail.resourceGroups.length,0); + await assert.rejects(c.getResourceVersions({itemId:'https://www.kbjfan.com/old-film/'}),/没有可播放媒体/); +}); From 1e13e8c8c65270f29a65af0d20be9643228d7e23 Mon Sep 17 00:00:00 2001 From: hzl <1803573449@qq.com> Date: Sat, 3 Oct 2026 22:50:00 +0800 Subject: [PATCH 6/6] Document approved library update and source limitations --- CHANGELOG.md | 6 +++--- README.md | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9018fa..5eef9a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,8 @@ -# 统一故障修复稿 — 2026-10-03 +# 统一故障修复 — 2026-10-03 整合 MissAV 1.5.11、ASMRLIB 1.2.2、JAVGG 1.0.2、SexBJCam 1.1.9、麻豆亚洲版 1.1.2、套路 SM 1.0.2、KBJ fan 1.0.2,原下载路径全部保留。整体 103 项媒体库回归及仓库 CI 对应检查通过;下文各独立修复的检查数是各自主分支基线的验证记录。 -当前稿件仍有明确限制:KBJ 原站内容未恢复;套路 SM 完整影片仍需正常权限;麻豆 IPX-559 只有有效原始 HLS 的证据,1080P 及用户报告所属源待确认。全部 iPhone 原生显示/播放验收待设备确认。本稿保持草稿,不更改默认分支。 +当前版本仍有明确限制:KBJ 原站内容未恢复;套路 SM 完整影片仍需正常权限;麻豆 IPX-559 只有有效原始 HLS 的证据,1080P 及用户报告所属源待确认。全部 iPhone 原生显示/播放验收待设备确认。用户已要求将本轮改动发布至原 GitHub 下载入口,并在 iPhone 更新测试。 # 麻豆 / 套路 SM / KBJ fan — 2026-10-03 @@ -10,7 +10,7 @@ - 普通 HLS 候选由前四条扩大到最多 12 条,限三个逻辑并发和 28 秒总预算;原生调用超时后无法由 JS 强制取消。读取并正常转交 API/公开播放器使用的 `Accept` 凭据,播放时刷新,资源版本不持有媒体凭据或签名 URL。网页专用线路不冒充原生 HLS;未知分辨率显示原始画质,全部失败时不返回假默认线路。 - 桌面已验证五个分类的两页、搜索两页、三部详情/清单/分片。麻豆 IPX-559 返回有效原始 HLS,但该线路没有分辨率证据,1080P 仍未确认;其他 CDN 仍有 403。另一部影片完成 1080P → 720P → 1080P 的刷新验证。 - 套路 SM 1.0.2 读取对应影片主播放器,不把下载/登录 HTML 当 MP4,不误取推荐区媒体。真实匿名样本均只提供预览,资源与说明明确标注;旧下载版本仍能识别,权限不足时报明原因。完整影片需要站点正常登录及权限,未宣称修复该限制。 -- KBJ fan 1.0.2 对连接、HTTP 状态、异常跳转、空响应及没有原站影片作明确诊断,HTTP 请求及正文限制为 8 秒。原域名当前连接异常/跳转其他站点,没有确认到替代站点,因此此项为诊断改进,内容未恢复。 +- KBJ fan 1.0.2 对连接、HTTP 状态、异常跳转、空响应及没有原站影片作明确诊断,HTTP 请求及正文限制为 8 秒。原域名当前连接异常/跳转其他站点,用户 iPhone 测试也确认打不开。没有确认到替代站点,因此此项为诊断改进,内容未恢复。 - 保留三个脚本原下载路径、媒体库 ID、原参数名及历史稳定标识。64 项独立媒体库回归(新增 18 项)与仓库 CI 对应检查通过;iPhone 实际显示和原生播放仍待验收。 # JAVGG 1.0.2 — 2026-10-03 diff --git a/README.md b/README.md index c5339f6..122fe56 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ GitHub Actions 会先生成兼容副本并验证;`main` 的检查通过后, | [madou8-domestic-mini-library.js](madou8-domestic-mini-library.js) | 1.2.1 | 1.2.2 | | [madou8-mini-library 5.js](madou8-mini-library%205.js) | 1.1.0 | 1.1.2 | | [manko-fun-mini-library.js](manko-fun-mini-library.js) | 1.8.0 | 1.8.1 | -| [missav-mini-library-download-working 6.js](missav-mini-library-download-working%206.js) | 1.5.9 | 1.5.10 | +| [missav-mini-library-download-working 6.js](missav-mini-library-download-working%206.js) | 1.5.9 | 1.5.11 | | [novipnoad-mini-library.js](novipnoad-mini-library.js) | 1.0.0 | 1.0.1 | | [pornhub.media-library.js](pornhub.media-library.js) | 1.0.0 | 1.0.1 | | [pornhub_int.js](pornhub_int.js) | 1.1.5 | 1.1.6 | @@ -61,19 +61,19 @@ GitHub Actions 会先生成兼容副本并验证;`main` 的检查通过后, 具体行为和设备验证限制见 [CHANGELOG.md](CHANGELOG.md)。 -## 统一故障修复稿 — 2026-10-03 +## 统一故障修复 — 2026-10-03 - 麻豆亚洲版 `madou8-mini-library 5.js` 为 1.1.2:首屏只取最近更新,分类预览随后加载;HTTP 错误及验证失败明确报告。五个分类前两页各返回 12 条且没有跨页重复;桌面首屏 1.543 秒。原文件名、ID、`baseUrl` 和 `madou8://` 版本载荷保持兼容。 - 播放检查最多 12 条实际普通 HLS 线路,使用三个逻辑并发任务和 28 秒预算,避免接口顺序变化时漏掉第五条以后的有效源。沿用网页 `xhrSetup` 的 `Accept` 凭据,每次从 API 刷新,不写入版本/缓存。专用 StreamPipe/service-worker 线路及明确 `native:false` 的线路不会伪装成原生地址。 - 麻豆 IPX-559 已验证原始 HLS 及 MPEG-TS 分片;来源未标明该线路分辨率,没有宣称 1080P 恢复。STCV-497 返回 720P;另一部普通影片返回 1080P/720P/480P/360P,并通过 1080P → 720P → 1080P 的桌面请求。仍有 CDN 返回 403;未通过伪造权限或签名规避。 - 套路 SM `taolusm-mini-library.js` 为 1.0.2:旧下载入口会跳到登录页,现从当前影片主播放器配置读取实际源。三部匿名 HTTP 样本仅提供公开预览,资源明确标注“公开预览(非完整影片)”;旧下载选择报告登录/观看权限,完整影片未宣称恢复。 -- KBJ fan `kbjfan-mini-library.js` 为 1.0.2:补充 HTTP 状态、响应正文、8 秒截止时间和域名跳转诊断,加载失败不再返回成功的空首页或假播放线路。当前原域名连接中断,另一次网页读取跳到其他站点;没有确认到官方替代地址,内容恢复仍待可用站点。 +- KBJ fan `kbjfan-mini-library.js` 为 1.0.2:补充 HTTP 状态、响应正文、8 秒截止时间和域名跳转诊断,加载失败不再返回成功的空首页或假播放线路。当前原域名连接中断,另一次网页读取跳到其他站点;用户 iPhone 测试也确认打不开。没有确认到官方替代地址,内容恢复仍待可用站点。 -三个新增修复原先独立通过 64 项回归;与 MissAV、ASMRLIB、JAVGG、SexBJCam 整合后共 103 项媒体库回归及仓库检查通过。以上真实媒体结果来自桌面 HTTP;DreamBy/iPhone 的显示、原生起播及验证状态衔接仍需设备确认。用户报告的 IPX-559 所属媒体库尚待确认,本轮按麻豆亚洲版检查该影片。原下载路径全部保留;本分支为等待统一确认的修复稿,整合入口为 [PR #13](https://github.com/bbnotcode/ph_js/pull/13),尚未合并默认分支。 +三个新增修复原先独立通过 64 项回归;与 MissAV、ASMRLIB、JAVGG、SexBJCam 整合后共 103 项媒体库回归及仓库检查通过。以上真实媒体结果来自桌面 HTTP;DreamBy/iPhone 的显示、原生起播及验证状态衔接仍需设备确认。用户报告的 IPX-559 所属媒体库尚待确认,本轮按麻豆亚洲版检查该影片。原下载路径全部保留;完整变更见 [PR #13](https://github.com/bbnotcode/ph_js/pull/13)。用户已要求发布这些改动,更新时沿用既有导入地址;无需删除媒体库重导。 ASMRLIB 1.2.2 的 BI/AB 使用“验证后播放”入口,在原详情页内选择线路并手动验证或点击播放。源设置中的“显示播放验证页面”默认开启。只有浏览器返回最终媒体地址才会交给原生播放器;若只返回网页或 blob,脚本会显示具体阶段。此流程的代码及模拟已验证,iPhone 嵌套媒体回传、自动交回和起播仍待设备确认。 -## JAVGG 1.0.2 播放修复稿 +## JAVGG 1.0.2 播放修复 `javgg-mini-library.js` 保持原下载路径、媒体库 ID、`baseUrl` 参数和旧 `javgg://` 载荷。按实际 `data-nume` 对应 VH、playmate、luluvdoo、SW 线路,静态读取公开播放器的打包代码,不执行远程脚本。资源列表仅包含本次成功读取到的真实画质,最高画质默认;地址在每次播放时刷新。 @@ -81,7 +81,7 @@ ASMRLIB 1.2.2 的 BI/AB 使用“验证后播放”入口,在原详情页内 匿名桌面 HTTP 已验证 CAWB-046 的 luluvdoo 1080p、SW 1080p/720p/480p 和 MIRD-287 的 luluvdoo 720p,master、变体与分片可请求;ATID-666 的 SW 清单当时返回 404。首页/详情 HTTP 还可能遇到站点浏览器检查,浏览器正常打开不等于设备脚本能读取。DreamBy 浏览器回传、iPhone 原生起播与切画质仍待实测。 -## SexBJCam 1.1.9 播放修复稿 +## SexBJCam 1.1.9 播放修复 保留 `sexbjcam-mini-library.js` 原下载路径、`sexbjcam-mini-library` ID、`baseURL` 参数、历史详情 URL 与 `quality:1080/720/480` ID。播放器改为先用 HTTP 读取公开打包配置,必要时只做一次 12 秒浏览器媒体捕获;HLS 正文使用最多 4 秒 HTTP 请求,不再用浏览器导航清单。详情、资源发现和播放各有 28 秒总时限;脚本截止时间不能强制取消宿主原生任务。