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 () => '';