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 /<html|<body|<a\b|<video\b|m3u8|mp4/i.test(text); +} + +function boolParam(ctx, key, fallback) { + const value = contextValue(ctx, key); + if (value === undefined || value === null || value === '') return fallback; + if (typeof value === 'boolean') return value; + return !/^(false|0|no|off|否|关闭)$/i.test(String(value).trim()); +} + +function numberParam(ctx, key, fallback) { + const value = Number(contextValue(ctx, key)); + return Number.isFinite(value) && value > 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 = /<a\b([^>]*)>([\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('<h1[^>]*>\\s*' + escapeRegExp(heading), 'i')) : -1; if (index < 0) return source; - const nextScript = source.indexOf('<script', index); const nextFooter = source.indexOf('<footer', index); - const endCandidates = [nextScript, nextFooter].filter(function (value) { return value > index; }).sort(function (a, b) { return a - b; }); + const endCandidates = [nextFooter].filter(function (value) { return value > index; }); return source.slice(index, endCandidates[0] || undefined); } @@ -767,6 +1996,25 @@ function parseCards(html, fallbackTitle, ctx) { return items; } +function mediaListBlock(html) { + const source = String(html || ''); + if (!source) return source; + const anchorPattern = /<a\b([^>]*)>([\s\S]*?)<\/a>/gi; + let match; + let firstMediaIndex = -1; + while ((match = anchorPattern.exec(source))) { + if (!isDetailHref(attr(match[1], 'href'))) continue; + firstMediaIndex = match.index; + break; + } + if (firstMediaIndex < 0) return source; + const headingIndex = source.lastIndexOf('<h1', firstMediaIndex); + const mainIndex = source.lastIndexOf('<main', firstMediaIndex); + const start = Math.max(headingIndex, mainIndex, 0); + const footerIndex = source.indexOf('<footer', firstMediaIndex); + return source.slice(start, footerIndex > firstMediaIndex ? footerIndex : undefined); +} + function mediaBlocks(html) { const source = String(html || ''); const blocks = []; @@ -798,7 +2046,9 @@ function expandBlock(source, start, end, fallback) { .sort(function (a, b) { return a - b; })[0]; const realEnd = close || (nextDetail >= 0 ? end + nextDetail : Math.min(source.length, end + 1800)); const block = source.slice(realStart, realEnd); - return isDetailHref(block) ? block : fallback; + return anchorsIn(block).some(function (anchor) { + return isDetailHref(anchor.href); + }) ? block : fallback; } function parseCard(block, fallbackTitle, rank, ctx) { @@ -872,42 +2122,237 @@ function toWideItem(item) { return item; } -function playbackGroups(detailURL, title, playUrl, ctx) { - const id = encodePayload({ url: detailURL, title: title, playUrl: playUrl || '' }); +async function playbackGroups(detailURL, title, playUrl, ctx, html) { + if (!isPlayableURL(playUrl)) return []; const headers = playbackHeaders(ctx || {}, detailURL); + const qualities = await discoverAvailableQualities(ctx || {}, playUrl, detailURL, html); + if (!qualities.length && containerOf(playUrl) === 'm3u8' && !qualityHeightFromURL(playUrl)) return []; + const sourceQualities = qualities.length ? qualities : [ + { + height: qualityHeightFromURL(playUrl), + label: qualityHeightFromURL(playUrl) ? qualityHeightFromURL(playUrl) + 'P' : '自动画质', + url: playUrl + } + ]; + const versions = sourceQualities.map(function (quality, index) { + const id = encodePayload({ + url: detailURL, + title: title, + quality: quality.height || 'auto' + }); + return { + id: id, + title: quality.label, + name: quality.label, + subtitle: index === 0 ? '最高可用画质' : '切换时请稍候', + url: quality.url, + playUrl: quality.url, + path: quality.url, + default: index === 0, + availability: 'playable', + container: containerOf(quality.url), + headers: headers, + header: headers, + Header: headers, + customHeaders: headers, + sourceName: 'MissAV', + action: { + type: 'play', + itemId: id, + versionId: id, + title: title || 'MissAV' + } + }; + }); return [ { id: 'missav-online', title: '在线播放', - versions: [ - { - id: id, - title: 'MissAV HLS', - name: '默认线路', - subtitle: 'MissAV', - url: playUrl || detailURL, - playUrl: playUrl || '', - path: playUrl || detailURL, - default: true, - availability: 'playable', - container: 'm3u8', - headers: headers, - header: headers, - Header: headers, - customHeaders: headers, - sourceName: 'MissAV', - action: { - type: 'play', - itemId: id, - versionId: id, - title: title || 'MissAV' - } - } - ] + versions: versions } ]; } +function resourceVersionCount(groups) { + return (groups || []).reduce(function (total, group) { + return total + (Array.isArray(group && group.versions) ? group.versions.length : 0); + }, 0); +} + +async function discoverAvailableQualities(ctx, playlistURL, referer, html) { + const candidates = extractHTMLQualities(html, ctx); + if (isPlayableURL(playlistURL)) { + const height = qualityHeightFromURL(playlistURL); + candidates.push({ + height: height, + label: height ? height + 'P' : '自动画质', + url: playlistURL + }); + } + + const urls = unique(candidates.map(function (item) { return item.url; })).slice(0, 8); + for (let index = 0; index < urls.length; index += 1) { + const variants = await discoverQualities(ctx, urls[index], referer); + variants.forEach(function (quality) { candidates.push(quality); }); + } + return normalizeQualities(candidates); +} + +function extractHTMLQualities(html, ctx) { + const source = decodeEscapes(String(html || '') + '\n' + unpackPackedScripts(html).join('\n')); + const qualities = []; + const assignment = /\b(source(?:[_-]?\d{3,4})?|videoUrl|video_url|hlsUrl)\s*[:=]\s*["']([^"']+\.(?:m3u8|mp4|mpd)[^"']*)["']/gi; + let match; + while ((match = assignment.exec(source))) { + const url = absoluteURL(ctx, decodeEscapes(match[2])); + if (!isPlayableURL(url)) continue; + const width = Number(firstMatch(match[1], /(\d{3,4})/)) || 0; + const height = qualityHeightFromURL(url) || qualityHeightFromWidth(width); + qualities.push({ + height: height, + width: width, + label: height ? height + 'P' : '自动画质', + url: url + }); + } + + const labeledSource = /(?:label|quality)\s*:\s*["'](\d{3,4})p?["'][\s\S]{0,240}?(?:file|src|url)\s*:\s*["']([^"']+\.(?:m3u8|mp4|mpd)[^"']*)["']/gi; + while ((match = labeledSource.exec(source))) { + const url = absoluteURL(ctx, decodeEscapes(match[2])); + if (!isPlayableURL(url)) continue; + const height = Number(match[1]) || qualityHeightFromURL(url); + qualities.push({ height: height, label: height + 'P', url: url }); + } + return normalizeQualities(qualities); +} + +function qualityHeightFromWidth(width) { + const value = Number(width) || 0; + if (value >= 1900) return 1080; + if (value >= 1200) return 720; + if (value >= 800) return 480; + if (value >= 600) return 360; + if (value >= 400) return 240; + return 0; +} + +function normalizeQualities(qualities) { + const output = []; + const seenHeight = {}; + const seenURL = {}; + (qualities || []).sort(function (a, b) { + return (b.height || 0) - (a.height || 0) || (b.bandwidth || 0) - (a.bandwidth || 0); + }).forEach(function (quality) { + const url = stringValue(quality && quality.url); + if (!url || seenURL[url]) return; + const height = Number(quality.height) || qualityHeightFromURL(url); + if (height && seenHeight[String(height)]) return; + seenURL[url] = true; + if (height) seenHeight[String(height)] = true; + output.push({ + height: height, + width: Number(quality.width) || 0, + bandwidth: Number(quality.bandwidth) || 0, + label: height ? height + 'P' : stringValue(quality.label) || '自动画质', + url: url + }); + }); + const hasNamedQuality = output.some(function (quality) { return quality.height > 0; }); + return hasNamedQuality + ? output.filter(function (quality) { return quality.height > 0; }) + : output; +} + +async function discoverQualities(ctx, playlistURL, referer) { + if (!isPlayableURL(playlistURL) || containerOf(playlistURL) !== 'm3u8') return []; + try { + const response = await httpGet(playlistURL, { + headers: playbackHeaders(ctx || {}, referer || playlistURL), + timeout: numberParam(ctx, 'requestTimeoutSeconds', 45), + timeoutSeconds: numberParam(ctx, 'requestTimeoutSeconds', 45), + useBrowserCookie: true, + attachBrowserCookie: true + }); + const playlist = responseText(response); + if (playlist.indexOf('#EXT-X-STREAM-INF') < 0) return []; + return parseMasterQualities(playlist, playlistURL); + } catch (error) { + return []; + } +} + +function parseMasterQualities(playlist, playlistURL) { + const lines = String(playlist || '').split(/\r?\n/); + const qualities = []; + const seen = {}; + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index].trim(); + if (line.indexOf('#EXT-X-STREAM-INF:') !== 0) continue; + const resolution = line.match(/\bRESOLUTION=(\d+)x(\d+)/i); + const bandwidth = Number(firstMatch(line, /\b(?:AVERAGE-)?BANDWIDTH=(\d+)/i)) || 0; + let uri = ''; + for (let next = index + 1; next < lines.length; next += 1) { + const candidate = lines[next].trim(); + if (!candidate) continue; + if (candidate[0] === '#') break; + uri = candidate; + break; + } + if (!uri) continue; + const height = resolution ? Number(resolution[2]) : qualityHeightFromURL(uri); + const url = resolveRelativeURL(playlistURL, uri); + const key = String(height || 0) + '|' + url; + if (!url || seen[key]) continue; + seen[key] = true; + qualities.push({ + height: height, + width: resolution ? Number(resolution[1]) : 0, + bandwidth: bandwidth, + label: height ? height + 'P' : '自动画质', + url: url + }); + } + return qualities.sort(function (a, b) { + return (b.height || 0) - (a.height || 0) || (b.bandwidth || 0) - (a.bandwidth || 0); + }); +} + +function selectQuality(qualities, requestedQuality) { + const list = Array.isArray(qualities) ? qualities : []; + if (!list.length) return null; + const requested = Number(requestedQuality); + if (requested > 0) { + const exact = list.find(function (item) { return Number(item.height) === requested; }); + if (exact) return exact; + } + return list[0]; +} + +function qualityHeightFromURL(url) { + const match = String(url || '').match(/(?:^|[\/_.-])(\d{3,4})p(?:[\/_.?#-]|$)/i); + return match ? Number(match[1]) : 0; +} + +function resolveRelativeURL(base, value) { + const target = decodeEscapes(String(value || '').trim()); + if (!target) return ''; + if (/^https?:\/\//i.test(target)) return target; + if (target.indexOf('//') === 0) return 'https:' + target; + const origin = originOf(base); + if (target[0] === '/') return origin + target; + const directory = String(base || '').replace(/[?#].*$/, '').replace(/\/[^/]*$/, '/'); + const combined = directory + target; + const prefix = firstMatch(combined, /^(https?:\/\/[^/]+)/i); + const path = combined.slice(prefix.length).split('/'); + const normalized = []; + path.forEach(function (part) { + if (!part || part === '.') return; + if (part === '..') normalized.pop(); + else normalized.push(part); + }); + return prefix + '/' + normalized.join('/'); +} + function playbackResult(ctx, url, referer) { const link = absoluteURL(ctx, url); const container = containerOf(link); @@ -938,9 +2383,9 @@ function playbackResult(ctx, url, referer) { function extractPlayableURL(html) { const source = decodeEscapes(String(html || '') + '\n' + unpackPackedScripts(html).join('\n')); return firstPlayable( + firstMatch(source, /\bsource\s*=\s*["']([^"']+\.(?:m3u8|mp4|mpd)[^"']*)["']/i), firstMatch(source, /source1280\s*=\s*["']([^"']+\.(?:m3u8|mp4|mpd)[^"']*)["']/i), firstMatch(source, /source842\s*=\s*["']([^"']+\.(?:m3u8|mp4|mpd)[^"']*)["']/i), - firstMatch(source, /source\s*=\s*["']([^"']+\.(?:m3u8|mp4|mpd)[^"']*)["']/i), firstMatch(source, /var\s+hlsUrl\s*=\s*["']([^"']+\.(?:m3u8|mp4|mpd)[^"']*)["']/i), firstMatch(source, /<video\b[^>]*\b(?:data-src|src)\s*=\s*["']([^"']+\.(?:m3u8|mp4|mpd)[^"']*)["']/i), firstMatch(source, /source\s+src\s*=\s*["']([^"']+\.(?:m3u8|mp4|mpd)[^"']*)["']/i), @@ -1020,6 +2465,16 @@ function playUrlFromContext(ctx) { ); } +function qualityFromContext(ctx) { + const payload = decodePayload(ctx && (ctx.versionId || ctx.sourceId || ctx.id || ctx.itemId)); + return firstNonEmpty( + payload && payload.quality, + ctx && ctx.quality, + ctx && ctx.height, + ctx && ctx.resolution + ); +} + function makeItemId(url, title, poster) { return encodePayload({ url: absoluteURL(null, url), title: title || '', poster: poster || '' }); } @@ -1042,6 +2497,29 @@ function encodePayload(data) { return MISSAV_DETAIL_PAYLOAD_PREFIX + parts.join('&'); } +function verificationPageId(url) { + return MISSAV_VERIFY_PAYLOAD_PREFIX + 'url=' + encodeURIComponent(String(url || MISSAV_DEFAULT_BASE)); +} + +function verificationUrlFromPageId(value) { + const text = stringValue(value); + if (text.indexOf(MISSAV_VERIFY_PAYLOAD_PREFIX) !== 0) return ''; + const parts = text.slice(MISSAV_VERIFY_PAYLOAD_PREFIX.length).split('&'); + for (let index = 0; index < parts.length; index += 1) { + const part = parts[index]; + const separator = part.indexOf('='); + const key = separator >= 0 ? part.slice(0, separator) : part; + if (key !== 'url') continue; + const raw = separator >= 0 ? part.slice(separator + 1) : ''; + try { + return decodeURIComponent(raw); + } catch (error) { + return raw; + } + } + return ''; +} + function decodePayload(value) { const text = stringValue(value); if (text.indexOf(MISSAV_DETAIL_PAYLOAD_PREFIX) !== 0) return null; @@ -1061,6 +2539,7 @@ function decodePayload(value) { function normalizePageId(ctx, value) { const id = stringValue(value) || 'new-release'; + if (id.indexOf(MISSAV_VERIFY_PAYLOAD_PREFIX) === 0) return id; const section = findSection(id); if (section) return section.id; const primary = findPrimaryCategory(id); @@ -1084,7 +2563,7 @@ function findSection(id) { function findPrimaryCategory(id) { const value = stringValue(id); - return MISSAV_PRIMARY_CATEGORIES.find(function (item) { + return MISSAV_SECTIONS.find(function (item) { return item.id === value || item.title === value || item.path === value || @@ -1095,7 +2574,26 @@ function findPrimaryCategory(id) { function isDetailHref(href) { const value = String(href || ''); - return /\/(?:dm\d+\/)?cn\/(?!new(?:[/?#]|$)|search(?:[/?#]|$)|genres?(?:[/?#]|$)|makers?(?:[/?#]|$)|actresses?(?:[/?#]|$)|actors?(?:[/?#]|$)|today-hot|weekly-hot|monthly-hot|uncensored-leak|chinese-subtitle|fc2(?:[/?#]|$)|heyzo(?:[/?#]|$)|tokyo-hot(?:[/?#]|$)|1pondo(?:[/?#]|$)|caribbeancom(?:[/?#]|$)|caribbeancompr(?:[/?#]|$)|10musume(?:[/?#]|$)|pacopacomama(?:[/?#]|$)|gachinco(?:[/?#]|$)|xxx-av(?:[/?#]|$)|siro(?:[/?#]|$)|luxu(?:[/?#]|$)|gana(?:[/?#]|$)|prestige-premium(?:[/?#]|$)|s-cute(?:[/?#]|$)|ara(?:[/?#]|$)|madou(?:[/?#]|$)|twav(?:[/?#]|$)|vr(?:[/?#]|$)|history|playlist|favorite|login|register)[^"'#?\/]+/i.test(value); + if (findSection(value)) return false; + if (isCategoryHref(value)) return false; + const path = value + .replace(/^https?:\/\/[^/]+/i, '') + .split(/[?#]/)[0] + .replace(/^\/+|\/+$/g, ''); + const parts = path.split('/').filter(Boolean); + if (/^dm\d+$/i.test(parts[0] || '')) parts.shift(); + if (/^(?:cn|en|ja|ko|ms|th|de|fr|vi|id)$/i.test(parts[0] || '')) parts.shift(); + if (parts.length !== 1) return false; + const slug = parts[0] || ''; + if (/^(?:new|release|random|search|history|playlists?|saved|favorite|login|register|vip)$/i.test(slug)) return false; + // Most MissAV codes use a hyphen (for example abc-123), while the + // 1pondo/Caribbeancompr/10musume/pacopacomama feeds use date-based + // underscore IDs such as pondo-072526_001 and 051726_001. + const standardCode = /^[a-z0-9]+(?:[-_][a-z0-9]+)*$/i.test(slug) && + /[a-z]/i.test(slug) && + /\d/.test(slug); + const dateOnlyCode = /^\d{6}_\d{2,3}$/i.test(slug); + return standardCode || dateOnlyCode; } function categorySlug(value) { @@ -1111,6 +2609,21 @@ function hasNextPage(html, page) { return total ? page < total : false; } +function paginationInfo(html, page, itemCount) { + const source = String(html || ''); + let totalPages = 0; + const pagePattern = /[?&]page=(\d+)/gi; + let match; + while ((match = pagePattern.exec(source))) { + totalPages = Math.max(totalPages, Number(match[1]) || 0); + } + const hasMore = hasNextPage(source, page) || (!totalPages && Number(itemCount) >= 12); + return { + hasMore: hasMore, + totalPages: totalPages || undefined + }; +} + function sectionBlock(html, title) { const source = String(html || ''); const index = source.search(new RegExp('<h[12][^>]*>\\s*' + escapeRegExp(title), 'i')); @@ -1390,9 +2903,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 || /<title>\s*Just a moment/i.test(text) || + /Checking your browser|Verifying you are human|Verify you are human|Ray ID/i.test(text) || /Enable JavaScript and cookies to continue/i.test(text) || - /cf-mitigated|cf-browser-verification/i.test(text); + /cf-mitigated|cf-browser-verification|cf-chl-|challenge-platform|turnstile/i.test(text); } function decodeEscapes(value) { @@ -1505,6 +3021,7 @@ const MissAVMiniLibrary = { search: search, onSearch: onSearch, getSearch: getSearch, + onAction: onAction, matchResources: matchResources, matchMovie: matchResources }; @@ -1528,6 +3045,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.js b/missav-mini-library-download-working.js index 4d143b5..5eee709 100644 --- a/missav-mini-library-download-working.js +++ b/missav-mini-library-download-working.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 /<html|<body|<a\b|<video\b|m3u8|mp4/i.test(text); +} + +function boolParam(ctx, key, fallback) { + const value = contextValue(ctx, key); + if (value === undefined || value === null || value === '') return fallback; + if (typeof value === 'boolean') return value; + return !/^(false|0|no|off|否|关闭)$/i.test(String(value).trim()); +} + +function numberParam(ctx, key, fallback) { + const value = Number(contextValue(ctx, key)); + return Number.isFinite(value) && value > 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 = /<a\b([^>]*)>([\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('<h1[^>]*>\\s*' + escapeRegExp(heading), 'i')) : -1; if (index < 0) return source; - const nextScript = source.indexOf('<script', index); const nextFooter = source.indexOf('<footer', index); - const endCandidates = [nextScript, nextFooter].filter(function (value) { return value > index; }).sort(function (a, b) { return a - b; }); + const endCandidates = [nextFooter].filter(function (value) { return value > index; }); return source.slice(index, endCandidates[0] || undefined); } @@ -767,6 +1996,25 @@ function parseCards(html, fallbackTitle, ctx) { return items; } +function mediaListBlock(html) { + const source = String(html || ''); + if (!source) return source; + const anchorPattern = /<a\b([^>]*)>([\s\S]*?)<\/a>/gi; + let match; + let firstMediaIndex = -1; + while ((match = anchorPattern.exec(source))) { + if (!isDetailHref(attr(match[1], 'href'))) continue; + firstMediaIndex = match.index; + break; + } + if (firstMediaIndex < 0) return source; + const headingIndex = source.lastIndexOf('<h1', firstMediaIndex); + const mainIndex = source.lastIndexOf('<main', firstMediaIndex); + const start = Math.max(headingIndex, mainIndex, 0); + const footerIndex = source.indexOf('<footer', firstMediaIndex); + return source.slice(start, footerIndex > firstMediaIndex ? footerIndex : undefined); +} + function mediaBlocks(html) { const source = String(html || ''); const blocks = []; @@ -798,7 +2046,9 @@ function expandBlock(source, start, end, fallback) { .sort(function (a, b) { return a - b; })[0]; const realEnd = close || (nextDetail >= 0 ? end + nextDetail : Math.min(source.length, end + 1800)); const block = source.slice(realStart, realEnd); - return isDetailHref(block) ? block : fallback; + return anchorsIn(block).some(function (anchor) { + return isDetailHref(anchor.href); + }) ? block : fallback; } function parseCard(block, fallbackTitle, rank, ctx) { @@ -872,42 +2122,237 @@ function toWideItem(item) { return item; } -function playbackGroups(detailURL, title, playUrl, ctx) { - const id = encodePayload({ url: detailURL, title: title, playUrl: playUrl || '' }); +async function playbackGroups(detailURL, title, playUrl, ctx, html) { + if (!isPlayableURL(playUrl)) return []; const headers = playbackHeaders(ctx || {}, detailURL); + const qualities = await discoverAvailableQualities(ctx || {}, playUrl, detailURL, html); + if (!qualities.length && containerOf(playUrl) === 'm3u8' && !qualityHeightFromURL(playUrl)) return []; + const sourceQualities = qualities.length ? qualities : [ + { + height: qualityHeightFromURL(playUrl), + label: qualityHeightFromURL(playUrl) ? qualityHeightFromURL(playUrl) + 'P' : '自动画质', + url: playUrl + } + ]; + const versions = sourceQualities.map(function (quality, index) { + const id = encodePayload({ + url: detailURL, + title: title, + quality: quality.height || 'auto' + }); + return { + id: id, + title: quality.label, + name: quality.label, + subtitle: index === 0 ? '最高可用画质' : '切换时请稍候', + url: quality.url, + playUrl: quality.url, + path: quality.url, + default: index === 0, + availability: 'playable', + container: containerOf(quality.url), + headers: headers, + header: headers, + Header: headers, + customHeaders: headers, + sourceName: 'MissAV', + action: { + type: 'play', + itemId: id, + versionId: id, + title: title || 'MissAV' + } + }; + }); return [ { id: 'missav-online', title: '在线播放', - versions: [ - { - id: id, - title: 'MissAV HLS', - name: '默认线路', - subtitle: 'MissAV', - url: playUrl || detailURL, - playUrl: playUrl || '', - path: playUrl || detailURL, - default: true, - availability: 'playable', - container: 'm3u8', - headers: headers, - header: headers, - Header: headers, - customHeaders: headers, - sourceName: 'MissAV', - action: { - type: 'play', - itemId: id, - versionId: id, - title: title || 'MissAV' - } - } - ] + versions: versions } ]; } +function resourceVersionCount(groups) { + return (groups || []).reduce(function (total, group) { + return total + (Array.isArray(group && group.versions) ? group.versions.length : 0); + }, 0); +} + +async function discoverAvailableQualities(ctx, playlistURL, referer, html) { + const candidates = extractHTMLQualities(html, ctx); + if (isPlayableURL(playlistURL)) { + const height = qualityHeightFromURL(playlistURL); + candidates.push({ + height: height, + label: height ? height + 'P' : '自动画质', + url: playlistURL + }); + } + + const urls = unique(candidates.map(function (item) { return item.url; })).slice(0, 8); + for (let index = 0; index < urls.length; index += 1) { + const variants = await discoverQualities(ctx, urls[index], referer); + variants.forEach(function (quality) { candidates.push(quality); }); + } + return normalizeQualities(candidates); +} + +function extractHTMLQualities(html, ctx) { + const source = decodeEscapes(String(html || '') + '\n' + unpackPackedScripts(html).join('\n')); + const qualities = []; + const assignment = /\b(source(?:[_-]?\d{3,4})?|videoUrl|video_url|hlsUrl)\s*[:=]\s*["']([^"']+\.(?:m3u8|mp4|mpd)[^"']*)["']/gi; + let match; + while ((match = assignment.exec(source))) { + const url = absoluteURL(ctx, decodeEscapes(match[2])); + if (!isPlayableURL(url)) continue; + const width = Number(firstMatch(match[1], /(\d{3,4})/)) || 0; + const height = qualityHeightFromURL(url) || qualityHeightFromWidth(width); + qualities.push({ + height: height, + width: width, + label: height ? height + 'P' : '自动画质', + url: url + }); + } + + const labeledSource = /(?:label|quality)\s*:\s*["'](\d{3,4})p?["'][\s\S]{0,240}?(?:file|src|url)\s*:\s*["']([^"']+\.(?:m3u8|mp4|mpd)[^"']*)["']/gi; + while ((match = labeledSource.exec(source))) { + const url = absoluteURL(ctx, decodeEscapes(match[2])); + if (!isPlayableURL(url)) continue; + const height = Number(match[1]) || qualityHeightFromURL(url); + qualities.push({ height: height, label: height + 'P', url: url }); + } + return normalizeQualities(qualities); +} + +function qualityHeightFromWidth(width) { + const value = Number(width) || 0; + if (value >= 1900) return 1080; + if (value >= 1200) return 720; + if (value >= 800) return 480; + if (value >= 600) return 360; + if (value >= 400) return 240; + return 0; +} + +function normalizeQualities(qualities) { + const output = []; + const seenHeight = {}; + const seenURL = {}; + (qualities || []).sort(function (a, b) { + return (b.height || 0) - (a.height || 0) || (b.bandwidth || 0) - (a.bandwidth || 0); + }).forEach(function (quality) { + const url = stringValue(quality && quality.url); + if (!url || seenURL[url]) return; + const height = Number(quality.height) || qualityHeightFromURL(url); + if (height && seenHeight[String(height)]) return; + seenURL[url] = true; + if (height) seenHeight[String(height)] = true; + output.push({ + height: height, + width: Number(quality.width) || 0, + bandwidth: Number(quality.bandwidth) || 0, + label: height ? height + 'P' : stringValue(quality.label) || '自动画质', + url: url + }); + }); + const hasNamedQuality = output.some(function (quality) { return quality.height > 0; }); + return hasNamedQuality + ? output.filter(function (quality) { return quality.height > 0; }) + : output; +} + +async function discoverQualities(ctx, playlistURL, referer) { + if (!isPlayableURL(playlistURL) || containerOf(playlistURL) !== 'm3u8') return []; + try { + const response = await httpGet(playlistURL, { + headers: playbackHeaders(ctx || {}, referer || playlistURL), + timeout: numberParam(ctx, 'requestTimeoutSeconds', 45), + timeoutSeconds: numberParam(ctx, 'requestTimeoutSeconds', 45), + useBrowserCookie: true, + attachBrowserCookie: true + }); + const playlist = responseText(response); + if (playlist.indexOf('#EXT-X-STREAM-INF') < 0) return []; + return parseMasterQualities(playlist, playlistURL); + } catch (error) { + return []; + } +} + +function parseMasterQualities(playlist, playlistURL) { + const lines = String(playlist || '').split(/\r?\n/); + const qualities = []; + const seen = {}; + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index].trim(); + if (line.indexOf('#EXT-X-STREAM-INF:') !== 0) continue; + const resolution = line.match(/\bRESOLUTION=(\d+)x(\d+)/i); + const bandwidth = Number(firstMatch(line, /\b(?:AVERAGE-)?BANDWIDTH=(\d+)/i)) || 0; + let uri = ''; + for (let next = index + 1; next < lines.length; next += 1) { + const candidate = lines[next].trim(); + if (!candidate) continue; + if (candidate[0] === '#') break; + uri = candidate; + break; + } + if (!uri) continue; + const height = resolution ? Number(resolution[2]) : qualityHeightFromURL(uri); + const url = resolveRelativeURL(playlistURL, uri); + const key = String(height || 0) + '|' + url; + if (!url || seen[key]) continue; + seen[key] = true; + qualities.push({ + height: height, + width: resolution ? Number(resolution[1]) : 0, + bandwidth: bandwidth, + label: height ? height + 'P' : '自动画质', + url: url + }); + } + return qualities.sort(function (a, b) { + return (b.height || 0) - (a.height || 0) || (b.bandwidth || 0) - (a.bandwidth || 0); + }); +} + +function selectQuality(qualities, requestedQuality) { + const list = Array.isArray(qualities) ? qualities : []; + if (!list.length) return null; + const requested = Number(requestedQuality); + if (requested > 0) { + const exact = list.find(function (item) { return Number(item.height) === requested; }); + if (exact) return exact; + } + return list[0]; +} + +function qualityHeightFromURL(url) { + const match = String(url || '').match(/(?:^|[\/_.-])(\d{3,4})p(?:[\/_.?#-]|$)/i); + return match ? Number(match[1]) : 0; +} + +function resolveRelativeURL(base, value) { + const target = decodeEscapes(String(value || '').trim()); + if (!target) return ''; + if (/^https?:\/\//i.test(target)) return target; + if (target.indexOf('//') === 0) return 'https:' + target; + const origin = originOf(base); + if (target[0] === '/') return origin + target; + const directory = String(base || '').replace(/[?#].*$/, '').replace(/\/[^/]*$/, '/'); + const combined = directory + target; + const prefix = firstMatch(combined, /^(https?:\/\/[^/]+)/i); + const path = combined.slice(prefix.length).split('/'); + const normalized = []; + path.forEach(function (part) { + if (!part || part === '.') return; + if (part === '..') normalized.pop(); + else normalized.push(part); + }); + return prefix + '/' + normalized.join('/'); +} + function playbackResult(ctx, url, referer) { const link = absoluteURL(ctx, url); const container = containerOf(link); @@ -938,9 +2383,9 @@ function playbackResult(ctx, url, referer) { function extractPlayableURL(html) { const source = decodeEscapes(String(html || '') + '\n' + unpackPackedScripts(html).join('\n')); return firstPlayable( + firstMatch(source, /\bsource\s*=\s*["']([^"']+\.(?:m3u8|mp4|mpd)[^"']*)["']/i), firstMatch(source, /source1280\s*=\s*["']([^"']+\.(?:m3u8|mp4|mpd)[^"']*)["']/i), firstMatch(source, /source842\s*=\s*["']([^"']+\.(?:m3u8|mp4|mpd)[^"']*)["']/i), - firstMatch(source, /source\s*=\s*["']([^"']+\.(?:m3u8|mp4|mpd)[^"']*)["']/i), firstMatch(source, /var\s+hlsUrl\s*=\s*["']([^"']+\.(?:m3u8|mp4|mpd)[^"']*)["']/i), firstMatch(source, /<video\b[^>]*\b(?:data-src|src)\s*=\s*["']([^"']+\.(?:m3u8|mp4|mpd)[^"']*)["']/i), firstMatch(source, /source\s+src\s*=\s*["']([^"']+\.(?:m3u8|mp4|mpd)[^"']*)["']/i), @@ -1020,6 +2465,16 @@ function playUrlFromContext(ctx) { ); } +function qualityFromContext(ctx) { + const payload = decodePayload(ctx && (ctx.versionId || ctx.sourceId || ctx.id || ctx.itemId)); + return firstNonEmpty( + payload && payload.quality, + ctx && ctx.quality, + ctx && ctx.height, + ctx && ctx.resolution + ); +} + function makeItemId(url, title, poster) { return encodePayload({ url: absoluteURL(null, url), title: title || '', poster: poster || '' }); } @@ -1042,6 +2497,29 @@ function encodePayload(data) { return MISSAV_DETAIL_PAYLOAD_PREFIX + parts.join('&'); } +function verificationPageId(url) { + return MISSAV_VERIFY_PAYLOAD_PREFIX + 'url=' + encodeURIComponent(String(url || MISSAV_DEFAULT_BASE)); +} + +function verificationUrlFromPageId(value) { + const text = stringValue(value); + if (text.indexOf(MISSAV_VERIFY_PAYLOAD_PREFIX) !== 0) return ''; + const parts = text.slice(MISSAV_VERIFY_PAYLOAD_PREFIX.length).split('&'); + for (let index = 0; index < parts.length; index += 1) { + const part = parts[index]; + const separator = part.indexOf('='); + const key = separator >= 0 ? part.slice(0, separator) : part; + if (key !== 'url') continue; + const raw = separator >= 0 ? part.slice(separator + 1) : ''; + try { + return decodeURIComponent(raw); + } catch (error) { + return raw; + } + } + return ''; +} + function decodePayload(value) { const text = stringValue(value); if (text.indexOf(MISSAV_DETAIL_PAYLOAD_PREFIX) !== 0) return null; @@ -1061,6 +2539,7 @@ function decodePayload(value) { function normalizePageId(ctx, value) { const id = stringValue(value) || 'new-release'; + if (id.indexOf(MISSAV_VERIFY_PAYLOAD_PREFIX) === 0) return id; const section = findSection(id); if (section) return section.id; const primary = findPrimaryCategory(id); @@ -1084,7 +2563,7 @@ function findSection(id) { function findPrimaryCategory(id) { const value = stringValue(id); - return MISSAV_PRIMARY_CATEGORIES.find(function (item) { + return MISSAV_SECTIONS.find(function (item) { return item.id === value || item.title === value || item.path === value || @@ -1095,7 +2574,26 @@ function findPrimaryCategory(id) { function isDetailHref(href) { const value = String(href || ''); - return /\/(?:dm\d+\/)?cn\/(?!new(?:[/?#]|$)|search(?:[/?#]|$)|genres?(?:[/?#]|$)|makers?(?:[/?#]|$)|actresses?(?:[/?#]|$)|actors?(?:[/?#]|$)|today-hot|weekly-hot|monthly-hot|uncensored-leak|chinese-subtitle|fc2(?:[/?#]|$)|heyzo(?:[/?#]|$)|tokyo-hot(?:[/?#]|$)|1pondo(?:[/?#]|$)|caribbeancom(?:[/?#]|$)|caribbeancompr(?:[/?#]|$)|10musume(?:[/?#]|$)|pacopacomama(?:[/?#]|$)|gachinco(?:[/?#]|$)|xxx-av(?:[/?#]|$)|siro(?:[/?#]|$)|luxu(?:[/?#]|$)|gana(?:[/?#]|$)|prestige-premium(?:[/?#]|$)|s-cute(?:[/?#]|$)|ara(?:[/?#]|$)|madou(?:[/?#]|$)|twav(?:[/?#]|$)|vr(?:[/?#]|$)|history|playlist|favorite|login|register)[^"'#?\/]+/i.test(value); + if (findSection(value)) return false; + if (isCategoryHref(value)) return false; + const path = value + .replace(/^https?:\/\/[^/]+/i, '') + .split(/[?#]/)[0] + .replace(/^\/+|\/+$/g, ''); + const parts = path.split('/').filter(Boolean); + if (/^dm\d+$/i.test(parts[0] || '')) parts.shift(); + if (/^(?:cn|en|ja|ko|ms|th|de|fr|vi|id)$/i.test(parts[0] || '')) parts.shift(); + if (parts.length !== 1) return false; + const slug = parts[0] || ''; + if (/^(?:new|release|random|search|history|playlists?|saved|favorite|login|register|vip)$/i.test(slug)) return false; + // Most MissAV codes use a hyphen (for example abc-123), while the + // 1pondo/Caribbeancompr/10musume/pacopacomama feeds use date-based + // underscore IDs such as pondo-072526_001 and 051726_001. + const standardCode = /^[a-z0-9]+(?:[-_][a-z0-9]+)*$/i.test(slug) && + /[a-z]/i.test(slug) && + /\d/.test(slug); + const dateOnlyCode = /^\d{6}_\d{2,3}$/i.test(slug); + return standardCode || dateOnlyCode; } function categorySlug(value) { @@ -1111,6 +2609,21 @@ function hasNextPage(html, page) { return total ? page < total : false; } +function paginationInfo(html, page, itemCount) { + const source = String(html || ''); + let totalPages = 0; + const pagePattern = /[?&]page=(\d+)/gi; + let match; + while ((match = pagePattern.exec(source))) { + totalPages = Math.max(totalPages, Number(match[1]) || 0); + } + const hasMore = hasNextPage(source, page) || (!totalPages && Number(itemCount) >= 12); + return { + hasMore: hasMore, + totalPages: totalPages || undefined + }; +} + function sectionBlock(html, title) { const source = String(html || ''); const index = source.search(new RegExp('<h[12][^>]*>\\s*' + escapeRegExp(title), 'i')); @@ -1390,9 +2903,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 || /<title>\s*Just a moment/i.test(text) || + /Checking your browser|Verifying you are human|Verify you are human|Ray ID/i.test(text) || /Enable JavaScript and cookies to continue/i.test(text) || - /cf-mitigated|cf-browser-verification/i.test(text); + /cf-mitigated|cf-browser-verification|cf-chl-|challenge-platform|turnstile/i.test(text); } function decodeEscapes(value) { @@ -1505,6 +3021,7 @@ const MissAVMiniLibrary = { search: search, onSearch: onSearch, getSearch: getSearch, + onAction: onAction, matchResources: matchResources, matchMovie: matchResources }; @@ -1528,6 +3045,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.js b/missav-mini-library.js index 4d143b5..44025a6 100644 --- a/missav-mini-library.js +++ b/missav-mini-library.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, diff --git a/sexbjcam-mini-library.js b/sexbjcam-mini-library.js index a061c81..f04b569 100644 --- a/sexbjcam-mini-library.js +++ b/sexbjcam-mini-library.js @@ -9,8 +9,8 @@ const WidgetMetadata = { id: 'sexbjcam-mini-library', name: 'SexBJCam', title: 'SexBJCam', - version: '1.1.3', - author: 'EL', + version: '1.1.7', + author: 'Alan huang', logo: SEXBJCAM_LOGO, icon: SEXBJCAM_LOGO, site: SEXBJCAM_DEFAULT_BASE, @@ -24,6 +24,8 @@ const SEXBJCAM_SECTIONS = [ { id: 'chinese-girl', title: 'Chinese Girl', path: '/category/chinese-gril/', style: 'discover.standard' } ]; let SEXBJCAM_REQUEST_NONCE = 0; +const SEXBJCAM_QUALITY_CACHE = {}; +const SEXBJCAM_QUALITY_CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000; function getManifest() { return { @@ -117,12 +119,17 @@ async function getDetail(rawCtx) { const detail = parseDetailHtml(ctx, html, detailURL); const related = parseListHtml(ctx, html).filter(function (item) { return item.id !== detailURL; }).slice(0, 18); let qualityVersions = []; + let qualityDiscoveryFailed = false; if (detail.embedURL) { - try { qualityVersions = await loadQualityVersions(ctx, detailURL, detail.title, detail.embedURL); } catch (_) {} + try { + qualityVersions = await loadQualityVersions(ctx, detailURL, detail.title, detail.embedURL); + } catch (_) { + qualityDiscoveryFailed = true; + } } const resourceGroups = qualityVersions.length ? [{ id: 'quality', title: '画质', versions: qualityVersions }] - : resourceGroupsFor(ctx, detailURL, detail.title, detail.embedURL); + : (qualityDiscoveryFailed ? [] : resourceGroupsFor(ctx, detailURL, detail.title, detail.embedURL)); return { pageType: 'detail', id: detailURL, type: 'movie', title: detail.title, poster: detail.poster, backdrop: detail.poster, detailImageAspectRatio: '16:9', @@ -154,7 +161,7 @@ async function getResourceVersions(rawCtx) { } let versions = []; if (embedURL) { - try { versions = await loadQualityVersions(ctx, detailURL, title, embedURL); } catch (_) {} + versions = await loadQualityVersions(ctx, detailURL, title, embedURL); } return { itemId: detailURL, @@ -300,20 +307,116 @@ function resourceGroupsFor(ctx, detailURL, title, embedURL) { } async function loadQualityVersions(ctx, detailURL, title, embedURL) { - const playerHTML = await fetchPlayerText(ctx, embedURL); - const masterURL = extractMediaURL(playerHTML, embedURL); - if (!masterURL) return []; - const headers = { Referer: embedURL, Origin: urlOrigin(embedURL), 'User-Agent': SEXBJCAM_UA }; - const manifest = await fetchSignedManifestText(ctx, masterURL, headers); - const variants = parseHlsVariants(manifest, masterURL); + let variants = []; + let lastError = null; + let manifestResolved = false; + let headers = { Referer: embedURL, Origin: urlOrigin(embedURL), 'User-Agent': SEXBJCAM_UA }; + for (let attempt = 0; attempt < 3 && !variants.length; attempt += 1) { + try { + const playerHTML = await fetchPlayerText(ctx, embedURL); + const masterURL = extractMediaURL(playerHTML, embedURL); + if (!masterURL) throw new Error('播放器没有返回 HLS 地址'); + const manifest = await fetchSignedManifestText(ctx, masterURL, headers); + if (!/^\s*#EXTM3U/i.test(manifest)) throw new Error('播放器没有返回有效 HLS 清单'); + manifestResolved = true; + variants = parseHlsVariants(manifest, masterURL); + } catch (error) { + lastError = error; + variants = []; + } + } + if (variants.length) { + writeQualityMetadata(embedURL, variants.map(function (variant) { + return { height: variant.height, bandwidth: variant.bandwidth || 0 }; + })); + } else { + variants = readQualityMetadata(embedURL); + } + if (!variants.length && !manifestResolved) { + const reason = lastError && lastError.message ? ':' + lastError.message : ''; + throw new Error('画质加载失败,请检查网络后重新打开资源列表' + reason); + } if (!variants.length) return []; + return buildQualityVersions(detailURL, title, embedURL, headers, variants); +} + +function readQualityMetadata(embedURL) { + const key = qualityCacheKey(embedURL); + const memory = normalizeQualityCacheEntry(SEXBJCAM_QUALITY_CACHE[key]); + if (memory.length) return memory; + const stores = qualityStores(); + for (let i = 0; i < stores.length; i += 1) { + try { + const cached = normalizeQualityCacheEntry(stores[i].get(key)); + if (cached.length) { + SEXBJCAM_QUALITY_CACHE[key] = { time: Date.now(), variants: cached }; + return cached; + } + } catch (_) {} + } + return []; +} + +function writeQualityMetadata(embedURL, variants) { + const key = qualityCacheKey(embedURL); + const stable = normalizeQualityMetadata(variants); + if (!stable.length) return; + const entry = { time: Date.now(), variants: stable }; + SEXBJCAM_QUALITY_CACHE[key] = entry; + const stores = qualityStores(); + for (let i = 0; i < stores.length; i += 1) { + try { stores[i].set(key, entry); } catch (_) {} + } +} + +function normalizeQualityCacheEntry(entry) { + if (!entry) return []; + if (Array.isArray(entry)) return normalizeQualityMetadata(entry); + const time = Number(entry.time || 0); + if (!time || Date.now() - time > SEXBJCAM_QUALITY_CACHE_TTL_MS) return []; + return normalizeQualityMetadata(entry.variants); +} + +function normalizeQualityMetadata(variants) { + const values = Array.isArray(variants) ? variants : []; + const stable = [], seen = {}; + for (let i = 0; i < values.length; i += 1) { + const height = Number(values[i] && values[i].height || 0); + if (!height || seen[height]) continue; + seen[height] = true; + stable.push({ height: height, bandwidth: Number(values[i].bandwidth || 0) }); + } + return stable.sort(function (a, b) { return b.height - a.height || b.bandwidth - a.bandwidth; }); +} + +function qualityStores() { + const stores = []; + const widget = typeof Widget !== 'undefined' ? Widget : null; + const cache = typeof $cache !== 'undefined' ? $cache : null; + if (widget && widget.storage && typeof widget.storage.get === 'function' && typeof widget.storage.set === 'function') { + stores.push(widget.storage); + } + if (cache && typeof cache.get === 'function' && typeof cache.set === 'function') stores.push(cache); + return stores; +} + +function qualityCacheKey(embedURL) { + const value = stringValue(embedURL).replace(/[?#].*$/, ''); + let hash = 5381; + for (let i = 0; i < value.length; i += 1) hash = ((hash << 5) + hash) ^ value.charCodeAt(i); + return 'sexbjcam:qualities:v1:' + (hash >>> 0).toString(16); +} + +function buildQualityVersions(detailURL, title, embedURL, headers, variants) { return variants.map(function (variant, index) { const qualityId = 'quality:' + variant.height; return { id: qualityId, name: variant.height + 'P', title: variant.height + 'P', - subtitle: index === 0 ? '最高画质 · 默认' : (variant.bandwidth ? formatBitrate(variant.bandwidth) : ''), + subtitle: index === 0 + ? '最高画质 · 默认 · 切换时请稍候' + : [variant.bandwidth ? formatBitrate(variant.bandwidth) : '', '切换时请稍候'].filter(Boolean).join(' · '), quality: variant.height + 'P', container: 'm3u8', default: index === 0, @@ -424,10 +527,10 @@ async function fetchPlayerText(ctx, url) { waitForMediaSource: true, headers: freshHeaders }); - const mediaURL = mediaURLFromBrowserResult(result); - if (mediaURL) return mediaURL; const browserText = unwrapText(result); if (browserText && extractMediaURL(browserText, url)) return browserText; + const mediaURL = mediaURLFromBrowserResult(result); + if (mediaURL) return mediaURL; } catch (_) {} } let normalError = null; @@ -481,9 +584,19 @@ function mediaURLFromBrowserResult(result) { } } for (let i = 0; i < candidates.length; i += 1) if (/\/master\.m3u8(?:$|[?#])/i.test(candidates[i])) return candidates[i]; + for (let i = 0; i < candidates.length; i += 1) { + const masterURL = masterURLFromVariant(candidates[i]); + if (masterURL) return masterURL; + } return candidates[0] || ''; } +function masterURLFromVariant(url) { + const value = stringValue(url); + if (!/\/index-[^/?#]+\.m3u8(?:$|[?#])/i.test(value)) return ''; + return value.replace(/\/index-[^/?#]+\.m3u8(?=[$?#])/i, '/master.m3u8'); +} + function cacheBustedURL(url) { SEXBJCAM_REQUEST_NONCE += 1; const separator = String(url || '').indexOf('?') >= 0 ? '&' : '?'; diff --git a/sexbjcam-network-recovery.test.js b/sexbjcam-network-recovery.test.js index ed0f4e8..630ebb9 100644 --- a/sexbjcam-network-recovery.test.js +++ b/sexbjcam-network-recovery.test.js @@ -2,7 +2,6 @@ const assert = require('assert'); const storageValues = new Map(); let networkGood = false; -let manifestBrowserRequests = 0; const detailURL = 'https://sexbjcam.com/2026/06/20/sample/'; const embedURL = 'https://player.example/embed/sample'; @@ -28,15 +27,14 @@ global.Widget = { http: { get(url) { if (String(url).indexOf(detailURL) === 0) return detailHTML; - if (networkGood && String(url).indexOf(masterURL) === 0) return masterManifest; throw new Error('network unavailable'); } }, browser: { fetch(url) { if (!networkGood) throw new Error('network unavailable'); - if (String(url).indexOf(embedURL) === 0) return { html: '<script>const source="' + masterURL + '";</script>', 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 '<script>const source="' + masterURL + '";</script>'; + 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: '<h1>Sample</h1><div id="players"><button data-url="https://bysetayico.com/e/sample">BI</button><button data-url="https://abyssplayer.com/sample">AB</button></div><div id="downloads"></div>' }); @@ -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: '<html></html>' }; }; 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" + } + } +}