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, /]*>([\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.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, /]*>([\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.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, /]*>([\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/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 = '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 () => '';