From 13d26b9fd8b32251482749a5587079503f3ca6fb Mon Sep 17 00:00:00 2001
From: hzl <1803573449@qq.com>
Date: Sun, 4 Oct 2026 08:41:16 +0800
Subject: [PATCH] Restore library snapshots from before the October 3 task
---
CHANGELOG.md | 12 +-
README.md | 35 +-
madou8-mini-library 5.js | 115 +-
missav-mini-library-CloudFlare.js | 397 ++++-
missav-mini-library-download-working 6.js | 1858 +++++++++++++++++++--
missav-mini-library-download-working.js | 1858 +++++++++++++++++++--
missav-mini-library.js | 4 +-
sexbjcam-mini-library.js | 141 +-
sexbjcam-network-recovery.test.js | 46 +-
tests/adapters.test.cjs | 43 +-
tools/library-aliases.json | 4 +-
tools/library-contracts.json | 27 +-
tools/library-first-versions.json | 44 -
tools/library-restoration.json | 46 +
14 files changed, 4070 insertions(+), 560 deletions(-)
delete mode 100644 tools/library-first-versions.json
create mode 100644 tools/library-restoration.json
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f6b71b8..1ee4a90 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,9 +1,17 @@
-# 七个媒体库恢复最早上传版 — 2026-10-04
+# 更正恢复时间:任务开始前的线上版本 — 2026-10-04
+
+- 用户更正为昨天(2026-10-03)开始本轮任务之前的版本。采用上海时间 2026-10-03 零点之前主分支最后快照 `2fd0121bfbf0cc812d1e9e39501384710e684ade`;十个目标文件与本轮最初审查基线 `8e43017532ec81e3338046ba6e32dff7655bd3dd` 相同。
+- 原样恢复 MissAV 普通版/CloudFlare 版 1.0.7、两个 download-working 入口 1.5.9、SexBJCam 1.1.7、ASMRLIB 1.2.0、JAVGG 1.0.0、麻豆亚洲版 1.1.0、套路 SM 1.0.0、KBJ fan 1.0.0,包含当时的参数和解析实现。
+- MissAV 两份 1.0.7 的原始内容不同,移出统一同步目标并分别恢复。仅两个相同的 download-working 1.5.9 继续同步,避免自动化覆盖已恢复的旧入口;Eporner、XXXFollow 同步规则保留。
+- 原 raw 地址和媒体库 ID 全部保留。截止时间、基线提交及十个路径 SHA-256 写入 `tools/library-restoration.json`,恢复检查覆盖对应历史内容和公开入口。SexBJCam 网络行为检查也恢复为该快照的原有测试。
+- 更正此前“恢复最早上传版”的操作。其他媒体库、Forward、Angel Live 和 Worker 内容不在本次恢复范围。
+
+# 七个媒体库恢复最早上传版 — 2026-10-04(历史记录,已更正)
- 用户明确要求恢复 GitHub 最早上传的第一版:MissAV 1.0.0、SexBJCam 1.1.3、ASMRLIB 1.2.0、JAVGG 1.0.0、麻豆亚洲版 1.0.1、套路 SM 1.0.0、KBJ fan 1.0.0。原始脚本逐字节恢复,版本号、参数和解析行为与原版一致。
- 保留七个原下载路径和媒体库 ID。MissAV 的四个入口全部同步为最早 `missav-mini-library.js` 的 1.0.0 原始内容;兼容同步任务继续保持一致。
- 麻豆亚洲版最早提交使用旧名 `madou8-mini-library.js`,之后迭代换名为当前 `madou8-mini-library 5.js`。经提交祖先、相同库 ID 和相同文件内容核对,恢复的是更早的 1.0.1;当前 5.js 下载地址保留。
-- 原始提交、原文件名和 SHA-256 存于 `tools/library-first-versions.json`,离线检查验证恢复内容。原版参数契约同步恢复;只撤回依赖已取消功能的回归检查,其他库和包装检查继续保留。
+- 原始提交、原文件名和 SHA-256 记录在此前提交 `504ce31723d3c295a565a0beb70f09d65e0aa470` 的 `tools/library-first-versions.json`,离线检查验证恢复内容。原版参数契约同步恢复;只撤回依赖已取消功能的回归检查,其他库和包装检查继续保留。
- 下文七个库的 2026-10-03 优化及播放修复为历史记录,现已撤回。其他媒体库、Forward 与 Angel Live 内容不在本次恢复范围。
# 统一故障修复 — 2026-10-03(历史记录,已撤回)
diff --git a/README.md b/README.md
index 70c3faa..2fc666e 100644
--- a/README.md
+++ b/README.md
@@ -4,23 +4,25 @@
已经导入 DreamBy 的入口继续使用原来的下载地址和媒体库 ID。每个下载入口都包含完整 JavaScript,不需要远程加载器。
-## 2026-10-04:七个媒体库恢复最早上传版
+## 2026-10-04:更正恢复到任务开始前的版本
-按用户确认,以下七个媒体库恢复到 GitHub 历史中最早上传的原始内容,包含原版版本号、参数和解析行为。此次恢复并非仅退回 2026-10-03 播放修复之前。
+用户更正恢复时间为昨天(2026-10-03)开始本轮任务之前。按上海时间 `2026-10-03 00:00:00 +08:00` 之前主分支最后的快照 `2fd0121bfbf0cc812d1e9e39501384710e684ade` 恢复。这十个目标路径与原审查基线 `8e43017532ec81e3338046ba6e32dff7655bd3dd` 完全同字节;两提交之间仅新增另一个库 `hsex-mini-library.js`。
-| 媒体库 / 当前下载文件 | 恢复版本 | 原始提交 |
-| --- | --- | --- |
-| [MissAV](missav-mini-library.js) | 1.0.0 | [a44d256](https://github.com/bbnotcode/ph_js/commit/a44d25631a06c29b4491610c96c965bb03c9711d) |
-| [SexBJCam](sexbjcam-mini-library.js) | 1.1.3 | [420f4fa](https://github.com/bbnotcode/ph_js/commit/420f4fa2a5812cb647249a184f59696cbbdb02fb) |
-| [ASMRLIB](asmrlib-mini-library.js) | 1.2.0 | [4a2ece9](https://github.com/bbnotcode/ph_js/commit/4a2ece99e4af217c1509c522c0d84d276b068b08) |
-| [JAVGG](javgg-mini-library.js) | 1.0.0 | [b17286b](https://github.com/bbnotcode/ph_js/commit/b17286ba023d392cf9a6e3935590e2bc5c528b06) |
-| [麻豆亚洲版](madou8-mini-library%205.js) | 1.0.1 | [2a16937](https://github.com/bbnotcode/ph_js/commit/2a16937466dfbb908ae1bab6a1347569299df1df) |
-| [套路 SM](taolusm-mini-library.js) | 1.0.0 | [fbb65e1](https://github.com/bbnotcode/ph_js/commit/fbb65e110e60561c4a557adb42c80dc2e173505f) |
-| [KBJ fan](kbjfan-mini-library.js) | 1.0.0 | [e943d61](https://github.com/bbnotcode/ph_js/commit/e943d616e2df05c3abf6fdf2a716780c9d6a0aff) |
+| 媒体库 / 当前下载文件 | 恢复版本 |
+| --- | --- |
+| [MissAV 普通版](missav-mini-library.js) | 1.0.7 |
+| [MissAV CloudFlare 版](missav-mini-library-CloudFlare.js) | 1.0.7 |
+| [MissAV download-working](missav-mini-library-download-working.js) / [working 6](missav-mini-library-download-working%206.js) | 1.5.9 |
+| [SexBJCam](sexbjcam-mini-library.js) | 1.1.7 |
+| [ASMRLIB](asmrlib-mini-library.js) | 1.2.0 |
+| [JAVGG](javgg-mini-library.js) | 1.0.0 |
+| [麻豆亚洲版](madou8-mini-library%205.js) | 1.1.0 |
+| [套路 SM](taolusm-mini-library.js) | 1.0.0 |
+| [KBJ fan](kbjfan-mini-library.js) | 1.0.0 |
-麻豆最早以 `madou8-mini-library.js` 上传,后来同一内容迭代为 `madou8-mini-library 5.js`;现将最早的 1.0.1 内容放回当前下载路径。MissAV 四个既有入口同步为同一份原始 1.0.0,避免兼容同步任务再次覆盖恢复结果。全部原 raw 下载地址保留。
+每个文件均恢复该时间点的原始内容、版本号和参数,原 raw 地址与库 ID 保留。MissAV 普通版和 CloudFlare 版虽然版本号相同,内容和参数不同,分别保留;只有内容相同的两个 download-working 文件继续同步。
-原始提交、原文件名及 SHA-256 记录在 [tools/library-first-versions.json](tools/library-first-versions.json)。恢复检查逐字节校验原版内容,语法、媒体库 ID 和原版公共入口继续验证。2026-10-03 的七库修复记录保留在下文作为历史记录。
+时间截止点、基线提交及十份文件 SHA-256 记录在 [tools/library-restoration.json](tools/library-restoration.json)。恢复检查校验每个路径的历史内容、真实 manifest、原版公共入口和对应版本行为。此前恢复最早上传版的操作已更正,下文播放修复为撤回的历史记录。
## 主维护文件与兼容入口
@@ -28,9 +30,9 @@
| --- | --- | --- |
| [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.0.0 | `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.9 | `missav-mini-library-download-working.js` |
-Eporner、XXXFollow 的两份文件原本内容相同。MissAV 的四个入口现均为原始 1.0.0 的完整兼容副本,保留 `missav-mini-library` ID 和原版 `baseURL`、`entryPath` 参数。
+Eporner、XXXFollow 的两份文件原本内容相同。MissAV 两个 download-working 入口继续保持相同内容;普通版和 CloudFlare 版独立保留。
日常只修改左侧主维护文件,再运行:
@@ -38,10 +40,11 @@ Eporner、XXXFollow 的两份文件原本内容相同。MissAV 的四个入口
node tools/sync-library-aliases.cjs
```
-GitHub Actions 会先生成兼容副本并验证;`main` 的检查通过后,如果副本有变化,机器人只提交这 5 个兼容文件。PR 要提交同步后的副本,检查会拒绝遗漏更新。主维护文件的新实现因此能继续通过所有旧下载路径获得。
+GitHub Actions 会先生成兼容副本并验证;`main` 的检查通过后,如果副本有变化,机器人只提交这 3 个兼容文件。PR 要提交同步后的副本,检查会拒绝遗漏更新。主维护文件的新实现因此能继续通过所有旧下载路径获得。
## 独立保留的入口
+- MissAV 普通版 `missav-mini-library.js`、CloudFlare 版 `missav-mini-library-CloudFlare.js` 各自保留任务之前的实现。
- 麻豆亚洲版 `madou8-mini-library 5.js` 与国产版 `madou8-domestic-mini-library.js` 内容范围和 ID 不同,分别保留。
- Jable、Pornhub、Hanime1、MissAV 的 Forward 与 DreamBy 入口分别保留。
- Hanime1 的 `.fwd` 包和加密的 `twitter视频.js` 保持原格式;Stripchat ZIP、两个订阅索引和插件主程序保持一致。
diff --git a/madou8-mini-library 5.js b/madou8-mini-library 5.js
index 1d01695..e4c8cf6 100644
--- a/madou8-mini-library 5.js
+++ b/madou8-mini-library 5.js
@@ -8,7 +8,7 @@ const WidgetMetadata = {
id: 'madou8-mini-library',
name: '麻豆视频',
title: '麻豆视频',
- version: '1.0.1',
+ version: '1.1.0',
requiredVersion: '0.0.1',
author: 'Alan huang',
site: MADOU8_DEFAULT_BASE,
@@ -19,7 +19,7 @@ const WidgetMetadata = {
const SECTIONS = [
{ id: 'recent', title: '最近更新', path: 'videos/recent', style: 'discover.ranked' },
- { id: 'hot-month', title: '本月热门', path: 'videos/hot/month', style: 'discover.spotlight' },
+ { id: 'hot-month', title: '本月热门', sourceTitle: '热门影片', path: 'videos/hot/month', style: 'discover.spotlight' },
{ id: 'new-releases', title: '新作上市', path: 'videos/new-releases', style: 'discover.posterCompact' },
{ id: '4k', title: '4K', path: 'videos/tag/4K', style: 'discover.posterCompact' },
{ id: 'collections', title: '合集', path: 'videos/tag/%E5%90%88%E9%9B%86', style: 'discover.posterCompact' }
@@ -38,12 +38,30 @@ function getManifest() {
}
async function getHome(ctx) {
+ let homeHtml = '';
let recent = [];
- try { recent = parseCards(await fetchText(ctx, listURL(ctx, SECTIONS[0], 1)), ctx).slice(0, 18); } catch (_) {}
+ try {
+ homeHtml = await fetchText(ctx, entryURL(ctx));
+ recent = parseHomeSectionCards(homeHtml, SECTIONS[0].title, ctx).slice(0, 18);
+ } catch (_) {}
+ if (!recent.length) {
+ try { recent = parseCards(await fetchText(ctx, listURL(ctx, SECTIONS[0], 1)), ctx).slice(0, 18); } catch (_) {}
+ }
+ const previewPairs = await Promise.all(SECTIONS.map(async function (section) {
+ if (section.id === 'recent' && recent.length) return { section: section, items: recent };
+ const embedded = parseHomeSectionCards(homeHtml, section.sourceTitle || section.title, ctx).slice(0, 3);
+ if (embedded.length) return { section: section, items: embedded };
+ try {
+ return { section: section, items: parseCards(await fetchText(ctx, listURL(ctx, section, 1)), ctx).slice(0, 3) };
+ } catch (_) { return { section: section, items: [] }; }
+ }));
+ const categoryItems = previewPairs.map(function (pair) {
+ return categoryPreviewCard(pair.section, pair.items);
+ }).filter(Boolean);
return {
pageType: 'home', id: 'madou8-home', title: WidgetMetadata.title, heroAspectRatio: '16:9',
hero: recent.slice(0, 6).map(wideItem),
- sections: [{ id: 'madou8-categories', title: '分类浏览', style: 'discover.annualCategories', lazy: false, items: SECTIONS.map(categoryCard) },
+ sections: [{ id: 'madou8-categories', title: '分类浏览', style: 'discover.annualPosterStack', lazy: false, items: categoryItems },
{ id: 'recent', title: '最近更新', style: 'discover.ranked', lazy: false, moreAction: categoryAction(SECTIONS[0]), items: ranked(recent) }
].concat(SECTIONS.slice(1).map(sectionShell))
};
@@ -116,8 +134,12 @@ async function resolvePlayback(ctx) {
ctx = normalizeContext(ctx);
const url = detailURL(ctx);
if (!url) throw new Error('播放参数无效:缺少详情地址或视频 ID');
- const html = await fetchText(ctx, url);
- const uid = String(ctx.videoUid || extractVideoUid(html) || '');
+ const decoded = decodeVersionId(ctx.versionId);
+ let uid = String(ctx.videoUid || decoded.uid || '');
+ if (!uid) {
+ const html = await fetchText(ctx, url);
+ uid = String(extractVideoUid(html) || '');
+ }
if (!uid) throw new Error('播放解析失败:详情页未找到 video_uid');
const info = await fetchStreamInfo(ctx, uid, url);
const requestedHeight = positiveInt(ctx.qualityId || decodeVersionId(ctx.versionId).height, 0);
@@ -153,12 +175,9 @@ async function discoverVariants(ctx, playlist, detailUrl) {
const body = await fetchHlsText(ctx, url, detailUrl);
const variants = parseMaster(body, url);
if (variants.length) {
- const checked = await Promise.all(variants.slice(0, 6).map(async function (variant) {
- return await verifyMediaStream(ctx, variant.url, detailUrl) ? variant : null;
- }));
- const healthy = checked.filter(Boolean).sort(function (a, b) { return b.height - a.height; });
- if (healthy.length) discovered.push.apply(discovered, healthy);
- } else if (/#EXTINF/i.test(body) && await probeFirstSegment(ctx, body, url, detailUrl)) {
+ discovered.push.apply(discovered, variants.slice(0, 6));
+ break;
+ } else if (/#EXTINF/i.test(body)) {
const inferred = inferQuality(url);
if (inferred) discovered.push(inferred);
}
@@ -176,7 +195,7 @@ async function chooseStream(ctx, playlist, requestedHeight, detailUrl) {
const body = await fetchHlsText(ctx, item.url, detailUrl);
const variants = parseMaster(body, item.url).sort(function (a, b) { return b.height - a.height; });
if (!variants.length) {
- if (!/#EXTINF/i.test(body) || !await probeFirstSegment(ctx, body, item.url, detailUrl)) continue;
+ if (!/#EXTINF/i.test(body)) continue;
const inferred = inferQuality(item.url) || { url: item.url, height: 0 };
inferred.url = item.url;
if (!fallback) fallback = inferred;
@@ -186,13 +205,18 @@ async function chooseStream(ctx, playlist, requestedHeight, detailUrl) {
? variants.filter(function (x) { return x.height === requestedHeight; }).concat(variants.filter(function (x) { return x.height !== requestedHeight; }))
: variants;
for (let j = 0; j < ordered.length; j++) {
- if (await verifyMediaStream(ctx, ordered[j].url, detailUrl)) return ordered[j];
+ try {
+ const mediaBody = await fetchHlsText(ctx, ordered[j].url, detailUrl);
+ if (!/#EXTINF/i.test(mediaBody)) continue;
+ if (!fallback) fallback = ordered[j];
+ return ordered[j];
+ } catch (_) {}
}
}
} catch (_) {}
}
if (fallback) return fallback;
- throw new Error('播放线路检查失败:所有清单或首个媒体分片均不可用');
+ throw new Error('播放线路检查失败:所有线路均未返回有效的 HLS 媒体清单');
}
async function fetchHlsText(ctx, url, detailUrl) {
@@ -201,39 +225,6 @@ async function fetchHlsText(ctx, url, detailUrl) {
return body;
}
-async function verifyMediaStream(ctx, url, detailUrl) {
- try {
- const body = await fetchHlsText(ctx, url, detailUrl);
- return /#EXTINF/i.test(body) && await probeFirstSegment(ctx, body, url, detailUrl);
- } catch (_) { return false; }
-}
-
-async function probeFirstSegment(ctx, playlistText, playlistUrl, detailUrl) {
- const segment = firstMediaURI(playlistText, playlistUrl);
- if (!segment) return false;
- try {
- const response = await httpGet(segment, { headers: Object.assign({}, playbackHeaders(ctx, detailUrl), { Range: 'bytes=0-1023' }) });
- const status = responseStatus(response);
- return status === 0 || status === 200 || status === 206;
- } catch (_) { return false; }
-}
-
-function firstMediaURI(text, playlistUrl) {
- const lines = String(text || '').split(/\r?\n/);
- for (let i = 0; i < lines.length; i++) {
- const line = lines[i].trim();
- if (line && line.charAt(0) !== '#') return resolveURL(line, playlistUrl);
- }
- return '';
-}
-
-function responseStatus(response) {
- if (!response || typeof response !== 'object') return 0;
- const value = response.status != null ? response.status : response.statusCode != null ? response.statusCode : response.code;
- const number = Number(value);
- return isFinite(number) ? number : 0;
-}
-
function inferQuality(url) {
const value = String(url || '');
let match = value.match(/(?:^|\/)(\d{3,4})x(\d{3,4})(?:\/|$)/i);
@@ -272,12 +263,37 @@ function parseCards(html, ctx) {
return items;
}
+function parseHomeSectionCards(html, title, ctx) {
+ html = String(html || '');
+ const headingPattern = new RegExp(']*>\\s*' + escapeRegExp(title) + '\\s*', 'i');
+ const heading = headingPattern.exec(html);
+ if (!heading) return [];
+ const blockMarker = /
]*class=["'][^"']*streamit-video-grid-block\b[^"']*["'][^>]*>/gi;
+ let start = -1; let match;
+ while ((match = blockMarker.exec(html)) && match.index < heading.index) start = match.index;
+ if (start < 0) start = heading.index;
+ blockMarker.lastIndex = heading.index + heading[0].length;
+ const next = blockMarker.exec(html);
+ return parseCards(html.slice(start, next ? next.index : html.length), ctx);
+}
+
function listURL(ctx, section, page) { return entryURL(ctx) + '/' + String(section.path || 'videos/recent').replace(/^\/+/, '') + (page > 1 ? '/page/' + page : ''); }
function detailURL(ctx) { const direct = firstNonEmpty(ctx && ctx.detailUrl, ctx && ctx.url); if (/^https?:\/\//i.test(direct)) return direct; const id = firstNonEmpty(ctx && ctx.itemId, ctx && ctx.id, decodeVersionId(ctx && ctx.versionId).slug); return id ? entryURL(ctx) + '/video/cid/' + encodeURIComponent(String(id).replace(/^.*\//, '')) : ''; }
function entryURL(ctx) { return baseURL(ctx) + MADOU8_ENTRY; }
function baseURL(ctx) { const raw = contextValue(normalizeContext(ctx), 'baseUrl') || MADOU8_DEFAULT_BASE; return String(raw).replace(/\/+$/, ''); }
function categoryAction(s) { return { type: 'category', pageId: s.id, title: s.title, path: s.path, itemAspectRatio: '16:9' }; }
-function categoryCard(s) { return { id: s.id, title: s.title, type: 'category', subtitle: '浏览' + s.title, action: categoryAction(s) }; }
+function categoryPreviewCard(s, items) {
+ const previews = (items || []).filter(function (item) { return item && (item.poster || item.backdrop); }).slice(0, 3);
+ if (!previews.length) return null;
+ const lead = previews[0];
+ return {
+ id: s.id, title: s.title, type: 'category', subtitle: '浏览' + s.title,
+ poster: lead.poster || lead.backdrop, backdrop: lead.backdrop || lead.poster,
+ imageHeaders: lead.imageHeaders, posterHeaders: lead.posterHeaders || lead.imageHeaders,
+ backdropHeaders: lead.backdropHeaders || lead.imageHeaders,
+ previewItems: previews, action: categoryAction(s)
+ };
+}
function sectionShell(s) { return { id: s.id, title: s.title, style: s.style, lazy: true, moreAction: categoryAction(s), items: [] }; }
function findSection(id) { id = String(id || ''); return SECTIONS.find(function (x) { return x.id === id || x.path === id; }); }
function ranked(items) { return items.map(function (x, i) { const y = Object.assign({}, x); y.rank = i + 1; return y; }); }
@@ -313,6 +329,7 @@ function firstNonEmpty() { for (let i = 0; i < arguments.length; i++) if (argume
function decodeHTML(s) { return String(s || '').replace(/&/g, '&').replace(/"/g, '"').replace(/'|'/g, "'").replace(/</g, '<').replace(/>/g, '>').replace(/(\d+);/g, function (_, n) { return String.fromCharCode(Number(n)); }); }
function cleanText(s) { return decodeHTML(String(s || '').replace(/<[^>]+>/g, ' ')).replace(/\s+/g, ' ').trim(); }
function cleanTitle(s) { return cleanText(s).replace(/^\s*麻豆视频\s*[|\-] */i, '').replace(/\s*[|\-] *麻豆视频\s*$/i, '').trim(); }
+function escapeRegExp(s) { return String(s || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
function positiveInt(v, fallback) { const n = Number(v); return isFinite(n) && n > 0 ? Math.floor(n) : fallback; }
function unique(a) { return a.filter(function (x, i) { return x && a.indexOf(x) === i; }); }
function uniqueBy(a, key) { const seen = {}; return a.filter(function (x) { const k = key(x); if (seen[k]) return false; seen[k] = true; return true; }); }
diff --git a/missav-mini-library-CloudFlare.js b/missav-mini-library-CloudFlare.js
index 4d143b5..f6cab42 100644
--- a/missav-mini-library-CloudFlare.js
+++ b/missav-mini-library-CloudFlare.js
@@ -11,8 +11,8 @@ const WidgetMetadata = {
id: 'missav-mini-library',
name: 'MissAV',
title: 'MissAV',
- version: '1.0.0',
- author: 'baiPlay',
+ version: '1.0.7',
+ author: 'alanhuang',
logo: MISSAV_LOGO,
icon: MISSAV_LOGO,
site: MISSAV_DEFAULT_BASE,
@@ -114,18 +114,55 @@ function getManifest() {
defaultValue: MISSAV_DEFAULT_ENTRY,
required: true,
description: '例如 /dm247/cn。MissAV 的 dm 前缀会变化,失效时可替换为当前可访问入口。'
+ },
+ {
+ name: 'backupBaseURLs',
+ title: '备用站点地址',
+ type: 'input',
+ defaultValue: 'https://missav.ai,https://missav123.com',
+ required: false,
+ description: '多个域名用逗号或换行分隔。网络不稳或主域名被拦截时会自动尝试备用域名。'
+ },
+ {
+ name: 'enableBrowserFallback',
+ title: 'Cloudflare 浏览器兜底',
+ type: 'boolean',
+ defaultValue: true,
+ required: false,
+ description: '遇到 Cloudflare 或普通 HTTP 失败时,使用 App 浏览器请求一次并复用浏览器 Cookie。'
+ },
+ {
+ name: 'browserVisible',
+ title: '显示验证窗口',
+ type: 'boolean',
+ defaultValue: false,
+ required: false,
+ description: '默认关闭,避免列表页自动跳出真人验证界面。需要时点击页面里的手动验证卡片。'
+ },
+ {
+ name: 'requestTimeoutSeconds',
+ title: '请求超时秒数',
+ type: 'number',
+ defaultValue: 45,
+ required: false,
+ description: '网络差时可调大到 60-90 秒。'
+ },
+ {
+ name: 'cacheMinutes',
+ title: '页面缓存分钟',
+ type: 'number',
+ defaultValue: 20,
+ required: false,
+ description: '短期缓存首页、分类、详情 HTML,减少重复触发 Cloudflare。'
}
]
};
}
async function getHome(ctx) {
- const html = await safeFetch(entryURL(ctx), baseURL(ctx) + '/');
+ const html = await safeFetch(ctx, entryURL(ctx), baseURL(ctx) + '/');
const hero = parseCards(sectionBlock(html, '推荐给你'), '推荐给你', ctx).slice(0, 10).map(toWideItem);
- const parsedSections = parseHomeSections(ctx, html);
- const mediaSections = parsedSections.length ? parsedSections : MISSAV_SECTIONS.map(function (section) {
- return sectionShell(ctx, section);
- });
+ const mediaSections = homeMediaSections(ctx, parseHomeSections(ctx, html));
const sections = [primaryCategoriesSection(ctx)].concat(mediaSections);
return {
@@ -138,6 +175,22 @@ async function getHome(ctx) {
};
}
+function homeMediaSections(ctx, parsedSections) {
+ const sections = [];
+ const seen = {};
+ (parsedSections || []).forEach(function (section) {
+ if (!section || !section.id || seen[section.id]) return;
+ seen[section.id] = true;
+ sections.push(section);
+ });
+ MISSAV_SECTIONS.forEach(function (section) {
+ if (seen[section.id]) return;
+ seen[section.id] = true;
+ sections.push(sectionShell(ctx, section));
+ });
+ return sections;
+}
+
function primaryCategoriesSection(ctx) {
return {
id: 'missav-primary-categories',
@@ -172,8 +225,9 @@ function categoryCard(ctx, category) {
async function getHomeSection(ctx) {
const sectionId = stringValue(ctx && (ctx.sectionId || ctx.id));
const section = findSection(sectionId) || MISSAV_SECTIONS[0];
+ const url = categoryURL(ctx, section.path);
try {
- const html = await fetchText(ctx, categoryURL(ctx, section.path));
+ const html = await fetchText(ctx, url, entryURL(ctx));
return {
id: section.id,
title: section.title,
@@ -183,7 +237,7 @@ async function getHomeSection(ctx) {
items: parseCards(html, section.title, ctx).slice(0, 18)
};
} catch (error) {
- return emptySection(section.id, section.title, section.style, error);
+ return verificationSection(ctx, section.id, section.title, section.style, url, error);
}
}
@@ -194,7 +248,12 @@ async function getCategory(ctx) {
const primary = findPrimaryCategory(pageId);
const path = section ? section.path : primary ? primary.path : pageId;
const url = pagedURL(categoryURL(ctx, path), page);
- const html = await fetchText(ctx, url);
+ let html = '';
+ try {
+ html = await fetchText(ctx, url, entryURL(ctx));
+ } catch (error) {
+ return verificationCategory(ctx, pageId, section ? section.title : primary ? primary.title : '需要验证', url, error, page);
+ }
const title = section ? section.title : primary ? primary.title : pageTitle(html) || 'MissAV';
const items = parseCards(html, title, ctx);
const categoryItems = items.length ? [] : parseCategoryCards(ctx, html, title);
@@ -215,7 +274,12 @@ async function getDetail(ctx) {
const detailURL = detailUrlFromContext(ctx);
if (!detailURL) throw new Error('MissAV 详情参数无效');
- const html = await fetchText(ctx, detailURL);
+ let html = '';
+ try {
+ html = await fetchText(ctx, detailURL, entryURL(ctx));
+ } catch (error) {
+ return verificationDetail(ctx, detailURL, error);
+ }
const fallbackTitle = titleFromUrl(detailURL);
const title = cleanText(
firstNonEmpty(
@@ -313,7 +377,7 @@ async function getResourceVersions(ctx) {
const direct = playUrlFromContext(ctx);
if (direct) return playbackGroups(detailURL, title, direct, ctx);
try {
- const html = await fetchText(ctx, detailURL);
+ const html = await fetchText(ctx, detailURL, entryURL(ctx));
return playbackGroups(detailURL, title, extractPlayableURL(html), ctx);
} catch (error) {
return playbackGroups(detailURL, title, '', ctx);
@@ -328,7 +392,7 @@ async function resolvePlayback(ctx) {
const detailURL = detailUrlFromContext(ctx);
if (!detailURL) throw new Error('MissAV 播放参数无效');
- const html = await fetchText(ctx, detailURL);
+ const html = await fetchText(ctx, detailURL, entryURL(ctx));
const url = firstNonEmpty(
extractPlayableURL(html),
await extractPlayableFromLinkedPlayers(ctx, html, detailURL),
@@ -390,7 +454,18 @@ async function search(ctx) {
if (!query) return { pageType: 'search', title: '搜索结果', items: [] };
const page = positiveInt(contextValue(ctx, 'page'), 1);
const url = pagedURL(categoryURL(ctx, '/search/' + encodeURIComponent(query.replace(/\\/g, ''))), page);
- const html = await fetchText(ctx, url);
+ let html = '';
+ try {
+ html = await fetchText(ctx, url, entryURL(ctx));
+ } catch (error) {
+ return {
+ pageType: 'search',
+ title: '搜索结果',
+ items: [verificationCard(ctx, '需要真人验证', url, error)],
+ page: page,
+ hasMore: false
+ };
+ }
const items = parseCards(html, query, ctx);
return {
pageType: 'search',
@@ -416,11 +491,24 @@ async function matchResources(ctx) {
return { results: (results.items || []).slice(0, 8) };
}
+async function onAction(ctx) {
+ const name = stringValue(ctx && (ctx.name || ctx.action || ctx.id));
+ const payload = (ctx && ctx.payload) || ctx || {};
+ if (name !== 'verifyCloudflare') return { handled: false };
+ const url = stringValue(payload.url || ctx.url) || entryURL(ctx);
+ const html = await browserHTML(ctx, url, url, true);
+ return {
+ handled: true,
+ ok: isUsableHTML(html),
+ message: isUsableHTML(html) ? '验证完成,可以返回刷新当前页面。' : '验证没有完成,请确认页面已加载并通过真人验证。'
+ };
+}
+
async function extractPlayableFromLinkedPlayers(ctx, html, referer) {
const urls = extractPlayerURLs(ctx, html, referer);
for (let index = 0; index < urls.length; index += 1) {
try {
- const playerHTML = await fetchText(ctx, urls[index]);
+ const playerHTML = await fetchText(ctx, urls[index], referer);
const playable = extractPlayableURL(playerHTML);
if (playable) return playable;
} catch (error) {
@@ -501,32 +589,67 @@ function pagedURL(url, page) {
return url + (url.indexOf('?') >= 0 ? '&' : '?') + 'page=' + page;
}
-async function fetchText(ctx, url) {
- const response = await httpGet(url, {
- headers: requestHeaders(ctx, url),
- useBrowserCookie: true,
- attachBrowserCookie: true,
- useBrowserFallback: true,
- browserFallback: true,
- allowBrowserFallback: true
- });
- const text = responseText(response);
- if (isCloudflare(text, response && response.status, response && response.headers)) {
- const browserText = await browserHTML(url, url);
- if (browserText && !isCloudflare(browserText)) return browserText;
- throw new Error('MissAV 启用了 Cloudflare 浏览器校验,当前 HTTP 环境无法直接读取页面。');
+async function fetchText(ctx, url, referer) {
+ const cached = getCachedText(ctx, url);
+ if (cached) return cached;
+
+ const urls = candidateURLs(ctx, url);
+ let lastError = null;
+ for (let index = 0; index < urls.length; index += 1) {
+ const currentURL = urls[index];
+ const requestReferer = referer || entryURL(ctx);
+ try {
+ const response = await httpGet(currentURL, requestOptions(ctx, requestReferer));
+ const text = responseText(response);
+ if (isUsableHTML(text, response && response.status, 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)) {
+ const browserText = await browserHTML(ctx, currentURL, requestReferer);
+ if (isUsableHTML(browserText)) {
+ setCachedText(ctx, currentURL, browserText);
+ if (currentURL !== url) setCachedText(ctx, url, browserText);
+ return browserText;
+ }
+ }
+ lastError = new Error('HTTP ' + (response && response.status ? response.status : 'empty') + ' ' + currentURL);
+ } catch (error) {
+ lastError = error;
+ const browserText = await browserHTML(ctx, currentURL, requestReferer);
+ if (isUsableHTML(browserText)) {
+ setCachedText(ctx, currentURL, browserText);
+ if (currentURL !== url) setCachedText(ctx, url, browserText);
+ return browserText;
+ }
+ }
}
- return text;
+ throw new Error('MissAV 页面读取失败,可能是 Cloudflare 验证、网络超时或当前域名不可达。可点击“手动完成验证”,或在“备用站点地址”里填写当前能打开的域名。' + (lastError && lastError.message ? ' 原因:' + lastError.message : ''));
}
-async function safeFetch(url, referer) {
+async function safeFetch(ctx, url, referer) {
try {
- return await fetchText({ baseURL: originOf(url), entryPath: pathOf(url) }, url, referer);
+ return await fetchText(ctx || { baseURL: originOf(url), entryPath: pathOf(url) }, url, referer);
} catch (error) {
return '';
}
}
+function requestOptions(ctx, referer) {
+ const timeout = numberParam(ctx, 'requestTimeoutSeconds', 45);
+ return {
+ headers: requestHeaders(ctx, referer),
+ timeout: timeout,
+ timeoutSeconds: timeout,
+ useBrowserCookie: true,
+ attachBrowserCookie: true,
+ useBrowserFallback: boolParam(ctx, 'enableBrowserFallback', true),
+ browserFallback: boolParam(ctx, 'enableBrowserFallback', true),
+ allowBrowserFallback: boolParam(ctx, 'enableBrowserFallback', true)
+ };
+}
+
function httpGet(url, options) {
if (typeof Widget !== 'undefined' && Widget.http && typeof Widget.http.get === 'function') {
return Widget.http.get(url, options || {});
@@ -570,13 +693,16 @@ function responseText(response) {
return String(response.data || response.body || '');
}
-async function browserHTML(url, referer) {
+async function browserHTML(ctx, url, referer, forceVisible) {
+ if (!boolParam(ctx, 'enableBrowserFallback', true)) return '';
if (typeof Widget === 'undefined' || !Widget.browser || typeof Widget.browser.fetch !== 'function') return '';
try {
+ const timeout = numberParam(ctx, 'requestTimeoutSeconds', 45);
const result = await Widget.browser.fetch(url, {
- visible: false,
- timeout: 60,
- waitAfterLoad: 2.5,
+ visible: forceVisible === true ? true : boolParam(ctx, 'browserVisible', false),
+ timeout: timeout,
+ timeoutSeconds: timeout,
+ waitAfterLoad: 4,
waitForAny: true,
waitForMediaSource: true,
headers: {
@@ -614,6 +740,134 @@ async function extractFromBrowser(url, referer) {
}
}
+function candidateURLs(ctx, url) {
+ const input = stringValue(url);
+ const urls = [input];
+ const origin = originOf(input);
+ const path = input.replace(/^https?:\/\/[^/]+/i, '');
+ candidatePathVariants(ctx, path).forEach(function (variant) {
+ const next = origin + variant;
+ if (urls.indexOf(next) < 0) urls.push(next);
+ });
+ backupBaseURLs(ctx).forEach(function (base) {
+ const root = base.replace(/\/+$/, '');
+ if (!root || root === origin) return;
+ candidatePathVariants(ctx, path).forEach(function (variant) {
+ const next = root + variant;
+ if (urls.indexOf(next) < 0) urls.push(next);
+ });
+ });
+ return urls.filter(Boolean);
+}
+
+function candidatePathVariants(ctx, path) {
+ const value = path && path[0] === '/' ? path : '/' + stringValue(path);
+ const variants = [value];
+ if (!isKnownCategoryPath(value)) return variants;
+ const match = value.match(/^\/(?:dm\d+\/)?cn(\/[^?#]*)([?#].*)?$/i);
+ if (match) {
+ const suffix = match[1] || '';
+ const query = match[2] || '';
+ const cnPath = '/cn' + suffix + query;
+ const rootPath = suffix + query;
+ if (variants.indexOf(cnPath) < 0) variants.push(cnPath);
+ if (variants.indexOf(rootPath) < 0) variants.push(rootPath);
+ } else {
+ const localePath = localePrefix(ctx) + value;
+ const cnPath = '/cn' + value;
+ if (variants.indexOf(localePath) < 0) variants.push(localePath);
+ if (variants.indexOf(cnPath) < 0) variants.push(cnPath);
+ }
+ return variants;
+}
+
+function isKnownCategoryPath(path) {
+ const clean = String(path || '').split(/[?#]/)[0].replace(/^\/(?:dm\d+\/)?cn/i, '') || '/';
+ return MISSAV_SECTIONS.concat(MISSAV_PRIMARY_CATEGORIES).some(function (item) {
+ return clean === item.path || clean === item.path.split('?')[0];
+ });
+}
+
+function backupBaseURLs(ctx) {
+ const raw = stringValue(contextValue(ctx, 'backupBaseURLs') || contextValue(ctx, 'backupBaseUrls') || contextValue(ctx, 'backup_base_urls'));
+ return raw.split(/[\n,,\s]+/).map(function (value) {
+ return value.replace(/\/+$/, '');
+ }).filter(function (value) {
+ return /^https?:\/\//i.test(value);
+ });
+}
+
+function cacheKey(url) {
+ return 'missav:html:' + String(url || '');
+}
+
+function memoryCache() {
+ if (typeof globalThis === 'undefined') return {};
+ if (!globalThis.__MISSAV_HTML_CACHE__) globalThis.__MISSAV_HTML_CACHE__ = {};
+ return globalThis.__MISSAV_HTML_CACHE__;
+}
+
+function getCachedText(ctx, url) {
+ const ttl = numberParam(ctx, 'cacheMinutes', 20) * 60 * 1000;
+ if (ttl <= 0) return '';
+ const key = cacheKey(url);
+ const now = Date.now();
+ const memory = memoryCache()[key];
+ if (memory && memory.expiresAt > now && memory.text) return memory.text;
+ const stored = cacheGet(key);
+ if (stored && stored.expiresAt > now && stored.text) return stored.text;
+ return '';
+}
+
+function setCachedText(ctx, url, text) {
+ if (!isUsableHTML(text)) return;
+ const ttl = numberParam(ctx, 'cacheMinutes', 20) * 60 * 1000;
+ if (ttl <= 0) return;
+ const value = { text: text, expiresAt: Date.now() + ttl };
+ const key = cacheKey(url);
+ memoryCache()[key] = value;
+ cacheSet(key, value);
+}
+
+function cacheGet(key) {
+ try {
+ if (typeof Widget !== 'undefined' && Widget.cache && typeof Widget.cache.get === 'function') return Widget.cache.get(key);
+ if (typeof $cache !== 'undefined' && typeof $cache.get === 'function') return $cache.get(key);
+ } catch (error) {
+ return null;
+ }
+ return null;
+}
+
+function cacheSet(key, value) {
+ try {
+ if (typeof Widget !== 'undefined' && Widget.cache && typeof Widget.cache.set === 'function') Widget.cache.set(key, value);
+ if (typeof $cache !== 'undefined' && typeof $cache.set === 'function') $cache.set(key, value);
+ } catch (error) {
+ // Cache is opportunistic; ignore unsupported host APIs.
+ }
+}
+
+function isUsableHTML(html, status, headers) {
+ const text = String(html || '');
+ if (!text || text.length < 80) return false;
+ if (Number(status) >= 400) return false;
+ if (isCloudflare(text, status, headers)) return false;
+ return / 0 ? value : fallback;
+}
+
function playableFromBrowserResult(result) {
if (!result) return '';
const keys = ['url', 'mediaURL', 'mediaUrl', 'videoURL', 'videoUrl', 'playURL', 'playUrl', 'src'];
@@ -645,7 +899,7 @@ function parseHomeSections(ctx, html) {
const predefined = findSection(title);
sections.push({
id: predefined ? predefined.id : 'missav-home-' + index,
- title: title,
+ title: predefined ? predefined.title : title,
style: predefined ? predefined.style : sectionStyle(title),
moreAction: predefined ? categoryAction(ctx, predefined) : undefined,
items: items
@@ -684,6 +938,68 @@ function emptySection(id, title, style, error) {
};
}
+function verificationCategory(ctx, id, title, url, error, page) {
+ return {
+ pageType: 'category',
+ id: id,
+ title: title || '需要验证',
+ style: 'media.posterGrid',
+ itemAspectRatio: '16:9',
+ items: [verificationCard(ctx, '需要真人验证', url, error)],
+ page: page || 1,
+ hasMore: false
+ };
+}
+
+function verificationSection(ctx, id, title, style, url, error) {
+ return {
+ id: id,
+ title: title,
+ style: style || 'discover.posterCompact',
+ lazy: false,
+ items: [verificationCard(ctx, '需要真人验证', url, error)]
+ };
+}
+
+function verificationDetail(ctx, url, error) {
+ const title = titleFromUrl(url) || '需要真人验证';
+ return {
+ pageType: 'detail',
+ id: makeItemId(url, title, ''),
+ title: title,
+ type: 'movie',
+ overview: cleanText(error && error.message) || '站点触发了 Cloudflare 真人验证。请返回列表页点击手动验证卡片,完成后刷新。',
+ detailImageAspectRatio: '16:9',
+ resourceGroups: playbackGroups(url, title, '', ctx),
+ recommendations: [
+ {
+ id: 'verify',
+ title: '访问受限',
+ style: 'discover.posterCompact',
+ items: [verificationCard(ctx, '手动完成验证', url, error)]
+ }
+ ]
+ };
+}
+
+function verificationCard(ctx, title, url, error) {
+ return {
+ id: 'verify-cloudflare-' + encodeURIComponent(url || entryURL(ctx)).slice(0, 120),
+ title: title || '手动完成验证',
+ subtitle: '点击后再打开验证界面,完成后返回刷新',
+ overview: cleanText(error && error.message) || '当前网络触发了 Cloudflare 真人验证。',
+ type: 'collection',
+ aspectRatio: '16:9',
+ action: {
+ type: 'custom',
+ name: 'verifyCloudflare',
+ id: 'verifyCloudflare',
+ title: '手动完成验证',
+ payload: { url: url || entryURL(ctx) }
+ }
+ };
+}
+
function categoryAction(ctx, section) {
return {
type: 'category',
@@ -1390,9 +1706,12 @@ function isDetailPageURL(url) {
function isCloudflare(html, status, headers) {
const text = String(html || '') + ' ' + JSON.stringify(headers || {});
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/i.test(text);
+ /cf-mitigated|cf-browser-verification|cf-chl-|challenge-platform|turnstile/i.test(text);
}
function decodeEscapes(value) {
@@ -1505,6 +1824,7 @@ const MissAVMiniLibrary = {
search: search,
onSearch: onSearch,
getSearch: getSearch,
+ onAction: onAction,
matchResources: matchResources,
matchMovie: matchResources
};
@@ -1528,6 +1848,7 @@ if (typeof globalThis !== 'undefined') {
globalThis.search = search;
globalThis.onSearch = onSearch;
globalThis.getSearch = getSearch;
+ globalThis.onAction = onAction;
globalThis.matchResources = matchResources;
globalThis.matchMovie = matchResources;
globalThis.__jsEvalReturn = __jsEvalReturn;
diff --git a/missav-mini-library-download-working 6.js b/missav-mini-library-download-working 6.js
index 4d143b5..5eee709 100644
--- a/missav-mini-library-download-working 6.js
+++ b/missav-mini-library-download-working 6.js
@@ -4,6 +4,7 @@ const MISSAV_DEFAULT_BASE = 'https://missav.ws';
const MISSAV_DEFAULT_ENTRY = '/dm247/cn';
const MISSAV_LOGO = 'https://missav.ws/favicon.ico';
const MISSAV_DETAIL_PAYLOAD_PREFIX = 'missav://detail?';
+const MISSAV_VERIFY_PAYLOAD_PREFIX = 'missav://verify?';
const MISSAV_UA =
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1';
@@ -11,8 +12,8 @@ const WidgetMetadata = {
id: 'missav-mini-library',
name: 'MissAV',
title: 'MissAV',
- version: '1.0.0',
- author: 'baiPlay',
+ version: '1.5.9',
+ author: 'Alan huang',
logo: MISSAV_LOGO,
icon: MISSAV_LOGO,
site: MISSAV_DEFAULT_BASE,
@@ -20,43 +21,72 @@ const WidgetMetadata = {
};
const MISSAV_SECTIONS = [
- { id: 'new-release', title: '新作上市', path: '/new', style: 'discover.spotlight' },
- { id: 'latest', title: '最近更新', path: '/new?sort=published_at', style: 'discover.ranked' },
- { id: 'chinese-subtitle', title: '中文字幕', path: '/chinese-subtitle', style: 'discover.posterCompact' },
- { id: 'uncensored-leak', title: '无码流出', aliases: ['无码影片'], path: '/uncensored-leak', style: 'discover.posterCompact' },
- { id: 'today-hot', title: '今日热门', path: '/today-hot', style: 'discover.ranked' },
- { id: 'weekly-hot', title: '本周热门', path: '/weekly-hot', style: 'discover.ranked' },
- { id: 'monthly-hot', title: '本月热门', path: '/monthly-hot', style: 'discover.ranked' },
- { id: 'fc2', title: 'FC2', path: '/fc2', style: 'discover.posterCompact' },
- { id: 'heyzo', title: 'HEYZO', path: '/heyzo', style: 'discover.posterCompact' },
- { id: 'tokyo-hot', title: '东京热', path: '/tokyo-hot', style: 'discover.posterCompact' },
- { id: '1pondo', title: '一本道', path: '/1pondo', style: 'discover.posterCompact' },
- { id: 'caribbeancom', title: 'Caribbeancom', path: '/caribbeancom', style: 'discover.posterCompact' },
- { id: 'caribbeancompr', title: 'Caribbeancompr', path: '/caribbeancompr', style: 'discover.posterCompact' },
- { id: '10musume', title: '10musume', path: '/10musume', style: 'discover.posterCompact' },
- { id: 'pacopacomama', title: 'pacopacomama', path: '/pacopacomama', style: 'discover.posterCompact' },
- { id: 'gachinco', title: 'Gachinco', path: '/gachinco', style: 'discover.posterCompact' },
- { id: 'xxx-av', title: 'XXX-AV', path: '/xxx-av', style: 'discover.posterCompact' },
- { id: 'siro', title: 'SIRO', path: '/siro', style: 'discover.posterCompact' },
- { id: 'luxu', title: 'LUXU', path: '/luxu', style: 'discover.posterCompact' },
- { id: 'gana', title: 'GANA', path: '/gana', style: 'discover.posterCompact' },
- { id: 'prestige-premium', title: 'PRESTIGE PREMIUM', path: '/prestige-premium', style: 'discover.posterCompact' },
- { id: 's-cute', title: 'S-CUTE', path: '/s-cute', style: 'discover.posterCompact' },
- { id: 'ara', title: 'ARA', path: '/ara', style: 'discover.posterCompact' },
- { id: 'madou', title: '麻豆传媒', path: '/madou', style: 'discover.posterCompact' },
- { id: 'twav', title: 'TWAV', path: '/twav', style: 'discover.posterCompact' },
- { id: 'vr', title: 'VR', path: '/vr', style: 'discover.posterCompact' }
+ { id: 'recommended', title: '推荐给你', path: '/', style: 'discover.spotlight', group: 'japanese', personalizedHome: true, randomCategory: true },
+ { id: 'nakadashi', title: '中出', path: '/cn/search/%E4%B8%AD%E5%87%BA', style: 'discover.posterCompact', group: 'japanese', eagerPagination: true },
+ { id: 'big-breasts', title: '巨乳', path: '/cn/search/%E5%B7%A8%E4%B9%B3', style: 'discover.posterCompact', group: 'japanese', eagerPagination: true },
+ { id: 'married-women', title: '人妻', path: '/cn/search/%E4%BA%BA%E5%A6%BB', style: 'discover.posterCompact', group: 'japanese', eagerPagination: true },
+ { id: 'chinese-subtitle', title: '中文字幕', path: '/dm278/cn/chinese-subtitle', style: 'discover.posterCompact', group: 'japanese' },
+ { id: 'latest', title: '最近更新', path: '/dm539/cn/new', style: 'discover.ranked', group: 'japanese', eagerPagination: true },
+ { id: 'new-release', title: '新作上市', path: '/dm635/cn/release', style: 'discover.spotlight', group: 'japanese', eagerPagination: true },
+ { id: 'random', title: '随机', aliases: ['好手气'], path: '/random', style: 'discover.posterCompact', group: 'japanese', randomCategory: true },
+ { id: 'uncensored-leak', title: '无码流出', aliases: ['无码影片'], path: '/dm817/cn/uncensored-leak', style: 'discover.posterCompact', group: 'uncensored' },
+ { id: 'actresses', title: '女优一览', path: '/cn/actresses', style: 'discover.posterCompact', group: 'japanese', categoryIndex: true },
+ { id: 'actress-ranking', title: '女优排行', path: '/cn/actresses/ranking', style: 'discover.ranked', group: 'japanese', categoryIndex: true },
+ { id: 'genres', title: '类型', path: '/cn/genres', style: 'discover.posterCompact', group: 'japanese', categoryIndex: true },
+ { id: 'makers', title: '发行商', path: '/cn/makers', style: 'discover.posterCompact', group: 'japanese', categoryIndex: true },
+ { id: 'vr', title: 'VR', path: '/cn/genres/VR', style: 'discover.posterCompact', group: 'japanese' },
+ { id: 'today-hot', title: '今日热门', path: '/dm301/cn/today-hot', style: 'discover.ranked', group: 'japanese', eagerPagination: true },
+ { id: 'weekly-hot', title: '本周热门', aliases: ['本週热门'], path: '/dm170/cn/weekly-hot', style: 'discover.ranked', group: 'japanese', eagerPagination: true },
+ { id: 'monthly-hot', title: '本月热门', path: '/dm273/cn/monthly-hot', style: 'discover.ranked', group: 'japanese', eagerPagination: true },
+
+ { id: 'siro', title: 'SIRO', path: '/dm36/cn/siro', style: 'discover.posterCompact', group: 'amateur' },
+ { id: 'luxu', title: 'LUXU', path: '/dm34/cn/luxu', style: 'discover.posterCompact', group: 'amateur' },
+ { id: 'gana', title: 'GANA', path: '/dm34/cn/gana', style: 'discover.posterCompact', group: 'amateur' },
+ { id: 'prestige-premium', title: 'PRESTIGE PREMIUM', aliases: ['MAAN'], path: '/dm1004/cn/maan', style: 'discover.posterCompact', group: 'amateur' },
+ { id: 's-cute', title: 'S-CUTE', aliases: ['SCUTE'], path: '/dm38/cn/scute', style: 'discover.posterCompact', group: 'amateur' },
+ { id: 'ara', title: 'ARA', path: '/dm34/cn/ara', style: 'discover.posterCompact', group: 'amateur' },
+
+ { id: 'fc2', title: 'FC2', path: '/dm597/cn/fc2', style: 'discover.posterCompact', group: 'uncensored' },
+ { id: 'heyzo', title: 'HEYZO', path: '/dm2208642/cn/heyzo', style: 'discover.posterCompact', group: 'uncensored' },
+ { id: 'tokyo-hot', title: '东京热', aliases: ['TOKYOHOT'], path: '/dm42/cn/tokyohot', style: 'discover.posterCompact', group: 'uncensored' },
+ { id: '1pondo', title: '一本道', path: '/dm5199603/cn/1pondo', style: 'discover.posterCompact', group: 'uncensored', artworkCodePrefixes: ['pondo-'], detailSlugPattern: '^pondo-\\d{6}_\\d{3}$' },
+ { id: 'caribbeancom', title: 'Caribbeancom', path: '/dm7704788/cn/caribbeancom', style: 'discover.posterCompact', group: 'uncensored' },
+ { id: 'caribbeancompr', title: 'Caribbeancompr', path: '/dm91887/cn/caribbeancompr', style: 'discover.posterCompact', group: 'uncensored', detailSlugPattern: '^\\d{6}_\\d{3}$' },
+ { id: '10musume', title: '10musume', path: '/dm7208981/cn/10musume', style: 'discover.posterCompact', group: 'uncensored', artworkCodePrefixes: ['musume-'], detailSlugPattern: '^musume-\\d{6}_\\d{2}$' },
+ { id: 'pacopacomama', title: 'pacopacomama', path: '/dm3600557/cn/pacopacomama', style: 'discover.posterCompact', group: 'uncensored', artworkCodePrefixes: ['pacopacomama-'], detailSlugPattern: '^pacopacomama-\\d{6}_\\d{3}$' },
+ { id: 'gachinco', title: 'Gachinco', path: '/dm150/cn/gachinco', style: 'discover.posterCompact', group: 'uncensored', artworkCodePrefixes: ['gachi'] },
+ { id: 'xxx-av', title: 'XXX-AV', aliases: ['XXXAV'], path: '/dm42/cn/xxxav', style: 'discover.posterCompact', group: 'uncensored' },
+ { id: 'marriedslash', title: '人妻斩', path: '/dm37/cn/marriedslash', style: 'discover.posterCompact', group: 'uncensored', artworkCodePrefixes: ['c0930-'] },
+ { id: 'naughty4610', title: '顽皮 4610', path: '/dm33/cn/naughty4610', style: 'discover.posterCompact', group: 'uncensored', artworkCodePrefixes: ['h4610-'] },
+ { id: 'naughty0930', title: '顽皮 0930', path: '/dm37/cn/naughty0930', style: 'discover.posterCompact', group: 'uncensored', artworkCodePrefixes: ['h0930-'] },
+
+ { id: 'madou', title: '麻豆传媒', path: '/dm63/cn/madou', style: 'discover.posterCompact', group: 'asian' },
+ { id: 'twav', title: 'TWAV', path: '/dm31/cn/twav', style: 'discover.posterCompact', group: 'asian' },
+ { id: 'furuke', title: 'Furuke', path: '/dm15/cn/furuke', style: 'discover.posterCompact', group: 'asian' },
+ { id: 'klive', title: '韩国直播', path: '/cn/klive', style: 'discover.posterCompact', group: 'asian' },
+ { id: 'clive', title: '中国直播', path: '/cn/clive', style: 'discover.posterCompact', group: 'asian' }
];
-const MISSAV_PRIMARY_CATEGORIES = [
- { id: 'latest', title: '最近更新', path: '/new?sort=published_at', subtitle: '按发布时间更新' },
- { id: 'new-release', title: '新作上市', path: '/new', subtitle: '最新上架作品' },
- { id: 'uncensored-leak', title: '无码流出', path: '/uncensored-leak', subtitle: '无码流出专区' },
- { id: 'actresses', title: '女优一览', path: '/actresses', subtitle: '按女优浏览' },
- { id: 'actress-ranking', title: '女优排行', path: '/actresses/ranking', subtitle: '站内女优排行' },
- { id: 'genres', title: '类型', path: '/genres', subtitle: '按题材类型浏览' },
- { id: 'makers', title: '发行商', path: '/makers', subtitle: '按发行商浏览' },
- { id: 'vr', title: 'VR', path: '/genres/VR', subtitle: 'VR 作品专区' }
+const MISSAV_CATEGORY_GROUPS = [
+ { id: 'japanese', title: '日本 AV / 综合分类' },
+ { id: 'amateur', title: '素人' },
+ { id: 'uncensored', title: '无码影片' },
+ { id: 'asian', title: '亚洲 AV' }
+];
+
+const MISSAV_HOME_MEDIA_SECTION_IDS = [
+ 'recommended',
+ 'nakadashi',
+ 'big-breasts',
+ 'married-women',
+ 'new-release',
+ 'latest',
+ 'chinese-subtitle',
+ 'uncensored-leak',
+ 'random',
+ 'today-hot',
+ 'weekly-hot',
+ 'monthly-hot'
];
function getManifest() {
@@ -114,19 +144,82 @@ function getManifest() {
defaultValue: MISSAV_DEFAULT_ENTRY,
required: true,
description: '例如 /dm247/cn。MissAV 的 dm 前缀会变化,失效时可替换为当前可访问入口。'
+ },
+ {
+ name: 'backupBaseURLs',
+ title: '备用站点地址',
+ type: 'input',
+ defaultValue: 'https://missav.ai,https://missav123.com',
+ required: false,
+ description: '多个域名用逗号或换行分隔。网络不稳或主域名被拦截时会自动尝试备用域名。'
+ },
+ {
+ name: 'enableBrowserFallback',
+ title: 'Cloudflare 浏览器兜底',
+ type: 'boolean',
+ defaultValue: true,
+ required: false,
+ description: '遇到 Cloudflare 或普通 HTTP 失败时,使用 App 浏览器请求一次并复用浏览器 Cookie。'
+ },
+ {
+ name: 'browserVisible',
+ title: '旧版显示窗口开关',
+ type: 'boolean',
+ defaultValue: false,
+ required: false,
+ description: '为兼容旧设置保留。自动请求始终隐藏;需要验证时请点击“手动完成验证”卡片。'
+ },
+ {
+ name: 'automaticBrowserFallback',
+ title: '自动静默浏览器兜底',
+ type: 'boolean',
+ defaultValue: true,
+ required: false,
+ description: '普通 HTTP 命中 Cloudflare 时静默尝试一次;关闭后只显示手动验证卡片。'
+ },
+ {
+ name: 'verificationCooldownMinutes',
+ title: '验证重试间隔分钟',
+ type: 'number',
+ defaultValue: 10,
+ required: false,
+ description: '静默验证失败后暂停自动重试,避免浏览时连续触发 Cloudflare。'
+ },
+ {
+ name: 'requestTimeoutSeconds',
+ title: '请求超时秒数',
+ type: 'number',
+ defaultValue: 45,
+ required: false,
+ description: '网络差时可调大到 60-90 秒。'
+ },
+ {
+ name: 'cacheMinutes',
+ title: '页面缓存分钟',
+ type: 'number',
+ defaultValue: 20,
+ required: false,
+ description: '短期缓存首页、分类、详情 HTML,减少重复触发 Cloudflare。'
+ },
+ {
+ name: 'categoryRestoreMinutes',
+ title: '分类浏览进度保留分钟',
+ type: 'number',
+ defaultValue: 60,
+ required: false,
+ description: '从影片详情返回分类时,恢复已经加载的分页项目,避免列表退回并卡在 12 项。'
}
]
};
}
async function getHome(ctx) {
- const html = await safeFetch(entryURL(ctx), baseURL(ctx) + '/');
+ ctx = normalizeContext(ctx);
+ const html = await safeFetch(ctx, entryURL(ctx), baseURL(ctx) + '/');
const hero = parseCards(sectionBlock(html, '推荐给你'), '推荐给你', ctx).slice(0, 10).map(toWideItem);
- const parsedSections = parseHomeSections(ctx, html);
- const mediaSections = parsedSections.length ? parsedSections : MISSAV_SECTIONS.map(function (section) {
- return sectionShell(ctx, section);
- });
- const sections = [primaryCategoriesSection(ctx)].concat(mediaSections);
+ const mediaSections = homeMediaSections(ctx, parseHomeSections(ctx, html));
+ const artwork = categoryArtworkFromSections(ctx, mediaSections);
+ const sections = categoryGroupSections(ctx, artwork).concat(mediaSections);
return {
pageType: 'home',
@@ -138,26 +231,81 @@ async function getHome(ctx) {
};
}
-function primaryCategoriesSection(ctx) {
- return {
- id: 'missav-primary-categories',
- title: '分类',
- style: 'discover.annualCategories',
- lazy: false,
- items: MISSAV_PRIMARY_CATEGORIES.map(function (category) {
- return categoryCard(ctx, category);
- })
- };
+function homeMediaSections(ctx, parsedSections) {
+ const sections = [];
+ const seen = {};
+ (parsedSections || []).forEach(function (section) {
+ if (!section || !section.id || seen[section.id]) return;
+ seen[section.id] = true;
+ sections.push(section);
+ });
+ MISSAV_SECTIONS.filter(function (section) {
+ return MISSAV_HOME_MEDIA_SECTION_IDS.indexOf(section.id) >= 0;
+ }).forEach(function (section) {
+ if (seen[section.id]) return;
+ seen[section.id] = true;
+ sections.push(sectionShell(ctx, section));
+ });
+ return sections;
}
-function categoryCard(ctx, category) {
- return {
+function categoryGroupSections(ctx, artwork) {
+ const sections = [];
+ MISSAV_CATEGORY_GROUPS.forEach(function (group) {
+ const categories = MISSAV_SECTIONS.filter(function (category) {
+ return category.group === group.id;
+ });
+ for (let offset = 0; offset < categories.length; offset += 6) {
+ const part = Math.floor(offset / 6);
+ const groupSectionId = 'missav-categories-' + group.id + (part ? '-' + (part + 1) : '');
+ const groupCategories = categories.slice(offset, offset + 6);
+ const hasCompleteArtwork = groupCategories.every(function (category) {
+ return categoryPreviewItems(
+ artwork && artwork[category.id] && artwork[category.id].length
+ ? artwork[category.id]
+ : cachedCategoryArtwork(ctx, category.id),
+ category
+ ).length >= 3;
+ });
+ sections.push({
+ id: groupSectionId,
+ title: group.title + (part ? '(续 ' + part + ')' : ''),
+ style: 'discover.annualWidePreview',
+ lazy: !hasCompleteArtwork,
+ loadAction: !hasCompleteArtwork
+ ? { type: 'custom', id: groupSectionId, sectionId: groupSectionId, title: group.title }
+ : undefined,
+ items: hasCompleteArtwork
+ ? groupCategories.map(function (category) {
+ return categoryCard(ctx, category, artwork && artwork[category.id]);
+ })
+ : []
+ });
+ }
+ });
+ return sections;
+}
+
+function categoryCard(ctx, category, livePreviewItems) {
+ const cachedPreviewItems = cachedCategoryArtwork(ctx, category.id);
+ const previewItems = categoryPreviewItems(
+ livePreviewItems && livePreviewItems.length
+ ? livePreviewItems
+ : cachedPreviewItems,
+ category
+ );
+ const cover = previewItems[0] && firstNonEmpty(
+ previewItems[0].backdrop,
+ previewItems[0].poster,
+ previewItems[0].thumbnailURL
+ );
+ const item = {
id: 'category-' + category.id,
title: category.title,
- subtitle: category.subtitle || '',
+ subtitle: category.subtitle || '浏览' + category.title + '相关内容',
type: 'category',
aspectRatio: '16:9',
- previewItems: [],
+ previewItems: previewItems,
action: {
type: 'category',
id: category.id,
@@ -167,37 +315,273 @@ function categoryCard(ctx, category) {
itemAspectRatio: '16:9'
}
};
+ if (cover) {
+ item.poster = cover;
+ item.backdrop = cover;
+ item.thumbnailURL = cover;
+ item.posterPath = cover;
+ item.backdropPath = cover;
+ item.image = cover;
+ item.imageURL = cover;
+ item.posterURL = cover;
+ item.backdropURL = cover;
+ item.cover = cover;
+ item.coverURL = cover;
+ item.imageFit = 'fill';
+ item.imageHeaders = imageHeaders(ctx, categoryURL(ctx, category.path));
+ item.posterHeaders = item.imageHeaders;
+ item.backdropHeaders = item.imageHeaders;
+ }
+ return item;
+}
+
+function categoryArtworkFromSections(ctx, sections) {
+ const artwork = {};
+ (sections || []).forEach(function (section) {
+ if (!section || !section.id || !section.items || !section.items.length) return;
+ const previews = categoryPreviewItems(section.items, findSection(section.id));
+ if (!previews.length) return;
+ artwork[section.id] = previews;
+ rememberCategoryArtwork(ctx, section.id, previews);
+ });
+ return artwork;
+}
+
+function categoryPreviewItems(items, category) {
+ const prefixes = category && Array.isArray(category.artworkCodePrefixes)
+ ? category.artworkCodePrefixes.map(function (value) {
+ return String(value || '').toLowerCase();
+ }).filter(Boolean)
+ : [];
+ return (items || []).filter(function (item) {
+ if (!item || !firstNonEmpty(item.backdrop, item.poster, item.thumbnailURL)) return false;
+ if (!prefixes.length && !(category && category.detailSlugPattern)) return true;
+ if (!categoryMatchesItem(item, category)) return false;
+ if (!prefixes.length) return true;
+ const target = stringValue(item.action && (item.action.url || item.action.itemId || item.action.id)).toLowerCase();
+ const title = stringValue(item.title).toLowerCase();
+ return prefixes.some(function (prefix) {
+ return target.indexOf('/' + prefix) >= 0 || title.indexOf(prefix) >= 0;
+ });
+ }).slice(0, 3).map(function (item, index) {
+ const image = firstNonEmpty(item.backdrop, item.poster, item.thumbnailURL);
+ return {
+ id: item.id,
+ title: item.title,
+ type: item.type || 'movie',
+ rank: index + 1,
+ poster: image,
+ backdrop: image,
+ thumbnailURL: image,
+ aspectRatio: '16:9',
+ action: item.action
+ };
+ });
+}
+
+function categoryMatchesItem(item, category) {
+ if (!category || !category.detailSlugPattern) return true;
+ const target = stringValue(item && item.action && (item.action.url || item.action.itemId || item.action.id));
+ const slug = categorySlug(pathOf(target)).toLowerCase();
+ try {
+ return new RegExp(category.detailSlugPattern, 'i').test(slug);
+ } catch (error) {
+ return true;
+ }
+}
+
+function filterCategoryItems(items, category) {
+ if (!category || !category.detailSlugPattern) return items || [];
+ return (items || []).filter(function (item) {
+ return categoryMatchesItem(item, category);
+ });
}
async function getHomeSection(ctx) {
+ ctx = normalizeContext(ctx);
const sectionId = stringValue(ctx && (ctx.sectionId || ctx.id));
+ const categoryGroup = categoryGroupFromSectionId(sectionId);
+ if (categoryGroup) return loadCategoryGroupSection(ctx, categoryGroup);
const section = findSection(sectionId) || MISSAV_SECTIONS[0];
+ if (section.personalizedHome) return personalizedRecommendationSection(ctx, section);
+ const url = categoryURL(ctx, section.path);
try {
- const html = await fetchText(ctx, categoryURL(ctx, section.path));
+ const html = await fetchText(ctx, url, entryURL(ctx));
+ const items = filterCategoryItems(
+ parseCards(mediaListBlock(html), section.title, ctx),
+ section
+ ).slice(0, 18);
+ rememberCategoryArtwork(ctx, section.id, items);
return {
id: section.id,
title: section.title,
style: section.style,
lazy: false,
moreAction: categoryAction(ctx, section),
- items: parseCards(html, section.title, ctx).slice(0, 18)
+ items: items
};
} catch (error) {
- return emptySection(section.id, section.title, section.style, error);
+ return verificationSection(ctx, section.id, section.title, section.style, url, error);
}
}
+function categoryGroupFromSectionId(sectionId) {
+ const value = stringValue(sectionId);
+ const match = /^missav-categories-([a-z]+)(?:-(\d+))?$/.exec(value);
+ if (!match) return null;
+ const group = MISSAV_CATEGORY_GROUPS.find(function (item) {
+ return item.id === match[1];
+ });
+ if (!group) return null;
+ const partNumber = Math.max(1, Number(match[2] || 1));
+ const categories = MISSAV_SECTIONS.filter(function (category) {
+ return category.group === group.id;
+ }).slice((partNumber - 1) * 6, partNumber * 6);
+ if (!categories.length) return null;
+ return {
+ id: value,
+ title: group.title + (partNumber > 1 ? '(续 ' + (partNumber - 1) + ')' : ''),
+ categories: categories
+ };
+}
+
+async function loadCategoryGroupSection(ctx, groupSection) {
+ const artwork = {};
+ const categories = groupSection.categories || [];
+ for (let offset = 0; offset < categories.length; offset += 2) {
+ const batch = categories.slice(offset, offset + 2);
+ const results = await Promise.all(batch.map(function (category) {
+ return loadCategoryPreviewItems(ctx, category);
+ }));
+ batch.forEach(function (category, index) {
+ artwork[category.id] = results[index];
+ });
+ }
+ return {
+ id: groupSection.id,
+ title: groupSection.title,
+ style: 'discover.annualWidePreview',
+ lazy: false,
+ items: categories.map(function (category) {
+ return categoryCard(ctx, category, artwork[category.id]);
+ })
+ };
+}
+
+async function loadCategoryPreviewItems(ctx, category) {
+ const cached = categoryPreviewItems(cachedCategoryArtwork(ctx, category.id), category);
+ if (cached.length >= 3) return cached;
+ try {
+ const url = categoryURL(ctx, category.path);
+ let html;
+ if (category.personalizedHome) {
+ html = await fetchText(ctx, entryURL(ctx), entryURL(ctx));
+ } else if (category.categoryIndex && isActressIndexURL(url)) {
+ html = await fetchActressIndexText(ctx, url, entryURL(ctx));
+ } else {
+ html = await fetchText(ctx, url, entryURL(ctx));
+ }
+ let items;
+ if (category.personalizedHome) {
+ items = parseCards(sectionBlock(html, category.title), category.title, ctx);
+ if (!items.length) items = parseCards(mediaListBlock(html), category.title, ctx);
+ } else if (category.categoryIndex) {
+ items = parseCategoryCards(ctx, html, category.title);
+ if (!items.length) items = parseCards(mediaListBlock(html), category.title, ctx);
+ } else {
+ items = filterCategoryItems(
+ parseCards(mediaListBlock(html), category.title, ctx),
+ category
+ );
+ }
+ const previews = categoryPreviewItems(items, category);
+ if (previews.length) rememberCategoryArtwork(ctx, category.id, previews);
+ return previews;
+ } catch (error) {
+ return cached;
+ }
+}
+
+async function personalizedRecommendationSection(ctx, section) {
+ let items = [];
+ const homeURL = entryURL(ctx);
+ const browserText = await browserHTML(ctx, homeURL, homeURL);
+ if (browserText) {
+ items = parseCards(sectionBlock(browserText, section.title), section.title, ctx).slice(0, 18);
+ }
+ if (!items.length) {
+ try {
+ const fallbackHTML = await fetchText(ctx, randomPageURL(ctx, 1), homeURL);
+ items = parseCards(fallbackHTML, section.title, ctx).slice(0, 18);
+ } catch (error) {
+ return verificationSection(ctx, section.id, section.title, section.style, homeURL, error);
+ }
+ }
+ rememberCategoryArtwork(ctx, section.id, items);
+ return {
+ id: section.id,
+ title: section.title,
+ style: section.style,
+ lazy: false,
+ moreAction: categoryAction(ctx, section),
+ items: items
+ };
+}
+
async function getCategory(ctx) {
- const page = positiveInt(contextValue(ctx, 'page'), 1);
+ ctx = normalizeContext(ctx);
+ const requestedPage = pageFromContext(ctx, 1);
const pageId = normalizePageId(ctx, ctx && (ctx.pageId || ctx.id));
+ const verificationURL = verificationUrlFromPageId(pageId);
+ if (verificationURL) return runVerificationCategory(ctx, pageId, verificationURL);
const section = findSection(pageId);
const primary = findPrimaryCategory(pageId);
const path = section ? section.path : primary ? primary.path : pageId;
- const url = pagedURL(categoryURL(ctx, path), page);
- const html = await fetchText(ctx, url);
+ const page = categoryFetchPage(ctx, pageId, requestedPage);
+ const url = section && section.randomCategory
+ ? randomPageURL(ctx, page)
+ : pagedURL(categoryURL(ctx, path), page);
+ let html = '';
+ try {
+ html = isActressWorksURL(url)
+ ? await fetchActressCategoryText(ctx, url, entryURL(ctx))
+ : isActressIndexURL(url)
+ ? await fetchActressIndexText(ctx, url, entryURL(ctx))
+ : await fetchText(ctx, url, entryURL(ctx));
+ } catch (error) {
+ return verificationCategory(ctx, pageId, section ? section.title : primary ? primary.title : '需要验证', url, error, page);
+ }
const title = section ? section.title : primary ? primary.title : pageTitle(html) || 'MissAV';
- const items = parseCards(html, title, ctx);
- const categoryItems = items.length ? [] : parseCategoryCards(ctx, html, title);
+ const categoryIndex = !!(section && section.categoryIndex);
+ const categoryItems = categoryIndex ? parseCategoryCards(ctx, html, title) : [];
+ const items = categoryItems.length
+ ? []
+ : filterCategoryItems(parseCards(mediaListBlock(html), title, ctx), section);
+ const fallbackCategoryItems = items.length || categoryItems.length ? [] : parseCategoryCards(ctx, html, title);
+ const listItems = items.length
+ ? items
+ : categoryItems.length
+ ? categoryItems
+ : fallbackCategoryItems;
+ if (section && items.length) rememberCategoryArtwork(ctx, section.id, items);
+ const pagination = section && section.randomCategory
+ ? { hasMore: true, totalPages: 99 }
+ : section && section.id === 'actress-ranking'
+ ? { hasMore: false, totalPages: 1 }
+ : paginationInfo(html, page, listItems.length);
+ if (items.length && requestedPage === 1 && section && section.eagerPagination) {
+ await prefetchNextCategoryPage(ctx, pageId, section, page, pagination);
+ }
+ const restored = listItems.length
+ ? rememberCategoryPage(ctx, pageId, page, listItems, pagination)
+ : null;
+ const responseItems = restored && restored.items.length
+ ? restored.items
+ : items.length
+ ? items
+ : listItems;
+ const responsePage = restored ? restored.page : page;
+ const responseHasMore = restored ? restored.hasMore : items.length ? pagination.hasMore : false;
return {
pageType: 'category',
@@ -205,17 +589,28 @@ async function getCategory(ctx) {
title: title,
style: items.length ? 'media.posterGrid' : 'discover.annualCategories',
itemAspectRatio: '16:9',
- items: items.length ? items : categoryItems,
- page: page,
- hasMore: items.length ? hasNextPage(html, page) : false
+ items: responseItems,
+ page: responsePage,
+ currentPage: responsePage,
+ pageIndex: responsePage,
+ nextPage: responseHasMore ? responsePage + 1 : undefined,
+ totalPages: restored && restored.totalPages ? restored.totalPages : pagination.totalPages,
+ pagecount: restored && restored.totalPages ? restored.totalPages : pagination.totalPages,
+ hasMore: responseHasMore
};
}
async function getDetail(ctx) {
+ ctx = normalizeContext(ctx);
const detailURL = detailUrlFromContext(ctx);
if (!detailURL) throw new Error('MissAV 详情参数无效');
- const html = await fetchText(ctx, detailURL);
+ let html = '';
+ try {
+ html = await fetchText(ctx, detailURL, entryURL(ctx));
+ } catch (error) {
+ return verificationDetail(ctx, detailURL, error);
+ }
const fallbackTitle = titleFromUrl(detailURL);
const title = cleanText(
firstNonEmpty(
@@ -247,9 +642,21 @@ async function getDetail(ctx) {
firstMatch(html, /(\d{1,2}:\d{2}:\d{2})/i)
);
const releaseDate = firstMatch(html, /(\d{4}-\d{2}-\d{2})/);
- const detailPlayableURL = absoluteURL(ctx, extractPlayableURL(html));
- const resourceGroups = playbackGroups(detailURL, title, detailPlayableURL, ctx);
- const defaultVersion = resourceGroups[0] && resourceGroups[0].versions && resourceGroups[0].versions[0];
+ let detailPlayableURL = absoluteURL(ctx, extractPlayableURL(html));
+ if (!detailPlayableURL) detailPlayableURL = await extractPlayableFromLinkedPlayers(ctx, html, detailURL);
+ let resourceGroups = await playbackGroups(detailURL, title, detailPlayableURL, ctx, html);
+ if (resourceVersionCount(resourceGroups) <= 1) {
+ const browserPlayableURL = await extractFromBrowser(detailURL, detailURL);
+ if (browserPlayableURL && browserPlayableURL !== detailPlayableURL) {
+ const browserGroups = await playbackGroups(detailURL, title, browserPlayableURL, ctx, html);
+ if (resourceVersionCount(browserGroups) > resourceVersionCount(resourceGroups)) {
+ detailPlayableURL = browserPlayableURL;
+ resourceGroups = browserGroups;
+ }
+ }
+ }
+ const versions = resourceGroups[0] && resourceGroups[0].versions || [];
+ const defaultVersion = versions[0];
return {
id: makeItemId(detailURL, title, poster),
@@ -271,19 +678,19 @@ async function getDetail(ctx) {
remarks: durationText || code,
resourceGroups: resourceGroups,
resourceSummary: {
- versionCount: defaultVersion ? 1 : 0,
+ versionCount: versions.length,
episodeCount: 0,
defaultVersionId: defaultVersion ? defaultVersion.id : ''
},
- mediaSources: detailPlayableURL ? [
+ mediaSources: defaultVersion && defaultVersion.url ? [
{
- id: encodePayload({ url: detailURL, title: title, playUrl: detailPlayableURL }),
- name: 'MissAV HLS',
- displayName: 'MissAV HLS',
+ id: defaultVersion.id,
+ name: defaultVersion.name,
+ displayName: defaultVersion.name,
protocol: 'hls',
container: 'm3u8',
- url: detailPlayableURL,
- path: detailPlayableURL,
+ url: defaultVersion.url,
+ path: defaultVersion.url,
headers: playbackHeaders(ctx, detailURL),
header: playbackHeaders(ctx, detailURL),
Header: playbackHeaders(ctx, detailURL),
@@ -308,32 +715,73 @@ async function getDetail(ctx) {
}
async function getResourceVersions(ctx) {
+ ctx = normalizeContext(ctx);
const detailURL = detailUrlFromContext(ctx);
const title = stringValue(ctx && (ctx.title || ctx.name)) || titleFromUrl(detailURL);
const direct = playUrlFromContext(ctx);
- if (direct) return playbackGroups(detailURL, title, direct, ctx);
+ if (direct) {
+ let directGroups = await playbackGroups(detailURL, title, direct, ctx);
+ if (resourceVersionCount(directGroups) <= 1 && detailURL) {
+ const browserPlayableURL = await extractFromBrowser(detailURL, detailURL);
+ if (browserPlayableURL && browserPlayableURL !== direct) {
+ const browserGroups = await playbackGroups(detailURL, title, browserPlayableURL, ctx);
+ if (resourceVersionCount(browserGroups) > resourceVersionCount(directGroups)) directGroups = browserGroups;
+ }
+ }
+ return directGroups;
+ }
try {
- const html = await fetchText(ctx, detailURL);
- return playbackGroups(detailURL, title, extractPlayableURL(html), ctx);
+ const html = await fetchText(ctx, detailURL, entryURL(ctx));
+ let playUrl = extractPlayableURL(html);
+ if (!playUrl) playUrl = await extractPlayableFromLinkedPlayers(ctx, html, detailURL);
+ let groups = await playbackGroups(detailURL, title, playUrl, ctx, html);
+ if (resourceVersionCount(groups) <= 1) {
+ const browserPlayableURL = await extractFromBrowser(detailURL, detailURL);
+ if (browserPlayableURL && browserPlayableURL !== playUrl) {
+ const browserGroups = await playbackGroups(detailURL, title, browserPlayableURL, ctx, html);
+ if (resourceVersionCount(browserGroups) > resourceVersionCount(groups)) groups = browserGroups;
+ }
+ }
+ return groups;
} catch (error) {
- return playbackGroups(detailURL, title, '', ctx);
+ return [];
}
}
async function resolvePlayback(ctx) {
+ ctx = normalizeContext(ctx);
+ const requestedQuality = qualityFromContext(ctx);
const direct = firstNonEmpty(playUrlFromContext(ctx), ctx && ctx.url, ctx && ctx.playUrl, ctx && ctx.videoUrl);
- if (isPlayableURL(direct) && !isDetailPageURL(direct)) {
+ if (!requestedQuality && isPlayableURL(direct) && !isDetailPageURL(direct)) {
return playbackResult(ctx, direct, ctx && ctx.referer);
}
const detailURL = detailUrlFromContext(ctx);
if (!detailURL) throw new Error('MissAV 播放参数无效');
- const html = await fetchText(ctx, detailURL);
- const url = firstNonEmpty(
+ let html = '';
+ try {
+ html = await fetchText(ctx, detailURL, entryURL(ctx));
+ } catch (error) {
+ // Playback has a separate, narrowly-scoped hidden browser media fallback.
+ }
+ const masterURL = firstNonEmpty(
extractPlayableURL(html),
await extractPlayableFromLinkedPlayers(ctx, html, detailURL),
await extractFromBrowser(detailURL, detailURL)
);
+ let url = masterURL;
+ if (masterURL) {
+ let qualities = await discoverAvailableQualities(ctx, masterURL, detailURL, html);
+ if (qualities.length <= 1) {
+ const browserMasterURL = await extractFromBrowser(detailURL, detailURL);
+ if (browserMasterURL && browserMasterURL !== masterURL) {
+ const browserQualities = await discoverAvailableQualities(ctx, browserMasterURL, detailURL, html);
+ if (browserQualities.length > qualities.length) qualities = browserQualities;
+ }
+ }
+ const selected = selectQuality(qualities, requestedQuality);
+ if (selected && selected.url) url = selected.url;
+ }
if (!url) {
throw new Error('未能解析到 MissAV 播放地址。站点可能启用了 Cloudflare 或更换了播放器脚本,请尝试更新入口路径或在 App 侧启用浏览器请求。');
}
@@ -386,18 +834,36 @@ async function play(flagOrInput, id) {
}
async function search(ctx) {
+ ctx = normalizeContext(ctx);
const query = stringValue(ctx && (ctx.query || ctx.keyword || ctx.text));
if (!query) return { pageType: 'search', title: '搜索结果', items: [] };
- const page = positiveInt(contextValue(ctx, 'page'), 1);
+ const page = pageFromContext(ctx, 1);
const url = pagedURL(categoryURL(ctx, '/search/' + encodeURIComponent(query.replace(/\\/g, ''))), page);
- const html = await fetchText(ctx, url);
+ let html = '';
+ try {
+ html = await fetchText(ctx, url, entryURL(ctx));
+ } catch (error) {
+ return {
+ pageType: 'search',
+ title: '搜索结果',
+ items: [verificationCard(ctx, '需要真人验证', url, error)],
+ page: page,
+ hasMore: false
+ };
+ }
const items = parseCards(html, query, ctx);
+ const pagination = paginationInfo(html, page, items.length);
return {
pageType: 'search',
title: '搜索结果',
items: items,
page: page,
- hasMore: hasNextPage(html, page)
+ currentPage: page,
+ pageIndex: page,
+ nextPage: pagination.hasMore ? page + 1 : undefined,
+ totalPages: pagination.totalPages,
+ pagecount: pagination.totalPages,
+ hasMore: pagination.hasMore
};
}
@@ -416,11 +882,33 @@ async function matchResources(ctx) {
return { results: (results.items || []).slice(0, 8) };
}
+async function onAction(ctx) {
+ ctx = normalizeContext(ctx);
+ const nestedAction = ctx && ctx.action && typeof ctx.action === 'object' ? ctx.action : {};
+ const name = stringValue(firstNonEmpty(
+ ctx && ctx.name,
+ nestedAction.name,
+ ctx && ctx.actionName,
+ nestedAction.id,
+ ctx && ctx.id
+ ));
+ const payload = nestedAction.payload || (ctx && ctx.payload) || nestedAction || ctx || {};
+ if (name !== 'verifyCloudflare') return { handled: false };
+ const url = stringValue(payload.url || ctx.url) || entryURL(ctx);
+ const html = await browserHTML(ctx, url, url, true);
+ if (isUsableHTML(html)) setCachedText(ctx, url, html);
+ return {
+ handled: true,
+ ok: isUsableHTML(html),
+ message: isUsableHTML(html) ? '验证完成,可以返回刷新当前页面。' : '验证没有完成,请确认页面已加载并通过真人验证。'
+ };
+}
+
async function extractPlayableFromLinkedPlayers(ctx, html, referer) {
const urls = extractPlayerURLs(ctx, html, referer);
for (let index = 0; index < urls.length; index += 1) {
try {
- const playerHTML = await fetchText(ctx, urls[index]);
+ const playerHTML = await fetchText(ctx, urls[index], referer);
const playable = extractPlayableURL(playerHTML);
if (playable) return playable;
} catch (error) {
@@ -456,6 +944,47 @@ function baseURL(ctx) {
) || MISSAV_DEFAULT_BASE).replace(/\/+$/, '');
}
+function normalizeContext(ctx) {
+ if (ctx === undefined || ctx === null) return {};
+ if (typeof ctx === 'string') {
+ const text = ctx.trim();
+ if (!text) return {};
+ if (text[0] === '{' && text[text.length - 1] === '}') {
+ try {
+ const parsed = JSON.parse(text);
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
+ } catch (error) {
+ return { query: text };
+ }
+ }
+ return { query: text };
+ }
+ return typeof ctx === 'object' ? ctx : {};
+}
+
+function pageFromContext(ctx, fallback) {
+ const input = normalizeContext(ctx);
+ const pagination = input.pagination && typeof input.pagination === 'object' ? input.pagination : {};
+ const pageInfo = input.pageInfo && typeof input.pageInfo === 'object' ? input.pageInfo : {};
+ return positiveInt(firstNonEmpty(
+ contextValue(input, 'page'),
+ contextValue(input, 'pg'),
+ contextValue(input, 'currentPage'),
+ contextValue(input, 'pageNumber'),
+ contextValue(input, 'pageIndex'),
+ pagination.page,
+ pagination.pg,
+ pagination.currentPage,
+ pagination.pageNumber,
+ pagination.pageIndex,
+ pageInfo.page,
+ pageInfo.pg,
+ pageInfo.currentPage,
+ pageInfo.pageNumber,
+ pageInfo.pageIndex
+ ), fallback);
+}
+
function entryPath(ctx) {
let value = stringValue(
contextValue(ctx, 'entryPath') ||
@@ -501,39 +1030,173 @@ function pagedURL(url, page) {
return url + (url.indexOf('?') >= 0 ? '&' : '?') + 'page=' + page;
}
-async function fetchText(ctx, url) {
- const response = await httpGet(url, {
- headers: requestHeaders(ctx, url),
- useBrowserCookie: true,
- attachBrowserCookie: true,
- useBrowserFallback: true,
- browserFallback: true,
- allowBrowserFallback: true
+function isActressWorksURL(url) {
+ const path = pathOf(url).split('?')[0].replace(/\/+$/, '');
+ return /\/(?:dm\d+\/)?cn\/actresses\/[^/]+$/i.test(path) &&
+ !/\/actresses\/ranking$/i.test(path);
+}
+
+function isActressIndexURL(url) {
+ const path = pathOf(url).split('?')[0].replace(/\/+$/, '');
+ return /\/(?:dm\d+\/)?cn\/actresses(?:\/ranking)?$/i.test(path);
+}
+
+async function fetchActressCategoryText(ctx, url, referer) {
+ const cached = getCachedText(ctx, url);
+ if (cached) return cached;
+
+ const fastContext = Object.assign({}, ctx || {}, {
+ requestTimeoutSeconds: Math.min(numberParam(ctx, 'requestTimeoutSeconds', 45), 12),
+ suppressBrowserFailureCooldown: true
});
- const text = responseText(response);
- if (isCloudflare(text, response && response.status, response && response.headers)) {
- const browserText = await browserHTML(url, url);
- if (browserText && !isCloudflare(browserText)) return browserText;
- throw new Error('MissAV 启用了 Cloudflare 浏览器校验,当前 HTTP 环境无法直接读取页面。');
+
+ let lastError = null;
+ try {
+ const options = requestOptions(fastContext, referer || url);
+ options.useBrowserFallback = false;
+ options.browserFallback = false;
+ options.allowBrowserFallback = false;
+ const response = await httpGet(url, options);
+ const text = responseText(response);
+ if (isUsableHTML(text, response && response.status, response && response.headers)) {
+ setCachedText(ctx, url, text);
+ return text;
+ }
+ throw new Error('HTTP ' + (response && response.status ? response.status : 'empty'));
+ } catch (error) {
+ lastError = error;
}
- return text;
+
+ // Actress work pages are challenged more often than ordinary categories.
+ // Reuse the verified browser session once with a short timeout instead of
+ // immediately appending another verification card to the existing 12 items.
+ const browserText = await browserHTML(fastContext, url, referer || url);
+ if (isVerifiedTargetHTML(fastContext, url, browserText)) {
+ setCachedText(ctx, url, browserText);
+ return browserText;
+ }
+
+ throw new Error(
+ '女优作品页读取失败。已短暂尝试复用刚才的验证状态,请点击一次真人验证后重试。' +
+ (lastError && lastError.message ? ' 原因:' + lastError.message : '')
+ );
+}
+
+async function fetchActressIndexText(ctx, url, referer) {
+ const cached = getCachedText(ctx, url);
+ if (cached && isVerifiedTargetHTML(ctx, url, cached)) return cached;
+
+ const fastContext = Object.assign({}, ctx || {}, {
+ requestTimeoutSeconds: Math.min(numberParam(ctx, 'requestTimeoutSeconds', 45), 12),
+ suppressBrowserFailureCooldown: true
+ });
+ let lastError = null;
+ try {
+ const options = requestOptions(fastContext, referer || url);
+ options.useBrowserFallback = false;
+ options.browserFallback = false;
+ options.allowBrowserFallback = false;
+ const response = await httpGet(url, options);
+ const text = responseText(response);
+ if (isVerifiedTargetHTML(fastContext, url, text)) {
+ setCachedText(ctx, url, text);
+ return text;
+ }
+ throw new Error('HTTP ' + (response && response.status ? response.status : 'empty'));
+ } catch (error) {
+ lastError = error;
+ }
+
+ const browserText = await browserHTML(fastContext, url, referer || url);
+ if (isVerifiedTargetHTML(fastContext, url, browserText)) {
+ setCachedText(ctx, url, browserText);
+ return browserText;
+ }
+
+ throw new Error(
+ '女优目录读取失败。已短暂尝试复用浏览器验证状态,请手动完成一次真人验证。' +
+ (lastError && lastError.message ? ' 原因:' + lastError.message : '')
+ );
+}
+
+async function fetchText(ctx, url, referer) {
+ const cached = getCachedText(ctx, url);
+ if (cached) return cached;
+
+ const urls = candidateURLs(ctx, url);
+ let lastError = null;
+ for (let index = 0; index < urls.length; index += 1) {
+ const currentURL = urls[index];
+ const requestReferer = referer || entryURL(ctx);
+ try {
+ const response = await httpGet(currentURL, requestOptions(ctx, requestReferer));
+ const text = responseText(response);
+ if (isUsableHTML(text, response && response.status, 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)) {
+ const browserText = await browserHTML(ctx, currentURL, requestReferer);
+ if (isUsableHTML(browserText)) {
+ setCachedText(ctx, currentURL, browserText);
+ if (currentURL !== url) setCachedText(ctx, url, browserText);
+ return browserText;
+ }
+ }
+ lastError = new Error('HTTP ' + (response && response.status ? response.status : 'empty') + ' ' + currentURL);
+ } catch (error) {
+ lastError = error;
+ const browserText = await browserHTML(ctx, currentURL, requestReferer);
+ if (isUsableHTML(browserText)) {
+ setCachedText(ctx, currentURL, browserText);
+ if (currentURL !== url) setCachedText(ctx, url, browserText);
+ return browserText;
+ }
+ }
+ }
+ throw new Error('MissAV 页面读取失败,可能是 Cloudflare 验证、网络超时或当前域名不可达。可点击“手动完成验证”,或在“备用站点地址”里填写当前能打开的域名。' + (lastError && lastError.message ? ' 原因:' + lastError.message : ''));
}
-async function safeFetch(url, referer) {
+async function safeFetch(ctx, url, referer) {
try {
- return await fetchText({ baseURL: originOf(url), entryPath: pathOf(url) }, url, referer);
+ return await fetchText(ctx || { baseURL: originOf(url), entryPath: pathOf(url) }, url, referer);
} catch (error) {
return '';
}
}
+function requestOptions(ctx, referer) {
+ const timeout = numberParam(ctx, 'requestTimeoutSeconds', 45);
+ return {
+ headers: requestHeaders(ctx, referer),
+ timeout: timeout,
+ timeoutSeconds: timeout,
+ useBrowserCookie: true,
+ attachBrowserCookie: true,
+ useBrowserFallback: boolParam(ctx, 'enableBrowserFallback', true),
+ browserFallback: boolParam(ctx, 'enableBrowserFallback', true),
+ allowBrowserFallback: boolParam(ctx, 'enableBrowserFallback', true)
+ };
+}
+
function httpGet(url, options) {
if (typeof Widget !== 'undefined' && Widget.http && typeof Widget.http.get === 'function') {
return Widget.http.get(url, options || {});
}
+ if (typeof Widget !== 'undefined' && Widget.http && typeof Widget.http.request === 'function') {
+ const widgetOptions = options || {};
+ if (!widgetOptions.method) widgetOptions.method = 'GET';
+ return Widget.http.request(url, widgetOptions);
+ }
if (typeof $http !== 'undefined' && typeof $http.get === 'function') {
return $http.get(url, options || {});
}
+ if (typeof $http !== 'undefined' && typeof $http.request === 'function') {
+ const legacyOptions = options || {};
+ if (!legacyOptions.method) legacyOptions.method = 'GET';
+ return $http.request(url, legacyOptions);
+ }
throw new Error('当前运行环境没有可用 HTTP 客户端。');
}
@@ -565,31 +1228,70 @@ function playbackHeaders(ctx, referer) {
function responseText(response) {
if (typeof response === 'string') return response;
if (!response) return '';
+ if (typeof response.text === 'string') return response.text;
if (typeof response.data === 'string') return response.data;
if (typeof response.body === 'string') return response.body;
+ if (response.data && typeof response.data.html === 'string') return response.data.html;
+ if (response.body && typeof response.body.html === 'string') return response.body.html;
+ if (typeof response.html === 'string') return response.html;
return String(response.data || response.body || '');
}
-async function browserHTML(url, referer) {
+async function browserHTML(ctx, url, referer, forceVisible) {
+ if (!boolParam(ctx, 'enableBrowserFallback', true)) return '';
+ if (forceVisible !== true && !boolParam(ctx, 'automaticBrowserFallback', true)) return '';
+ if (forceVisible !== true && browserRetryBlocked(ctx, url)) return '';
if (typeof Widget === 'undefined' || !Widget.browser || typeof Widget.browser.fetch !== 'function') return '';
try {
+ const visible = forceVisible === true;
+ const configuredTimeout = numberParam(ctx, 'requestTimeoutSeconds', 45);
+ const timeout = visible ? Math.min(configuredTimeout, 20) : configuredTimeout;
const result = await Widget.browser.fetch(url, {
- visible: false,
- timeout: 60,
- waitAfterLoad: 2.5,
- waitForAny: true,
- waitForMediaSource: true,
+ visible: visible,
+ timeout: timeout,
+ timeoutSeconds: timeout,
+ waitAfterLoad: visible ? 1.5 : 3,
headers: {
'User-Agent': MISSAV_UA,
Referer: referer || url
}
});
- return responseText(result.html ? { data: result.html } : result);
+ const html = responseText(result.html ? { data: result.html } : result);
+ if (isUsableHTML(html)) {
+ clearBrowserFailure(url);
+ return html;
+ }
+ rememberBrowserFailure(ctx, url);
+ return '';
} catch (error) {
+ rememberBrowserFailure(ctx, url);
return '';
}
}
+function browserFailureKey(url) {
+ const scope = isActressWorksURL(url) || isActressIndexURL(url)
+ ? pathOf(url).split('?')[0].replace(/\/+$/, '')
+ : '';
+ return 'missav:browser-failure:' + originOf(url) + (scope ? ':' + scope : '');
+}
+
+function browserRetryBlocked(ctx, url) {
+ const value = cacheGet(browserFailureKey(url));
+ return !!(value && Number(value.retryAfter) > Date.now());
+}
+
+function rememberBrowserFailure(ctx, url) {
+ if (boolParam(ctx, 'suppressBrowserFailureCooldown', false)) return;
+ const minutes = Math.max(0, numberParam(ctx, 'verificationCooldownMinutes', 10));
+ if (!minutes) return;
+ cacheSet(browserFailureKey(url), { retryAfter: Date.now() + minutes * 60 * 1000 });
+}
+
+function clearBrowserFailure(url) {
+ cacheSet(browserFailureKey(url), { retryAfter: 0 });
+}
+
async function extractFromBrowser(url, referer) {
if (typeof Widget === 'undefined' || !Widget.browser || typeof Widget.browser.fetch !== 'function') return '';
try {
@@ -599,6 +1301,8 @@ async function extractFromBrowser(url, referer) {
waitAfterLoad: 3,
waitForMediaSource: true,
waitForAny: true,
+ captureRequests: true,
+ captureMedia: true,
headers: {
'User-Agent': MISSAV_UA,
Referer: referer || url
@@ -614,24 +1318,324 @@ async function extractFromBrowser(url, referer) {
}
}
+function candidateURLs(ctx, url) {
+ const input = stringValue(url);
+ const urls = [input];
+ const origin = originOf(input);
+ const path = input.replace(/^https?:\/\/[^/]+/i, '');
+ candidatePathVariants(ctx, path).forEach(function (variant) {
+ const next = origin + variant;
+ if (urls.indexOf(next) < 0) urls.push(next);
+ });
+ backupBaseURLs(ctx).forEach(function (base) {
+ const root = base.replace(/\/+$/, '');
+ if (!root || root === origin) return;
+ candidatePathVariants(ctx, path).forEach(function (variant) {
+ const next = root + variant;
+ if (urls.indexOf(next) < 0) urls.push(next);
+ });
+ });
+ return urls.filter(Boolean);
+}
+
+function candidatePathVariants(ctx, path) {
+ const value = path && path[0] === '/' ? path : '/' + stringValue(path);
+ const variants = [value];
+ if (!isKnownCategoryPath(value)) return variants;
+ const match = value.match(/^\/(?:dm\d+\/)?cn(\/[^?#]*)([?#].*)?$/i);
+ if (match) {
+ const suffix = match[1] || '';
+ const query = match[2] || '';
+ const cnPath = '/cn' + suffix + query;
+ const rootPath = suffix + query;
+ if (variants.indexOf(cnPath) < 0) variants.push(cnPath);
+ if (variants.indexOf(rootPath) < 0) variants.push(rootPath);
+ } else {
+ const localePath = localePrefix(ctx) + value;
+ const cnPath = '/cn' + value;
+ if (variants.indexOf(localePath) < 0) variants.push(localePath);
+ if (variants.indexOf(cnPath) < 0) variants.push(cnPath);
+ }
+ return variants;
+}
+
+function isKnownCategoryPath(path) {
+ const clean = String(path || '').split(/[?#]/)[0].replace(/^\/(?:dm\d+\/)?cn/i, '') || '/';
+ return MISSAV_SECTIONS.some(function (item) {
+ return categorySlug(clean) === categorySlug(item.path);
+ });
+}
+
+function backupBaseURLs(ctx) {
+ const raw = stringValue(contextValue(ctx, 'backupBaseURLs') || contextValue(ctx, 'backupBaseUrls') || contextValue(ctx, 'backup_base_urls'));
+ return raw.split(/[\n,,\s]+/).map(function (value) {
+ return value.replace(/\/+$/, '');
+ }).filter(function (value) {
+ return /^https?:\/\//i.test(value);
+ });
+}
+
+function cacheKey(url) {
+ return 'missav:html:' + String(url || '');
+}
+
+function categoryArtworkKey(ctx, pageId) {
+ return 'missav:category-artwork:v4:' + baseURL(ctx) + ':' + String(pageId || '');
+}
+
+function rememberCategoryArtwork(ctx, pageId, items) {
+ const previews = categoryPreviewItems(items, findSection(pageId));
+ if (!previews.length) return;
+ const key = categoryArtworkKey(ctx, pageId);
+ const value = {
+ items: previews,
+ expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000
+ };
+ memoryCache()[key] = value;
+ cacheSet(key, value);
+}
+
+function cachedCategoryArtwork(ctx, pageId) {
+ const key = categoryArtworkKey(ctx, pageId);
+ const now = Date.now();
+ let value = memoryCache()[key] || cacheGet(key);
+ if (typeof value === 'string') {
+ try {
+ value = JSON.parse(value);
+ } catch (error) {
+ value = null;
+ }
+ }
+ if (!value || value.expiresAt <= now || !Array.isArray(value.items)) return [];
+ memoryCache()[key] = value;
+ return value.items;
+}
+
+function categoryStateKey(ctx, pageId) {
+ return 'missav:category-state:v6:' + baseURL(ctx) + ':' + String(pageId || '');
+}
+
+function memoryCache() {
+ if (typeof globalThis === 'undefined') return {};
+ if (!globalThis.__MISSAV_HTML_CACHE__) globalThis.__MISSAV_HTML_CACHE__ = {};
+ return globalThis.__MISSAV_HTML_CACHE__;
+}
+
+function categoryStateFromCache(ctx, pageId) {
+ const key = categoryStateKey(ctx, pageId);
+ const now = Date.now();
+ let state = memoryCache()[key] || cacheGet(key);
+ if (typeof state === 'string') {
+ try {
+ state = JSON.parse(state);
+ } catch (error) {
+ state = null;
+ }
+ }
+ if (!state || state.expiresAt <= now || !state.pages) return null;
+ memoryCache()[key] = state;
+ return state;
+}
+
+function randomPageURL(ctx, page) {
+ const bucket = ((Math.max(1, Number(page) || 1) - 1) % 99) + 2;
+ return baseURL(ctx) + '/random/' + bucket;
+}
+
+async function prefetchNextCategoryPage(ctx, pageId, section, page, pagination) {
+ if (!pagination || !pagination.hasMore) return;
+ const nextPage = page + 1;
+ const state = categoryStateFromCache(ctx, pageId);
+ if (state && state.pages && state.pages[String(nextPage)]) return;
+ try {
+ const url = pagedURL(categoryURL(ctx, section.path), nextPage);
+ const html = await fetchText(ctx, url, entryURL(ctx));
+ const items = parseCards(html, section.title, ctx);
+ if (!items.length) return;
+ rememberCategoryPage(ctx, pageId, nextPage, items, paginationInfo(html, nextPage, items.length));
+ } catch (error) {
+ // Page 1 remains usable; ordinary scrolling can retry the next page later.
+ }
+}
+
+function contiguousCategoryPage(state) {
+ if (!state || !state.pages || !state.pages['1']) return 0;
+ let page = 1;
+ while (state.pages[String(page + 1)]) page += 1;
+ return page;
+}
+
+function categoryFetchPage(ctx, pageId, requestedPage) {
+ if (requestedPage <= 1) return 1;
+ const state = categoryStateFromCache(ctx, pageId);
+ if (!state || !state.pages[String(requestedPage)]) return requestedPage;
+ const lastLoadedPage = contiguousCategoryPage(state);
+ return lastLoadedPage >= requestedPage ? lastLoadedPage + 1 : requestedPage;
+}
+
+function rememberCategoryPage(ctx, pageId, page, items, pagination) {
+ const key = categoryStateKey(ctx, pageId);
+ const ttl = numberParam(ctx, 'categoryRestoreMinutes', 60) * 60 * 1000;
+ const existing = categoryStateFromCache(ctx, pageId);
+ const state = existing || { pages: {} };
+ state.pages[String(page)] = (items || []).slice(0, 120);
+ state.hasMoreByPage = state.hasMoreByPage || {};
+ state.hasMoreByPage[String(page)] = !!(pagination && pagination.hasMore);
+ state.totalPages = Math.max(Number(state.totalPages) || 0, Number(pagination && pagination.totalPages) || 0);
+ state.expiresAt = Date.now() + ttl;
+
+ const pageNumbers = Object.keys(state.pages).map(Number).filter(function (value) {
+ return Number.isFinite(value) && value > 0;
+ }).sort(function (a, b) { return a - b; });
+ while (pageNumbers.length > 20) {
+ const removed = pageNumbers.shift();
+ delete state.pages[String(removed)];
+ delete state.hasMoreByPage[String(removed)];
+ }
+ memoryCache()[key] = state;
+ cacheSet(key, state);
+
+ if (page !== 1) {
+ return {
+ items: items,
+ page: page,
+ hasMore: !!(pagination && pagination.hasMore),
+ totalPages: Number(pagination && pagination.totalPages) || state.totalPages
+ };
+ }
+
+ const lastContiguousPage = contiguousCategoryPage(state);
+ if (lastContiguousPage === 1) {
+ return {
+ items: items,
+ page: 1,
+ hasMore: !!(pagination && pagination.hasMore),
+ totalPages: Number(pagination && pagination.totalPages) || state.totalPages
+ };
+ }
+
+ const merged = [];
+ const seen = {};
+ for (let current = 1; current <= lastContiguousPage; current += 1) {
+ (state.pages[String(current)] || []).forEach(function (item) {
+ const identity = stringValue(item && (item.id || item.url || item.title));
+ if (identity && seen[identity]) return;
+ if (identity) seen[identity] = true;
+ merged.push(item);
+ });
+ }
+ const latestHasMore = state.hasMoreByPage[String(lastContiguousPage)];
+ return {
+ items: merged.slice(0, 240),
+ page: lastContiguousPage,
+ hasMore: latestHasMore !== undefined
+ ? latestHasMore
+ : state.totalPages > lastContiguousPage,
+ totalPages: state.totalPages
+ };
+}
+
+function getCachedText(ctx, url) {
+ const ttl = numberParam(ctx, 'cacheMinutes', 20) * 60 * 1000;
+ if (ttl <= 0) return '';
+ const key = cacheKey(url);
+ const now = Date.now();
+ const memory = memoryCache()[key];
+ if (memory && memory.expiresAt > now && memory.text) return memory.text;
+ const stored = cacheGet(key);
+ if (stored && stored.expiresAt > now && stored.text) return stored.text;
+ return '';
+}
+
+function setCachedText(ctx, url, text) {
+ if (!isUsableHTML(text)) return;
+ const configuredMinutes = numberParam(ctx, 'cacheMinutes', 20);
+ const minimumMinutes = isActressIndexURL(url) ? 60 : isActressWorksURL(url) ? 30 : 0;
+ const ttl = Math.max(configuredMinutes, minimumMinutes) * 60 * 1000;
+ if (ttl <= 0) return;
+ const value = { text: text, expiresAt: Date.now() + ttl };
+ const key = cacheKey(url);
+ memoryCache()[key] = value;
+ cacheSet(key, value);
+}
+
+function cacheGet(key) {
+ try {
+ if (typeof Widget !== 'undefined' && Widget.cache && typeof Widget.cache.get === 'function') return Widget.cache.get(key);
+ if (typeof $cache !== 'undefined' && typeof $cache.get === 'function') return $cache.get(key);
+ } catch (error) {
+ return null;
+ }
+ return null;
+}
+
+function cacheSet(key, value) {
+ try {
+ if (typeof Widget !== 'undefined' && Widget.cache && typeof Widget.cache.set === 'function') Widget.cache.set(key, value);
+ if (typeof $cache !== 'undefined' && typeof $cache.set === 'function') $cache.set(key, value);
+ } catch (error) {
+ // Cache is opportunistic; ignore unsupported host APIs.
+ }
+}
+
+function isUsableHTML(html, status, headers) {
+ const text = String(html || '');
+ if (!text || text.length < 80) return false;
+ if (Number(status) >= 400) return false;
+ if (isCloudflare(text, status, headers)) return false;
+ return / 0 ? value : fallback;
+}
+
function playableFromBrowserResult(result) {
if (!result) return '';
+ const candidates = [];
const keys = ['url', 'mediaURL', 'mediaUrl', 'videoURL', 'videoUrl', 'playURL', 'playUrl', 'src'];
for (let index = 0; index < keys.length; index += 1) {
const value = result[keys[index]];
- if (isPlayableURL(value)) return value;
+ if (isPlayableURL(value)) candidates.push(value);
}
- const arrays = [result.mediaSources, result.mediaRequests, result.requests, result.responses, result.urls];
+ const arrays = [
+ result.mediaSources,
+ result.mediaRequests,
+ result.capturedRequests,
+ result.requests,
+ result.responses,
+ result.urls
+ ];
for (let a = 0; a < arrays.length; a += 1) {
const array = arrays[a];
if (!Array.isArray(array)) continue;
for (let i = 0; i < array.length; i += 1) {
const item = array[i];
const value = typeof item === 'string' ? item : firstNonEmpty(item && item.url, item && item.src, item && item.responseURL);
- if (isPlayableURL(value)) return value;
+ if (isPlayableURL(value)) candidates.push(value);
}
}
- return '';
+ return unique(candidates).sort(function (left, right) {
+ return playableCandidateScore(right) - playableCandidateScore(left);
+ })[0] || '';
+}
+
+function playableCandidateScore(url) {
+ const value = String(url || '');
+ let score = 0;
+ if (/\.m3u8(?:[?#]|$)/i.test(value)) score += 20;
+ if (/(?:master|playlist|index)\.m3u8(?:[?#]|$)/i.test(value)) score += 40;
+ if (/\/\d{3,4}p(?:\/|[?#]|$)/i.test(value)) score -= 30;
+ if (/\.(?:ts|m2ts)(?:[?#]|$)/i.test(value)) score -= 100;
+ return score;
}
function parseHomeSections(ctx, html) {
@@ -684,6 +1688,199 @@ function emptySection(id, title, style, error) {
};
}
+function verificationCategory(ctx, id, title, url, error, page) {
+ return {
+ pageType: 'category',
+ id: id,
+ title: title || '需要验证',
+ style: 'media.posterGrid',
+ itemAspectRatio: '16:9',
+ items: [verificationCard(ctx, '需要真人验证', url, error)],
+ page: page || 1,
+ hasMore: false
+ };
+}
+
+function verificationSection(ctx, id, title, style, url, error) {
+ return {
+ id: id,
+ title: title,
+ style: style || 'discover.posterCompact',
+ lazy: false,
+ items: [verificationCard(ctx, '需要真人验证', url, error)]
+ };
+}
+
+function verificationDetail(ctx, url, error) {
+ const title = titleFromUrl(url) || '需要真人验证';
+ return {
+ pageType: 'detail',
+ id: makeItemId(url, title, ''),
+ title: '需要真人验证',
+ type: 'collection',
+ mediaType: 'collection',
+ overview: cleanText(error && error.message) || '站点触发了 Cloudflare 真人验证。请返回列表页点击手动验证卡片,完成后刷新。',
+ detailImageAspectRatio: '16:9',
+ playable: false,
+ isPlayable: false,
+ resourceGroups: [],
+ mediaSources: [],
+ playbackSummary: {
+ versionCount: 0,
+ episodeCount: 0,
+ defaultVersionId: ''
+ },
+ recommendations: [
+ {
+ id: 'verify',
+ title: '访问受限',
+ style: 'discover.posterCompact',
+ items: [verificationCard(ctx, '手动完成验证', url, error)]
+ }
+ ]
+ };
+}
+
+function verificationCard(ctx, title, url, error) {
+ const targetURL = url || entryURL(ctx);
+ const pageId = verificationPageId(targetURL);
+ return {
+ id: pageId,
+ title: title || '手动完成验证',
+ subtitle: '点击后再打开验证界面,完成后返回刷新',
+ overview: cleanText(error && error.message) || '当前网络触发了 Cloudflare 真人验证。',
+ type: 'category',
+ mediaType: 'category',
+ playable: false,
+ isPlayable: false,
+ aspectRatio: '16:9',
+ action: {
+ type: 'category',
+ id: pageId,
+ pageId: pageId,
+ title: '手动完成验证',
+ url: targetURL,
+ itemAspectRatio: '16:9'
+ }
+ };
+}
+
+async function runVerificationCategory(ctx, pageId, url) {
+ const requestedPage = Math.max(pageFromContext(ctx, 1), pageFromURL(url, 1));
+ if (requestedPage > 1 || isVerifiedTargetHTML(ctx, url, getCachedText(ctx, url))) {
+ return resumeVerifiedCategory(ctx, pageId, url, requestedPage);
+ }
+ let html = await browserHTML(ctx, url, url, true);
+ if (!isVerifiedTargetHTML(ctx, url, html)) {
+ const refreshedHTML = await fetchAfterVerification(ctx, url);
+ if (isVerifiedTargetHTML(ctx, url, refreshedHTML)) html = refreshedHTML;
+ }
+ const ok = isVerifiedTargetHTML(ctx, url, html);
+ if (ok) {
+ setCachedText(ctx, url, html);
+ if (isActressWorksURL(url) || isActressIndexURL(url)) {
+ resetCategoryState(ctx, normalizePageId(ctx, url));
+ return resumeVerifiedCategory(ctx, pageId, url, 1);
+ }
+ }
+ const retryCard = verificationCard(
+ ctx,
+ '验证未完成,点击重新验证',
+ url,
+ new Error('浏览器尚未返回可读取的影片页面')
+ );
+ return {
+ pageType: 'category',
+ id: pageId,
+ title: ok ? '验证完成' : '验证未完成',
+ style: 'media.posterGrid',
+ itemAspectRatio: '16:9',
+ page: 1,
+ hasMore: false,
+ items: ok
+ ? [{
+ id: pageId + '-success',
+ title: '验证完成',
+ subtitle: '请返回原页面继续浏览',
+ type: 'collection',
+ mediaType: 'collection',
+ playable: false,
+ isPlayable: false,
+ aspectRatio: '16:9',
+ action: { type: 'none' }
+ }]
+ : [retryCard]
+ };
+}
+
+async function resumeVerifiedCategory(ctx, verificationPageId, targetURL, page) {
+ const stablePageId = normalizePageId(ctx, targetURL);
+ const resumedContext = Object.assign({}, ctx || {}, {
+ id: stablePageId,
+ pageId: stablePageId,
+ page: page,
+ currentPage: page,
+ pageIndex: page
+ });
+ let result = await getCategory(resumedContext);
+ result.id = stablePageId;
+ result.verificationTarget = targetURL;
+ return result;
+}
+
+function resetCategoryState(ctx, pageId) {
+ const key = categoryStateKey(ctx, pageId);
+ delete memoryCache()[key];
+ cacheSet(key, { pages: {}, expiresAt: 0 });
+}
+
+function isVerifiedTargetHTML(ctx, url, html) {
+ if (!isUsableHTML(html)) return false;
+ if (isActressWorksURL(url)) {
+ return parseCards(html, pageTitle(html) || '女优作品', ctx).length > 0;
+ }
+ if (isActressIndexURL(url)) {
+ return parseCategoryCards(ctx, html, /\/ranking$/i.test(pathOf(url)) ? '女优排行' : '女优一览').length > 0;
+ }
+ return true;
+}
+
+async function fetchAfterVerification(ctx, url) {
+ const retryContext = Object.assign({}, ctx || {}, {
+ requestTimeoutSeconds: Math.min(numberParam(ctx, 'requestTimeoutSeconds', 45), 6)
+ });
+ for (let attempt = 0; attempt < 1; attempt += 1) {
+ try {
+ await verificationCookieDelay(500);
+ const options = requestOptions(retryContext, url);
+ options.useBrowserFallback = false;
+ options.browserFallback = false;
+ options.allowBrowserFallback = false;
+ options.useBrowserCookie = true;
+ options.attachBrowserCookie = true;
+ const response = await httpGet(url, options);
+ const html = responseText(response);
+ if (isVerifiedTargetHTML(ctx, url, html)) return html;
+ } catch (error) {
+ // Return a retry card quickly instead of leaving the verification page spinning.
+ }
+ }
+ return '';
+}
+
+function verificationCookieDelay(milliseconds) {
+ if (typeof Promise === 'undefined') return null;
+ if (typeof setTimeout !== 'function') return Promise.resolve();
+ return new Promise(function (resolve) {
+ setTimeout(resolve, milliseconds);
+ });
+}
+
+function pageFromURL(url, fallback) {
+ const match = String(url || '').match(/[?&]page=(\d+)/i);
+ return positiveInt(match && match[1], fallback);
+}
+
function categoryAction(ctx, section) {
return {
type: 'category',
@@ -696,32 +1893,65 @@ function categoryAction(ctx, section) {
function parseCategoryCards(ctx, html, fallbackTitle) {
const source = categoryListBlock(html, fallbackTitle);
- const items = [];
- const seen = {};
- anchorsIn(source).forEach(function (anchor) {
- const href = anchor.href;
- const title = cleanText(anchor.text || anchor.title);
- if (!title || seen[href + title] || isJunkCategoryTitle(title)) return;
- if (!isCategoryHref(href)) return;
+ const records = {};
+ const order = [];
+ const pattern = /]*)>([\s\S]*?)<\/a>/gi;
+ let match;
+ while ((match = pattern.exec(source))) {
+ const href = attr(match[1], 'href');
+ if (!isCategoryHref(href)) continue;
const url = absoluteURL(ctx, href);
- seen[href + title] = true;
- items.push({
- id: 'category-link-' + items.length + '-' + encodeURIComponent(title),
- title: title,
+ const rawTitle = cleanText(firstNonEmpty(
+ attr(match[1], 'title'),
+ attr(match[2], 'alt'),
+ match[2]
+ ));
+ const title = categoryCardTitle(rawTitle);
+ const image = absoluteURL(ctx, pickImage(match[0]));
+ if (!records[url]) {
+ records[url] = { url: url, title: '', image: '' };
+ order.push(url);
+ }
+ if (title && !isJunkCategoryTitle(title)) records[url].title = records[url].title || title;
+ if (image) records[url].image = records[url].image || image;
+ }
+
+ return order.map(function (url, index) {
+ const record = records[url];
+ if (!record || !record.title) return null;
+ const item = {
+ id: 'category-link-' + index + '-' + encodeURIComponent(record.title),
+ title: record.title,
subtitle: fallbackTitle || '分类',
type: 'category',
- aspectRatio: '16:9',
+ mediaType: 'category',
+ playable: false,
+ isPlayable: false,
+ aspectRatio: record.image ? '2:3' : '16:9',
action: {
type: 'category',
- id: url,
- pageId: url,
- title: title,
- url: url,
+ id: record.url,
+ pageId: record.url,
+ title: record.title,
+ url: record.url,
itemAspectRatio: '16:9'
}
- });
- });
- return items.slice(0, 80);
+ };
+ if (record.image) {
+ item.poster = record.image;
+ item.thumbnailURL = record.image;
+ item.imageHeaders = imageHeaders(ctx, record.url);
+ }
+ return item;
+ }).filter(Boolean).slice(0, 120);
+}
+
+function categoryCardTitle(value) {
+ return cleanText(value)
+ .replace(/\s+\d+\s*(?:条|部)影片[\s\S]*$/i, '')
+ .replace(/\s+\d{4}\s*出道[\s\S]*$/i, '')
+ .replace(/\s+第\s*\d+\s*名[\s\S]*$/i, '')
+ .trim();
}
function categoryListBlock(html, title) {
@@ -729,9 +1959,8 @@ function categoryListBlock(html, title) {
const heading = cleanText(title || '');
const index = heading ? source.search(new RegExp(']*>\\s*' + escapeRegExp(heading), 'i')) : -1;
if (index < 0) return source;
- const nextScript = source.indexOf('', capturedRequests: [{ url: masterURL, requestHeaders: { Referer: embedURL, Origin: 'https://player.example', 'User-Agent': 'Fixture browser' } }] };
- if (String(url).indexOf(masterURL) === 0) { manifestBrowserRequests++; return { html: masterManifest }; }
+ if (String(url).indexOf(embedURL) === 0) return '';
+ if (String(url).indexOf(masterURL) === 0) return masterManifest;
throw new Error('unexpected browser URL: ' + url);
}
},
@@ -48,47 +46,35 @@ global.Widget = {
const library = require('./sexbjcam-mini-library.js');
-// Version 1.1.3 returns its original dynamic default line after a failed
-// quality probe; the later cache/deadline recovery implementation is rolled back.
async function run() {
const weakDetail = await library.getDetail({ itemId: detailURL });
- assert.strictEqual(weakDetail.resourceGroups[0].id, 'online', '初版弱网详情保留动态默认线路');
- assert.deepStrictEqual(
- weakDetail.resourceGroups[0].versions.map((version) => version.name),
- ['默认线路']
- );
- assert.strictEqual(weakDetail.resourceGroups[0].versions[0].action.embedURL, embedURL);
+ assert.deepStrictEqual(weakDetail.resourceGroups, [], '弱网失败不能被保存成默认线路');
- const weakVersions = await library.getResourceVersions({ itemId: detailURL, embedURL });
- assert.strictEqual(weakVersions.groups[0].id, 'online', '初版弱网资源探测返回原有默认线路');
- assert.strictEqual(weakVersions.groups[0].versions[0].action.itemId, detailURL);
+ await assert.rejects(
+ library.getResourceVersions({ itemId: detailURL, embedURL }),
+ (error) => error instanceof Error,
+ '弱网资源探测应失败并允许客户端稍后重试'
+ );
networkGood = true;
const recovered = await library.getResourceVersions({ itemId: detailURL, embedURL });
assert.deepStrictEqual(
recovered.groups[0].versions.map((version) => version.name),
['1080P', '720P', '480P'],
- '网络恢复后同一视频重新发现全部画质'
+ '网络恢复后同一视频应重新发现全部画质'
);
- assert.strictEqual(recovered.groups[0].versions[0].default, true);
- assert(recovered.groups[0].versions.slice(1).every((version) => !version.default));
- assert(manifestBrowserRequests > 0, '保留初版浏览器读取 HLS 清单的实际流程');
networkGood = false;
- const weakAgain = await library.getResourceVersions({ itemId: detailURL, embedURL });
+ const cached = await library.getResourceVersions({ itemId: detailURL, embedURL });
assert.deepStrictEqual(
- weakAgain.groups[0].versions.map((version) => version.name),
- ['默认线路'],
- '初版没有持久画质缓存,再次弱网时返回动态默认线路'
+ cached.groups[0].versions.map((version) => version.name),
+ ['1080P', '720P', '480P'],
+ '成功发现后的稳定画质元数据应可跨网络波动复用'
);
- assert.strictEqual(storageValues.size, 0, '初版不写入后续版本的画质缓存');
- networkGood = true;
- const playback = await library.resolvePlayback({ itemId: detailURL, embedURL, versionId: 'quality:720' });
- assert(playback.url.includes('/720/index.m3u8'), '网络恢复后按原版画质标识选择 720P');
- assert.strictEqual(playback.headers.Referer, embedURL, '保持原版播放器请求头');
- assert.strictEqual(playback.headers.Origin, 'https://player.example');
- console.log('SexBJCam first-upload network behavior test passed');
+ const serializedCache = JSON.stringify(Array.from(storageValues.values()));
+ assert(!serializedCache.includes('token=fresh'), '不得缓存签名播放 URL');
+ console.log('SexBJCam network recovery test passed');
}
run().catch((error) => {
diff --git a/tests/adapters.test.cjs b/tests/adapters.test.cjs
index 4947cc3..0626c34 100644
--- a/tests/adapters.test.cjs
+++ b/tests/adapters.test.cjs
@@ -228,9 +228,11 @@ test('XVideos attaches the configured session token without printing it', async
assert.equal(options.headers.Cookie,'session_token='+token); assert.ok(logs.every(line=>!line.includes(token)));
});
-// These entrypoints intentionally restore their original uploaded bytes and contracts.
-// Recovery behavior added after those uploads is no longer part of this release.
-const firstVersions = JSON.parse(fs.readFileSync(path.join(root, 'tools/library-first-versions.json'), 'utf8'));
+// These entrypoints restore the online snapshot before the 2026-10-03 task.
+// Later recovery behavior is intentionally absent from the restored scripts.
+const restoration = JSON.parse(fs.readFileSync(path.join(root, 'tools/library-restoration.json'), 'utf8'));
+assert.equal(restoration.cutoff, '2026-10-03T00:00:00+08:00');
+assert.match(restoration.baselineCommit, /^[0-9a-f]{40}$/);
const restoredIds = {
'missav-mini-library.js': 'missav-mini-library',
'sexbjcam-mini-library.js': 'sexbjcam-mini-library',
@@ -238,17 +240,18 @@ const restoredIds = {
'javgg-mini-library.js': 'javgg-mini-library',
'madou8-mini-library 5.js': 'madou8-mini-library',
'taolusm-mini-library.js': 'taolusm-mini-library',
- 'kbjfan-mini-library.js': 'kbjfan-mini-library'
+ 'kbjfan-mini-library.js': 'kbjfan-mini-library',
+ 'missav-mini-library-CloudFlare.js': 'missav-mini-library',
+ 'missav-mini-library-download-working.js': 'missav-mini-library',
+ 'missav-mini-library-download-working 6.js': 'missav-mini-library'
};
-assert.deepEqual(Object.keys(firstVersions).sort(), Object.keys(restoredIds).sort());
-for (const [file, original] of Object.entries(firstVersions)) {
- test(`${file}: bytes match the recorded first upload`, () => {
- assert.match(original.commit, /^[0-9a-f]{40}$/);
- assert.ok(original.sourcePath.endsWith('.js'));
+assert.deepEqual(Object.keys(restoration.files).sort(), Object.keys(restoredIds).sort());
+for (const [file, original] of Object.entries(restoration.files)) {
+ test(`${file}: bytes match the pre-task online snapshot`, () => {
const bytes = fs.readFileSync(path.join(root, file));
assert.equal(crypto.createHash('sha256').update(bytes).digest('hex'), original.sha256);
});
- test(`${file}: restored manifest and public entrypoints retain the first-upload contract`, () => {
+ test(`${file}: restored manifest and public entrypoints retain the pre-task snapshot contract`, () => {
const c = load(file);
const manifest = c.getManifest();
assert.equal(manifest.id, restoredIds[file]);
@@ -260,13 +263,15 @@ for (const [file, original] of Object.entries(firstVersions)) {
}
});
}
-test('MissAV legacy download paths contain the same first-upload implementation', () => {
- const bytes = fs.readFileSync(path.join(root, 'missav-mini-library.js'));
- for (const file of ['missav-mini-library-CloudFlare.js', 'missav-mini-library-download-working.js', 'missav-mini-library-download-working 6.js']) {
- assert.deepEqual(fs.readFileSync(path.join(root, file)), bytes, file);
- }
+test('MissAV pre-task entrypoints preserve their distinct implementations and working alias', () => {
+ const canonical = fs.readFileSync(path.join(root, 'missav-mini-library.js'));
+ const cloudflare = fs.readFileSync(path.join(root, 'missav-mini-library-CloudFlare.js'));
+ const working = fs.readFileSync(path.join(root, 'missav-mini-library-download-working.js'));
+ assert.notDeepEqual(canonical, cloudflare);
+ assert.notDeepEqual(canonical, working);
+ assert.deepEqual(fs.readFileSync(path.join(root, 'missav-mini-library-download-working 6.js')), working);
});
-test('MissAV first upload retains direct HLS playback and its detail identifier', async () => {
+test('MissAV pre-task snapshot retains direct HLS playback and its detail identifier', async () => {
const c = load('missav-mini-library.js'); let requests = 0;
c.Widget.http.get = async () => { requests++; throw new Error('direct playback should not fetch detail'); };
const itemId = c.makeItemId('https://missav.ws/cn/sample', 'Sample', '');
@@ -275,7 +280,7 @@ test('MissAV first upload retains direct HLS playback and its detail identifier'
assert.equal(playback.url, 'https://cdn.test/original.m3u8');
assert.equal(playback.container, 'm3u8'); assert.equal(requests, 0);
});
-test('ASMRLIB first upload retains separate embedded player lines and their version identifiers', async () => {
+test('ASMRLIB pre-task snapshot retains separate embedded player lines and their version identifiers', async () => {
const c = load('asmrlib-mini-library.js');
const post = 'becd2651e7d56ca656d27766d036dcee';
c.Widget.http.get = async () => ({ data: 'Sample
' });
@@ -287,14 +292,14 @@ test('ASMRLIB first upload retains separate embedded player lines and their vers
assert.deepEqual(plain(versions.map(x => c.asmrVersionPlayerURL(x.action))), ['https://bysetayico.com/e/sample', 'https://abyssplayer.com/sample']);
assert.equal(versions[0].default, true); assert.equal(versions[1].default, false);
});
-test('KBJ first upload object search retains the configured domain and page', async () => {
+test('KBJ pre-task snapshot object search retains the configured domain and page', async () => {
const c = load('kbjfan-mini-library.js'); const requests = [];
c.Widget.http.get = async url => { requests.push(url); return { data: '' }; };
const result = await c.search({ query: 'A&B #tag', page: 2, params: { baseURL: 'https://kbj.test' } });
assert.deepEqual(requests, ['https://kbj.test/?s=A%26B%20%23tag&paged=2']);
assert.equal(result.pageType, 'search'); assert.equal(result.page, 2);
});
-test('Taolu first upload retains its download version and original playback gateway', () => {
+test('Taolu pre-task snapshot retains its download version and original playback gateway', () => {
const c = load('taolusm-mini-library.js');
const groups = c.getResourceVersions({ itemId: '121576' });
assert.equal(groups[0].versions[0].id, 'download-121576');
diff --git a/tools/library-aliases.json b/tools/library-aliases.json
index 93b0c8e..d240659 100644
--- a/tools/library-aliases.json
+++ b/tools/library-aliases.json
@@ -2,8 +2,6 @@
"eporner-mini-library 2.js": ["eporner-mini-library.js"],
"xxxfollow-mini-library 5.js": ["xxxfollow-mini-library.js"],
"missav-mini-library-download-working 6.js": [
- "missav-mini-library-download-working.js",
- "missav-mini-library.js",
- "missav-mini-library-CloudFlare.js"
+ "missav-mini-library-download-working.js"
]
}
diff --git a/tools/library-contracts.json b/tools/library-contracts.json
index 919990a..bd692d4 100644
--- a/tools/library-contracts.json
+++ b/tools/library-contracts.json
@@ -278,7 +278,12 @@
"id": "missav-mini-library",
"parameters": [
"baseURL",
- "entryPath"
+ "entryPath",
+ "backupBaseURLs",
+ "enableBrowserFallback",
+ "browserVisible",
+ "requestTimeoutSeconds",
+ "cacheMinutes"
],
"entryPoints": [
"getManifest",
@@ -296,7 +301,15 @@
"id": "missav-mini-library",
"parameters": [
"baseURL",
- "entryPath"
+ "entryPath",
+ "backupBaseURLs",
+ "enableBrowserFallback",
+ "browserVisible",
+ "automaticBrowserFallback",
+ "verificationCooldownMinutes",
+ "requestTimeoutSeconds",
+ "cacheMinutes",
+ "categoryRestoreMinutes"
],
"entryPoints": [
"getManifest",
@@ -314,7 +327,15 @@
"id": "missav-mini-library",
"parameters": [
"baseURL",
- "entryPath"
+ "entryPath",
+ "backupBaseURLs",
+ "enableBrowserFallback",
+ "browserVisible",
+ "automaticBrowserFallback",
+ "verificationCooldownMinutes",
+ "requestTimeoutSeconds",
+ "cacheMinutes",
+ "categoryRestoreMinutes"
],
"entryPoints": [
"getManifest",
diff --git a/tools/library-first-versions.json b/tools/library-first-versions.json
deleted file mode 100644
index 5d6a64b..0000000
--- a/tools/library-first-versions.json
+++ /dev/null
@@ -1,44 +0,0 @@
-{
- "missav-mini-library.js": {
- "commit": "a44d25631a06c29b4491610c96c965bb03c9711d",
- "version": "1.0.0",
- "sha256": "5fa423c1762260c0ad39f50a7581b22b5036442e2021b593c3ab114afc2e9b53",
- "sourcePath": "missav-mini-library.js"
- },
- "sexbjcam-mini-library.js": {
- "commit": "420f4fa2a5812cb647249a184f59696cbbdb02fb",
- "version": "1.1.3",
- "sha256": "7b682e9a1791655dab2ccfc14aafa736c1e82026a21bf39125b3e251a41383a5",
- "sourcePath": "sexbjcam-mini-library.js"
- },
- "asmrlib-mini-library.js": {
- "commit": "4a2ece99e4af217c1509c522c0d84d276b068b08",
- "version": "1.2.0",
- "sha256": "2cb404dbbab67626d762248dbe050ab7e534ea68278ff745c5d304b6a487faa0",
- "sourcePath": "asmrlib-mini-library.js"
- },
- "javgg-mini-library.js": {
- "commit": "b17286ba023d392cf9a6e3935590e2bc5c528b06",
- "version": "1.0.0",
- "sha256": "39092a7afb7107182c33fc923d93cb99c20ea3fb97d01810ef1f4e974de8885e",
- "sourcePath": "javgg-mini-library.js"
- },
- "madou8-mini-library 5.js": {
- "commit": "2a16937466dfbb908ae1bab6a1347569299df1df",
- "sourcePath": "madou8-mini-library.js",
- "version": "1.0.1",
- "sha256": "806590d98c709021d198d6720c396bce0a8bdaeb8415475841384fb44cde83bf"
- },
- "taolusm-mini-library.js": {
- "commit": "fbb65e110e60561c4a557adb42c80dc2e173505f",
- "version": "1.0.0",
- "sha256": "04646ae4b033e37719291cb196bcecb11b9c0917d065eb29b97cf4e15c9ddc8f",
- "sourcePath": "taolusm-mini-library.js"
- },
- "kbjfan-mini-library.js": {
- "commit": "e943d616e2df05c3abf6fdf2a716780c9d6a0aff",
- "version": "1.0.0",
- "sha256": "761d79a9a4e96ced1484bede83d123dc6cc9af5cc94f13fdc49bc26f84b810e1",
- "sourcePath": "kbjfan-mini-library.js"
- }
-}
diff --git a/tools/library-restoration.json b/tools/library-restoration.json
new file mode 100644
index 0000000..17bae8c
--- /dev/null
+++ b/tools/library-restoration.json
@@ -0,0 +1,46 @@
+{
+ "cutoff": "2026-10-03T00:00:00+08:00",
+ "baselineCommit": "2fd0121bfbf0cc812d1e9e39501384710e684ade",
+ "files": {
+ "missav-mini-library.js": {
+ "version": "1.0.7",
+ "sha256": "f7f0d3492fdc83f13363cc2e26991f4fd3452c53bae5436ffd6648d6334b188e"
+ },
+ "sexbjcam-mini-library.js": {
+ "version": "1.1.7",
+ "sha256": "034d60b44eac74e044aa73b244a10004b91a1a28669deb7022a006fc81a4ed31"
+ },
+ "asmrlib-mini-library.js": {
+ "version": "1.2.0",
+ "sha256": "2cb404dbbab67626d762248dbe050ab7e534ea68278ff745c5d304b6a487faa0"
+ },
+ "javgg-mini-library.js": {
+ "version": "1.0.0",
+ "sha256": "39092a7afb7107182c33fc923d93cb99c20ea3fb97d01810ef1f4e974de8885e"
+ },
+ "madou8-mini-library 5.js": {
+ "version": "1.1.0",
+ "sha256": "7ea8fc7aec7352e470fc0019f509723dce4e8c5f1a1f05d4ab692bca8f3bb488"
+ },
+ "taolusm-mini-library.js": {
+ "version": "1.0.0",
+ "sha256": "04646ae4b033e37719291cb196bcecb11b9c0917d065eb29b97cf4e15c9ddc8f"
+ },
+ "kbjfan-mini-library.js": {
+ "version": "1.0.0",
+ "sha256": "761d79a9a4e96ced1484bede83d123dc6cc9af5cc94f13fdc49bc26f84b810e1"
+ },
+ "missav-mini-library-CloudFlare.js": {
+ "version": "1.0.7",
+ "sha256": "0b3b348c0efcecdfb091a7822c265812faaf9d59d4cf70c04cb6d7caa60b9cc4"
+ },
+ "missav-mini-library-download-working.js": {
+ "version": "1.5.9",
+ "sha256": "20f5cc483a756e52a7eab4d306a978c8b1d1bf04c75a3d3584c68a1915d15ce6"
+ },
+ "missav-mini-library-download-working 6.js": {
+ "version": "1.5.9",
+ "sha256": "20f5cc483a756e52a7eab4d306a978c8b1d1bf04c75a3d3584c68a1915d15ce6"
+ }
+ }
+}