diff --git a/CHANGELOG.md b/CHANGELOG.md index 5eef9a0..f6b71b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,12 @@ -# 统一故障修复 — 2026-10-03 +# 七个媒体库恢复最早上传版 — 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`,离线检查验证恢复内容。原版参数契约同步恢复;只撤回依赖已取消功能的回归检查,其他库和包装检查继续保留。 +- 下文七个库的 2026-10-03 优化及播放修复为历史记录,现已撤回。其他媒体库、Forward 与 Angel Live 内容不在本次恢复范围。 + +# 统一故障修复 — 2026-10-03(历史记录,已撤回) 整合 MissAV 1.5.11、ASMRLIB 1.2.2、JAVGG 1.0.2、SexBJCam 1.1.9、麻豆亚洲版 1.1.2、套路 SM 1.0.2、KBJ fan 1.0.2,原下载路径全部保留。整体 103 项媒体库回归及仓库 CI 对应检查通过;下文各独立修复的检查数是各自主分支基线的验证记录。 diff --git a/README.md b/README.md index 122fe56..70c3faa 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,25 @@ 本仓库同时保存 DreamBy(baiPlay)自定义媒体库、Forward 脚本和 Angel Live 插件。不同协议的版本号不能互相比较;亚洲版、国产版等不同内容分支分别维护。 -已经导入 DreamBy 的入口继续使用原来的下载地址。更新沿用原文件路径、媒体库 ID 和原参数名;不用删除后重新导入。每个下载入口都包含完整 JavaScript,不需要远程加载器。 +已经导入 DreamBy 的入口继续使用原来的下载地址和媒体库 ID。每个下载入口都包含完整 JavaScript,不需要远程加载器。 + +## 2026-10-04:七个媒体库恢复最早上传版 + +按用户确认,以下七个媒体库恢复到 GitHub 历史中最早上传的原始内容,包含原版版本号、参数和解析行为。此次恢复并非仅退回 2026-10-03 播放修复之前。 + +| 媒体库 / 当前下载文件 | 恢复版本 | 原始提交 | +| --- | --- | --- | +| [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) | + +麻豆最早以 `madou8-mini-library.js` 上传,后来同一内容迭代为 `madou8-mini-library 5.js`;现将最早的 1.0.1 内容放回当前下载路径。MissAV 四个既有入口同步为同一份原始 1.0.0,避免兼容同步任务再次覆盖恢复结果。全部原 raw 下载地址保留。 + +原始提交、原文件名及 SHA-256 记录在 [tools/library-first-versions.json](tools/library-first-versions.json)。恢复检查逐字节校验原版内容,语法、媒体库 ID 和原版公共入口继续验证。2026-10-03 的七库修复记录保留在下文作为历史记录。 ## 主维护文件与兼容入口 @@ -10,11 +28,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.5.11 | `missav-mini-library-download-working.js`、`missav-mini-library.js`、`missav-mini-library-CloudFlare.js` | - -Eporner、XXXFollow 的两份文件原本内容相同。MissAV 的两个旧 1.0.7 实现升级为当前维护版的兼容副本,保留 `missav-mini-library` ID、旧参数名和 `missav://detail?` 历史条目格式。 +| [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 1.5.11 修正正常 HTTP 200 页面被误判成 Cloudflare 验证页的问题,并为播放阶段增加脚本侧请求截止时间。旧下载地址保持不变。桌面实际页面解析和离线回归已验证;iPhone 原生起播仍需设备确认,桌面匿名 HTTP 请求视频 CDN 的 403 与地址解析结果分别记录。 +Eporner、XXXFollow 的两份文件原本内容相同。MissAV 的四个入口现均为原始 1.0.0 的完整兼容副本,保留 `missav-mini-library` ID 和原版 `baseURL`、`entryPath` 参数。 日常只修改左侧主维护文件,再运行: @@ -31,7 +47,7 @@ GitHub Actions 会先生成兼容副本并验证;`main` 的检查通过后, - Hanime1 的 `.fwd` 包和加密的 `twitter视频.js` 保持原格式;Stripchat ZIP、两个订阅索引和插件主程序保持一致。 - `ttdm-mini-library (1).js`、`pektino-mini-library 3.js` 保持现有文件名。 -## 2026-10-03 更新版本 +## 2026-10-03 更新版本(历史记录,七库现已恢复原版) | 文件 | 原版本 | 新版本 | | --- | --- | --- | @@ -61,7 +77,7 @@ GitHub Actions 会先生成兼容副本并验证;`main` 的检查通过后, 具体行为和设备验证限制见 [CHANGELOG.md](CHANGELOG.md)。 -## 统一故障修复 — 2026-10-03 +## 统一故障修复 — 2026-10-03(历史记录,已撤回) - 麻豆亚洲版 `madou8-mini-library 5.js` 为 1.1.2:首屏只取最近更新,分类预览随后加载;HTTP 错误及验证失败明确报告。五个分类前两页各返回 12 条且没有跨页重复;桌面首屏 1.543 秒。原文件名、ID、`baseUrl` 和 `madou8://` 版本载荷保持兼容。 - 播放检查最多 12 条实际普通 HLS 线路,使用三个逻辑并发任务和 28 秒预算,避免接口顺序变化时漏掉第五条以后的有效源。沿用网页 `xhrSetup` 的 `Accept` 凭据,每次从 API 刷新,不写入版本/缓存。专用 StreamPipe/service-worker 线路及明确 `native:false` 的线路不会伪装成原生地址。 @@ -73,7 +89,7 @@ GitHub Actions 会先生成兼容副本并验证;`main` 的检查通过后, ASMRLIB 1.2.2 的 BI/AB 使用“验证后播放”入口,在原详情页内选择线路并手动验证或点击播放。源设置中的“显示播放验证页面”默认开启。只有浏览器返回最终媒体地址才会交给原生播放器;若只返回网页或 blob,脚本会显示具体阶段。此流程的代码及模拟已验证,iPhone 嵌套媒体回传、自动交回和起播仍待设备确认。 -## JAVGG 1.0.2 播放修复 +## JAVGG 1.0.2 播放修复(历史记录,已撤回) `javgg-mini-library.js` 保持原下载路径、媒体库 ID、`baseUrl` 参数和旧 `javgg://` 载荷。按实际 `data-nume` 对应 VH、playmate、luluvdoo、SW 线路,静态读取公开播放器的打包代码,不执行远程脚本。资源列表仅包含本次成功读取到的真实画质,最高画质默认;地址在每次播放时刷新。 @@ -81,7 +97,7 @@ ASMRLIB 1.2.2 的 BI/AB 使用“验证后播放”入口,在原详情页内 匿名桌面 HTTP 已验证 CAWB-046 的 luluvdoo 1080p、SW 1080p/720p/480p 和 MIRD-287 的 luluvdoo 720p,master、变体与分片可请求;ATID-666 的 SW 清单当时返回 404。首页/详情 HTTP 还可能遇到站点浏览器检查,浏览器正常打开不等于设备脚本能读取。DreamBy 浏览器回传、iPhone 原生起播与切画质仍待实测。 -## SexBJCam 1.1.9 播放修复 +## SexBJCam 1.1.9 播放修复(历史记录,已撤回) 保留 `sexbjcam-mini-library.js` 原下载路径、`sexbjcam-mini-library` ID、`baseURL` 参数、历史详情 URL 与 `quality:1080/720/480` ID。播放器改为先用 HTTP 读取公开打包配置,必要时只做一次 12 秒浏览器媒体捕获;HLS 正文使用最多 4 秒 HTTP 请求,不再用浏览器导航清单。详情、资源发现和播放各有 28 秒总时限;脚本截止时间不能强制取消宿主原生任务。 diff --git a/asmrlib-mini-library.js b/asmrlib-mini-library.js index 1ebe3d5..6491719 100644 --- a/asmrlib-mini-library.js +++ b/asmrlib-mini-library.js @@ -2,7 +2,7 @@ * ASMRLIB - Dreamby / baiPlay 自定义媒体库 * Source: https://asmrlib.com/ * @author Alan huang - * @version 1.2.2 + * @version 1.2.0 */ const ASMRLIB_BASE = 'https://asmrlib.com'; @@ -89,17 +89,16 @@ function asmrResponseText(response) { return ''; } -async function asmrHTTP(url, referer, timeout) { +async function asmrHTTP(url, referer) { const headers = asmrHeaders(referer); - timeout = timeout || 30; let response; if (typeof Widget !== 'undefined' && Widget.http) { - if (typeof Widget.http.get === 'function') response = await Widget.http.get(url, { headers: headers, timeout: timeout, timeoutSeconds: timeout }); - else if (typeof Widget.http.request === 'function') response = await Widget.http.request({ url: url, method: 'GET', headers: headers, timeout: timeout, timeoutSeconds: timeout }); + if (typeof Widget.http.get === 'function') response = await Widget.http.get(url, { headers: headers, timeout: 30 }); + else if (typeof Widget.http.request === 'function') response = await Widget.http.request({ url: url, method: 'GET', headers: headers, timeout: 30 }); } if (response === undefined && typeof $http !== 'undefined' && $http) { - if (typeof $http.get === 'function') response = await $http.get(url, { headers: headers, timeout: timeout, timeoutSeconds: timeout }); - else if (typeof $http.request === 'function') response = await $http.request({ url: url, method: 'GET', headers: headers, timeout: timeout, timeoutSeconds: timeout }); + if (typeof $http.get === 'function') response = await $http.get(url, { headers: headers, timeout: 30 }); + else if (typeof $http.request === 'function') response = await $http.request({ url: url, method: 'GET', headers: headers, timeout: 30 }); } if (response === undefined && typeof fetch === 'function') { const nativeResponse = await fetch(url, { headers: headers }); @@ -113,7 +112,7 @@ async function asmrHTTP(url, referer, timeout) { function asmrId(raw) { const value = asmrText(raw); - const match = value.match(/(?:asmrlib(?:-page)?:\/\/post\/|\/posts\/)([a-f0-9]{32})/i); + const match = value.match(/(?:asmrlib:\/\/post\/|\/posts\/)([a-f0-9]{32})/i); return match ? match[1] : (/^[a-f0-9]{32}$/i.test(value) ? value : ''); } function asmrPayload(id) { return 'asmrlib://post/' + asmrId(id); } @@ -192,22 +191,31 @@ function getManifest() { id: 'asmrlib', name: 'ASMRLIB', title: 'ASMRLIB', - version: '1.2.2', + version: '1.2.0', author: 'Alan huang', logo: ASMRLIB_LOGO, icon: ASMRLIB_LOGO, capabilities: { search: true, aggregation: true, playbackHistory: true }, - aggregation: { search: true, playbackHistory: true }, - parameters: [{ - name: 'browserVisible', title: '显示播放验证页面', type: 'boolean', defaultValue: true, - description: 'BI 需要手动验证;AB 必须在详情页内打开。请在页面里选择线路并点击验证或播放,捕获到媒体后返回播放器。' - }] + aggregation: { search: true, playbackHistory: true } }; } async function getHome() { const html = await asmrHTTP(ASMRLIB_BASE + '/', ASMRLIB_BASE + '/'); const latest = asmrParseList(html); + const categoryItems = ASMRLIB_CATEGORIES.slice(1).map(function (category, index) { + return { + id: 'asmrlib-category-' + category.id, + title: category.title, + subtitle: '浏览 ' + category.title + ' 标签', + type: 'collection', + poster: latest[index % Math.max(latest.length, 1)] ? latest[index % latest.length].poster : ASMRLIB_LOGO, + backdrop: latest[index % Math.max(latest.length, 1)] ? latest[index % latest.length].poster : ASMRLIB_LOGO, + imageHeaders: asmrImageHeaders(), + previewItems: latest.slice(index, index + 3), + action: { type: 'category', pageId: category.id, title: category.title } + }; + }); return { pageType: 'home', id: 'asmrlib-home', @@ -216,7 +224,7 @@ async function getHome() { hero: latest.slice(0, 6), sections: [ { id: 'latest', title: '最新更新', style: 'discover.standard', lazy: false, items: latest, moreAction: { type: 'category', pageId: 'latest', title: '最新更新' } }, - { id: 'categories', title: '热门标签', style: 'discover.annualListPreview', lazy: true, loadAction: { type: 'custom', id: 'categories', sectionId: 'categories', title: '热门标签' }, items: [] } + { id: 'categories', title: '热门标签', style: 'discover.annualListPreview', lazy: false, items: categoryItems } ] }; } @@ -224,22 +232,8 @@ async function getHome() { async function getHomeSection(input) { const id = asmrText(asmrPick(input, ['sectionId', 'id'], 'latest')); if (id === 'categories') { - const categories = ASMRLIB_CATEGORIES.slice(1); - const cards = []; let cursor = 0; - await Promise.all(Array.from({ length: Math.min(2, categories.length) }, async function () { - while (cursor < categories.length) { - const index = cursor++; const category = categories[index]; - try { - const page = await getCategory({ pageId: category.id, page: 1 }); - const previews = (page.items || []).slice(0, 3); const lead = previews[0]; - if (!lead || !lead.poster) continue; - cards[index] = { id: 'asmrlib-category-' + category.id, title: category.title, subtitle: '浏览 ' + category.title + ' 标签', - type: 'collection', poster: lead.poster, backdrop: lead.backdrop || lead.poster, imageHeaders: asmrImageHeaders(), previewItems: previews, - action: { type: 'category', pageId: category.id, title: category.title } }; - } catch (_) {} - } - })); - return { id: 'categories', title: '热门标签', style: 'discover.annualListPreview', lazy: false, items: cards.filter(Boolean) }; + const home = await getHome(); + return home.sections[1]; } const page = await getCategory({ pageId: id, page: 1 }); return { id: id, title: page.title, style: 'discover.standard', lazy: false, items: page.items || [] }; @@ -296,21 +290,6 @@ function asmrDetailData(html, id) { } function asmrResourceGroups(detail) { - const embedded = detail.versions.some(function (version) { - return /^https?:\/\/(?:bysetayico\.com|abyssplayer\.com)(?:\/|$)/i.test(version.playerUrl); - }); - if (embedded) { - // These players require the site's iframe/interaction context. Present one - // page entry; the user chooses BI/AB inside that page instead of promising - // that a standalone hidden request can select either embedded player. - return [{ id: 'online', title: '在线播放', versions: [{ - id: 'asmrlib-page://post/' + detail.id, - name: '验证后播放(BI / AB)', - subtitle: '在页面里选择 BI 或 AB,再点击验证或播放', - default: true, - action: { type: 'play', itemId: asmrPayload(detail.id), versionId: 'asmrlib-page://post/' + detail.id, title: detail.title } - }] }]; - } return [{ id: 'online', title: '在线播放', @@ -353,38 +332,21 @@ function asmrPlayable(value) { return /^https?:\/\//i.test(asmrText(value)) && /\.(?:m3u8|mp4|mpd|m4v|mov|webm)(?:[?#]|$)/i.test(asmrText(value)); } function asmrBrowserMedia(result) { - return asmrCapturedMedia(result).url; -} -function asmrCapturedMedia(result) { - if (!result) return { url: '', headers: {} }; - const candidates = []; - const add = function (value, headers) { - if (asmrPlayable(value) && !/\/(?:ads?|advert|preview|trailer)(?:[\/_.-]|$)/i.test(value)) candidates.push({ url: value, headers: asmrMediaHeaders(headers) }); - }; + if (!result) return ''; const directKeys = ['url', 'mediaURL', 'mediaUrl', 'videoURL', 'videoUrl', 'playURL', 'playUrl', 'src']; - for (let i = 0; i < directKeys.length; i += 1) add(result[directKeys[i]], result.mediaHeaders || result.requestHeaders); - const arrays = [result.mediaSources, result.mediaRequests, result.capturedRequests, result.requests, result.responses, result.urls]; + for (let i = 0; i < directKeys.length; i += 1) if (asmrPlayable(result[directKeys[i]])) return result[directKeys[i]]; + const arrays = [result.mediaSources, result.mediaRequests, result.requests, result.responses, result.urls]; for (let a = 0; a < arrays.length; a += 1) { if (!Array.isArray(arrays[a])) continue; for (let i = 0; i < arrays[a].length; i += 1) { const entry = arrays[a][i]; const value = typeof entry === 'string' ? entry : entry && (entry.url || entry.src || entry.responseURL); - add(value, entry && (entry.requestHeaders || entry.headers)); + if (asmrPlayable(value) && !/^blob:/i.test(value)) return value; } } - if (candidates.length) return candidates[0]; const text = asmrResponseText(result).replace(/\\\//g, '/'); const match = text.match(/https?:\/\/[^\s"'<>\\]+\.(?:m3u8|mp4|mpd|m4v|mov|webm)(?:\?[^\s"'<>\\]*)?/i); - if (match) add(match[0], null); - return candidates[0] || { url: '', headers: {} }; -} -function asmrMediaHeaders(headers) { - const output = {}; - Object.keys(headers || {}).forEach(function (key) { - const canonical = { referer: 'Referer', origin: 'Origin', 'user-agent': 'User-Agent', cookie: 'Cookie' }[key.toLowerCase()]; - if (canonical && typeof headers[key] === 'string') output[canonical] = headers[key]; - }); - return output; + return match && asmrPlayable(match[0]) ? match[0] : ''; } function asmrVersionPlayerURL(input) { const versionId = asmrText(asmrPick(input, ['versionId', 'resourceId', 'lineId'], '')); @@ -423,7 +385,7 @@ async function asmrResolveUP(playerUrl) { if (!code) return ''; const api = 'https://v.upn.one/api/v1/video?id=' + encodeURIComponent(code) + '&w=390&h=844&r=' + encodeURIComponent(ASMRLIB_BASE); - const encrypted = await asmrHTTP(api, ASMRLIB_BASE + '/', 5); + const encrypted = await asmrHTTP(api, ASMRLIB_BASE + '/'); const data = asmrDecryptUP(encrypted); const candidates = [data.source, data.cfNative]; if (data.hlsVideoTiktok && data.streamingConfig) { @@ -453,66 +415,69 @@ async function asmrResolveUP(playerUrl) { async function resolvePlayback(input) { const direct = asmrText(asmrPick(input, ['url', 'path', 'playUrl', 'videoUrl'], '')); if (asmrPlayable(direct)) return asmrPlaybackResult(direct, direct); - const playerUrl = asmrVersionPlayerURL(input); - const itemId = asmrId(asmrPick(input, ['itemId', 'id'], '')) || - asmrId(asmrPick(input, ['versionId'], '')) || asmrId(playerUrl); - if (!itemId && !asmrUPCode(playerUrl)) throw new Error('缺少 ASMRLIB 内容 ID;请重新打开影片详情'); + let playerUrl = asmrVersionPlayerURL(input); + if (!playerUrl) { + const detail = await getDetail(input); + const version = detail.resourceGroups[0] && detail.resourceGroups[0].versions[0]; + playerUrl = version ? asmrVersionPlayerURL(version.action) : ''; + } + if (!playerUrl) throw new Error('没有找到 ASMRLIB 播放线路'); if (typeof Widget === 'undefined' || !Widget.browser || typeof Widget.browser.fetch !== 'function') { throw new Error('当前 Dreamby 版本不支持设备内播放器解析'); } - if (asmrUPCode(playerUrl)) { - try { - const media = await asmrBounded('up-api', 5, function () { return asmrResolveUP(playerUrl); }); - if (media) return asmrPlaybackResult(media, playerUrl); - } catch (error) { - if (!itemId) throw new Error('旧 UP 线路解析失败;stage=up-api;请重新打开影片详情选择当前线路'); + const candidates = [playerUrl]; + const itemId = asmrId(asmrPick(input, ['itemId', 'id'], '')); + let media = ''; + let resolvedReferer = playerUrl; + for (let i = 0; i < 2 && !media; i += 1) { + if (!candidates[i] && i === 1 && itemId) { + try { + const detail = await getDetail({ itemId: asmrPayload(itemId) }); + const versions = detail.resourceGroups[0] ? detail.resourceGroups[0].versions : []; + versions.forEach(function (version) { + const alternate = asmrVersionPlayerURL(version.action); + if (alternate && candidates.indexOf(alternate) < 0) candidates.push(alternate); + }); + } catch (_) {} } + const candidate = candidates[i]; + if (!candidate) continue; + if (asmrUPCode(candidate)) { + try { + media = await asmrResolveUP(candidate); + if (media) { + resolvedReferer = candidate; + break; + } + } catch (_) {} + continue; + } + try { + const result = await Widget.browser.fetch(candidate, { + visible: false, + timeout: 18, + timeoutSeconds: 18, + waitAfterLoad: 1.2, + waitForAny: true, + waitForMediaSource: true, + captureRequests: true, + captureMedia: true, + headers: asmrHeaders(ASMRLIB_BASE + '/') + }); + media = asmrBrowserMedia(result); + if (media) resolvedReferer = candidate; + } catch (_) {} } - const visibleValue = asmrPick(input, ['browserVisible'], true); - const visible = !/^(?:false|0|no|off)$/i.test(String(visibleValue)); - const pageURL = ASMRLIB_BASE + '/posts/' + itemId; - // BI needs a human action; AB redirects when top.location == self.location. - // Keep the original page and iframe instead of loading either player alone. - const result = await asmrBounded('page-media', 20, function () { - return Widget.browser.fetch(pageURL, { - visible: visible, timeout: 20, timeoutSeconds: 20, waitAfterLoad: 1.2, - waitForMediaSource: true, headers: asmrHeaders(ASMRLIB_BASE + '/') - }); - }); - const media = asmrCapturedMedia(result); - if (!media.url) { - const html = asmrResponseText(result); - const blobOnly = /blob:/i.test(JSON.stringify(result || {})); - const verification = /验证你是真人|verify.{0,20}human|captcha|turnstile/i.test(html); - const stage = verification || !visible ? 'verification-required' : 'page-media'; - throw new Error('ASMRLIB 未返回可交给播放器的媒体;stage=' + stage + - ';keys=' + Object.keys(result || {}).slice(0, 12).join(',') + ';blobOnly=' + blobOnly + - (stage === 'verification-required' ? ';请启用显示播放验证页面,并手动完成验证或点击播放' : ';若页面内已播放,宿主可能没有回传 iframe 媒体地址')); - } - // A captured media request supplies its own Referer/Origin. The outer ASMR - // page's origin is not necessarily the embedded player's media origin. - return asmrPlaybackResult(media.url, '', Object.assign({ 'User-Agent': ASMRLIB_UA }, media.headers)); -} - -async function asmrBounded(stage, seconds, operation) { - let timer; - try { - return await Promise.race([ - Promise.resolve().then(operation), - new Promise(function (_, reject) { timer = setTimeout(function () { reject(new Error('ASMRLIB 请求超时;stage=' + stage)); }, seconds * 1000); }) - ]); - } catch (error) { - if (String(error && error.message).indexOf('ASMRLIB 请求超时;stage=' + stage) === 0) throw error; - throw new Error('ASMRLIB 请求失败;stage=' + stage + ';请检查网络或宿主浏览器支持'); - } finally { clearTimeout(timer); } + if (!media) throw new Error('快速线路和备用线路均未捕获到媒体,请稍后重试'); + return asmrPlaybackResult(media, resolvedReferer); } -function asmrPlaybackResult(url, referer, capturedHeaders) { +function asmrPlaybackResult(url, referer) { const container = /\.m3u8(?:[?#]|$)/i.test(url) ? 'm3u8' : (/\.mpd(?:[?#]|$)/i.test(url) ? 'mpd' : 'mp4'); const originMatch = asmrText(referer).match(/^(https?:\/\/[^/]+)/i); return { url: url, container: container, - headers: capturedHeaders || { Referer: referer || ASMRLIB_BASE + '/', Origin: originMatch ? originMatch[1] : ASMRLIB_BASE, 'User-Agent': ASMRLIB_UA }, + headers: { Referer: referer || ASMRLIB_BASE + '/', Origin: originMatch ? originMatch[1] : ASMRLIB_BASE, 'User-Agent': ASMRLIB_UA }, startPositionSeconds: 0, isLive: false, streamKind: container === 'm3u8' ? 'hls' : 'file' }; } diff --git a/javgg-mini-library.js b/javgg-mini-library.js index d78faf0..4cbc0f3 100644 --- a/javgg-mini-library.js +++ b/javgg-mini-library.js @@ -9,7 +9,7 @@ const WidgetMetadata = { id: 'javgg-mini-library', name: 'JAVGG', title: 'JAVGG', - version: '1.0.2', + version: '1.0.0', requiredVersion: '0.0.1', author: 'Alan huang', site: JAVGG_DEFAULT_BASE, @@ -218,80 +218,54 @@ async function getDetail(rawCtx) { } async function getResourceVersions(rawCtx) { - return withPlaybackBudget(rawCtx, discoverResourceVersions, '画质发现'); -} - -async function discoverResourceVersions(rawCtx) { const ctx = normalizeContext(rawCtx); const directPayload = decodePayload(firstNonEmpty(ctx.versionId, ctx.itemId, ctx.id)); const detailUrl = firstNonEmpty(directPayload.detailUrl, detailURLFromContext(ctx)); if (!detailUrl) return []; - const html = await fetchText(ctx, detailUrl, detailUrl, null, 4, 'detail-http'); + const html = await fetchText(ctx, detailUrl); const title = cleanText(firstNonEmpty(ctx.title, metaContent(html, 'property', 'og:title'), pageTitle(html))); const players = parsePlayers(html); - if (!players.length) throw stageError('detail-players', '源站详情页当前没有可用播放线路'); - const attempts = await inspectPlayerQualities(ctx, detailUrl, players); - const groups = attempts.filter(function (attempt) { return attempt.qualities.length; }) - .map(function (attempt) { return qualityGroup(detailUrl, title, attempt.player, attempt.qualities); }); - if (groups.length) return groups; - // Browser execution is one fallback only when static discovery produced no usable line. - const candidate = attempts.filter(function (attempt) { return !attempt.media; })[0]; - if (candidate && remainingPlaybackSeconds(ctx, 8) > 0.5) { - const captured = await extractFromBrowser(candidate.player.url, detailUrl, ctx, 8); - if (captured) { - const retry = await discoverQualities(ctx, detailUrl, candidate.player, false, captured); - if (retry.qualities.length) return [qualityGroup(detailUrl, title, retry.player, retry.qualities)]; - candidate.stage = retry.stage; - } + if (!players.length) throw new Error('源站详情页当前没有可用播放线路'); + const groups = []; + for (let index = 0; index < players.length; index += 1) { + const qualities = await discoverQualities(ctx, detailUrl, players[index]); + if (qualities.length) groups.push(qualityGroup(detailUrl, title, players[index], qualities)); } - const diagnostic = attempts.map(function (attempt) { return attempt.player.line + ':' + attempt.stage; }).join(', '); - throw stageError('quality-discovery', '未发现可用画质;' + diagnostic + ';请稍后重试'); + if (!groups.length) throw new Error('暂时无法发现可用画质,请稍后重试'); + return groups; } -async function resolvePlaybackWithinBudget(rawCtx) { +async function resolvePlayback(rawCtx) { const ctx = normalizeContext(rawCtx); const payload = decodePayload(firstNonEmpty(ctx.versionId, ctx.itemId, ctx.id)); let playerUrl = firstNonEmpty(ctx.playerUrl, payload.playerUrl); let detailUrl = firstNonEmpty(payload.detailUrl, detailURLFromContext(ctx)); let line = firstNonEmpty(payload.line, ctx.line); - const requestedHeight = positiveInt(firstNonEmpty(payload.height, ctx.height), 0); - let media; if (!playerUrl && detailUrl) { - const players = parsePlayers(await fetchText(ctx, detailUrl, detailUrl, null, 4, 'detail-http')); - const selected = players.filter(function (item) { return payload.lineId ? item.lineId === payload.lineId : (line && item.line === line); }); - const attempts = await inspectPlayerQualities(ctx, detailUrl, selected.length ? selected : players); - const usable = attempts.filter(function (attempt) { return attempt.qualities.length; }); - usable.sort(function (a, b) { - const aExact = a.qualities.some(function (q) { return q.height === requestedHeight && requestedHeight > 0; }); - const bExact = b.qualities.some(function (q) { return q.height === requestedHeight && requestedHeight > 0; }); - return Number(bExact) - Number(aExact) || (b.qualities[0].height || 0) - (a.qualities[0].height || 0); - }); - const chosen = usable[0]; - const player = chosen ? chosen.player : (selected[0] || players[0]); - playerUrl = player && player.url; - line = player && player.line; - media = chosen && chosen.media; + const players = parsePlayers(await fetchText(ctx, detailUrl)); + const selected = players.filter(function (item) { return !line || item.line === line; })[0] || players[0]; + playerUrl = selected && selected.url; + line = selected && selected.line; } - if (!playerUrl) throw stageError('detail-players', '未找到 JAVGG 播放器地址'); - if (!media) media = await resolvePlayerMedia(ctx, playerUrl, detailUrl, true); - if (!media.url) throw stageError(media.stage || 'player-media', '线路 ' + (line || '') + ' 暂时无法解析,请切换其他线路'); - let playable = media.url; - if (/\.m3u8(?:$|[?#])/i.test(playable)) { + if (!playerUrl) throw new Error('未找到 JAVGG 播放器地址'); + + let playable = await resolvePlayerMedia(ctx, playerUrl, detailUrl); + if (!playable) throw new Error('线路 ' + (line || '') + ' 暂时无法解析,请切换其他线路'); + const requestedHeight = positiveInt(firstNonEmpty(payload.height, ctx.height), 0); + if (requestedHeight && /\.m3u8(?:$|[?#])/i.test(playable)) { try { - const playlist = await fetchText(ctx, playable, playerUrl, media.headers, 4, 'manifest-http'); - if (!/#EXTM3U/i.test(playlist)) throw stageError('manifest-not-hls', '来源未返回 HLS 清单'); + const playlist = await fetchText(ctx, playable, playerUrl); const variants = parseMasterPlaylist(playable, playlist); - const selected = variants.filter(function (item) { return item.height === requestedHeight; })[0] || variants[0]; + const selected = variants.filter(function (item) { return item.height === requestedHeight; })[0]; if (selected) playable = selected.url; } catch (error) { - if (/-4\d\d$|verification$|not-hls$/.test(error.stage || '')) throw error; - // Preserve the fresh real master only when probing fails transiently. + // The master itself remains a valid highest/automatic fallback. } } return { url: playable, container: /\.m3u8(?:$|[?#])/i.test(playable) ? 'm3u8' : 'mp4', - headers: media.headers, + headers: playbackHeaders(playerUrl), startPositionSeconds: 0, isLive: false, streamKind: /\.m3u8(?:$|[?#])/i.test(playable) ? 'hls' : 'file' @@ -376,56 +350,32 @@ function parseCards(ctx, html) { function parsePlayers(html) { const text = String(html || ''); - const options = {}; - const optionPattern = /]*)>([\s\S]*?)<\/li>/gi; - let match; - while ((match = optionPattern.exec(text))) { - if (!/\bdooplay_player_option\b/i.test(attributeValue(match[1], 'class'))) continue; - const number = attributeValue(match[1], 'data-nume'); - const server = firstMatch(match[2], /]*class=["'][^"']*\bserver\b[^"']*["'][^>]*>([\s\S]*?)<\/span>/i); - const title = firstMatch(match[2], /]*class=["'][^"']*\btitle\b[^"']*["'][^>]*>([\s\S]*?)<\/span>/i); - options[number] = cleanText(server || title) || ('线路 ' + number); - } const results = []; - // source-player-N matches data-nume=N, even if a frame is missing or reordered. - const sourcePattern = /]*\bid=["']source-player-\d+["'][^>]*)>([\s\S]*?]*>)/gi; - while ((match = sourcePattern.exec(text))) { - const id = attributeValue(match[1], 'id').match(/^source-player-(\d+)$/); - if (!id) continue; - const frame = firstMatch(match[2], /]*)>/i); - const url = iframeURL(frame); - if (url) results.push({ lineId: id[1], line: options[id[1]] || ('线路 ' + id[1]), url: url }); - } - if (!results.length) { - const frames = text.match(/]*>/gi) || []; - const numbers = Object.keys(options); - frames.forEach(function (frame) { - const url = iframeURL(frame); - if (!url) return; - const number = numbers[results.length] || String(results.length + 1); - results.push({ lineId: number, line: options[number] || ('线路 ' + number), url: url }); - }); + const iframeURLs = []; + const iframePattern = /]*(?:data-lazy-src|data-src|src)=["']([^"']+)["'][^>]*>/gi; + let match; + while ((match = iframePattern.exec(text))) { + const url = htmlDecode(match[1]); + if (/earnvidjav|javggvideo|streamhgjav|\/embed\/|\/e\/|\/t\//i.test(url)) iframeURLs.push(url); } - return uniqueBy(results, function (player) { return player.url; }); -} - -function attributeValue(tag, name) { - return htmlDecode(firstMatch(tag, new RegExp('\\b' + escapeRegExp(name) + '\\s*=\\s*["\']([^"\']*)', 'i'))); -} - -function iframeURL(tag) { - const url = firstNonEmpty(attributeValue(tag, 'data-lazy-src'), attributeValue(tag, 'data-src'), attributeValue(tag, 'src')); - return /^https?:\/\//i.test(url) && /earnvidjav|javggvideo|streamhgjav|\/embed\/|\/e\/|\/t\//i.test(url) ? url : ''; + const optionPattern = /]*class=["'][^"']*\bdooplay_player_option\b[^"']*["'][^>]*data-post=["']([^"']+)["'][^>]*data-nume=["']([^"']+)["'][^>]*>[\s\S]*?(?:]*class=["'][^"']*\btitle\b[^"']*["'][^>]*>([\s\S]*?)<\/span>|Server[\s\S]*?([A-Z]{1,5}))[\s\S]*?<\/li>/gi; + const labels = []; + while ((match = optionPattern.exec(text))) labels.push(cleanText(match[3] || match[4])); + iframeURLs.forEach(function (url, index) { + const line = labels[index] || ['VH', 'TB', 'SW'][index] || ('线路 ' + (index + 1)); + results.push({ line: line, url: url }); + }); + return uniqueBy(results, function (x) { return x.url; }); } function qualityGroup(detailUrl, title, player, qualities) { return { - id: 'line-' + (player.lineId || player.line), + id: 'line-' + player.line, title: player.line + ' 线路', versions: qualities.map(function (quality, index) { const payload = encodePayload({ kind: 'play', detailUrl: detailUrl, playerUrl: player.url, - line: player.line, lineId: player.lineId, height: quality.height || 0, title: title + line: player.line, height: quality.height || 0, title: title }); return { id: payload, @@ -442,50 +392,34 @@ function qualityGroup(detailUrl, title, player, qualities) { }; } -async function inspectPlayerQualities(ctx, detailUrl, players) { - const attempts = new Array(players.length); - let next = 0; - async function worker() { - while (next < players.length) { - const index = next++; - attempts[index] = await discoverQualities(ctx, detailUrl, players[index], false); +async function discoverQualities(ctx, detailUrl, player) { + const mediaURL = await resolvePlayerMedia(ctx, player.url, detailUrl); + if (!mediaURL) return []; + if (!/\.m3u8(?:$|[?#])/i.test(mediaURL)) return [{ name: 'MP4', height: 0 }]; + try { + const playlist = await fetchText(ctx, mediaURL, player.url); + const variants = parseMasterPlaylist(mediaURL, playlist); + if (variants.length) { + return variants.map(function (variant) { + return { name: variant.height ? variant.height + 'p' : 'HLS', height: variant.height || 0 }; + }); } + if (/#EXTINF/i.test(playlist)) return [{ name: 'HLS 原始画质', height: 0 }]; + } catch (error) { + return []; } - await Promise.all([worker(), worker()]); - return attempts; -} - -async function discoverQualities(ctx, detailUrl, player, allowBrowser, captured) { - const media = captured || await resolvePlayerMedia(ctx, player.url, detailUrl, allowBrowser); - const result = { player: player, qualities: [], media: media.url ? media : null, stage: media.stage || 'player-media' }; - if (!media.url) return result; - if (!/\.m3u8(?:$|[?#])/i.test(media.url)) { - result.qualities = [{ name: 'MP4', height: 0 }]; - return result; - } - try { - const playlist = await fetchText(ctx, media.url, player.url, media.headers, 4, 'manifest-http'); - const variants = parseMasterPlaylist(media.url, playlist); - if (variants.length) result.qualities = variants.map(function (variant) { - return { name: variant.height ? variant.height + 'p' : 'HLS', height: variant.height || 0 }; - }); - else if (/#EXTINF/i.test(playlist)) result.qualities = [{ name: 'HLS 原始画质', height: 0 }]; - else result.stage = 'manifest-not-hls'; - } catch (error) { result.stage = error.stage || 'manifest-http'; } - return result; + return []; } -async function resolvePlayerMedia(ctx, playerUrl, detailUrl, allowBrowser) { - let stage = 'player-media'; +async function resolvePlayerMedia(ctx, playerUrl, detailUrl) { + let playable = ''; try { - const playable = extractPlayableURL(await fetchText(ctx, playerUrl, detailUrl || playerUrl, null, 4, 'player-http')); - if (playable) return { url: playable, headers: playbackHeaders(playerUrl) }; - } catch (error) { stage = error.stage || 'player-http'; } - if (allowBrowser) { - const captured = await extractFromBrowser(playerUrl, detailUrl, ctx, 10); - if (captured) return captured; + playable = extractPlayableURL(await fetchText(ctx, playerUrl, detailUrl || playerUrl)); + } catch (error) { + playable = ''; } - return { url: '', stage: stage }; + if (!playable) playable = await extractFromBrowser(playerUrl, detailUrl); + return playable; } function parseMasterPlaylist(masterURL, text) { @@ -516,21 +450,19 @@ function resolveRelativeURL(base, value) { return String(base || '').replace(/[?#].*$/, '').replace(/\/[^/]*$/, '/') + value; } -async function fetchText(ctx, url, referer, headers, limit, stage) { - const seconds = remainingPlaybackSeconds(ctx, limit || 6); - const response = await runStage(ctx, stage || 'http', seconds, function () { - return httpGet(url, { - headers: headers || requestHeaders(referer || url), - timeout: seconds, timeoutSeconds: seconds, - useBrowserCookie: false, attachBrowserCookie: false, - useBrowserFallback: false, browserFallback: false - }); +async function fetchText(ctx, url, referer) { + const response = await httpGet(url, { + headers: requestHeaders(referer || url), + useBrowserCookie: false, + attachBrowserCookie: false, + useBrowserFallback: false, + browserFallback: false }); const text = responseText(response); - const status = Number(response && (response.status || response.statusCode)); - if (status >= 400) throw stageError((stage || 'http') + '-' + status, '来源请求失败(HTTP ' + status + ')'); - if (isVerificationPage(text, status)) throw stageError((stage || 'http') + '-verification', '源站要求浏览器验证'); - if (!text) throw stageError((stage || 'http') + '-empty', '来源返回空内容'); + if (!text) throw new Error('请求失败: ' + url); + if (isVerificationPage(text, response && (response.status || response.statusCode))) { + throw new Error('源站返回了浏览器验证页'); + } return text; } @@ -550,54 +482,45 @@ async function httpGet(url, options) { throw new Error('当前环境没有可用的 HTTP 客户端'); } -async function extractFromBrowser(url, referer, ctx, limit) { - if (typeof Widget === 'undefined' || !Widget.browser || typeof Widget.browser.fetch !== 'function') return null; - const seconds = playbackBrowserSeconds(ctx, limit || 10, 1); +async function extractFromBrowser(url, referer) { + if (typeof Widget === 'undefined' || !Widget.browser || typeof Widget.browser.fetch !== 'function') return ''; try { - const result = await runStage(ctx, 'browser-media', seconds, function () { - return Widget.browser.fetch(url, { - visible: false, timeout: seconds, timeoutSeconds: seconds, - waitAfterLoad: 1, waitForMediaSource: true, - headers: requestHeaders(referer || url) - }); + const result = await Widget.browser.fetch(url, { + visible: false, + timeout: 70, + waitAfterLoad: 4, + waitForAny: true, + waitForMediaSource: true, + headers: requestHeaders(referer || url) }); - const captured = firstPlayableInBrowserResult(result); - if (captured) return captured; - const link = extractPlayableURL(responseText(result)) || extractPlayableURL(result && result.html); - if (link) return { url: link, headers: { 'User-Agent': JAVGG_UA } }; - const keys = Object.keys(result || {}).slice(0, 12).join(','); - const blobOnly = /blob:https?:/.test(JSON.stringify((result && (result.mediaSources || result.capturedRequests || result.mediaRequests)) || [])); - throw stageError(blobOnly ? 'browser-blob-only' : 'browser-no-media', '浏览器未回传可用媒体;blobOnly=' + blobOnly + ';keys=' + keys); + return firstPlayableInBrowserResult(result) || + extractPlayableURL(responseText(result)) || + extractPlayableURL(result && result.html); } catch (error) { - throw stageError(error.stage || 'browser-media', error.stage ? error.message.replace(/;stage=.*$/, '') : '浏览器媒体捕获失败'); + return ''; } } function firstPlayableInBrowserResult(result) { - if (!result) return null; - const arrays = [result.capturedRequests, result.mediaRequests, result.requests, result.responses, result.mediaSources, result.urls, [result]]; + if (!result) return ''; + const keys = ['url', 'mediaURL', 'mediaUrl', 'videoURL', 'videoUrl', 'playURL', 'playUrl', 'src']; + for (let i = 0; i < keys.length; i += 1) { + if (isPlayableURL(result[keys[i]])) return result[keys[i]]; + } + const arrays = [result.mediaSources, result.mediaRequests, result.requests, result.responses, result.urls]; for (let a = 0; a < arrays.length; a += 1) { if (!Array.isArray(arrays[a])) continue; for (let i = 0; i < arrays[a].length; i += 1) { const item = arrays[a][i]; - const url = typeof item === 'string' ? item : firstNonEmpty(item && item.url, item && item.src, item && item.responseURL, item && item.mediaURL, item && item.mediaUrl, item && item.videoURL, item && item.videoUrl, item && item.playURL, item && item.playUrl); - if (isPlayableURL(url)) return { url: url, headers: capturedHeaders(item && (item.requestHeaders || item.headers)) }; + const value = typeof item === 'string' ? item : firstNonEmpty(item && item.url, item && item.src, item && item.responseURL); + if (isPlayableURL(value) && !/^blob:/i.test(value)) return value; } } - return null; -} - -function capturedHeaders(input) { - const headers = { 'User-Agent': JAVGG_UA }; - if (input && typeof input === 'object') Object.keys(input).forEach(function (key) { - const canonical = { 'user-agent': 'User-Agent', referer: 'Referer', origin: 'Origin', cookie: 'Cookie' }[key.toLowerCase()]; - if (canonical) headers[canonical] = String(input[key]); - }); - return headers; + return ''; } function extractPlayableURL(value) { - const text = htmlDecode(responseText(value) + '\n' + unpackPlayerScripts(responseText(value)).join('\n')).replace(/\\\//g, '/'); + let text = htmlDecode(responseText(value)).replace(/\\\//g, '/'); const patterns = [ /(?:urlPlay|file|src)\s*[:=]\s*["'](https?:\/\/[^"']+\.(?:m3u8|mp4)(?:\?[^"']*)?)/i, /(https?:\/\/[^\s"'<>\\]+\.(?:m3u8|mp4)(?:\?[^\s"'<>\\]*)?)/i @@ -609,35 +532,6 @@ function extractPlayableURL(value) { return ''; } -// Decode the public Dean Edwards packing format as data; never execute source scripts. -function unpackPlayerScripts(value) { - const text = String(value || '').slice(0, 500000); - const pattern = /eval\(function\(p,a,c,k,e,[dr]\)[\s\S]*?\(\s*'((?:\\.|[^'\\])*)'\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*'((?:\\.|[^'\\])*)'\.split\('\|'\)/g; - const output = []; - let match; - while (output.length < 4 && (match = pattern.exec(text))) { - const radix = Number(match[2]), count = Number(match[3]); - if (radix < 2 || radix > 62 || count < 1 || count > 2000 || match[1].length > 100000) continue; - const words = decodePackedString(match[4]).split('|'); - const dictionary = Object.create(null); - for (let index = 0; index < count; index += 1) if (words[index]) dictionary[baseEncode(index, radix)] = words[index]; - output.push(decodePackedString(match[1]).replace(/\b[0-9a-zA-Z]+\b/g, function (token) { return dictionary[token] || token; })); - } - return output; -} - -function decodePackedString(text) { - return String(text || '').replace(/\\(['"\\])/g, '$1').replace(/\\x([0-9a-f]{2})|\\u([0-9a-f]{4})/gi, function (_, x, u) { return String.fromCharCode(parseInt(x || u, 16)); }); -} - -function baseEncode(number, radix) { - const alphabet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; - if (!number) return '0'; - let token = ''; - while (number > 0) { token = alphabet[number % radix] + token; number = Math.floor(number / radix); } - return token; -} - function requestHeaders(referer) { return { 'User-Agent': JAVGG_UA, @@ -651,13 +545,11 @@ function imageHeaders(referer) { } function playbackHeaders(playerUrl) { - const headers = { 'User-Agent': JAVGG_UA }; - // Observed on luluvdoo master, variant and segment requests (not the detail-page URL). - if (/^https:\/\/luluvdoo\.com(?:\/|$)/i.test(String(playerUrl))) { - headers.Referer = 'https://luluvdoo.com/'; - headers.Origin = 'https://luluvdoo.com'; - } - return headers; + return { + 'User-Agent': JAVGG_UA, + Referer: playerUrl, + Origin: originOf(playerUrl) + }; } function baseURL(ctx) { @@ -754,7 +646,7 @@ function responseText(response) { function isVerificationPage(html, status) { const text = String(html || '').slice(0, 30000); - return Number(status) === 403 || /Just a moment|Checking (?:your )?browser|cf-browser-verification|cf-chl-/i.test(text); + return Number(status) === 403 || /Just a moment|Checking your browser|cf-browser-verification|cf-chl-/i.test(text); } function absoluteURL(ctx, value) { @@ -913,54 +805,3 @@ if (typeof globalThis !== 'undefined') { Object.keys(exported).forEach(function (key) { globalThis[key] = exported[key]; }); } if (typeof module !== 'undefined' && module.exports) module.exports = exported; - -function stageError(stage, message) { - const error = new Error(message + ';stage=' + stage); - error.stage = stage; - return error; -} - -async function runStage(ctx, stage, seconds, operation) { - const budget = ctx && ctx.__playbackBudget; - if (budget && budget.expired) throw stageError('total-deadline', '解析超时'); - let timer; - try { - const timeout = new Promise(function (_, reject) { - timer = setTimeout(function () { reject(stageError(stage + '-timeout', '来源请求超时')); }, seconds * 1000); - }); - return await Promise.race([Promise.resolve().then(operation), timeout]); - } finally { clearTimeout(timer); } -} - -async function withPlaybackBudget(input, operation, label) { - const ctx = Object.assign({}, normalizeContext(input)); - const budget = { endAt: Date.now() + 28000, expired: false, browserCalls: 0 }; - ctx.__playbackBudget = budget; - let timer; - try { - const timeout = new Promise(function (_, reject) { - timer = setTimeout(function () { budget.expired = true; reject(stageError('total-deadline', (label || '播放解析') + '超时;请稍后重试')); }, 28000); - }); - return await Promise.race([Promise.resolve().then(function () { return operation(ctx); }), timeout]); - } finally { clearTimeout(timer); budget.expired = true; } -} - -function remainingPlaybackSeconds(ctx, limit) { - const budget = ctx && ctx.__playbackBudget; - if (!budget) return limit; - const remaining = (budget.endAt - Date.now()) / 1000; - if (budget.expired || remaining <= 0) throw new Error('播放解析超时;stage=total-deadline'); - return Math.max(0.1, Math.min(limit, remaining)); -} - -function playbackBrowserSeconds(ctx, limit, maxCalls) { - const timeout = remainingPlaybackSeconds(ctx, limit); - const budget = ctx && ctx.__playbackBudget; - if (budget && budget.browserCalls >= maxCalls) throw new Error('浏览器回退次数已用完;stage=browser-fallback-limit'); - if (budget) budget.browserCalls++; - return timeout; -} - -async function resolvePlayback(input) { - return withPlaybackBudget(input, resolvePlaybackWithinBudget); -} diff --git a/kbjfan-mini-library.js b/kbjfan-mini-library.js index 84f93e0..61b6819 100644 --- a/kbjfan-mini-library.js +++ b/kbjfan-mini-library.js @@ -9,8 +9,8 @@ const WidgetMetadata = { id: 'kbjfan-mini-library', name: 'KBJFan', title: 'KBJFan', - version: '1.0.2', - author: 'Alan huang', + version: '1.0.0', + author: 'EL', logo: KBJFAN_LOGO, icon: KBJFAN_LOGO, site: KBJFAN_DEFAULT_BASE, @@ -60,8 +60,12 @@ function getManifest() { } async function getHome(ctx) { - ctx = normalizeRuntimeContext(ctx); - const hero = (await loadSectionItems(ctx, KBJFAN_SECTIONS[0], 1)).slice(0, 8); + let hero = []; + try { + hero = (await loadSectionItems(ctx, KBJFAN_SECTIONS[0], 1)).slice(0, 8); + } catch (_) { + hero = []; + } return { pageType: 'home', id: 'kbjfan-home', @@ -83,7 +87,6 @@ async function getHome(ctx) { } async function getHomeSection(ctx) { - ctx = normalizeRuntimeContext(ctx); const section = findSection(ctx && (ctx.sectionId || ctx.id)) || KBJFAN_SECTIONS[0]; try { return { @@ -107,12 +110,9 @@ async function getHomeSection(ctx) { } async function getCategory(ctx) { - ctx = normalizeRuntimeContext(ctx); const section = findSection(ctx && (ctx.pageId || ctx.id || ctx.category || ctx.genreId)) || KBJFAN_SECTIONS[0]; const page = positiveInt(contextValue(ctx, 'page'), 1); const html = await fetchText(ctx, categoryURL(ctx, section, page)); - const items = parseListHtml(ctx, html); - if (!items.length && page === 1) throw new Error('KBJFan 当前页面没有原站影片,请确认可用站点地址'); return { pageType: 'category', id: section.id, @@ -121,12 +121,11 @@ async function getCategory(ctx) { itemAspectRatio: '16:9', page: page, hasMore: hasNextPage(html, page), - items: items + items: parseListHtml(ctx, html) }; } async function getDetail(ctx) { - ctx = normalizeRuntimeContext(ctx); const detailURL = detailURLFromContext(ctx); if (!detailURL) throw new Error('KBJFan 详情参数无效'); const html = await fetchText(ctx, detailURL); @@ -150,7 +149,6 @@ async function getDetail(ctx) { } async function getResourceVersions(ctx) { - ctx = normalizeRuntimeContext(ctx); const detailURL = detailURLFromContext(ctx); if (!detailURL) return []; let videoURL = directMediaURLFromContext(ctx); @@ -161,12 +159,10 @@ async function getResourceVersions(ctx) { videoURL = detail.videoURL; title = detail.title || title; } - if (!videoURL) throw new Error('KBJFan 当前详情没有可播放媒体,可能是站点已迁移或内容不可用'); return resourceGroupsFor(ctx, detailURL, title, videoURL); } async function resolvePlayback(ctx) { - ctx = normalizeRuntimeContext(ctx); let detailURL = detailURLFromContext(ctx); let videoURL = directMediaURLFromContext(ctx); if (!videoURL && detailURL) { @@ -186,7 +182,6 @@ async function resolvePlayback(ctx) { } async function search(ctx) { - ctx = normalizeRuntimeContext(ctx); const query = cleanText(contextValue(ctx, 'query') || contextValue(ctx, 'keyword') || contextValue(ctx, 'text')); const page = positiveInt(contextValue(ctx, 'page'), 1); if (!query) return { pageType: 'search', title: '搜索结果', keyword: '', page: page, hasMore: false, items: [] }; @@ -267,7 +262,6 @@ function parseDetailHtml(ctx, html, detailURL) { } function resourceGroupsFor(ctx, detailURL, title, videoURL) { - if (!videoURL) return []; return [{ id: 'online', title: '在线播放', @@ -286,49 +280,14 @@ function resourceGroupsFor(ctx, detailURL, title, videoURL) { } async function loadSectionItems(ctx, section, page) { - const items = parseListHtml(ctx, await fetchText(ctx, categoryURL(ctx, section, page))); - if (!items.length && page === 1) throw new Error('KBJFan 当前页面没有原站影片;请确认站点地址是否仍提供 KBJFan 内容。'); - return items; + return parseListHtml(ctx, await fetchText(ctx, categoryURL(ctx, section, page))); } async function fetchText(ctx, url) { - try { - const response = await kbjDeadline(async function () { - const options = { headers: requestHeaders(ctx, url), timeout: 8, timeoutSeconds: 8, browserFallback: false }; - let response; - if (typeof Widget !== 'undefined' && Widget.http && typeof Widget.http.get === 'function') response = await Widget.http.get(url, options); - else if (typeof Widget !== 'undefined' && Widget.http && typeof Widget.http.request === 'function') response = await Widget.http.request(Object.assign({ url: url, method: 'GET' }, options)); - else if (typeof $http !== 'undefined' && typeof $http.get === 'function') response = await $http.get(url, options); - else throw new Error('当前环境没有 HTTP 客户端'); - const final = response && (response.finalURL || response.urlEffective || response.responseURL || response.url); - if (final && kbjHost(final) && kbjHost(final) !== kbjHost(baseURL(ctx))) throw new Error('原域名已跳转到其他站点,请更新为已确认的 KBJFan 地址'); - const status = Number(response && (response.statusCode || response.status) || 200); - if (status >= 400) throw new Error('站点返回 HTTP ' + status); - const text = await kbjResponseText(response); - if (!text) throw new Error('站点返回空内容'); - if (/Just a moment|Checking (?:your )?browser|cf-chl-|cf-mitigated|Cloudflare Ray ID/i.test(text)) throw new Error('站点返回验证页,未收到影片内容'); - return text; - }, 8000); - return response; - } catch (error) { - throw new Error('KBJFan 加载失败:' + stringValue(error && error.message || error) + '。原下载路径仍保留;无法连接或站点已迁移时需确认可用站点地址。'); - } -} -function kbjHost(url) { const match = String(url || '').match(/^https?:\/\/([^/?#]+)/i); return match ? match[1].toLowerCase().replace(/^www\./, '') : ''; } -async function kbjResponseText(response) { - if (typeof response === 'string') return response; - if (!response) return ''; - if (typeof response.text === 'function') return String(await response.text()); - for (const value of [response.data, response.body, response.html, response.text]) { - if (typeof value === 'string') return value; - if (value && typeof value.html === 'string') return value.html; - } - return ''; -} -async function kbjDeadline(work, milliseconds) { - let timer; - try { return await Promise.race([Promise.resolve().then(work), new Promise(function (_, reject) { timer = setTimeout(function () { reject(new Error('HTTP 等待超时(8 秒)')); }, milliseconds); })]); } - finally { clearTimeout(timer); } + const response = await Widget.http.get(url, { headers: requestHeaders(ctx, url) }); + const data = response && (response.data || response.body || response.text); + if (!data) throw new Error('请求失败: ' + url); + return String(data); } function requestHeaders(ctx, referer) { @@ -424,16 +383,3 @@ function escapeRegExp(value) { return stringValue(value).replace(/[.*+?^${}()|[\ const KBJFAN_API = { getManifest, getHome, getHomeSection, getCategory, getDetail, getResourceVersions, resolvePlayback, search, onSearch, getSearch, play, getPlayback }; if (typeof globalThis !== 'undefined') Object.keys(KBJFAN_API).forEach(function (key) { globalThis[key] = KBJFAN_API[key]; }); if (typeof module !== 'undefined' && module.exports) module.exports = KBJFAN_API; - -function normalizeRuntimeContext(input) { - let ctx = input; - if (typeof ctx === 'string') { try { ctx = JSON.parse(ctx); } catch (_) { ctx = {}; } } - if (!ctx || typeof ctx !== 'object' || Array.isArray(ctx)) return {}; - const nested = {}; - ['params', 'config', 'settings', 'parameters', 'pagination', 'pageInfo'].forEach(function (key) { - let bag = ctx[key]; - if (typeof bag === 'string') { try { bag = JSON.parse(bag); } catch (_) { bag = null; } } - if (bag && typeof bag === 'object' && !Array.isArray(bag)) Object.keys(bag).forEach(function (name) { if (nested[name] === undefined) nested[name] = bag[name]; }); - }); - return Object.assign(nested, ctx); -} diff --git a/madou8-mini-library 5.js b/madou8-mini-library 5.js index 0a2cf5c..1d01695 100644 --- a/madou8-mini-library 5.js +++ b/madou8-mini-library 5.js @@ -8,7 +8,7 @@ const WidgetMetadata = { id: 'madou8-mini-library', name: '麻豆视频', title: '麻豆视频', - version: '1.1.2', + version: '1.0.1', 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: '本月热门', sourceTitle: '热门影片', path: 'videos/hot/month', style: 'discover.spotlight' }, + { id: 'hot-month', title: '本月热门', 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,38 +38,27 @@ function getManifest() { } async function getHome(ctx) { - ctx = madouContext(ctx, 12000); - const recent = parseCards(await fetchText(ctx, listURL(ctx, SECTIONS[0], 1)), ctx).slice(0, 18); - if (!recent.length) throw new Error('麻豆首页未收到影片列表;请检查站点地址或网络验证状态'); + let recent = []; + try { recent = parseCards(await fetchText(ctx, listURL(ctx, SECTIONS[0], 1)), ctx).slice(0, 18); } catch (_) {} return { pageType: 'home', id: 'madou8-home', title: WidgetMetadata.title, heroAspectRatio: '16:9', hero: recent.slice(0, 6).map(wideItem), - sections: [{ id: 'madou8-categories', title: '分类浏览', style: 'discover.annualPosterStack', lazy: true, - loadAction: { type: 'custom', id: 'madou8-categories', sectionId: 'madou8-categories' }, items: [] }, + sections: [{ id: 'madou8-categories', title: '分类浏览', style: 'discover.annualCategories', lazy: false, items: SECTIONS.map(categoryCard) }, { id: 'recent', title: '最近更新', style: 'discover.ranked', lazy: false, moreAction: categoryAction(SECTIONS[0]), items: ranked(recent) } ].concat(SECTIONS.slice(1).map(sectionShell)) }; } async function getHomeSection(ctx) { - ctx = madouContext(ctx, 20000); - if ((ctx.sectionId || ctx.id) === 'madou8-categories') { - const pairs = await madouMap(SECTIONS, 2, async function (section) { - try { return categoryPreviewCard(section, parseCards(await fetchText(ctx, listURL(ctx, section, 1)), ctx)); } catch (_) { return null; } - }); - const items = pairs.filter(Boolean); - return { id: 'madou8-categories', title: '分类浏览', style: 'discover.annualPosterStack', lazy: false, items: items, error: items.length ? undefined : '分类预览加载失败,请稍后重试' }; - } const section = findSection(ctx && (ctx.sectionId || ctx.id || ctx.pageId)) || SECTIONS[0]; try { const items = parseCards(await fetchText(ctx, listURL(ctx, section, 1)), ctx).slice(0, 18); - if (!items.length) throw new Error('当前分类未返回影片条目'); return { id: section.id, title: section.title, style: section.style, lazy: false, moreAction: categoryAction(section), items: section.style === 'discover.ranked' ? ranked(items) : items }; } catch (error) { return emptySection(section, error); } } async function getCategory(ctx) { - ctx = madouContext(ctx); + ctx = normalizeContext(ctx); const page = positiveInt(contextValue(ctx, 'page'), 1); const section = findSection(ctx.pageId || ctx.id) || { id: String(ctx.pageId || ctx.id || 'recent'), title: ctx.title || '麻豆视频', path: ctx.path || 'videos/recent' }; try { @@ -81,7 +70,7 @@ async function getCategory(ctx) { } async function search(ctx) { - ctx = madouContext(ctx); + ctx = normalizeContext(ctx); const query = String(ctx.query || ctx.keyword || ctx.text || '').trim(); const page = positiveInt(contextValue(ctx, 'page'), 1); if (!query) return { pageType: 'search', title: '搜索', query: '', page: page, hasMore: false, items: [] }; @@ -91,7 +80,7 @@ async function search(ctx) { } async function getDetail(ctx) { - ctx = madouContext(ctx); + ctx = normalizeContext(ctx); const url = detailURL(ctx); if (!url) throw new Error('详情参数无效'); const html = await fetchText(ctx, url); @@ -115,7 +104,7 @@ async function getDetail(ctx) { } async function getResourceVersions(ctx) { - ctx = madouContext(ctx); + ctx = normalizeContext(ctx); const url = detailURL(ctx); if (!url) return { groups: [] }; const html = await fetchText(ctx, url); @@ -124,31 +113,26 @@ async function getResourceVersions(ctx) { } async function resolvePlayback(ctx) { - ctx = madouContext(ctx); + ctx = normalizeContext(ctx); const url = detailURL(ctx); if (!url) throw new Error('播放参数无效:缺少详情地址或视频 ID'); - const decoded = decodeVersionId(ctx.versionId); - let uid = String(ctx.videoUid || decoded.uid || ''); - if (!uid) { - const html = await fetchText(ctx, url); - uid = String(extractVideoUid(html) || ''); - } + const html = await fetchText(ctx, url); + const uid = String(ctx.videoUid || 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); const chosen = await chooseStream(ctx, info.playlist || [], requestedHeight, url); if (!chosen || !chosen.url) throw new Error('播放解析失败:流接口未返回可播放的 M3U8'); - return { url: chosen.url, container: 'm3u8', headers: chosen.headers || playbackHeaders(ctx, url), startPositionSeconds: 0, isLive: false, streamKind: 'hls' }; + return { url: chosen.url, container: 'm3u8', headers: playbackHeaders(ctx, url), startPositionSeconds: 0, isLive: false, streamKind: 'vod' }; } async function buildPlaybackGroups(ctx, detailUrl, uid, title) { const info = await fetchStreamInfo(ctx, uid, detailUrl); const variants = await discoverVariants(ctx, info.playlist || [], detailUrl); - if (!variants.length) throw new Error('没有可验证的 HLS 线路;专用网页播放器线路不能作为原生播放地址'); - const qualities = variants; + const qualities = variants.length ? variants : [{ height: 0, label: '自动' }]; return [{ id: 'hls', title: '在线线路', versions: qualities.map(function (q, index) { const id = encodeVersionId({ url: detailUrl, uid: uid, height: q.height || 0 }); - return { id: id, name: q.height ? q.height + 'P' : '原始画质', subtitle: q.height ? '切换时请稍候' : '源清单未标明分辨率', container: 'm3u8', default: index === 0, action: { type: 'play', itemId: videoSlug(detailUrl), detailUrl: detailUrl, videoUid: uid, qualityId: q.height || 0, versionId: id, title: title } }; + return { id: id, name: q.height ? q.height + 'P' : q.label, subtitle: q.height ? '切换时请稍候' : '自动画质', container: 'm3u8', default: index === 0, headers: playbackHeaders(ctx, detailUrl), action: { type: 'play', itemId: videoSlug(detailUrl), detailUrl: detailUrl, videoUid: uid, qualityId: q.height || 0, versionId: id, title: title } }; }) }]; } @@ -160,70 +144,94 @@ async function fetchStreamInfo(ctx, uid, detailUrl) { return data; } -function nativePlaylist(playlist) { - return (playlist || []).filter(function (item) { - return item && /^https?:\/\//i.test(String(item.url || '')) && item.native !== false && - (!item.playMode || item.playMode === 'fetchm3u8') && !/\.enc(?:[/:]|$)/i.test(item.url); - }).slice(0, 12); -} -function streamHeaders(ctx, detailUrl, item) { - const headers = playbackHeaders(ctx, detailUrl); - // The public player's xhrSetup uses this exact Accept value for signed lines. - if (item && typeof item.urlSign === 'string' && item.urlSign) headers.Accept = '*/*;sign=' + encodeURIComponent(item.urlSign); - return headers; -} async function discoverVariants(ctx, playlist, detailUrl) { - const results = await madouMap(nativePlaylist(playlist), 3, async function (item) { + const discovered = []; + for (let i = 0; i < Math.min(playlist.length, 4); i++) { + const url = playlist[i] && playlist[i].url; + if (!/^https?:\/\//i.test(String(url || ''))) continue; try { - const headers = streamHeaders(ctx, detailUrl, item); - const body = await fetchHlsText(ctx, item.url, detailUrl, headers); - const variants = parseMaster(body, item.url); - if (variants.length) return variants; - if (/#EXTINF/i.test(body)) return [inferQuality(item.url) || { height: 0, label: '原始画质' }]; + 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)) { + const inferred = inferQuality(url); + if (inferred) discovered.push(inferred); + } } catch (_) {} - return []; - }); - return uniqueBy([].concat.apply([], results), function (x) { return x.height; }).sort(function (a, b) { return b.height - a.height; }); + } + return uniqueBy(discovered, function (x) { return x.height; }).sort(function (a, b) { return b.height - a.height; }); } async function chooseStream(ctx, playlist, requestedHeight, detailUrl) { - const failures = []; - const results = await madouMap(nativePlaylist(playlist), 3, async function (item) { + let fallback = null; + for (let i = 0; i < Math.min(playlist.length, 4); i++) { + const item = playlist[i]; + if (!item || !/^https?:\/\//i.test(String(item.url || ''))) continue; try { - const headers = streamHeaders(ctx, detailUrl, item); - const body = await fetchHlsText(ctx, item.url, detailUrl, headers); + 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)) throw new Error('empty-media-playlist'); - return Object.assign(inferQuality(item.url) || { height: 0 }, { url: item.url, headers: headers }); + if (!/#EXTINF/i.test(body) || !await probeFirstSegment(ctx, body, item.url, detailUrl)) continue; + const inferred = inferQuality(item.url) || { url: item.url, height: 0 }; + inferred.url = item.url; + if (!fallback) fallback = inferred; + if (!requestedHeight || inferred.height === requestedHeight) return inferred; + } else { + const ordered = requestedHeight + ? variants.filter(function (x) { return x.height === requestedHeight; }).concat(variants.filter(function (x) { return x.height !== requestedHeight; })) + : variants; + for (let j = 0; j < ordered.length; j++) { + if (await verifyMediaStream(ctx, ordered[j].url, detailUrl)) return ordered[j]; + } } - const ordered = requestedHeight ? variants.filter(function (q) { return q.height === requestedHeight; }).concat(variants.filter(function (q) { return q.height !== requestedHeight; })) : variants; - for (const variant of ordered.slice(0, 6)) { - try { - if (/#EXTINF/i.test(await fetchHlsText(ctx, variant.url, detailUrl, headers))) return Object.assign(variant, { headers: headers }); - } catch (_) {} - } - throw new Error('variant-unavailable'); - } catch (error) { failures.push(errorMessage(error)); return null; } - }); - const candidates = results.filter(Boolean).sort(function (a, b) { return (b.height || 0) - (a.height || 0); }); - const selected = requestedHeight && candidates.find(function (q) { return q.height === requestedHeight; }); - if (selected || candidates.length) return selected || candidates[0]; - throw new Error('播放线路检查失败:未收到有效 HLS(' + unique(failures).slice(0, 3).join(';') + ');专用网页播放器线路不支持原生交回'); + } catch (_) {} + } + if (fallback) return fallback; + throw new Error('播放线路检查失败:所有清单或首个媒体分片均不可用'); } -async function fetchHlsText(ctx, url, detailUrl, headers) { - const body = await fetchText(ctx, url, headers || playbackHeaders(ctx, detailUrl), true); - if (!/^\s*#EXTM3U/i.test(body)) throw new Error('HLS 正文无效'); +async function fetchHlsText(ctx, url, detailUrl) { + const body = await fetchText(ctx, url, playbackHeaders(ctx, detailUrl)); + if (!/^\s*#EXTM3U/i.test(body)) throw new Error('HLS 清单无效:' + url); return body; } -async function madouMap(items, concurrency, work) { - const results = new Array(items.length); let cursor = 0; - await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, async function () { - while (cursor < items.length) { const index = cursor++; results[index] = await work(items[index], index); } - })); - return results; +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) { @@ -264,45 +272,12 @@ 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 explicit = firstNonEmpty(ctx && ctx.detailUrl, ctx && ctx.detailURL); - if (/^https?:\/\//i.test(explicit)) return explicit; - for (const value of [ctx && ctx.itemId, ctx && ctx.id, ctx && ctx.url]) { - if (/^https?:\/\//i.test(String(value || '')) && videoSlug(value)) return String(value); - } - const id = firstNonEmpty(ctx && ctx.itemId, ctx && ctx.id, decodeVersionId(ctx && ctx.versionId).slug); - return id && !/^https?:/i.test(id) ? entryURL(ctx) + '/video/cid/' + encodeURIComponent(String(id).replace(/^.*\//, '')) : ''; -} +function 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 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 categoryCard(s) { return { id: s.id, title: s.title, type: 'category', subtitle: '浏览' + s.title, 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; }); } @@ -318,90 +293,26 @@ function attribute(tag, name) { const m = String(tag).match(new RegExp('\\b' + n function imageHeaders(ctx, referer) { return { Referer: referer || entryURL(ctx) + '/', 'User-Agent': MADOU8_UA }; } function playbackHeaders(ctx, referer) { return { Referer: referer || entryURL(ctx) + '/', 'User-Agent': MADOU8_UA, Accept: '*/*' }; } -async function fetchText(ctx, url, extraHeaders, mediaRequest) { - const headers = Object.assign({ 'User-Agent': MADOU8_UA, Accept: 'text/html,application/xhtml+xml,application/json,application/vnd.apple.mpegurl,*/*;q=0.8', Referer: entryURL(ctx) + '/', 'Cache-Control': 'no-cache' }, extraHeaders || {}); - const response = await madouStage(ctx, mediaRequest ? 'hls-http' : 'page-http', mediaRequest ? 4 : 6, async function () { - const r = await httpGet(url, { headers: headers, timeout: mediaRequest ? 4 : 6, timeoutSeconds: mediaRequest ? 4 : 6, browserFallback: false }); - return { status: Number(r && (r.statusCode || r.status) || 200), text: await unwrapResponse(r) }; - }); - let text = response.text; - const challenge = /Just a moment|Checking (?:your )?browser|cf-chl-|cf-mitigated|Cloudflare Ray ID/i.test(text); - if (challenge && !mediaRequest && !(ctx && ctx.__browserUsed) && typeof Widget !== 'undefined' && Widget.browser && typeof Widget.browser.fetch === 'function') { - ctx.__browserUsed = true; - text = await madouStage(ctx, 'page-browser', 8, async function () { - return await unwrapResponse(await Widget.browser.fetch(url, { visible: false, timeout: 8, timeoutSeconds: 8, waitAfterLoad: 1, headers: headers })); - }); - if (!text || /Just a moment|Checking (?:your )?browser|cf-chl-|cf-mitigated|Cloudflare Ray ID/i.test(text)) throw new Error('站点验证未返回影片内容,stage=page-browser'); - } else if (response.status >= 400) throw new Error('HTTP ' + response.status + ',stage=' + (mediaRequest ? 'hls-http' : 'page-http')); - else if (challenge) throw new Error('站点返回验证页,stage=' + (mediaRequest ? 'hls-http' : 'page-http')); - if (!text) throw new Error('HTTP 返回空内容'); - return text; -} -function madouContext(ctx, milliseconds) { return Object.assign({}, normalizeContext(ctx), { __deadline: Date.now() + (milliseconds || 28000), __browserUsed: false }); } -async function madouStage(ctx, stage, seconds, work) { - const limit = Math.min(seconds * 1000, ctx && ctx.__deadline ? ctx.__deadline - Date.now() : seconds * 1000); - if (limit <= 0) throw new Error('解析总时限已到,stage=total-deadline'); - let timer; - try { return await Promise.race([Promise.resolve().then(work), new Promise(function (_, reject) { timer = setTimeout(function () { reject(new Error('等待超时,stage=' + stage)); }, limit); })]); } - finally { clearTimeout(timer); } -} - +async function fetchText(ctx, url, extraHeaders) { const response = await httpGet(url, { headers: Object.assign({ 'User-Agent': MADOU8_UA, Accept: 'text/html,application/xhtml+xml,application/json,application/vnd.apple.mpegurl,*/*;q=0.8', Referer: entryURL(ctx) + '/' }, extraHeaders || {}) }); const text = unwrapResponse(response); if (!text) throw new Error('HTTP 返回空内容:' + url); if (/Just a moment|cf-mitigated|Cloudflare Ray ID/i.test(text)) throw new Error('站点返回 Cloudflare 验证页'); return text; } async function httpGet(url, options) { if (typeof Widget !== 'undefined' && Widget && Widget.http) { if (typeof Widget.http.get === 'function') return Widget.http.get(url, options || {}); if (typeof Widget.http.request === 'function') return Widget.http.request(Object.assign({ url: url, method: 'GET' }, options || {})); } if (typeof $http !== 'undefined' && $http) { if (typeof $http.get === 'function') return $http.get(url, options || {}); if (typeof $http.request === 'function') return $http.request(Object.assign({ url: url, method: 'GET' }, options || {})); } if (typeof fetch === 'function') return fetch(url, options || {}); throw new Error('当前环境没有可用的 HTTP 客户端'); } -async function unwrapResponse(value) { - if (value == null) return ''; - if (typeof value === 'string') return value; - if (typeof value.text === 'function') return String(await value.text()); - for (const candidate of [value.data, value.body, value.html, value.text]) { - if (typeof candidate === 'string') return candidate; - if (candidate && typeof candidate.html === 'string') return candidate.html; - } - return value.data && typeof value.data === 'object' ? JSON.stringify(value.data) : ''; -} -function normalizeContext(input) { - let ctx = input; - if (typeof ctx === 'string') { try { ctx = JSON.parse(ctx); } catch (_) { return {}; } } - if (!ctx || typeof ctx !== 'object' || Array.isArray(ctx)) return {}; - const nested = {}; - for (const name of ['params', 'config', 'settings', 'parameters', 'pagination', 'pageInfo']) { - let bag = ctx[name]; - if (typeof bag === 'string') { try { bag = JSON.parse(bag); } catch (_) { bag = null; } } - if (bag && typeof bag === 'object') Object.assign(nested, bag); - } - return Object.assign(nested, ctx); -} - +function unwrapResponse(value) { if (value == null) return ''; if (typeof value === 'string') return value; if (typeof value.text === 'function') return value.text(); if (typeof value.body === 'string') return value.body; if (typeof value.data === 'string') return value.data; if (value.data && typeof value.data.html === 'string') return value.data.html; if (typeof value.text === 'string') return value.text; return JSON.stringify(value.data || value.body || value); } +function normalizeContext(ctx) { if (typeof ctx === 'string') { try { return JSON.parse(ctx); } catch (_) { return {}; } } return ctx && typeof ctx === 'object' ? ctx : {}; } function contextValue(ctx, key) { ctx = normalizeContext(ctx); const bags = [ctx, ctx.params, ctx.config, ctx.settings, ctx.parameters, ctx.pagination, ctx.pageInfo]; for (let i = 0; i < bags.length; i++) if (bags[i] && bags[i][key] != null && bags[i][key] !== '') return bags[i][key]; return ''; } function parseJSON(s) { try { return JSON.parse(String(s || '')); } catch (_) { return null; } } function encodeVersionId(x) { return 'madou8://' + encodeURIComponent(JSON.stringify({ slug: videoSlug(x.url), url: x.url, uid: x.uid, height: x.height || 0 })); } function decodeVersionId(v) { const s = String(v || ''); if (s.indexOf('madou8://') !== 0) return {}; try { return JSON.parse(decodeURIComponent(s.slice(9))); } catch (_) { return {}; } } -function resolveURL(value, base) { - const input = String(value || '').trim(); if (!input) return ''; - if (/^https?:\/\//i.test(input)) return input; - if (typeof URL === 'function') { try { return new URL(input, base).toString(); } catch (_) {} } - const origin = String(base || '').match(/^(https?:)\/\/([^/?#]+)/i); if (!origin) return ''; - if (input.indexOf('//') === 0) return origin[1] + input; - const root = origin[1] + '//' + origin[2]; - if (input.charAt(0) === '?') return String(base).split(/[?#]/)[0] + input; - const suffix = (input.match(/[?#][\s\S]*$/) || [''])[0]; - const relativePath = input.split(/[?#]/)[0]; - const path = relativePath.charAt(0) === '/' ? relativePath : String(base).slice(root.length).split(/[?#]/)[0].replace(/[^/]*$/, '') + relativePath; - const parts = []; - path.split('/').forEach(function (part) { if (part === '..') parts.pop(); else if (part && part !== '.') parts.push(part); }); - return root + '/' + parts.join('/') + (/\/$/.test(path) ? '/' : '') + suffix; -} - +function resolveURL(value, base) { try { return new URL(value, base).toString(); } catch (_) { if (/^https?:\/\//i.test(value)) return value; return ''; } } function absoluteURL(ctx, value) { return resolveURL(decodeHTML(value), baseURL(ctx) + '/'); } function firstMatch(s, re) { const m = String(s || '').match(re); return m ? m[1] || '' : ''; } function firstNonEmpty() { for (let i = 0; i < arguments.length; i++) if (arguments[i] != null && String(arguments[i]).trim()) return String(arguments[i]).trim(); return ''; } 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 7646e27..4d143b5 100644 --- a/missav-mini-library-CloudFlare.js +++ b/missav-mini-library-CloudFlare.js @@ -4,7 +4,6 @@ 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'; @@ -12,8 +11,8 @@ const WidgetMetadata = { id: 'missav-mini-library', name: 'MissAV', title: 'MissAV', - version: '1.5.11', - author: 'Alan huang', + version: '1.0.0', + author: 'baiPlay', logo: MISSAV_LOGO, icon: MISSAV_LOGO, site: MISSAV_DEFAULT_BASE, @@ -21,72 +20,43 @@ const WidgetMetadata = { }; const MISSAV_SECTIONS = [ - { 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' } + { 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' } ]; -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' +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 作品专区' } ]; function getManifest() { @@ -144,82 +114,19 @@ 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) { - ctx = normalizeContext(ctx); - const html = await safeFetch(ctx, entryURL(ctx), baseURL(ctx) + '/'); + const html = await safeFetch(entryURL(ctx), baseURL(ctx) + '/'); const hero = parseCards(sectionBlock(html, '推荐给你'), '推荐给你', ctx).slice(0, 10).map(toWideItem); - const mediaSections = homeMediaSections(ctx, parseHomeSections(ctx, html)); - const artwork = categoryArtworkFromSections(ctx, mediaSections); - const sections = categoryGroupSections(ctx, artwork).concat(mediaSections); + 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); return { pageType: 'home', @@ -231,81 +138,26 @@ 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.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 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 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 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 = { +function categoryCard(ctx, category) { + return { id: 'category-' + category.id, title: category.title, - subtitle: category.subtitle || '浏览' + category.title + '相关内容', + subtitle: category.subtitle || '', type: 'category', aspectRatio: '16:9', - previewItems: previewItems, + previewItems: [], action: { type: 'category', id: category.id, @@ -315,273 +167,37 @@ function categoryCard(ctx, category, livePreviewItems) { 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, url, entryURL(ctx)); - const items = filterCategoryItems( - parseCards(mediaListBlock(html), section.title, ctx), - section - ).slice(0, 18); - rememberCategoryArtwork(ctx, section.id, items); + const html = await fetchText(ctx, categoryURL(ctx, section.path)); return { id: section.id, title: section.title, style: section.style, lazy: false, moreAction: categoryAction(ctx, section), - items: items + items: parseCards(html, section.title, ctx).slice(0, 18) }; } catch (error) { - return verificationSection(ctx, section.id, section.title, section.style, url, error); + return emptySection(section.id, section.title, section.style, 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) { - ctx = normalizeContext(ctx); - const requestedPage = pageFromContext(ctx, 1); + const page = positiveInt(contextValue(ctx, 'page'), 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 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 url = pagedURL(categoryURL(ctx, path), page); + const html = await fetchText(ctx, url); const title = section ? section.title : primary ? primary.title : pageTitle(html) || 'MissAV'; - 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; + const items = parseCards(html, title, ctx); + const categoryItems = items.length ? [] : parseCategoryCards(ctx, html, title); return { pageType: 'category', @@ -589,28 +205,17 @@ async function getCategory(ctx) { title: title, style: items.length ? 'media.posterGrid' : 'discover.annualCategories', itemAspectRatio: '16:9', - 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 + items: items.length ? items : categoryItems, + page: page, + hasMore: items.length ? hasNextPage(html, page) : false }; } async function getDetail(ctx) { - ctx = normalizeContext(ctx); const detailURL = detailUrlFromContext(ctx); if (!detailURL) throw new Error('MissAV 详情参数无效'); - let html = ''; - try { - html = await fetchText(ctx, detailURL, entryURL(ctx)); - } catch (error) { - return verificationDetail(ctx, detailURL, error); - } + const html = await fetchText(ctx, detailURL); const fallbackTitle = titleFromUrl(detailURL); const title = cleanText( firstNonEmpty( @@ -642,21 +247,9 @@ async function getDetail(ctx) { firstMatch(html, /(\d{1,2}:\d{2}:\d{2})/i) ); const releaseDate = firstMatch(html, /(\d{4}-\d{2}-\d{2})/); - 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]; + const detailPlayableURL = absoluteURL(ctx, extractPlayableURL(html)); + const resourceGroups = playbackGroups(detailURL, title, detailPlayableURL, ctx); + const defaultVersion = resourceGroups[0] && resourceGroups[0].versions && resourceGroups[0].versions[0]; return { id: makeItemId(detailURL, title, poster), @@ -678,19 +271,19 @@ async function getDetail(ctx) { remarks: durationText || code, resourceGroups: resourceGroups, resourceSummary: { - versionCount: versions.length, + versionCount: defaultVersion ? 1 : 0, episodeCount: 0, defaultVersionId: defaultVersion ? defaultVersion.id : '' }, - mediaSources: defaultVersion && defaultVersion.url ? [ + mediaSources: detailPlayableURL ? [ { - id: defaultVersion.id, - name: defaultVersion.name, - displayName: defaultVersion.name, + id: encodePayload({ url: detailURL, title: title, playUrl: detailPlayableURL }), + name: 'MissAV HLS', + displayName: 'MissAV HLS', protocol: 'hls', container: 'm3u8', - url: defaultVersion.url, - path: defaultVersion.url, + url: detailPlayableURL, + path: detailPlayableURL, headers: playbackHeaders(ctx, detailURL), header: playbackHeaders(ctx, detailURL), Header: playbackHeaders(ctx, detailURL), @@ -715,70 +308,34 @@ 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) { - 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; - } + if (direct) return playbackGroups(detailURL, title, direct, ctx); try { - 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; + const html = await fetchText(ctx, detailURL); + return playbackGroups(detailURL, title, extractPlayableURL(html), ctx); } catch (error) { - return []; + return playbackGroups(detailURL, title, '', ctx); } } -async function resolvePlaybackWithinBudget(ctx) { - ctx = normalizeContext(ctx); - const requestedQuality = qualityFromContext(ctx); +async function resolvePlayback(ctx) { const direct = firstNonEmpty(playUrlFromContext(ctx), ctx && ctx.url, ctx && ctx.playUrl, ctx && ctx.videoUrl); - const stableDetailURL = detailUrlFromContext(ctx); - if (!isDetailPageURL(stableDetailURL) && !requestedQuality && isPlayableURL(direct) && !isDetailPageURL(direct)) { + if (isPlayableURL(direct) && !isDetailPageURL(direct)) { return playbackResult(ctx, direct, ctx && ctx.referer); } const detailURL = detailUrlFromContext(ctx); if (!detailURL) throw new Error('MissAV 播放参数无效'); - let html = ''; - try { - html = await fetchText(ctx, detailURL, entryURL(ctx)); - } catch (error) { - recordPlaybackFailure(ctx, error); - } - let masterURL = extractPlayableURL(html); - if (!masterURL) masterURL = await extractPlayableFromLinkedPlayers(ctx, html, detailURL); - if (!masterURL) masterURL = await extractFromBrowser(detailURL, detailURL, ctx); - let url = masterURL; - if (masterURL) { - const qualities = await discoverAvailableQualities(ctx, masterURL, detailURL, html); - // A valid master can let the native player choose a variant. Optional quality - // discovery must not spend a browser round or prevent that handoff. - const selected = selectQuality(qualities, requestedQuality); - if (selected && selected.url) url = selected.url; - } + const html = await fetchText(ctx, detailURL); + const url = firstNonEmpty( + extractPlayableURL(html), + await extractPlayableFromLinkedPlayers(ctx, html, detailURL), + await extractFromBrowser(detailURL, detailURL) + ); if (!url) { - const failures = ctx.__playbackBudget.failures.join(';'); - throw new Error('未能解析到 MissAV 播放地址;stage=media-discovery' + (failures ? ';' + failures : '') + '。请检查站点地址;若提示验证,请先手动完成验证。'); + throw new Error('未能解析到 MissAV 播放地址。站点可能启用了 Cloudflare 或更换了播放器脚本,请尝试更新入口路径或在 App 侧启用浏览器请求。'); } return playbackResult(ctx, absoluteURL(ctx, url), detailURL); } @@ -829,36 +386,18 @@ 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 = pageFromContext(ctx, 1); + const page = positiveInt(contextValue(ctx, 'page'), 1); const url = pagedURL(categoryURL(ctx, '/search/' + encodeURIComponent(query.replace(/\\/g, ''))), page); - 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 html = await fetchText(ctx, url); const items = parseCards(html, query, ctx); - const pagination = paginationInfo(html, page, items.length); return { pageType: 'search', title: '搜索结果', items: items, page: page, - currentPage: page, - pageIndex: page, - nextPage: pagination.hasMore ? page + 1 : undefined, - totalPages: pagination.totalPages, - pagecount: pagination.totalPages, - hasMore: pagination.hasMore + hasMore: hasNextPage(html, page) }; } @@ -877,39 +416,15 @@ 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).filter(function (url) { - return !/googletagmanager\.com|snaptrckr\.|myavlive\.com|mayzaent\.com|rallytrck\./i.test(originOf(url)); - }).slice(0, ctx && ctx.__playbackBudget ? 1 : 5); + const urls = extractPlayerURLs(ctx, html, referer); for (let index = 0; index < urls.length; index += 1) { try { - const playerHTML = await fetchText(ctx, urls[index], referer); + const playerHTML = await fetchText(ctx, urls[index]); const playable = extractPlayableURL(playerHTML); if (playable) return playable; } catch (error) { - recordPlaybackFailure(ctx, error); + // Ignore broken auxiliary player pages and continue with browser fallback. } } return ''; @@ -941,47 +456,6 @@ 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') || @@ -1027,187 +501,39 @@ function pagedURL(url, page) { return url + (url.indexOf('?') >= 0 ? '&' : '?') + 'page=' + page; } -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 +async function fetchText(ctx, url) { + const response = await httpGet(url, { + headers: requestHeaders(ctx, url), + useBrowserCookie: true, + attachBrowserCookie: true, + useBrowserFallback: true, + browserFallback: true, + allowBrowserFallback: 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 (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; - } - - // 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; + 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 环境无法直接读取页面。'); } - - throw new Error( - '女优作品页读取失败。已短暂尝试复用刚才的验证状态,请点击一次真人验证后重试。' + - (lastError && lastError.message ? ' 原因:' + lastError.message : '') - ); + return text; } -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; +async function safeFetch(url, referer) { 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) { - if (ctx && ctx.__playbackBudget) { - // Refresh player authorization on each play and reserve the one browser - // request for media capture, rather than an HTML-only verification attempt. - return runPlaybackStage(ctx, 'detail-http', 6, async function () { - const response = await httpGet(url, requestOptions(ctx, referer || entryURL(ctx))); - const text = responseText(response); - const status = responseStatus(response); - if (!isUsableHTML(text, status, response && response.headers)) { - throw playbackStageError('detail-http', 'HTTP ' + (status || 'empty') + (isCloudflare(text, status, response && response.headers) ? ';需要验证或站点拒绝请求' : ';页面不可用')); - } - return text; - }); - } - const cached = getCachedText(ctx, url); - if (cached) return cached; - - 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, responseStatus(response), response && response.headers)) { - setCachedText(ctx, currentURL, text); - if (currentURL !== url) setCachedText(ctx, url, text); - return text; - } - if (isCloudflare(text, responseStatus(response), 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(ctx, url, referer) { - try { - return await fetchText(ctx || { baseURL: originOf(url), entryPath: pathOf(url) }, url, referer); + return await fetchText({ baseURL: originOf(url), entryPath: pathOf(url) }, url, referer); } catch (error) { return ''; } } -function requestOptions(ctx, referer) { - const timeout = remainingPlaybackSeconds(ctx, ctx && ctx.__playbackBudget ? 6 : numberParam(ctx, 'requestTimeoutSeconds', 45)); - const browserFallback = !(ctx && ctx.__playbackBudget) && boolParam(ctx, 'enableBrowserFallback', true); - return { - headers: requestHeaders(ctx, referer), - timeout: timeout, - timeoutSeconds: timeout, - useBrowserCookie: true, - attachBrowserCookie: true, - useBrowserFallback: browserFallback, - browserFallback: browserFallback, - allowBrowserFallback: browserFallback - }; -} - 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 客户端。'); } @@ -1239,434 +565,73 @@ 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 || ''); } -function responseStatus(response) { - return Number(response && (response.statusCode || response.status)) || 0; -} - -async function browserHTML(ctx, url, referer, forceVisible) { - if (!boolParam(ctx, 'enableBrowserFallback', true)) return ''; - if (forceVisible !== true && !boolParam(ctx, 'automaticBrowserFallback', true)) return ''; - if (forceVisible !== true && browserRetryBlocked(ctx, url)) return ''; +async function browserHTML(url, referer) { 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 = playbackBrowserSeconds(ctx, visible ? Math.min(configuredTimeout, 20) : ctx && ctx.__playbackBudget ? Math.min(configuredTimeout, 12) : configuredTimeout, 1); - if (!timeout) return ''; const result = await Widget.browser.fetch(url, { - visible: visible, - timeout: timeout, - timeoutSeconds: timeout, - waitAfterLoad: visible ? 1.5 : 3, + visible: false, + timeout: 60, + waitAfterLoad: 2.5, + waitForAny: true, + waitForMediaSource: true, headers: { 'User-Agent': MISSAV_UA, Referer: referer || url } }); - const html = responseText(result.html ? { data: result.html } : result); - if (isUsableHTML(html)) { - clearBrowserFailure(url); - return html; - } - rememberBrowserFailure(ctx, url); - return ''; + return responseText(result.html ? { data: result.html } : result); } 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, ctx) { - if (ctx && ctx.__playbackBudget && ctx.__playbackBudget.browserCalls >= 1) return ''; - if (!boolParam(ctx, 'enableBrowserFallback', true) || !boolParam(ctx, 'automaticBrowserFallback', true)) { - recordPlaybackFailure(ctx, new Error('stage=browser-media;浏览器回退已关闭')); - return ''; - } - if (typeof Widget === 'undefined' || !Widget.browser || typeof Widget.browser.fetch !== 'function') { - recordPlaybackFailure(ctx, new Error('stage=browser-media;宿主未提供浏览器接口')); - return ''; - } +async function extractFromBrowser(url, referer) { + if (typeof Widget === 'undefined' || !Widget.browser || typeof Widget.browser.fetch !== 'function') return ''; try { - const timeout = playbackBrowserSeconds(ctx, 12, 1); - const result = await runPlaybackStage(ctx, 'browser-media', timeout, function () { return Widget.browser.fetch(url, { + const result = await Widget.browser.fetch(url, { visible: false, - timeout: timeout, - timeoutSeconds: timeout, + timeout: 70, waitAfterLoad: 3, waitForMediaSource: true, waitForAny: true, - captureRequests: true, - captureMedia: true, headers: { 'User-Agent': MISSAV_UA, Referer: referer || url } - }); }); - const playable = firstNonEmpty( + }); + return firstNonEmpty( playableFromBrowserResult(result), extractPlayableURL(responseText(result)), extractPlayableURL(result && result.html) ); - if (!playable) { - recordPlaybackFailure(ctx, new Error('stage=browser-media;未返回媒体地址;keys=' + Object.keys(result || {}).slice(0, 12).join(','))); - } - return playable; } catch (error) { - recordPlaybackFailure(ctx, error); return ''; } } -function candidateURLs(ctx, url) { - const input = stringValue(url); - const urls = [input]; - const origin = originOf(input); - const path = input.replace(/^https?:\/\/[^/]+/i, ''); - candidatePathVariants(ctx, path).forEach(function (variant) { - const next = origin + variant; - if (urls.indexOf(next) < 0) urls.push(next); - }); - backupBaseURLs(ctx).forEach(function (base) { - const root = base.replace(/\/+$/, ''); - if (!root || root === origin) return; - candidatePathVariants(ctx, path).forEach(function (variant) { - const next = root + variant; - if (urls.indexOf(next) < 0) urls.push(next); - }); - }); - return urls.filter(Boolean); -} - -function candidatePathVariants(ctx, path) { - const value = path && path[0] === '/' ? path : '/' + stringValue(path); - const variants = [value]; - if (!isKnownCategoryPath(value)) return variants; - const match = value.match(/^\/(?:dm\d+\/)?cn(\/[^?#]*)([?#].*)?$/i); - if (match) { - const suffix = match[1] || ''; - const query = match[2] || ''; - const cnPath = '/cn' + suffix + query; - const rootPath = suffix + query; - if (variants.indexOf(cnPath) < 0) variants.push(cnPath); - if (variants.indexOf(rootPath) < 0) variants.push(rootPath); - } else { - const localePath = localePrefix(ctx) + value; - const cnPath = '/cn' + value; - if (variants.indexOf(localePath) < 0) variants.push(localePath); - if (variants.indexOf(cnPath) < 0) variants.push(cnPath); - } - return variants; -} - -function isKnownCategoryPath(path) { - const clean = String(path || '').split(/[?#]/)[0].replace(/^\/(?:dm\d+\/)?cn/i, '') || '/'; - return MISSAV_SECTIONS.some(function (item) { - return categorySlug(clean) === categorySlug(item.path); - }); -} - -function backupBaseURLs(ctx) { - const raw = stringValue(contextValue(ctx, 'backupBaseURLs') || contextValue(ctx, 'backupBaseUrls') || contextValue(ctx, 'backup_base_urls')); - return raw.split(/[\n,,\s]+/).map(function (value) { - return value.replace(/\/+$/, ''); - }).filter(function (value) { - return /^https?:\/\//i.test(value); - }); -} - -function cacheKey(url) { - return 'missav:html:' + String(url || ''); -} - -function categoryArtworkKey(ctx, pageId) { - return 'missav:category-artwork:v4:' + baseURL(ctx) + ':' + String(pageId || ''); -} - -function rememberCategoryArtwork(ctx, pageId, items) { - const previews = categoryPreviewItems(items, findSection(pageId)); - if (!previews.length) return; - const key = categoryArtworkKey(ctx, pageId); - const value = { - items: previews, - expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000 - }; - memoryCache()[key] = value; - cacheSet(key, value); -} - -function cachedCategoryArtwork(ctx, pageId) { - const key = categoryArtworkKey(ctx, pageId); - const now = Date.now(); - let value = memoryCache()[key] || cacheGet(key); - if (typeof value === 'string') { - try { - value = JSON.parse(value); - } catch (error) { - value = null; - } - } - if (!value || value.expiresAt <= now || !Array.isArray(value.items)) return []; - memoryCache()[key] = value; - return value.items; -} - -function categoryStateKey(ctx, pageId) { - return 'missav:category-state:v6:' + baseURL(ctx) + ':' + String(pageId || ''); -} - -function memoryCache() { - if (typeof globalThis === 'undefined') return {}; - if (!globalThis.__MISSAV_HTML_CACHE__) globalThis.__MISSAV_HTML_CACHE__ = {}; - return globalThis.__MISSAV_HTML_CACHE__; -} - -function categoryStateFromCache(ctx, pageId) { - const key = categoryStateKey(ctx, pageId); - const now = Date.now(); - let state = memoryCache()[key] || cacheGet(key); - if (typeof state === 'string') { - try { - state = JSON.parse(state); - } catch (error) { - state = null; - } - } - if (!state || state.expiresAt <= now || !state.pages) return null; - memoryCache()[key] = state; - return state; -} - -function randomPageURL(ctx, page) { - const bucket = ((Math.max(1, Number(page) || 1) - 1) % 99) + 2; - return baseURL(ctx) + '/random/' + bucket; -} - -async function prefetchNextCategoryPage(ctx, pageId, section, page, pagination) { - if (!pagination || !pagination.hasMore) return; - const nextPage = page + 1; - const state = categoryStateFromCache(ctx, pageId); - if (state && state.pages && state.pages[String(nextPage)]) return; - try { - const url = pagedURL(categoryURL(ctx, section.path), nextPage); - const html = await fetchText(ctx, url, entryURL(ctx)); - const items = parseCards(html, section.title, ctx); - if (!items.length) return; - rememberCategoryPage(ctx, pageId, nextPage, items, paginationInfo(html, nextPage, items.length)); - } catch (error) { - // Page 1 remains usable; ordinary scrolling can retry the next page later. - } -} - -function contiguousCategoryPage(state) { - if (!state || !state.pages || !state.pages['1']) return 0; - let page = 1; - while (state.pages[String(page + 1)]) page += 1; - return page; -} - -function categoryFetchPage(ctx, pageId, requestedPage) { - if (requestedPage <= 1) return 1; - const state = categoryStateFromCache(ctx, pageId); - if (!state || !state.pages[String(requestedPage)]) return requestedPage; - const lastLoadedPage = contiguousCategoryPage(state); - return lastLoadedPage >= requestedPage ? lastLoadedPage + 1 : requestedPage; -} - -function rememberCategoryPage(ctx, pageId, page, items, pagination) { - const key = categoryStateKey(ctx, pageId); - const ttl = numberParam(ctx, 'categoryRestoreMinutes', 60) * 60 * 1000; - const existing = categoryStateFromCache(ctx, pageId); - const state = existing || { pages: {} }; - state.pages[String(page)] = (items || []).slice(0, 120); - state.hasMoreByPage = state.hasMoreByPage || {}; - state.hasMoreByPage[String(page)] = !!(pagination && pagination.hasMore); - state.totalPages = Math.max(Number(state.totalPages) || 0, Number(pagination && pagination.totalPages) || 0); - state.expiresAt = Date.now() + ttl; - - const pageNumbers = Object.keys(state.pages).map(Number).filter(function (value) { - return Number.isFinite(value) && value > 0; - }).sort(function (a, b) { return a - b; }); - while (pageNumbers.length > 20) { - const removed = pageNumbers.shift(); - delete state.pages[String(removed)]; - delete state.hasMoreByPage[String(removed)]; - } - memoryCache()[key] = state; - cacheSet(key, state); - - if (page !== 1) { - return { - items: items, - page: page, - hasMore: !!(pagination && pagination.hasMore), - totalPages: Number(pagination && pagination.totalPages) || state.totalPages - }; - } - - const lastContiguousPage = contiguousCategoryPage(state); - if (lastContiguousPage === 1) { - return { - items: items, - page: 1, - hasMore: !!(pagination && pagination.hasMore), - totalPages: Number(pagination && pagination.totalPages) || state.totalPages - }; - } - - const merged = []; - const seen = {}; - for (let current = 1; current <= lastContiguousPage; current += 1) { - (state.pages[String(current)] || []).forEach(function (item) { - const identity = stringValue(item && (item.id || item.url || item.title)); - if (identity && seen[identity]) return; - if (identity) seen[identity] = true; - merged.push(item); - }); - } - const latestHasMore = state.hasMoreByPage[String(lastContiguousPage)]; - return { - items: merged.slice(0, 240), - page: lastContiguousPage, - hasMore: latestHasMore !== undefined - ? latestHasMore - : state.totalPages > lastContiguousPage, - totalPages: state.totalPages - }; -} - -function getCachedText(ctx, url) { - const ttl = numberParam(ctx, 'cacheMinutes', 20) * 60 * 1000; - if (ttl <= 0) return ''; - const key = cacheKey(url); - const now = Date.now(); - const memory = memoryCache()[key]; - if (memory && memory.expiresAt > now && memory.text) return memory.text; - const stored = cacheGet(key); - if (stored && stored.expiresAt > now && stored.text) return stored.text; - return ''; -} - -function setCachedText(ctx, url, text) { - if (!isUsableHTML(text)) return; - const configuredMinutes = numberParam(ctx, 'cacheMinutes', 20); - const minimumMinutes = isActressIndexURL(url) ? 60 : isActressWorksURL(url) ? 30 : 0; - const ttl = Math.max(configuredMinutes, minimumMinutes) * 60 * 1000; - if (ttl <= 0) return; - const value = { text: text, expiresAt: Date.now() + ttl }; - const key = cacheKey(url); - memoryCache()[key] = value; - cacheSet(key, value); -} - -function cacheGet(key) { - try { - if (typeof Widget !== 'undefined' && Widget.cache && typeof Widget.cache.get === 'function') return Widget.cache.get(key); - if (typeof $cache !== 'undefined' && typeof $cache.get === 'function') return $cache.get(key); - } catch (error) { - return null; - } - return null; -} - -function cacheSet(key, value) { - try { - if (typeof Widget !== 'undefined' && Widget.cache && typeof Widget.cache.set === 'function') Widget.cache.set(key, value); - if (typeof $cache !== 'undefined' && typeof $cache.set === 'function') $cache.set(key, value); - } catch (error) { - // Cache is opportunistic; ignore unsupported host APIs. - } -} - -function isUsableHTML(html, status, headers) { - const text = String(html || ''); - if (!text || text.length < 80) return false; - if (Number(status) >= 400) return false; - if (isCloudflare(text, status, headers)) return false; - return / 0 ? value : fallback; -} - function playableFromBrowserResult(result) { if (!result) return ''; - const candidates = []; const keys = ['url', 'mediaURL', 'mediaUrl', 'videoURL', 'videoUrl', 'playURL', 'playUrl', 'src']; for (let index = 0; index < keys.length; index += 1) { const value = result[keys[index]]; - if (isPlayableURL(value)) candidates.push(value); + if (isPlayableURL(value)) return value; } - const arrays = [ - result.mediaSources, - result.mediaRequests, - result.capturedRequests, - result.requests, - result.responses, - result.urls - ]; + const arrays = [result.mediaSources, result.mediaRequests, 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)) candidates.push(value); + if (isPlayableURL(value)) return value; } } - 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; + return ''; } function parseHomeSections(ctx, html) { @@ -1719,199 +684,6 @@ 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', @@ -1924,65 +696,32 @@ function categoryAction(ctx, section) { function parseCategoryCards(ctx, html, fallbackTitle) { const source = categoryListBlock(html, fallbackTitle); - const records = {}; - const order = []; - const pattern = /]*)>([\s\S]*?)<\/a>/gi; - let match; - while ((match = pattern.exec(source))) { - const href = attr(match[1], 'href'); - if (!isCategoryHref(href)) continue; + const 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 url = absoluteURL(ctx, href); - 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, + seen[href + title] = true; + items.push({ + id: 'category-link-' + items.length + '-' + encodeURIComponent(title), + title: title, subtitle: fallbackTitle || '分类', type: 'category', - mediaType: 'category', - playable: false, - isPlayable: false, - aspectRatio: record.image ? '2:3' : '16:9', + aspectRatio: '16:9', action: { type: 'category', - id: record.url, - pageId: record.url, - title: record.title, - url: record.url, + id: url, + pageId: url, + title: title, + url: url, itemAspectRatio: '16:9' } - }; - 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(); + }); + }); + return items.slice(0, 80); } function categoryListBlock(html, title) { @@ -1990,8 +729,9 @@ function categoryListBlock(html, title) { const heading = cleanText(title || ''); const index = heading ? source.search(new RegExp(']*>\\s*' + escapeRegExp(heading), 'i')) : -1; if (index < 0) return source; + const nextScript = source.indexOf(' index; }); + const endCandidates = [nextScript, nextFooter].filter(function (value) { return value > index; }).sort(function (a, b) { return a - b; }); return source.slice(index, endCandidates[0] || undefined); } @@ -2027,25 +767,6 @@ function parseCards(html, fallbackTitle, ctx) { return items; } -function mediaListBlock(html) { - const source = String(html || ''); - if (!source) return source; - const anchorPattern = /]*)>([\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(' firstMediaIndex ? footerIndex : undefined); -} - function mediaBlocks(html) { const source = String(html || ''); const blocks = []; @@ -2077,9 +798,7 @@ 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 anchorsIn(block).some(function (anchor) { - return isDetailHref(anchor.href); - }) ? block : fallback; + return isDetailHref(block) ? block : fallback; } function parseCard(block, fallbackTitle, rank, ctx) { @@ -2153,250 +872,42 @@ function toWideItem(item) { return item; } -async function playbackGroups(detailURL, title, playUrl, ctx, html) { - if (!isPlayableURL(playUrl)) return []; +function playbackGroups(detailURL, title, playUrl, ctx) { + const id = encodePayload({ url: detailURL, title: title, playUrl: playUrl || '' }); 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: versions + 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' + } + } + ] } ]; } -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 = ctx && ctx.__playbackBudget - ? [playlistURL] - : unique(candidates.map(function (item) { return item.url; })).slice(0, 8); - let next = 0; - await Promise.all(Array.from({ length: Math.min(2, urls.length) }, async function () { - while (next < urls.length) { - remainingPlaybackSeconds(ctx, 5); - const variants = await discoverQualities(ctx, urls[next++], 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 budget = ctx && ctx.__playbackBudget; - const remaining = budget ? (budget.endAt - Date.now()) / 1000 : 5.5; - if (remaining <= 0.5) return []; - // Leave room to return an already discovered URL before the outer deadline. - const timeout = remainingPlaybackSeconds(ctx, Math.min(5, remaining - 0.5)); - const response = await runPlaybackStage(ctx, 'master-http', timeout, function () { return httpGet(playlistURL, { - headers: playbackHeaders(ctx || {}, referer || playlistURL), - timeout: timeout, - timeoutSeconds: timeout, - useBrowserCookie: true, - attachBrowserCookie: true - }); }); - if (responseStatus(response) >= 400) throw playbackStageError('master-http', 'HTTP ' + responseStatus(response)); - const playlist = responseText(response); - if (playlist.indexOf('#EXT-X-STREAM-INF') < 0) return []; - return parseMasterQualities(playlist, playlistURL); - } catch (error) { - recordPlaybackFailure(ctx, error); - return []; - } -} - -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); @@ -2427,9 +938,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, /]*\b(?:data-src|src)\s*=\s*["']([^"']+\.(?:m3u8|mp4|mpd)[^"']*)["']/i), firstMatch(source, /source\s+src\s*=\s*["']([^"']+\.(?:m3u8|mp4|mpd)[^"']*)["']/i), @@ -2509,16 +1020,6 @@ 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 || '' }); } @@ -2541,29 +1042,6 @@ 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; @@ -2583,7 +1061,6 @@ 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); @@ -2607,7 +1084,7 @@ function findSection(id) { function findPrimaryCategory(id) { const value = stringValue(id); - return MISSAV_SECTIONS.find(function (item) { + return MISSAV_PRIMARY_CATEGORIES.find(function (item) { return item.id === value || item.title === value || item.path === value || @@ -2618,26 +1095,7 @@ function findPrimaryCategory(id) { function isDetailHref(href) { const value = String(href || ''); - 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; + 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); } function categorySlug(value) { @@ -2653,21 +1111,6 @@ 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(']*>\\s*' + escapeRegExp(title), 'i')); @@ -2945,20 +1388,11 @@ function isDetailPageURL(url) { } function isCloudflare(html, status, headers) { - const text = String(html || ''); - const title = cleanText(firstMatch(text, /]*>([\s\S]*?)<\/title>/i)); - const mitigated = Object.keys(headers || {}).some(function (key) { - return key.toLowerCase() === 'cf-mitigated' && /challenge/i.test(String(headers[key])); - }); - // Cloudflare's passive /challenge-platform/scripts/jsd/main.js also occurs - // on successful media pages. Only explicit blocking-page evidence counts. + const text = String(html || '') + ' ' + JSON.stringify(headers || {}); return Number(status) === 403 || - Number(status) === 429 || - Number(status) === 503 || - mitigated || - /^(?:Just a moment|Attention Required|Checking your browser|Verifying you are human|Verify you are human)/i.test(title) || - /\bid\s*=\s*["'](?:challenge-form|cf-challenge-running|cf-browser-verification)["']/i.test(text) || - /Enable JavaScript and cookies to continue/i.test(stripTags(text)); + /\s*Just a moment/i.test(text) || + /Enable JavaScript and cookies to continue/i.test(text) || + /cf-mitigated|cf-browser-verification/i.test(text); } function decodeEscapes(value) { @@ -3071,7 +1505,6 @@ const MissAVMiniLibrary = { search: search, onSearch: onSearch, getSearch: getSearch, - onAction: onAction, matchResources: matchResources, matchMovie: matchResources }; @@ -3095,7 +1528,6 @@ 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; @@ -3104,71 +1536,3 @@ if (typeof globalThis !== 'undefined') { if (typeof module !== 'undefined' && module.exports) { module.exports = MissAVMiniLibrary; } - -async function withPlaybackBudget(input, operation) { - let ctx = input; - if (typeof ctx === 'string') { try { ctx = JSON.parse(ctx); } catch (_) { ctx = {}; } } - ctx = Object.assign({}, ctx && typeof ctx === 'object' ? ctx : {}); - const budget = { endAt: Date.now() + 28000, expired: false, browserCalls: 0, stage: 'start', failures: [] }; - ctx.__playbackBudget = budget; - let timer; - try { - const timeout = new Promise(function (_, reject) { - timer = setTimeout(function () { budget.expired = true; reject(new Error('播放解析超时;stage=total-deadline;lastStage=' + budget.stage + ';请稍后重试')); }, 28000); - }); - return await Promise.race([Promise.resolve().then(function () { return operation(ctx); }), timeout]); - } finally { clearTimeout(timer); budget.expired = true; } -} - -function recordPlaybackFailure(ctx, error) { - const budget = ctx && ctx.__playbackBudget; - if (!budget) return; - // No cookies, signed media URLs, or raw host exception text in diagnostics. - const raw = String(error && error.message || ''); - const message = /stage=(?:detail-http|browser-media|master-http|total-deadline)/.test(raw) - ? raw.replace(/https?:\/\/[^\s;]+/gi, '[URL]').slice(0, 200) - : '请求失败;stage=' + budget.stage; - if (budget.failures.indexOf(message) < 0) budget.failures.push(message); -} - -async function runPlaybackStage(ctx, stage, limit, operation) { - const timeout = remainingPlaybackSeconds(ctx, limit); - const budget = ctx && ctx.__playbackBudget; - if (budget) budget.stage = stage; - let timer; - try { - return await Promise.race([ - Promise.resolve().then(function () { remainingPlaybackSeconds(ctx, limit); return operation(); }), - new Promise(function (_, reject) { timer = setTimeout(function () { reject(playbackStageError(stage, '请求超时')); }, timeout * 1000); }) - ]); - } catch (error) { - if (error && error.missavPlaybackStage === true) throw error; - throw playbackStageError(stage, '请求失败'); - } finally { clearTimeout(timer); } -} - -function playbackStageError(stage, reason) { - const error = new Error(reason + ';stage=' + stage); - error.missavPlaybackStage = true; - return error; -} - -function remainingPlaybackSeconds(ctx, limit) { - const budget = ctx && ctx.__playbackBudget; - if (!budget) return limit; - const remaining = (budget.endAt - Date.now()) / 1000; - if (budget.expired || remaining <= 0) throw new Error('播放解析超时;stage=total-deadline'); - return Math.max(0.1, Math.min(limit, remaining)); -} - -function playbackBrowserSeconds(ctx, limit, maxCalls) { - const timeout = remainingPlaybackSeconds(ctx, limit); - const budget = ctx && ctx.__playbackBudget; - if (budget && budget.browserCalls >= maxCalls) throw new Error('浏览器回退次数已用完;stage=browser-fallback-limit'); - if (budget) budget.browserCalls++; - return timeout; -} - -async function resolvePlayback(input) { - return withPlaybackBudget(input, resolvePlaybackWithinBudget); -} diff --git a/missav-mini-library-download-working 6.js b/missav-mini-library-download-working 6.js index 7646e27..4d143b5 100644 --- a/missav-mini-library-download-working 6.js +++ b/missav-mini-library-download-working 6.js @@ -4,7 +4,6 @@ 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'; @@ -12,8 +11,8 @@ const WidgetMetadata = { id: 'missav-mini-library', name: 'MissAV', title: 'MissAV', - version: '1.5.11', - author: 'Alan huang', + version: '1.0.0', + author: 'baiPlay', logo: MISSAV_LOGO, icon: MISSAV_LOGO, site: MISSAV_DEFAULT_BASE, @@ -21,72 +20,43 @@ const WidgetMetadata = { }; const MISSAV_SECTIONS = [ - { 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' } + { 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' } ]; -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' +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 作品专区' } ]; function getManifest() { @@ -144,82 +114,19 @@ 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) { - ctx = normalizeContext(ctx); - const html = await safeFetch(ctx, entryURL(ctx), baseURL(ctx) + '/'); + const html = await safeFetch(entryURL(ctx), baseURL(ctx) + '/'); const hero = parseCards(sectionBlock(html, '推荐给你'), '推荐给你', ctx).slice(0, 10).map(toWideItem); - const mediaSections = homeMediaSections(ctx, parseHomeSections(ctx, html)); - const artwork = categoryArtworkFromSections(ctx, mediaSections); - const sections = categoryGroupSections(ctx, artwork).concat(mediaSections); + 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); return { pageType: 'home', @@ -231,81 +138,26 @@ 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.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 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 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 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 = { +function categoryCard(ctx, category) { + return { id: 'category-' + category.id, title: category.title, - subtitle: category.subtitle || '浏览' + category.title + '相关内容', + subtitle: category.subtitle || '', type: 'category', aspectRatio: '16:9', - previewItems: previewItems, + previewItems: [], action: { type: 'category', id: category.id, @@ -315,273 +167,37 @@ function categoryCard(ctx, category, livePreviewItems) { 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, url, entryURL(ctx)); - const items = filterCategoryItems( - parseCards(mediaListBlock(html), section.title, ctx), - section - ).slice(0, 18); - rememberCategoryArtwork(ctx, section.id, items); + const html = await fetchText(ctx, categoryURL(ctx, section.path)); return { id: section.id, title: section.title, style: section.style, lazy: false, moreAction: categoryAction(ctx, section), - items: items + items: parseCards(html, section.title, ctx).slice(0, 18) }; } catch (error) { - return verificationSection(ctx, section.id, section.title, section.style, url, error); + return emptySection(section.id, section.title, section.style, 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) { - ctx = normalizeContext(ctx); - const requestedPage = pageFromContext(ctx, 1); + const page = positiveInt(contextValue(ctx, 'page'), 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 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 url = pagedURL(categoryURL(ctx, path), page); + const html = await fetchText(ctx, url); const title = section ? section.title : primary ? primary.title : pageTitle(html) || 'MissAV'; - 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; + const items = parseCards(html, title, ctx); + const categoryItems = items.length ? [] : parseCategoryCards(ctx, html, title); return { pageType: 'category', @@ -589,28 +205,17 @@ async function getCategory(ctx) { title: title, style: items.length ? 'media.posterGrid' : 'discover.annualCategories', itemAspectRatio: '16:9', - 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 + items: items.length ? items : categoryItems, + page: page, + hasMore: items.length ? hasNextPage(html, page) : false }; } async function getDetail(ctx) { - ctx = normalizeContext(ctx); const detailURL = detailUrlFromContext(ctx); if (!detailURL) throw new Error('MissAV 详情参数无效'); - let html = ''; - try { - html = await fetchText(ctx, detailURL, entryURL(ctx)); - } catch (error) { - return verificationDetail(ctx, detailURL, error); - } + const html = await fetchText(ctx, detailURL); const fallbackTitle = titleFromUrl(detailURL); const title = cleanText( firstNonEmpty( @@ -642,21 +247,9 @@ async function getDetail(ctx) { firstMatch(html, /(\d{1,2}:\d{2}:\d{2})/i) ); const releaseDate = firstMatch(html, /(\d{4}-\d{2}-\d{2})/); - 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]; + const detailPlayableURL = absoluteURL(ctx, extractPlayableURL(html)); + const resourceGroups = playbackGroups(detailURL, title, detailPlayableURL, ctx); + const defaultVersion = resourceGroups[0] && resourceGroups[0].versions && resourceGroups[0].versions[0]; return { id: makeItemId(detailURL, title, poster), @@ -678,19 +271,19 @@ async function getDetail(ctx) { remarks: durationText || code, resourceGroups: resourceGroups, resourceSummary: { - versionCount: versions.length, + versionCount: defaultVersion ? 1 : 0, episodeCount: 0, defaultVersionId: defaultVersion ? defaultVersion.id : '' }, - mediaSources: defaultVersion && defaultVersion.url ? [ + mediaSources: detailPlayableURL ? [ { - id: defaultVersion.id, - name: defaultVersion.name, - displayName: defaultVersion.name, + id: encodePayload({ url: detailURL, title: title, playUrl: detailPlayableURL }), + name: 'MissAV HLS', + displayName: 'MissAV HLS', protocol: 'hls', container: 'm3u8', - url: defaultVersion.url, - path: defaultVersion.url, + url: detailPlayableURL, + path: detailPlayableURL, headers: playbackHeaders(ctx, detailURL), header: playbackHeaders(ctx, detailURL), Header: playbackHeaders(ctx, detailURL), @@ -715,70 +308,34 @@ 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) { - 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; - } + if (direct) return playbackGroups(detailURL, title, direct, ctx); try { - 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; + const html = await fetchText(ctx, detailURL); + return playbackGroups(detailURL, title, extractPlayableURL(html), ctx); } catch (error) { - return []; + return playbackGroups(detailURL, title, '', ctx); } } -async function resolvePlaybackWithinBudget(ctx) { - ctx = normalizeContext(ctx); - const requestedQuality = qualityFromContext(ctx); +async function resolvePlayback(ctx) { const direct = firstNonEmpty(playUrlFromContext(ctx), ctx && ctx.url, ctx && ctx.playUrl, ctx && ctx.videoUrl); - const stableDetailURL = detailUrlFromContext(ctx); - if (!isDetailPageURL(stableDetailURL) && !requestedQuality && isPlayableURL(direct) && !isDetailPageURL(direct)) { + if (isPlayableURL(direct) && !isDetailPageURL(direct)) { return playbackResult(ctx, direct, ctx && ctx.referer); } const detailURL = detailUrlFromContext(ctx); if (!detailURL) throw new Error('MissAV 播放参数无效'); - let html = ''; - try { - html = await fetchText(ctx, detailURL, entryURL(ctx)); - } catch (error) { - recordPlaybackFailure(ctx, error); - } - let masterURL = extractPlayableURL(html); - if (!masterURL) masterURL = await extractPlayableFromLinkedPlayers(ctx, html, detailURL); - if (!masterURL) masterURL = await extractFromBrowser(detailURL, detailURL, ctx); - let url = masterURL; - if (masterURL) { - const qualities = await discoverAvailableQualities(ctx, masterURL, detailURL, html); - // A valid master can let the native player choose a variant. Optional quality - // discovery must not spend a browser round or prevent that handoff. - const selected = selectQuality(qualities, requestedQuality); - if (selected && selected.url) url = selected.url; - } + const html = await fetchText(ctx, detailURL); + const url = firstNonEmpty( + extractPlayableURL(html), + await extractPlayableFromLinkedPlayers(ctx, html, detailURL), + await extractFromBrowser(detailURL, detailURL) + ); if (!url) { - const failures = ctx.__playbackBudget.failures.join(';'); - throw new Error('未能解析到 MissAV 播放地址;stage=media-discovery' + (failures ? ';' + failures : '') + '。请检查站点地址;若提示验证,请先手动完成验证。'); + throw new Error('未能解析到 MissAV 播放地址。站点可能启用了 Cloudflare 或更换了播放器脚本,请尝试更新入口路径或在 App 侧启用浏览器请求。'); } return playbackResult(ctx, absoluteURL(ctx, url), detailURL); } @@ -829,36 +386,18 @@ 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 = pageFromContext(ctx, 1); + const page = positiveInt(contextValue(ctx, 'page'), 1); const url = pagedURL(categoryURL(ctx, '/search/' + encodeURIComponent(query.replace(/\\/g, ''))), page); - 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 html = await fetchText(ctx, url); const items = parseCards(html, query, ctx); - const pagination = paginationInfo(html, page, items.length); return { pageType: 'search', title: '搜索结果', items: items, page: page, - currentPage: page, - pageIndex: page, - nextPage: pagination.hasMore ? page + 1 : undefined, - totalPages: pagination.totalPages, - pagecount: pagination.totalPages, - hasMore: pagination.hasMore + hasMore: hasNextPage(html, page) }; } @@ -877,39 +416,15 @@ 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).filter(function (url) { - return !/googletagmanager\.com|snaptrckr\.|myavlive\.com|mayzaent\.com|rallytrck\./i.test(originOf(url)); - }).slice(0, ctx && ctx.__playbackBudget ? 1 : 5); + const urls = extractPlayerURLs(ctx, html, referer); for (let index = 0; index < urls.length; index += 1) { try { - const playerHTML = await fetchText(ctx, urls[index], referer); + const playerHTML = await fetchText(ctx, urls[index]); const playable = extractPlayableURL(playerHTML); if (playable) return playable; } catch (error) { - recordPlaybackFailure(ctx, error); + // Ignore broken auxiliary player pages and continue with browser fallback. } } return ''; @@ -941,47 +456,6 @@ 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') || @@ -1027,187 +501,39 @@ function pagedURL(url, page) { return url + (url.indexOf('?') >= 0 ? '&' : '?') + 'page=' + page; } -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 +async function fetchText(ctx, url) { + const response = await httpGet(url, { + headers: requestHeaders(ctx, url), + useBrowserCookie: true, + attachBrowserCookie: true, + useBrowserFallback: true, + browserFallback: true, + allowBrowserFallback: 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 (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; - } - - // 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; + 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 环境无法直接读取页面。'); } - - throw new Error( - '女优作品页读取失败。已短暂尝试复用刚才的验证状态,请点击一次真人验证后重试。' + - (lastError && lastError.message ? ' 原因:' + lastError.message : '') - ); + return text; } -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; +async function safeFetch(url, referer) { 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) { - if (ctx && ctx.__playbackBudget) { - // Refresh player authorization on each play and reserve the one browser - // request for media capture, rather than an HTML-only verification attempt. - return runPlaybackStage(ctx, 'detail-http', 6, async function () { - const response = await httpGet(url, requestOptions(ctx, referer || entryURL(ctx))); - const text = responseText(response); - const status = responseStatus(response); - if (!isUsableHTML(text, status, response && response.headers)) { - throw playbackStageError('detail-http', 'HTTP ' + (status || 'empty') + (isCloudflare(text, status, response && response.headers) ? ';需要验证或站点拒绝请求' : ';页面不可用')); - } - return text; - }); - } - const cached = getCachedText(ctx, url); - if (cached) return cached; - - 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, responseStatus(response), response && response.headers)) { - setCachedText(ctx, currentURL, text); - if (currentURL !== url) setCachedText(ctx, url, text); - return text; - } - if (isCloudflare(text, responseStatus(response), 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(ctx, url, referer) { - try { - return await fetchText(ctx || { baseURL: originOf(url), entryPath: pathOf(url) }, url, referer); + return await fetchText({ baseURL: originOf(url), entryPath: pathOf(url) }, url, referer); } catch (error) { return ''; } } -function requestOptions(ctx, referer) { - const timeout = remainingPlaybackSeconds(ctx, ctx && ctx.__playbackBudget ? 6 : numberParam(ctx, 'requestTimeoutSeconds', 45)); - const browserFallback = !(ctx && ctx.__playbackBudget) && boolParam(ctx, 'enableBrowserFallback', true); - return { - headers: requestHeaders(ctx, referer), - timeout: timeout, - timeoutSeconds: timeout, - useBrowserCookie: true, - attachBrowserCookie: true, - useBrowserFallback: browserFallback, - browserFallback: browserFallback, - allowBrowserFallback: browserFallback - }; -} - 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 客户端。'); } @@ -1239,434 +565,73 @@ 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 || ''); } -function responseStatus(response) { - return Number(response && (response.statusCode || response.status)) || 0; -} - -async function browserHTML(ctx, url, referer, forceVisible) { - if (!boolParam(ctx, 'enableBrowserFallback', true)) return ''; - if (forceVisible !== true && !boolParam(ctx, 'automaticBrowserFallback', true)) return ''; - if (forceVisible !== true && browserRetryBlocked(ctx, url)) return ''; +async function browserHTML(url, referer) { 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 = playbackBrowserSeconds(ctx, visible ? Math.min(configuredTimeout, 20) : ctx && ctx.__playbackBudget ? Math.min(configuredTimeout, 12) : configuredTimeout, 1); - if (!timeout) return ''; const result = await Widget.browser.fetch(url, { - visible: visible, - timeout: timeout, - timeoutSeconds: timeout, - waitAfterLoad: visible ? 1.5 : 3, + visible: false, + timeout: 60, + waitAfterLoad: 2.5, + waitForAny: true, + waitForMediaSource: true, headers: { 'User-Agent': MISSAV_UA, Referer: referer || url } }); - const html = responseText(result.html ? { data: result.html } : result); - if (isUsableHTML(html)) { - clearBrowserFailure(url); - return html; - } - rememberBrowserFailure(ctx, url); - return ''; + return responseText(result.html ? { data: result.html } : result); } 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, ctx) { - if (ctx && ctx.__playbackBudget && ctx.__playbackBudget.browserCalls >= 1) return ''; - if (!boolParam(ctx, 'enableBrowserFallback', true) || !boolParam(ctx, 'automaticBrowserFallback', true)) { - recordPlaybackFailure(ctx, new Error('stage=browser-media;浏览器回退已关闭')); - return ''; - } - if (typeof Widget === 'undefined' || !Widget.browser || typeof Widget.browser.fetch !== 'function') { - recordPlaybackFailure(ctx, new Error('stage=browser-media;宿主未提供浏览器接口')); - return ''; - } +async function extractFromBrowser(url, referer) { + if (typeof Widget === 'undefined' || !Widget.browser || typeof Widget.browser.fetch !== 'function') return ''; try { - const timeout = playbackBrowserSeconds(ctx, 12, 1); - const result = await runPlaybackStage(ctx, 'browser-media', timeout, function () { return Widget.browser.fetch(url, { + const result = await Widget.browser.fetch(url, { visible: false, - timeout: timeout, - timeoutSeconds: timeout, + timeout: 70, waitAfterLoad: 3, waitForMediaSource: true, waitForAny: true, - captureRequests: true, - captureMedia: true, headers: { 'User-Agent': MISSAV_UA, Referer: referer || url } - }); }); - const playable = firstNonEmpty( + }); + return firstNonEmpty( playableFromBrowserResult(result), extractPlayableURL(responseText(result)), extractPlayableURL(result && result.html) ); - if (!playable) { - recordPlaybackFailure(ctx, new Error('stage=browser-media;未返回媒体地址;keys=' + Object.keys(result || {}).slice(0, 12).join(','))); - } - return playable; } catch (error) { - recordPlaybackFailure(ctx, error); return ''; } } -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)) candidates.push(value); + if (isPlayableURL(value)) return value; } - const arrays = [ - result.mediaSources, - result.mediaRequests, - result.capturedRequests, - result.requests, - result.responses, - result.urls - ]; + const arrays = [result.mediaSources, result.mediaRequests, 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)) candidates.push(value); + if (isPlayableURL(value)) return value; } } - 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; + return ''; } function parseHomeSections(ctx, html) { @@ -1719,199 +684,6 @@ 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', @@ -1924,65 +696,32 @@ function categoryAction(ctx, section) { function parseCategoryCards(ctx, html, fallbackTitle) { const source = categoryListBlock(html, fallbackTitle); - 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 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 url = absoluteURL(ctx, href); - 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, + seen[href + title] = true; + items.push({ + id: 'category-link-' + items.length + '-' + encodeURIComponent(title), + title: title, subtitle: fallbackTitle || '分类', type: 'category', - mediaType: 'category', - playable: false, - isPlayable: false, - aspectRatio: record.image ? '2:3' : '16:9', + aspectRatio: '16:9', action: { type: 'category', - id: record.url, - pageId: record.url, - title: record.title, - url: record.url, + id: url, + pageId: url, + title: title, + url: url, itemAspectRatio: '16:9' } - }; - 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(); + }); + }); + return items.slice(0, 80); } function categoryListBlock(html, title) { @@ -1990,8 +729,9 @@ 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 = [nextFooter].filter(function (value) { return value > index; }); + const endCandidates = [nextScript, nextFooter].filter(function (value) { return value > index; }).sort(function (a, b) { return a - b; }); return source.slice(index, endCandidates[0] || undefined); } @@ -2027,25 +767,6 @@ 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 = []; @@ -2077,9 +798,7 @@ 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 anchorsIn(block).some(function (anchor) { - return isDetailHref(anchor.href); - }) ? block : fallback; + return isDetailHref(block) ? block : fallback; } function parseCard(block, fallbackTitle, rank, ctx) { @@ -2153,250 +872,42 @@ function toWideItem(item) { return item; } -async function playbackGroups(detailURL, title, playUrl, ctx, html) { - if (!isPlayableURL(playUrl)) return []; +function playbackGroups(detailURL, title, playUrl, ctx) { + const id = encodePayload({ url: detailURL, title: title, playUrl: playUrl || '' }); 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: versions + 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' + } + } + ] } ]; } -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 = ctx && ctx.__playbackBudget - ? [playlistURL] - : unique(candidates.map(function (item) { return item.url; })).slice(0, 8); - let next = 0; - await Promise.all(Array.from({ length: Math.min(2, urls.length) }, async function () { - while (next < urls.length) { - remainingPlaybackSeconds(ctx, 5); - const variants = await discoverQualities(ctx, urls[next++], 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 budget = ctx && ctx.__playbackBudget; - const remaining = budget ? (budget.endAt - Date.now()) / 1000 : 5.5; - if (remaining <= 0.5) return []; - // Leave room to return an already discovered URL before the outer deadline. - const timeout = remainingPlaybackSeconds(ctx, Math.min(5, remaining - 0.5)); - const response = await runPlaybackStage(ctx, 'master-http', timeout, function () { return httpGet(playlistURL, { - headers: playbackHeaders(ctx || {}, referer || playlistURL), - timeout: timeout, - timeoutSeconds: timeout, - useBrowserCookie: true, - attachBrowserCookie: true - }); }); - if (responseStatus(response) >= 400) throw playbackStageError('master-http', 'HTTP ' + responseStatus(response)); - const playlist = responseText(response); - if (playlist.indexOf('#EXT-X-STREAM-INF') < 0) return []; - return parseMasterQualities(playlist, playlistURL); - } catch (error) { - recordPlaybackFailure(ctx, error); - return []; - } -} - -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); @@ -2427,9 +938,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), @@ -2509,16 +1020,6 @@ 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 || '' }); } @@ -2541,29 +1042,6 @@ 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; @@ -2583,7 +1061,6 @@ 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); @@ -2607,7 +1084,7 @@ function findSection(id) { function findPrimaryCategory(id) { const value = stringValue(id); - return MISSAV_SECTIONS.find(function (item) { + return MISSAV_PRIMARY_CATEGORIES.find(function (item) { return item.id === value || item.title === value || item.path === value || @@ -2618,26 +1095,7 @@ function findPrimaryCategory(id) { function isDetailHref(href) { const value = String(href || ''); - 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; + 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); } function categorySlug(value) { @@ -2653,21 +1111,6 @@ 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')); @@ -2945,20 +1388,11 @@ function isDetailPageURL(url) { } function isCloudflare(html, status, headers) { - const text = String(html || ''); - const title = cleanText(firstMatch(text, /<title\b[^>]*>([\s\S]*?)<\/title>/i)); - const mitigated = Object.keys(headers || {}).some(function (key) { - return key.toLowerCase() === 'cf-mitigated' && /challenge/i.test(String(headers[key])); - }); - // Cloudflare's passive /challenge-platform/scripts/jsd/main.js also occurs - // on successful media pages. Only explicit blocking-page evidence counts. + const text = String(html || '') + ' ' + JSON.stringify(headers || {}); return Number(status) === 403 || - Number(status) === 429 || - Number(status) === 503 || - mitigated || - /^(?:Just a moment|Attention Required|Checking your browser|Verifying you are human|Verify you are human)/i.test(title) || - /\bid\s*=\s*["'](?:challenge-form|cf-challenge-running|cf-browser-verification)["']/i.test(text) || - /Enable JavaScript and cookies to continue/i.test(stripTags(text)); + /<title>\s*Just a moment/i.test(text) || + /Enable JavaScript and cookies to continue/i.test(text) || + /cf-mitigated|cf-browser-verification/i.test(text); } function decodeEscapes(value) { @@ -3071,7 +1505,6 @@ const MissAVMiniLibrary = { search: search, onSearch: onSearch, getSearch: getSearch, - onAction: onAction, matchResources: matchResources, matchMovie: matchResources }; @@ -3095,7 +1528,6 @@ 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; @@ -3104,71 +1536,3 @@ if (typeof globalThis !== 'undefined') { if (typeof module !== 'undefined' && module.exports) { module.exports = MissAVMiniLibrary; } - -async function withPlaybackBudget(input, operation) { - let ctx = input; - if (typeof ctx === 'string') { try { ctx = JSON.parse(ctx); } catch (_) { ctx = {}; } } - ctx = Object.assign({}, ctx && typeof ctx === 'object' ? ctx : {}); - const budget = { endAt: Date.now() + 28000, expired: false, browserCalls: 0, stage: 'start', failures: [] }; - ctx.__playbackBudget = budget; - let timer; - try { - const timeout = new Promise(function (_, reject) { - timer = setTimeout(function () { budget.expired = true; reject(new Error('播放解析超时;stage=total-deadline;lastStage=' + budget.stage + ';请稍后重试')); }, 28000); - }); - return await Promise.race([Promise.resolve().then(function () { return operation(ctx); }), timeout]); - } finally { clearTimeout(timer); budget.expired = true; } -} - -function recordPlaybackFailure(ctx, error) { - const budget = ctx && ctx.__playbackBudget; - if (!budget) return; - // No cookies, signed media URLs, or raw host exception text in diagnostics. - const raw = String(error && error.message || ''); - const message = /stage=(?:detail-http|browser-media|master-http|total-deadline)/.test(raw) - ? raw.replace(/https?:\/\/[^\s;]+/gi, '[URL]').slice(0, 200) - : '请求失败;stage=' + budget.stage; - if (budget.failures.indexOf(message) < 0) budget.failures.push(message); -} - -async function runPlaybackStage(ctx, stage, limit, operation) { - const timeout = remainingPlaybackSeconds(ctx, limit); - const budget = ctx && ctx.__playbackBudget; - if (budget) budget.stage = stage; - let timer; - try { - return await Promise.race([ - Promise.resolve().then(function () { remainingPlaybackSeconds(ctx, limit); return operation(); }), - new Promise(function (_, reject) { timer = setTimeout(function () { reject(playbackStageError(stage, '请求超时')); }, timeout * 1000); }) - ]); - } catch (error) { - if (error && error.missavPlaybackStage === true) throw error; - throw playbackStageError(stage, '请求失败'); - } finally { clearTimeout(timer); } -} - -function playbackStageError(stage, reason) { - const error = new Error(reason + ';stage=' + stage); - error.missavPlaybackStage = true; - return error; -} - -function remainingPlaybackSeconds(ctx, limit) { - const budget = ctx && ctx.__playbackBudget; - if (!budget) return limit; - const remaining = (budget.endAt - Date.now()) / 1000; - if (budget.expired || remaining <= 0) throw new Error('播放解析超时;stage=total-deadline'); - return Math.max(0.1, Math.min(limit, remaining)); -} - -function playbackBrowserSeconds(ctx, limit, maxCalls) { - const timeout = remainingPlaybackSeconds(ctx, limit); - const budget = ctx && ctx.__playbackBudget; - if (budget && budget.browserCalls >= maxCalls) throw new Error('浏览器回退次数已用完;stage=browser-fallback-limit'); - if (budget) budget.browserCalls++; - return timeout; -} - -async function resolvePlayback(input) { - return withPlaybackBudget(input, resolvePlaybackWithinBudget); -} diff --git a/missav-mini-library-download-working.js b/missav-mini-library-download-working.js index 7646e27..4d143b5 100644 --- a/missav-mini-library-download-working.js +++ b/missav-mini-library-download-working.js @@ -4,7 +4,6 @@ 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'; @@ -12,8 +11,8 @@ const WidgetMetadata = { id: 'missav-mini-library', name: 'MissAV', title: 'MissAV', - version: '1.5.11', - author: 'Alan huang', + version: '1.0.0', + author: 'baiPlay', logo: MISSAV_LOGO, icon: MISSAV_LOGO, site: MISSAV_DEFAULT_BASE, @@ -21,72 +20,43 @@ const WidgetMetadata = { }; const MISSAV_SECTIONS = [ - { 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' } + { 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' } ]; -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' +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 作品专区' } ]; function getManifest() { @@ -144,82 +114,19 @@ 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) { - ctx = normalizeContext(ctx); - const html = await safeFetch(ctx, entryURL(ctx), baseURL(ctx) + '/'); + const html = await safeFetch(entryURL(ctx), baseURL(ctx) + '/'); const hero = parseCards(sectionBlock(html, '推荐给你'), '推荐给你', ctx).slice(0, 10).map(toWideItem); - const mediaSections = homeMediaSections(ctx, parseHomeSections(ctx, html)); - const artwork = categoryArtworkFromSections(ctx, mediaSections); - const sections = categoryGroupSections(ctx, artwork).concat(mediaSections); + 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); return { pageType: 'home', @@ -231,81 +138,26 @@ 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.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 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 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 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 = { +function categoryCard(ctx, category) { + return { id: 'category-' + category.id, title: category.title, - subtitle: category.subtitle || '浏览' + category.title + '相关内容', + subtitle: category.subtitle || '', type: 'category', aspectRatio: '16:9', - previewItems: previewItems, + previewItems: [], action: { type: 'category', id: category.id, @@ -315,273 +167,37 @@ function categoryCard(ctx, category, livePreviewItems) { 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, url, entryURL(ctx)); - const items = filterCategoryItems( - parseCards(mediaListBlock(html), section.title, ctx), - section - ).slice(0, 18); - rememberCategoryArtwork(ctx, section.id, items); + const html = await fetchText(ctx, categoryURL(ctx, section.path)); return { id: section.id, title: section.title, style: section.style, lazy: false, moreAction: categoryAction(ctx, section), - items: items + items: parseCards(html, section.title, ctx).slice(0, 18) }; } catch (error) { - return verificationSection(ctx, section.id, section.title, section.style, url, error); + return emptySection(section.id, section.title, section.style, 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) { - ctx = normalizeContext(ctx); - const requestedPage = pageFromContext(ctx, 1); + const page = positiveInt(contextValue(ctx, 'page'), 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 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 url = pagedURL(categoryURL(ctx, path), page); + const html = await fetchText(ctx, url); const title = section ? section.title : primary ? primary.title : pageTitle(html) || 'MissAV'; - 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; + const items = parseCards(html, title, ctx); + const categoryItems = items.length ? [] : parseCategoryCards(ctx, html, title); return { pageType: 'category', @@ -589,28 +205,17 @@ async function getCategory(ctx) { title: title, style: items.length ? 'media.posterGrid' : 'discover.annualCategories', itemAspectRatio: '16:9', - 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 + items: items.length ? items : categoryItems, + page: page, + hasMore: items.length ? hasNextPage(html, page) : false }; } async function getDetail(ctx) { - ctx = normalizeContext(ctx); const detailURL = detailUrlFromContext(ctx); if (!detailURL) throw new Error('MissAV 详情参数无效'); - let html = ''; - try { - html = await fetchText(ctx, detailURL, entryURL(ctx)); - } catch (error) { - return verificationDetail(ctx, detailURL, error); - } + const html = await fetchText(ctx, detailURL); const fallbackTitle = titleFromUrl(detailURL); const title = cleanText( firstNonEmpty( @@ -642,21 +247,9 @@ async function getDetail(ctx) { firstMatch(html, /(\d{1,2}:\d{2}:\d{2})/i) ); const releaseDate = firstMatch(html, /(\d{4}-\d{2}-\d{2})/); - 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]; + const detailPlayableURL = absoluteURL(ctx, extractPlayableURL(html)); + const resourceGroups = playbackGroups(detailURL, title, detailPlayableURL, ctx); + const defaultVersion = resourceGroups[0] && resourceGroups[0].versions && resourceGroups[0].versions[0]; return { id: makeItemId(detailURL, title, poster), @@ -678,19 +271,19 @@ async function getDetail(ctx) { remarks: durationText || code, resourceGroups: resourceGroups, resourceSummary: { - versionCount: versions.length, + versionCount: defaultVersion ? 1 : 0, episodeCount: 0, defaultVersionId: defaultVersion ? defaultVersion.id : '' }, - mediaSources: defaultVersion && defaultVersion.url ? [ + mediaSources: detailPlayableURL ? [ { - id: defaultVersion.id, - name: defaultVersion.name, - displayName: defaultVersion.name, + id: encodePayload({ url: detailURL, title: title, playUrl: detailPlayableURL }), + name: 'MissAV HLS', + displayName: 'MissAV HLS', protocol: 'hls', container: 'm3u8', - url: defaultVersion.url, - path: defaultVersion.url, + url: detailPlayableURL, + path: detailPlayableURL, headers: playbackHeaders(ctx, detailURL), header: playbackHeaders(ctx, detailURL), Header: playbackHeaders(ctx, detailURL), @@ -715,70 +308,34 @@ 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) { - 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; - } + if (direct) return playbackGroups(detailURL, title, direct, ctx); try { - 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; + const html = await fetchText(ctx, detailURL); + return playbackGroups(detailURL, title, extractPlayableURL(html), ctx); } catch (error) { - return []; + return playbackGroups(detailURL, title, '', ctx); } } -async function resolvePlaybackWithinBudget(ctx) { - ctx = normalizeContext(ctx); - const requestedQuality = qualityFromContext(ctx); +async function resolvePlayback(ctx) { const direct = firstNonEmpty(playUrlFromContext(ctx), ctx && ctx.url, ctx && ctx.playUrl, ctx && ctx.videoUrl); - const stableDetailURL = detailUrlFromContext(ctx); - if (!isDetailPageURL(stableDetailURL) && !requestedQuality && isPlayableURL(direct) && !isDetailPageURL(direct)) { + if (isPlayableURL(direct) && !isDetailPageURL(direct)) { return playbackResult(ctx, direct, ctx && ctx.referer); } const detailURL = detailUrlFromContext(ctx); if (!detailURL) throw new Error('MissAV 播放参数无效'); - let html = ''; - try { - html = await fetchText(ctx, detailURL, entryURL(ctx)); - } catch (error) { - recordPlaybackFailure(ctx, error); - } - let masterURL = extractPlayableURL(html); - if (!masterURL) masterURL = await extractPlayableFromLinkedPlayers(ctx, html, detailURL); - if (!masterURL) masterURL = await extractFromBrowser(detailURL, detailURL, ctx); - let url = masterURL; - if (masterURL) { - const qualities = await discoverAvailableQualities(ctx, masterURL, detailURL, html); - // A valid master can let the native player choose a variant. Optional quality - // discovery must not spend a browser round or prevent that handoff. - const selected = selectQuality(qualities, requestedQuality); - if (selected && selected.url) url = selected.url; - } + const html = await fetchText(ctx, detailURL); + const url = firstNonEmpty( + extractPlayableURL(html), + await extractPlayableFromLinkedPlayers(ctx, html, detailURL), + await extractFromBrowser(detailURL, detailURL) + ); if (!url) { - const failures = ctx.__playbackBudget.failures.join(';'); - throw new Error('未能解析到 MissAV 播放地址;stage=media-discovery' + (failures ? ';' + failures : '') + '。请检查站点地址;若提示验证,请先手动完成验证。'); + throw new Error('未能解析到 MissAV 播放地址。站点可能启用了 Cloudflare 或更换了播放器脚本,请尝试更新入口路径或在 App 侧启用浏览器请求。'); } return playbackResult(ctx, absoluteURL(ctx, url), detailURL); } @@ -829,36 +386,18 @@ 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 = pageFromContext(ctx, 1); + const page = positiveInt(contextValue(ctx, 'page'), 1); const url = pagedURL(categoryURL(ctx, '/search/' + encodeURIComponent(query.replace(/\\/g, ''))), page); - 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 html = await fetchText(ctx, url); const items = parseCards(html, query, ctx); - const pagination = paginationInfo(html, page, items.length); return { pageType: 'search', title: '搜索结果', items: items, page: page, - currentPage: page, - pageIndex: page, - nextPage: pagination.hasMore ? page + 1 : undefined, - totalPages: pagination.totalPages, - pagecount: pagination.totalPages, - hasMore: pagination.hasMore + hasMore: hasNextPage(html, page) }; } @@ -877,39 +416,15 @@ 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).filter(function (url) { - return !/googletagmanager\.com|snaptrckr\.|myavlive\.com|mayzaent\.com|rallytrck\./i.test(originOf(url)); - }).slice(0, ctx && ctx.__playbackBudget ? 1 : 5); + const urls = extractPlayerURLs(ctx, html, referer); for (let index = 0; index < urls.length; index += 1) { try { - const playerHTML = await fetchText(ctx, urls[index], referer); + const playerHTML = await fetchText(ctx, urls[index]); const playable = extractPlayableURL(playerHTML); if (playable) return playable; } catch (error) { - recordPlaybackFailure(ctx, error); + // Ignore broken auxiliary player pages and continue with browser fallback. } } return ''; @@ -941,47 +456,6 @@ 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') || @@ -1027,187 +501,39 @@ function pagedURL(url, page) { return url + (url.indexOf('?') >= 0 ? '&' : '?') + 'page=' + page; } -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 +async function fetchText(ctx, url) { + const response = await httpGet(url, { + headers: requestHeaders(ctx, url), + useBrowserCookie: true, + attachBrowserCookie: true, + useBrowserFallback: true, + browserFallback: true, + allowBrowserFallback: 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 (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; - } - - // 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; + 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 环境无法直接读取页面。'); } - - throw new Error( - '女优作品页读取失败。已短暂尝试复用刚才的验证状态,请点击一次真人验证后重试。' + - (lastError && lastError.message ? ' 原因:' + lastError.message : '') - ); + return text; } -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; +async function safeFetch(url, referer) { 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) { - if (ctx && ctx.__playbackBudget) { - // Refresh player authorization on each play and reserve the one browser - // request for media capture, rather than an HTML-only verification attempt. - return runPlaybackStage(ctx, 'detail-http', 6, async function () { - const response = await httpGet(url, requestOptions(ctx, referer || entryURL(ctx))); - const text = responseText(response); - const status = responseStatus(response); - if (!isUsableHTML(text, status, response && response.headers)) { - throw playbackStageError('detail-http', 'HTTP ' + (status || 'empty') + (isCloudflare(text, status, response && response.headers) ? ';需要验证或站点拒绝请求' : ';页面不可用')); - } - return text; - }); - } - const cached = getCachedText(ctx, url); - if (cached) return cached; - - 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, responseStatus(response), response && response.headers)) { - setCachedText(ctx, currentURL, text); - if (currentURL !== url) setCachedText(ctx, url, text); - return text; - } - if (isCloudflare(text, responseStatus(response), 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(ctx, url, referer) { - try { - return await fetchText(ctx || { baseURL: originOf(url), entryPath: pathOf(url) }, url, referer); + return await fetchText({ baseURL: originOf(url), entryPath: pathOf(url) }, url, referer); } catch (error) { return ''; } } -function requestOptions(ctx, referer) { - const timeout = remainingPlaybackSeconds(ctx, ctx && ctx.__playbackBudget ? 6 : numberParam(ctx, 'requestTimeoutSeconds', 45)); - const browserFallback = !(ctx && ctx.__playbackBudget) && boolParam(ctx, 'enableBrowserFallback', true); - return { - headers: requestHeaders(ctx, referer), - timeout: timeout, - timeoutSeconds: timeout, - useBrowserCookie: true, - attachBrowserCookie: true, - useBrowserFallback: browserFallback, - browserFallback: browserFallback, - allowBrowserFallback: browserFallback - }; -} - 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 客户端。'); } @@ -1239,434 +565,73 @@ 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 || ''); } -function responseStatus(response) { - return Number(response && (response.statusCode || response.status)) || 0; -} - -async function browserHTML(ctx, url, referer, forceVisible) { - if (!boolParam(ctx, 'enableBrowserFallback', true)) return ''; - if (forceVisible !== true && !boolParam(ctx, 'automaticBrowserFallback', true)) return ''; - if (forceVisible !== true && browserRetryBlocked(ctx, url)) return ''; +async function browserHTML(url, referer) { 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 = playbackBrowserSeconds(ctx, visible ? Math.min(configuredTimeout, 20) : ctx && ctx.__playbackBudget ? Math.min(configuredTimeout, 12) : configuredTimeout, 1); - if (!timeout) return ''; const result = await Widget.browser.fetch(url, { - visible: visible, - timeout: timeout, - timeoutSeconds: timeout, - waitAfterLoad: visible ? 1.5 : 3, + visible: false, + timeout: 60, + waitAfterLoad: 2.5, + waitForAny: true, + waitForMediaSource: true, headers: { 'User-Agent': MISSAV_UA, Referer: referer || url } }); - const html = responseText(result.html ? { data: result.html } : result); - if (isUsableHTML(html)) { - clearBrowserFailure(url); - return html; - } - rememberBrowserFailure(ctx, url); - return ''; + return responseText(result.html ? { data: result.html } : result); } 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, ctx) { - if (ctx && ctx.__playbackBudget && ctx.__playbackBudget.browserCalls >= 1) return ''; - if (!boolParam(ctx, 'enableBrowserFallback', true) || !boolParam(ctx, 'automaticBrowserFallback', true)) { - recordPlaybackFailure(ctx, new Error('stage=browser-media;浏览器回退已关闭')); - return ''; - } - if (typeof Widget === 'undefined' || !Widget.browser || typeof Widget.browser.fetch !== 'function') { - recordPlaybackFailure(ctx, new Error('stage=browser-media;宿主未提供浏览器接口')); - return ''; - } +async function extractFromBrowser(url, referer) { + if (typeof Widget === 'undefined' || !Widget.browser || typeof Widget.browser.fetch !== 'function') return ''; try { - const timeout = playbackBrowserSeconds(ctx, 12, 1); - const result = await runPlaybackStage(ctx, 'browser-media', timeout, function () { return Widget.browser.fetch(url, { + const result = await Widget.browser.fetch(url, { visible: false, - timeout: timeout, - timeoutSeconds: timeout, + timeout: 70, waitAfterLoad: 3, waitForMediaSource: true, waitForAny: true, - captureRequests: true, - captureMedia: true, headers: { 'User-Agent': MISSAV_UA, Referer: referer || url } - }); }); - const playable = firstNonEmpty( + }); + return firstNonEmpty( playableFromBrowserResult(result), extractPlayableURL(responseText(result)), extractPlayableURL(result && result.html) ); - if (!playable) { - recordPlaybackFailure(ctx, new Error('stage=browser-media;未返回媒体地址;keys=' + Object.keys(result || {}).slice(0, 12).join(','))); - } - return playable; } catch (error) { - recordPlaybackFailure(ctx, error); return ''; } } -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)) candidates.push(value); + if (isPlayableURL(value)) return value; } - const arrays = [ - result.mediaSources, - result.mediaRequests, - result.capturedRequests, - result.requests, - result.responses, - result.urls - ]; + const arrays = [result.mediaSources, result.mediaRequests, 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)) candidates.push(value); + if (isPlayableURL(value)) return value; } } - 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; + return ''; } function parseHomeSections(ctx, html) { @@ -1719,199 +684,6 @@ 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', @@ -1924,65 +696,32 @@ function categoryAction(ctx, section) { function parseCategoryCards(ctx, html, fallbackTitle) { const source = categoryListBlock(html, fallbackTitle); - 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 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 url = absoluteURL(ctx, href); - 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, + seen[href + title] = true; + items.push({ + id: 'category-link-' + items.length + '-' + encodeURIComponent(title), + title: title, subtitle: fallbackTitle || '分类', type: 'category', - mediaType: 'category', - playable: false, - isPlayable: false, - aspectRatio: record.image ? '2:3' : '16:9', + aspectRatio: '16:9', action: { type: 'category', - id: record.url, - pageId: record.url, - title: record.title, - url: record.url, + id: url, + pageId: url, + title: title, + url: url, itemAspectRatio: '16:9' } - }; - 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(); + }); + }); + return items.slice(0, 80); } function categoryListBlock(html, title) { @@ -1990,8 +729,9 @@ 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 = [nextFooter].filter(function (value) { return value > index; }); + const endCandidates = [nextScript, nextFooter].filter(function (value) { return value > index; }).sort(function (a, b) { return a - b; }); return source.slice(index, endCandidates[0] || undefined); } @@ -2027,25 +767,6 @@ 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 = []; @@ -2077,9 +798,7 @@ 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 anchorsIn(block).some(function (anchor) { - return isDetailHref(anchor.href); - }) ? block : fallback; + return isDetailHref(block) ? block : fallback; } function parseCard(block, fallbackTitle, rank, ctx) { @@ -2153,250 +872,42 @@ function toWideItem(item) { return item; } -async function playbackGroups(detailURL, title, playUrl, ctx, html) { - if (!isPlayableURL(playUrl)) return []; +function playbackGroups(detailURL, title, playUrl, ctx) { + const id = encodePayload({ url: detailURL, title: title, playUrl: playUrl || '' }); 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: versions + 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' + } + } + ] } ]; } -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 = ctx && ctx.__playbackBudget - ? [playlistURL] - : unique(candidates.map(function (item) { return item.url; })).slice(0, 8); - let next = 0; - await Promise.all(Array.from({ length: Math.min(2, urls.length) }, async function () { - while (next < urls.length) { - remainingPlaybackSeconds(ctx, 5); - const variants = await discoverQualities(ctx, urls[next++], 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 budget = ctx && ctx.__playbackBudget; - const remaining = budget ? (budget.endAt - Date.now()) / 1000 : 5.5; - if (remaining <= 0.5) return []; - // Leave room to return an already discovered URL before the outer deadline. - const timeout = remainingPlaybackSeconds(ctx, Math.min(5, remaining - 0.5)); - const response = await runPlaybackStage(ctx, 'master-http', timeout, function () { return httpGet(playlistURL, { - headers: playbackHeaders(ctx || {}, referer || playlistURL), - timeout: timeout, - timeoutSeconds: timeout, - useBrowserCookie: true, - attachBrowserCookie: true - }); }); - if (responseStatus(response) >= 400) throw playbackStageError('master-http', 'HTTP ' + responseStatus(response)); - const playlist = responseText(response); - if (playlist.indexOf('#EXT-X-STREAM-INF') < 0) return []; - return parseMasterQualities(playlist, playlistURL); - } catch (error) { - recordPlaybackFailure(ctx, error); - return []; - } -} - -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); @@ -2427,9 +938,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), @@ -2509,16 +1020,6 @@ 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 || '' }); } @@ -2541,29 +1042,6 @@ 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; @@ -2583,7 +1061,6 @@ 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); @@ -2607,7 +1084,7 @@ function findSection(id) { function findPrimaryCategory(id) { const value = stringValue(id); - return MISSAV_SECTIONS.find(function (item) { + return MISSAV_PRIMARY_CATEGORIES.find(function (item) { return item.id === value || item.title === value || item.path === value || @@ -2618,26 +1095,7 @@ function findPrimaryCategory(id) { function isDetailHref(href) { const value = String(href || ''); - 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; + 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); } function categorySlug(value) { @@ -2653,21 +1111,6 @@ 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')); @@ -2945,20 +1388,11 @@ function isDetailPageURL(url) { } function isCloudflare(html, status, headers) { - const text = String(html || ''); - const title = cleanText(firstMatch(text, /<title\b[^>]*>([\s\S]*?)<\/title>/i)); - const mitigated = Object.keys(headers || {}).some(function (key) { - return key.toLowerCase() === 'cf-mitigated' && /challenge/i.test(String(headers[key])); - }); - // Cloudflare's passive /challenge-platform/scripts/jsd/main.js also occurs - // on successful media pages. Only explicit blocking-page evidence counts. + const text = String(html || '') + ' ' + JSON.stringify(headers || {}); return Number(status) === 403 || - Number(status) === 429 || - Number(status) === 503 || - mitigated || - /^(?:Just a moment|Attention Required|Checking your browser|Verifying you are human|Verify you are human)/i.test(title) || - /\bid\s*=\s*["'](?:challenge-form|cf-challenge-running|cf-browser-verification)["']/i.test(text) || - /Enable JavaScript and cookies to continue/i.test(stripTags(text)); + /<title>\s*Just a moment/i.test(text) || + /Enable JavaScript and cookies to continue/i.test(text) || + /cf-mitigated|cf-browser-verification/i.test(text); } function decodeEscapes(value) { @@ -3071,7 +1505,6 @@ const MissAVMiniLibrary = { search: search, onSearch: onSearch, getSearch: getSearch, - onAction: onAction, matchResources: matchResources, matchMovie: matchResources }; @@ -3095,7 +1528,6 @@ 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; @@ -3104,71 +1536,3 @@ if (typeof globalThis !== 'undefined') { if (typeof module !== 'undefined' && module.exports) { module.exports = MissAVMiniLibrary; } - -async function withPlaybackBudget(input, operation) { - let ctx = input; - if (typeof ctx === 'string') { try { ctx = JSON.parse(ctx); } catch (_) { ctx = {}; } } - ctx = Object.assign({}, ctx && typeof ctx === 'object' ? ctx : {}); - const budget = { endAt: Date.now() + 28000, expired: false, browserCalls: 0, stage: 'start', failures: [] }; - ctx.__playbackBudget = budget; - let timer; - try { - const timeout = new Promise(function (_, reject) { - timer = setTimeout(function () { budget.expired = true; reject(new Error('播放解析超时;stage=total-deadline;lastStage=' + budget.stage + ';请稍后重试')); }, 28000); - }); - return await Promise.race([Promise.resolve().then(function () { return operation(ctx); }), timeout]); - } finally { clearTimeout(timer); budget.expired = true; } -} - -function recordPlaybackFailure(ctx, error) { - const budget = ctx && ctx.__playbackBudget; - if (!budget) return; - // No cookies, signed media URLs, or raw host exception text in diagnostics. - const raw = String(error && error.message || ''); - const message = /stage=(?:detail-http|browser-media|master-http|total-deadline)/.test(raw) - ? raw.replace(/https?:\/\/[^\s;]+/gi, '[URL]').slice(0, 200) - : '请求失败;stage=' + budget.stage; - if (budget.failures.indexOf(message) < 0) budget.failures.push(message); -} - -async function runPlaybackStage(ctx, stage, limit, operation) { - const timeout = remainingPlaybackSeconds(ctx, limit); - const budget = ctx && ctx.__playbackBudget; - if (budget) budget.stage = stage; - let timer; - try { - return await Promise.race([ - Promise.resolve().then(function () { remainingPlaybackSeconds(ctx, limit); return operation(); }), - new Promise(function (_, reject) { timer = setTimeout(function () { reject(playbackStageError(stage, '请求超时')); }, timeout * 1000); }) - ]); - } catch (error) { - if (error && error.missavPlaybackStage === true) throw error; - throw playbackStageError(stage, '请求失败'); - } finally { clearTimeout(timer); } -} - -function playbackStageError(stage, reason) { - const error = new Error(reason + ';stage=' + stage); - error.missavPlaybackStage = true; - return error; -} - -function remainingPlaybackSeconds(ctx, limit) { - const budget = ctx && ctx.__playbackBudget; - if (!budget) return limit; - const remaining = (budget.endAt - Date.now()) / 1000; - if (budget.expired || remaining <= 0) throw new Error('播放解析超时;stage=total-deadline'); - return Math.max(0.1, Math.min(limit, remaining)); -} - -function playbackBrowserSeconds(ctx, limit, maxCalls) { - const timeout = remainingPlaybackSeconds(ctx, limit); - const budget = ctx && ctx.__playbackBudget; - if (budget && budget.browserCalls >= maxCalls) throw new Error('浏览器回退次数已用完;stage=browser-fallback-limit'); - if (budget) budget.browserCalls++; - return timeout; -} - -async function resolvePlayback(input) { - return withPlaybackBudget(input, resolvePlaybackWithinBudget); -} diff --git a/missav-mini-library.js b/missav-mini-library.js index 7646e27..4d143b5 100644 --- a/missav-mini-library.js +++ b/missav-mini-library.js @@ -4,7 +4,6 @@ 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'; @@ -12,8 +11,8 @@ const WidgetMetadata = { id: 'missav-mini-library', name: 'MissAV', title: 'MissAV', - version: '1.5.11', - author: 'Alan huang', + version: '1.0.0', + author: 'baiPlay', logo: MISSAV_LOGO, icon: MISSAV_LOGO, site: MISSAV_DEFAULT_BASE, @@ -21,72 +20,43 @@ const WidgetMetadata = { }; const MISSAV_SECTIONS = [ - { 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' } + { 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' } ]; -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' +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 作品专区' } ]; function getManifest() { @@ -144,82 +114,19 @@ 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) { - ctx = normalizeContext(ctx); - const html = await safeFetch(ctx, entryURL(ctx), baseURL(ctx) + '/'); + const html = await safeFetch(entryURL(ctx), baseURL(ctx) + '/'); const hero = parseCards(sectionBlock(html, '推荐给你'), '推荐给你', ctx).slice(0, 10).map(toWideItem); - const mediaSections = homeMediaSections(ctx, parseHomeSections(ctx, html)); - const artwork = categoryArtworkFromSections(ctx, mediaSections); - const sections = categoryGroupSections(ctx, artwork).concat(mediaSections); + 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); return { pageType: 'home', @@ -231,81 +138,26 @@ 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.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 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 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 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 = { +function categoryCard(ctx, category) { + return { id: 'category-' + category.id, title: category.title, - subtitle: category.subtitle || '浏览' + category.title + '相关内容', + subtitle: category.subtitle || '', type: 'category', aspectRatio: '16:9', - previewItems: previewItems, + previewItems: [], action: { type: 'category', id: category.id, @@ -315,273 +167,37 @@ function categoryCard(ctx, category, livePreviewItems) { 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, url, entryURL(ctx)); - const items = filterCategoryItems( - parseCards(mediaListBlock(html), section.title, ctx), - section - ).slice(0, 18); - rememberCategoryArtwork(ctx, section.id, items); + const html = await fetchText(ctx, categoryURL(ctx, section.path)); return { id: section.id, title: section.title, style: section.style, lazy: false, moreAction: categoryAction(ctx, section), - items: items + items: parseCards(html, section.title, ctx).slice(0, 18) }; } catch (error) { - return verificationSection(ctx, section.id, section.title, section.style, url, error); + return emptySection(section.id, section.title, section.style, 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) { - ctx = normalizeContext(ctx); - const requestedPage = pageFromContext(ctx, 1); + const page = positiveInt(contextValue(ctx, 'page'), 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 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 url = pagedURL(categoryURL(ctx, path), page); + const html = await fetchText(ctx, url); const title = section ? section.title : primary ? primary.title : pageTitle(html) || 'MissAV'; - 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; + const items = parseCards(html, title, ctx); + const categoryItems = items.length ? [] : parseCategoryCards(ctx, html, title); return { pageType: 'category', @@ -589,28 +205,17 @@ async function getCategory(ctx) { title: title, style: items.length ? 'media.posterGrid' : 'discover.annualCategories', itemAspectRatio: '16:9', - 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 + items: items.length ? items : categoryItems, + page: page, + hasMore: items.length ? hasNextPage(html, page) : false }; } async function getDetail(ctx) { - ctx = normalizeContext(ctx); const detailURL = detailUrlFromContext(ctx); if (!detailURL) throw new Error('MissAV 详情参数无效'); - let html = ''; - try { - html = await fetchText(ctx, detailURL, entryURL(ctx)); - } catch (error) { - return verificationDetail(ctx, detailURL, error); - } + const html = await fetchText(ctx, detailURL); const fallbackTitle = titleFromUrl(detailURL); const title = cleanText( firstNonEmpty( @@ -642,21 +247,9 @@ async function getDetail(ctx) { firstMatch(html, /(\d{1,2}:\d{2}:\d{2})/i) ); const releaseDate = firstMatch(html, /(\d{4}-\d{2}-\d{2})/); - 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]; + const detailPlayableURL = absoluteURL(ctx, extractPlayableURL(html)); + const resourceGroups = playbackGroups(detailURL, title, detailPlayableURL, ctx); + const defaultVersion = resourceGroups[0] && resourceGroups[0].versions && resourceGroups[0].versions[0]; return { id: makeItemId(detailURL, title, poster), @@ -678,19 +271,19 @@ async function getDetail(ctx) { remarks: durationText || code, resourceGroups: resourceGroups, resourceSummary: { - versionCount: versions.length, + versionCount: defaultVersion ? 1 : 0, episodeCount: 0, defaultVersionId: defaultVersion ? defaultVersion.id : '' }, - mediaSources: defaultVersion && defaultVersion.url ? [ + mediaSources: detailPlayableURL ? [ { - id: defaultVersion.id, - name: defaultVersion.name, - displayName: defaultVersion.name, + id: encodePayload({ url: detailURL, title: title, playUrl: detailPlayableURL }), + name: 'MissAV HLS', + displayName: 'MissAV HLS', protocol: 'hls', container: 'm3u8', - url: defaultVersion.url, - path: defaultVersion.url, + url: detailPlayableURL, + path: detailPlayableURL, headers: playbackHeaders(ctx, detailURL), header: playbackHeaders(ctx, detailURL), Header: playbackHeaders(ctx, detailURL), @@ -715,70 +308,34 @@ 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) { - 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; - } + if (direct) return playbackGroups(detailURL, title, direct, ctx); try { - 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; + const html = await fetchText(ctx, detailURL); + return playbackGroups(detailURL, title, extractPlayableURL(html), ctx); } catch (error) { - return []; + return playbackGroups(detailURL, title, '', ctx); } } -async function resolvePlaybackWithinBudget(ctx) { - ctx = normalizeContext(ctx); - const requestedQuality = qualityFromContext(ctx); +async function resolvePlayback(ctx) { const direct = firstNonEmpty(playUrlFromContext(ctx), ctx && ctx.url, ctx && ctx.playUrl, ctx && ctx.videoUrl); - const stableDetailURL = detailUrlFromContext(ctx); - if (!isDetailPageURL(stableDetailURL) && !requestedQuality && isPlayableURL(direct) && !isDetailPageURL(direct)) { + if (isPlayableURL(direct) && !isDetailPageURL(direct)) { return playbackResult(ctx, direct, ctx && ctx.referer); } const detailURL = detailUrlFromContext(ctx); if (!detailURL) throw new Error('MissAV 播放参数无效'); - let html = ''; - try { - html = await fetchText(ctx, detailURL, entryURL(ctx)); - } catch (error) { - recordPlaybackFailure(ctx, error); - } - let masterURL = extractPlayableURL(html); - if (!masterURL) masterURL = await extractPlayableFromLinkedPlayers(ctx, html, detailURL); - if (!masterURL) masterURL = await extractFromBrowser(detailURL, detailURL, ctx); - let url = masterURL; - if (masterURL) { - const qualities = await discoverAvailableQualities(ctx, masterURL, detailURL, html); - // A valid master can let the native player choose a variant. Optional quality - // discovery must not spend a browser round or prevent that handoff. - const selected = selectQuality(qualities, requestedQuality); - if (selected && selected.url) url = selected.url; - } + const html = await fetchText(ctx, detailURL); + const url = firstNonEmpty( + extractPlayableURL(html), + await extractPlayableFromLinkedPlayers(ctx, html, detailURL), + await extractFromBrowser(detailURL, detailURL) + ); if (!url) { - const failures = ctx.__playbackBudget.failures.join(';'); - throw new Error('未能解析到 MissAV 播放地址;stage=media-discovery' + (failures ? ';' + failures : '') + '。请检查站点地址;若提示验证,请先手动完成验证。'); + throw new Error('未能解析到 MissAV 播放地址。站点可能启用了 Cloudflare 或更换了播放器脚本,请尝试更新入口路径或在 App 侧启用浏览器请求。'); } return playbackResult(ctx, absoluteURL(ctx, url), detailURL); } @@ -829,36 +386,18 @@ 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 = pageFromContext(ctx, 1); + const page = positiveInt(contextValue(ctx, 'page'), 1); const url = pagedURL(categoryURL(ctx, '/search/' + encodeURIComponent(query.replace(/\\/g, ''))), page); - 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 html = await fetchText(ctx, url); const items = parseCards(html, query, ctx); - const pagination = paginationInfo(html, page, items.length); return { pageType: 'search', title: '搜索结果', items: items, page: page, - currentPage: page, - pageIndex: page, - nextPage: pagination.hasMore ? page + 1 : undefined, - totalPages: pagination.totalPages, - pagecount: pagination.totalPages, - hasMore: pagination.hasMore + hasMore: hasNextPage(html, page) }; } @@ -877,39 +416,15 @@ 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).filter(function (url) { - return !/googletagmanager\.com|snaptrckr\.|myavlive\.com|mayzaent\.com|rallytrck\./i.test(originOf(url)); - }).slice(0, ctx && ctx.__playbackBudget ? 1 : 5); + const urls = extractPlayerURLs(ctx, html, referer); for (let index = 0; index < urls.length; index += 1) { try { - const playerHTML = await fetchText(ctx, urls[index], referer); + const playerHTML = await fetchText(ctx, urls[index]); const playable = extractPlayableURL(playerHTML); if (playable) return playable; } catch (error) { - recordPlaybackFailure(ctx, error); + // Ignore broken auxiliary player pages and continue with browser fallback. } } return ''; @@ -941,47 +456,6 @@ 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') || @@ -1027,187 +501,39 @@ function pagedURL(url, page) { return url + (url.indexOf('?') >= 0 ? '&' : '?') + 'page=' + page; } -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 +async function fetchText(ctx, url) { + const response = await httpGet(url, { + headers: requestHeaders(ctx, url), + useBrowserCookie: true, + attachBrowserCookie: true, + useBrowserFallback: true, + browserFallback: true, + allowBrowserFallback: 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 (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; - } - - // 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; + 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 环境无法直接读取页面。'); } - - throw new Error( - '女优作品页读取失败。已短暂尝试复用刚才的验证状态,请点击一次真人验证后重试。' + - (lastError && lastError.message ? ' 原因:' + lastError.message : '') - ); + return text; } -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; +async function safeFetch(url, referer) { 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) { - if (ctx && ctx.__playbackBudget) { - // Refresh player authorization on each play and reserve the one browser - // request for media capture, rather than an HTML-only verification attempt. - return runPlaybackStage(ctx, 'detail-http', 6, async function () { - const response = await httpGet(url, requestOptions(ctx, referer || entryURL(ctx))); - const text = responseText(response); - const status = responseStatus(response); - if (!isUsableHTML(text, status, response && response.headers)) { - throw playbackStageError('detail-http', 'HTTP ' + (status || 'empty') + (isCloudflare(text, status, response && response.headers) ? ';需要验证或站点拒绝请求' : ';页面不可用')); - } - return text; - }); - } - const cached = getCachedText(ctx, url); - if (cached) return cached; - - 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, responseStatus(response), response && response.headers)) { - setCachedText(ctx, currentURL, text); - if (currentURL !== url) setCachedText(ctx, url, text); - return text; - } - if (isCloudflare(text, responseStatus(response), 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(ctx, url, referer) { - try { - return await fetchText(ctx || { baseURL: originOf(url), entryPath: pathOf(url) }, url, referer); + return await fetchText({ baseURL: originOf(url), entryPath: pathOf(url) }, url, referer); } catch (error) { return ''; } } -function requestOptions(ctx, referer) { - const timeout = remainingPlaybackSeconds(ctx, ctx && ctx.__playbackBudget ? 6 : numberParam(ctx, 'requestTimeoutSeconds', 45)); - const browserFallback = !(ctx && ctx.__playbackBudget) && boolParam(ctx, 'enableBrowserFallback', true); - return { - headers: requestHeaders(ctx, referer), - timeout: timeout, - timeoutSeconds: timeout, - useBrowserCookie: true, - attachBrowserCookie: true, - useBrowserFallback: browserFallback, - browserFallback: browserFallback, - allowBrowserFallback: browserFallback - }; -} - 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 客户端。'); } @@ -1239,434 +565,73 @@ 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 || ''); } -function responseStatus(response) { - return Number(response && (response.statusCode || response.status)) || 0; -} - -async function browserHTML(ctx, url, referer, forceVisible) { - if (!boolParam(ctx, 'enableBrowserFallback', true)) return ''; - if (forceVisible !== true && !boolParam(ctx, 'automaticBrowserFallback', true)) return ''; - if (forceVisible !== true && browserRetryBlocked(ctx, url)) return ''; +async function browserHTML(url, referer) { 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 = playbackBrowserSeconds(ctx, visible ? Math.min(configuredTimeout, 20) : ctx && ctx.__playbackBudget ? Math.min(configuredTimeout, 12) : configuredTimeout, 1); - if (!timeout) return ''; const result = await Widget.browser.fetch(url, { - visible: visible, - timeout: timeout, - timeoutSeconds: timeout, - waitAfterLoad: visible ? 1.5 : 3, + visible: false, + timeout: 60, + waitAfterLoad: 2.5, + waitForAny: true, + waitForMediaSource: true, headers: { 'User-Agent': MISSAV_UA, Referer: referer || url } }); - const html = responseText(result.html ? { data: result.html } : result); - if (isUsableHTML(html)) { - clearBrowserFailure(url); - return html; - } - rememberBrowserFailure(ctx, url); - return ''; + return responseText(result.html ? { data: result.html } : result); } 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, ctx) { - if (ctx && ctx.__playbackBudget && ctx.__playbackBudget.browserCalls >= 1) return ''; - if (!boolParam(ctx, 'enableBrowserFallback', true) || !boolParam(ctx, 'automaticBrowserFallback', true)) { - recordPlaybackFailure(ctx, new Error('stage=browser-media;浏览器回退已关闭')); - return ''; - } - if (typeof Widget === 'undefined' || !Widget.browser || typeof Widget.browser.fetch !== 'function') { - recordPlaybackFailure(ctx, new Error('stage=browser-media;宿主未提供浏览器接口')); - return ''; - } +async function extractFromBrowser(url, referer) { + if (typeof Widget === 'undefined' || !Widget.browser || typeof Widget.browser.fetch !== 'function') return ''; try { - const timeout = playbackBrowserSeconds(ctx, 12, 1); - const result = await runPlaybackStage(ctx, 'browser-media', timeout, function () { return Widget.browser.fetch(url, { + const result = await Widget.browser.fetch(url, { visible: false, - timeout: timeout, - timeoutSeconds: timeout, + timeout: 70, waitAfterLoad: 3, waitForMediaSource: true, waitForAny: true, - captureRequests: true, - captureMedia: true, headers: { 'User-Agent': MISSAV_UA, Referer: referer || url } - }); }); - const playable = firstNonEmpty( + }); + return firstNonEmpty( playableFromBrowserResult(result), extractPlayableURL(responseText(result)), extractPlayableURL(result && result.html) ); - if (!playable) { - recordPlaybackFailure(ctx, new Error('stage=browser-media;未返回媒体地址;keys=' + Object.keys(result || {}).slice(0, 12).join(','))); - } - return playable; } catch (error) { - recordPlaybackFailure(ctx, error); return ''; } } -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)) candidates.push(value); + if (isPlayableURL(value)) return value; } - const arrays = [ - result.mediaSources, - result.mediaRequests, - result.capturedRequests, - result.requests, - result.responses, - result.urls - ]; + const arrays = [result.mediaSources, result.mediaRequests, 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)) candidates.push(value); + if (isPlayableURL(value)) return value; } } - 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; + return ''; } function parseHomeSections(ctx, html) { @@ -1719,199 +684,6 @@ 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', @@ -1924,65 +696,32 @@ function categoryAction(ctx, section) { function parseCategoryCards(ctx, html, fallbackTitle) { const source = categoryListBlock(html, fallbackTitle); - 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 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 url = absoluteURL(ctx, href); - 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, + seen[href + title] = true; + items.push({ + id: 'category-link-' + items.length + '-' + encodeURIComponent(title), + title: title, subtitle: fallbackTitle || '分类', type: 'category', - mediaType: 'category', - playable: false, - isPlayable: false, - aspectRatio: record.image ? '2:3' : '16:9', + aspectRatio: '16:9', action: { type: 'category', - id: record.url, - pageId: record.url, - title: record.title, - url: record.url, + id: url, + pageId: url, + title: title, + url: url, itemAspectRatio: '16:9' } - }; - 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(); + }); + }); + return items.slice(0, 80); } function categoryListBlock(html, title) { @@ -1990,8 +729,9 @@ 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 = [nextFooter].filter(function (value) { return value > index; }); + const endCandidates = [nextScript, nextFooter].filter(function (value) { return value > index; }).sort(function (a, b) { return a - b; }); return source.slice(index, endCandidates[0] || undefined); } @@ -2027,25 +767,6 @@ 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 = []; @@ -2077,9 +798,7 @@ 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 anchorsIn(block).some(function (anchor) { - return isDetailHref(anchor.href); - }) ? block : fallback; + return isDetailHref(block) ? block : fallback; } function parseCard(block, fallbackTitle, rank, ctx) { @@ -2153,250 +872,42 @@ function toWideItem(item) { return item; } -async function playbackGroups(detailURL, title, playUrl, ctx, html) { - if (!isPlayableURL(playUrl)) return []; +function playbackGroups(detailURL, title, playUrl, ctx) { + const id = encodePayload({ url: detailURL, title: title, playUrl: playUrl || '' }); 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: versions + 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' + } + } + ] } ]; } -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 = ctx && ctx.__playbackBudget - ? [playlistURL] - : unique(candidates.map(function (item) { return item.url; })).slice(0, 8); - let next = 0; - await Promise.all(Array.from({ length: Math.min(2, urls.length) }, async function () { - while (next < urls.length) { - remainingPlaybackSeconds(ctx, 5); - const variants = await discoverQualities(ctx, urls[next++], 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 budget = ctx && ctx.__playbackBudget; - const remaining = budget ? (budget.endAt - Date.now()) / 1000 : 5.5; - if (remaining <= 0.5) return []; - // Leave room to return an already discovered URL before the outer deadline. - const timeout = remainingPlaybackSeconds(ctx, Math.min(5, remaining - 0.5)); - const response = await runPlaybackStage(ctx, 'master-http', timeout, function () { return httpGet(playlistURL, { - headers: playbackHeaders(ctx || {}, referer || playlistURL), - timeout: timeout, - timeoutSeconds: timeout, - useBrowserCookie: true, - attachBrowserCookie: true - }); }); - if (responseStatus(response) >= 400) throw playbackStageError('master-http', 'HTTP ' + responseStatus(response)); - const playlist = responseText(response); - if (playlist.indexOf('#EXT-X-STREAM-INF') < 0) return []; - return parseMasterQualities(playlist, playlistURL); - } catch (error) { - recordPlaybackFailure(ctx, error); - return []; - } -} - -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); @@ -2427,9 +938,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), @@ -2509,16 +1020,6 @@ 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 || '' }); } @@ -2541,29 +1042,6 @@ 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; @@ -2583,7 +1061,6 @@ 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); @@ -2607,7 +1084,7 @@ function findSection(id) { function findPrimaryCategory(id) { const value = stringValue(id); - return MISSAV_SECTIONS.find(function (item) { + return MISSAV_PRIMARY_CATEGORIES.find(function (item) { return item.id === value || item.title === value || item.path === value || @@ -2618,26 +1095,7 @@ function findPrimaryCategory(id) { function isDetailHref(href) { const value = String(href || ''); - 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; + 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); } function categorySlug(value) { @@ -2653,21 +1111,6 @@ 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')); @@ -2945,20 +1388,11 @@ function isDetailPageURL(url) { } function isCloudflare(html, status, headers) { - const text = String(html || ''); - const title = cleanText(firstMatch(text, /<title\b[^>]*>([\s\S]*?)<\/title>/i)); - const mitigated = Object.keys(headers || {}).some(function (key) { - return key.toLowerCase() === 'cf-mitigated' && /challenge/i.test(String(headers[key])); - }); - // Cloudflare's passive /challenge-platform/scripts/jsd/main.js also occurs - // on successful media pages. Only explicit blocking-page evidence counts. + const text = String(html || '') + ' ' + JSON.stringify(headers || {}); return Number(status) === 403 || - Number(status) === 429 || - Number(status) === 503 || - mitigated || - /^(?:Just a moment|Attention Required|Checking your browser|Verifying you are human|Verify you are human)/i.test(title) || - /\bid\s*=\s*["'](?:challenge-form|cf-challenge-running|cf-browser-verification)["']/i.test(text) || - /Enable JavaScript and cookies to continue/i.test(stripTags(text)); + /<title>\s*Just a moment/i.test(text) || + /Enable JavaScript and cookies to continue/i.test(text) || + /cf-mitigated|cf-browser-verification/i.test(text); } function decodeEscapes(value) { @@ -3071,7 +1505,6 @@ const MissAVMiniLibrary = { search: search, onSearch: onSearch, getSearch: getSearch, - onAction: onAction, matchResources: matchResources, matchMovie: matchResources }; @@ -3095,7 +1528,6 @@ 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; @@ -3104,71 +1536,3 @@ if (typeof globalThis !== 'undefined') { if (typeof module !== 'undefined' && module.exports) { module.exports = MissAVMiniLibrary; } - -async function withPlaybackBudget(input, operation) { - let ctx = input; - if (typeof ctx === 'string') { try { ctx = JSON.parse(ctx); } catch (_) { ctx = {}; } } - ctx = Object.assign({}, ctx && typeof ctx === 'object' ? ctx : {}); - const budget = { endAt: Date.now() + 28000, expired: false, browserCalls: 0, stage: 'start', failures: [] }; - ctx.__playbackBudget = budget; - let timer; - try { - const timeout = new Promise(function (_, reject) { - timer = setTimeout(function () { budget.expired = true; reject(new Error('播放解析超时;stage=total-deadline;lastStage=' + budget.stage + ';请稍后重试')); }, 28000); - }); - return await Promise.race([Promise.resolve().then(function () { return operation(ctx); }), timeout]); - } finally { clearTimeout(timer); budget.expired = true; } -} - -function recordPlaybackFailure(ctx, error) { - const budget = ctx && ctx.__playbackBudget; - if (!budget) return; - // No cookies, signed media URLs, or raw host exception text in diagnostics. - const raw = String(error && error.message || ''); - const message = /stage=(?:detail-http|browser-media|master-http|total-deadline)/.test(raw) - ? raw.replace(/https?:\/\/[^\s;]+/gi, '[URL]').slice(0, 200) - : '请求失败;stage=' + budget.stage; - if (budget.failures.indexOf(message) < 0) budget.failures.push(message); -} - -async function runPlaybackStage(ctx, stage, limit, operation) { - const timeout = remainingPlaybackSeconds(ctx, limit); - const budget = ctx && ctx.__playbackBudget; - if (budget) budget.stage = stage; - let timer; - try { - return await Promise.race([ - Promise.resolve().then(function () { remainingPlaybackSeconds(ctx, limit); return operation(); }), - new Promise(function (_, reject) { timer = setTimeout(function () { reject(playbackStageError(stage, '请求超时')); }, timeout * 1000); }) - ]); - } catch (error) { - if (error && error.missavPlaybackStage === true) throw error; - throw playbackStageError(stage, '请求失败'); - } finally { clearTimeout(timer); } -} - -function playbackStageError(stage, reason) { - const error = new Error(reason + ';stage=' + stage); - error.missavPlaybackStage = true; - return error; -} - -function remainingPlaybackSeconds(ctx, limit) { - const budget = ctx && ctx.__playbackBudget; - if (!budget) return limit; - const remaining = (budget.endAt - Date.now()) / 1000; - if (budget.expired || remaining <= 0) throw new Error('播放解析超时;stage=total-deadline'); - return Math.max(0.1, Math.min(limit, remaining)); -} - -function playbackBrowserSeconds(ctx, limit, maxCalls) { - const timeout = remainingPlaybackSeconds(ctx, limit); - const budget = ctx && ctx.__playbackBudget; - if (budget && budget.browserCalls >= maxCalls) throw new Error('浏览器回退次数已用完;stage=browser-fallback-limit'); - if (budget) budget.browserCalls++; - return timeout; -} - -async function resolvePlayback(input) { - return withPlaybackBudget(input, resolvePlaybackWithinBudget); -} diff --git a/sexbjcam-mini-library.js b/sexbjcam-mini-library.js index 8c3fd9c..a061c81 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.9', - author: 'Alan huang', + version: '1.1.3', + author: 'EL', logo: SEXBJCAM_LOGO, icon: SEXBJCAM_LOGO, site: SEXBJCAM_DEFAULT_BASE, @@ -24,8 +24,6 @@ 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 { @@ -111,7 +109,7 @@ async function getCategory(rawCtx) { }; } -async function getDetailWithinBudget(rawCtx) { +async function getDetail(rawCtx) { const ctx = normalizeContext(rawCtx); const detailURL = detailURLFromContext(ctx); if (!detailURL) throw new Error('SexBJCam 详情参数无效'); @@ -120,13 +118,11 @@ async function getDetailWithinBudget(rawCtx) { const related = parseListHtml(ctx, html).filter(function (item) { return item.id !== detailURL; }).slice(0, 18); let qualityVersions = []; if (detail.embedURL) { - try { - qualityVersions = await loadQualityVersions(ctx, detailURL, detail.title, detail.embedURL); - } catch (_) { /* Dynamic resource loading can retry; do not cache a fake default line. */ } + try { qualityVersions = await loadQualityVersions(ctx, detailURL, detail.title, detail.embedURL); } catch (_) {} } const resourceGroups = qualityVersions.length ? [{ id: 'quality', title: '画质', versions: qualityVersions }] - : []; + : 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', @@ -145,7 +141,7 @@ async function getDetailWithinBudget(rawCtx) { }; } -async function getResourceVersionsWithinBudget(rawCtx) { +async function getResourceVersions(rawCtx) { const ctx = normalizeContext(rawCtx); const detailURL = detailURLFromContext(ctx); if (!detailURL) return { itemId: '', groups: [] }; @@ -156,15 +152,18 @@ async function getResourceVersionsWithinBudget(rawCtx) { embedURL = detail.embedURL; title = detail.title || title; } - if (!embedURL) throw stageError('detail-player', '没有解析到 SexBJCam 播放器地址'); - const versions = await loadQualityVersions(ctx, detailURL, title, embedURL); + let versions = []; + if (embedURL) { + try { versions = await loadQualityVersions(ctx, detailURL, title, embedURL); } catch (_) {} + } return { itemId: detailURL, - groups: versions.length ? [{ id: 'quality', title: '画质', versions: versions }] : [] + groups: versions.length ? [{ id: 'quality', title: '画质', versions: versions }] : + resourceGroupsFor(ctx, detailURL, title, embedURL) }; } -async function resolvePlaybackWithinBudget(rawCtx) { +async function resolvePlayback(rawCtx) { const ctx = normalizeContext(rawCtx); const detailURL = detailURLFromContext(ctx); let embedURL = embedURLFromContext(ctx); @@ -176,7 +175,8 @@ async function resolvePlaybackWithinBudget(rawCtx) { const html = await fetchPlayerText(ctx, embedURL); const masterURL = extractMediaURL(html, embedURL); if (!masterURL) throw new Error('没有解析到 SexBJCam HLS 地址'); - const headers = ctx.__mediaHeaders || minimalMediaHeaders(); + const origin = urlOrigin(embedURL); + const headers = { Referer: embedURL, Origin: origin, 'User-Agent': SEXBJCAM_UA }; const quality = qualityFromContext(ctx); let mediaURL = masterURL; if (/\.m3u8(?:$|[?#])/i.test(masterURL)) { @@ -185,10 +185,7 @@ async function resolvePlaybackWithinBudget(rawCtx) { const variants = parseHlsVariants(manifest, masterURL); const selected = selectVariant(variants, quality); if (selected && selected.url) mediaURL = selected.url; - } catch (error) { - if (/-4\d\d$|verification$|not-hls$|manifest-empty$/.test(error.stage || '')) throw error; - // A fresh real URL can still be handed off after a transient quality-probe failure. - } + } catch (_) {} } return { url: mediaURL, container: mediaContainer(mediaURL), @@ -259,8 +256,8 @@ function parseListHtml(ctx, html) { function parseDetailHtml(ctx, html, detailURL) { const article = firstMatch(html, /(<article\b[^>]*\bitemprop=["']video["'][^>]*>[\s\S]*?<\/article>)/i)[0] || String(html || ''); const title = cleanText(firstNonEmpty( + metaItemContent(article, 'name'), metaContent(html, 'property', 'og:title'), firstMatch(article, /<h1\b[^>]*>([\s\S]*?)<\/h1>/i)[0], - metaContent(html, 'property', 'og:title'), metaItemContent(article, 'name'), firstMatch(html, /<title\b[^>]*>([\s\S]*?)<\/title>/i)[0] )).replace(/\s*[–|-]\s*sexbjcam\s*$/i, ''); const poster = absoluteURL(ctx, firstNonEmpty( @@ -288,114 +285,39 @@ function parseDetailHtml(ctx, html, detailURL) { }; } -async function loadQualityVersions(ctx, detailURL, title, embedURL) { - let variants = []; - let original = false; - try { - const playerHTML = await fetchPlayerText(ctx, embedURL); - const masterURL = extractMediaURL(playerHTML, embedURL); - if (!masterURL) throw stageError('player-media', '播放器没有返回 HLS 地址'); - const headers = ctx.__mediaHeaders || minimalMediaHeaders(); - const manifest = await fetchSignedManifestText(ctx, masterURL, headers); - variants = parseHlsVariants(manifest, masterURL); - original = !variants.length && /#EXTINF|#EXT-X-STREAM-INF/i.test(manifest); - if (!variants.length && !original) throw stageError('manifest-empty', 'HLS 清单没有媒体条目'); - if (variants.length) writeQualityMetadata(embedURL, variants.map(function (variant) { - return { height: variant.height, bandwidth: variant.bandwidth || 0 }; - })); - } catch (error) { - // Previously proven resolution metadata is a fallback, never a cached signed URL. - if (!/-4\d\d$|verification$|not-hls$|manifest-empty$/.test(error.stage || '')) variants = readQualityMetadata(embedURL); - if (!variants.length) throw stageError(error.stage || 'quality-discovery', '画质加载失败,请检查网络后重新打开资源列表'); - } - if (original) return [{ - id: 'quality:original', name: 'HLS 原始画质', title: 'HLS 原始画质', - subtitle: '源站返回原始 HLS · 播放时刷新地址', container: 'm3u8', default: true, - action: { type: 'play', itemId: detailURL, versionId: 'quality:original', embedURL: embedURL, title: title } - }]; - return buildQualityVersions(detailURL, title, embedURL, variants); -} - -function readQualityMetadata(embedURL) { - 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; +function resourceGroupsFor(ctx, detailURL, title, embedURL) { + return [{ + id: 'online', title: '在线播放', + versions: [{ + id: detailURL, name: '默认线路', title: title || '默认线路', + subtitle: '动态解析 HLS', container: 'm3u8', default: true, + action: { + type: 'play', itemId: detailURL, versionId: detailURL, + embedURL: embedURL || undefined, title: title || '默认线路' } - } 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, variants) { +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); + if (!variants.length) return []; 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) : '', '切换时请稍候'].filter(Boolean).join(' · '), + subtitle: index === 0 ? '最高画质 · 默认' : (variant.bandwidth ? formatBitrate(variant.bandwidth) : ''), quality: variant.height + 'P', container: 'm3u8', default: index === 0, + headers: headers, action: { type: 'play', itemId: detailURL, versionId: qualityId, quality: variant.height, embedURL: embedURL, title: title || variant.height + 'P' @@ -435,7 +357,9 @@ function selectVariant(variants, requestedHeight) { if (!variants || !variants.length) return null; if (!requestedHeight) return variants[0]; for (let i = 0; i < variants.length; i += 1) if (variants[i].height === requestedHeight) return variants[i]; - return variants[0]; + return variants.reduce(function (best, item) { + return Math.abs(item.height - requestedHeight) < Math.abs(best.height - requestedHeight) ? item : best; + }, variants[0]); } function qualityFromContext(ctx) { @@ -443,7 +367,7 @@ function qualityFromContext(ctx) { contextValue(ctx, 'quality'), contextValue(ctx, 'height'), contextValue(ctx, 'versionId'), contextValue(ctx, 'version') ); - const match = /^(?:quality:)?(\d{3,4})p?$/i.exec(raw); + const match = /(?:quality:)?(\d{3,4})p?/i.exec(raw); return match ? Number(match[1]) : 0; } @@ -473,96 +397,66 @@ async function loadSectionItems(ctx, section, page) { return parseListHtml(ctx, html); } -async function fetchText(ctx, url, headers, limit, stage) { - const seconds = remainingPlaybackSeconds(ctx, limit || 6); - return runStage(ctx, stage || 'http', seconds, async function () { - let response; - try { response = await httpRequest(url, headers || requestHeaders(ctx, url), seconds); } - catch (_) { throw stageError(stage || 'http', '来源请求失败'); } - const text = await unwrapText(response); - const status = Number(response && (response.status || response.statusCode)); - if (status >= 400) throw stageError((stage || 'http') + '-' + status, '来源请求失败(HTTP ' + status + ')'); - if (isVerificationPage(text)) throw stageError((stage || 'http') + '-verification', '源站要求浏览器验证'); - if (!text) throw stageError((stage || 'http') + '-empty', '来源返回空内容'); - return text; - }); +async function fetchText(ctx, url, headers) { + const response = await httpRequest(url, headers || requestHeaders(ctx, url)); + const text = unwrapText(response); + if (!text) throw new Error('请求失败: ' + url); + return text; } async function fetchPlayerText(ctx, url) { const referer = detailURLFromContext(ctx) || baseURL(ctx) + '/'; - const headers = { - Referer: referer, 'User-Agent': SEXBJCAM_UA, + const freshHeaders = { + Referer: referer, + 'User-Agent': SEXBJCAM_UA, Accept: 'text/html,application/xhtml+xml', - 'Cache-Control': 'no-cache, no-store, max-age=0', Pragma: 'no-cache' + 'Cache-Control': 'no-cache, no-store, max-age=0', + Pragma: 'no-cache' }; - let lastError; - try { - const text = await fetchText(ctx, cacheBustedURL(url), headers, 6, 'player-http'); - if (extractMediaURL(text, url)) { - ctx.__mediaHeaders = minimalMediaHeaders(); - return text; - } - lastError = stageError('player-media', '播放器页面未包含媒体配置'); - } catch (error) { lastError = error; } const widget = typeof Widget !== 'undefined' ? Widget : null; - if (!widget || !widget.browser || typeof widget.browser.fetch !== 'function') throw lastError; - const seconds = playbackBrowserSeconds(ctx, 12, 1); - return runStage(ctx, 'browser-media', seconds, async function () { - let result; + if (widget && widget.browser && typeof widget.browser.fetch === 'function') { try { - result = await widget.browser.fetch(cacheBustedURL(url), { - visible: false, timeout: seconds, timeoutSeconds: seconds, - waitAfterLoad: 1, waitForMediaSource: true, headers: headers + const result = await widget.browser.fetch(cacheBustedURL(url), { + visible: false, + timeout: 60, + waitAfterLoad: 3, + waitForAny: true, + waitForMediaSource: true, + headers: freshHeaders }); - } catch (_) { throw stageError('browser-media', '浏览器媒体捕获失败'); } - const browserText = await unwrapText(result); - const capturedURL = mediaURLFromBrowserResult(result); - const explicitURL = extractMediaURL(browserText, url); - const mediaURL = /\/master\.m3u8(?:$|[?#])/i.test(capturedURL) ? capturedURL : (explicitURL || capturedURL); - if (mediaURL) { - ctx.__mediaHeaders = capturedMediaHeaders(result, mediaURL) || minimalMediaHeaders(); - return mediaURL; - } - const keys = Object.keys(result || {}).slice(0, 12).join(','); - const blobOnly = /blob:https?:/.test(JSON.stringify(result && (result.mediaSources || result.capturedRequests || result.mediaRequests) || [])); - const stage = isVerificationPage(browserText) ? 'browser-verification' : (blobOnly ? 'browser-blob-only' : 'browser-no-media'); - throw stageError(stage, '浏览器未回传可用媒体;blobOnly=' + blobOnly + ';keys=' + keys); - }); + const mediaURL = mediaURLFromBrowserResult(result); + if (mediaURL) return mediaURL; + const browserText = unwrapText(result); + if (browserText && extractMediaURL(browserText, url)) return browserText; + } catch (_) {} + } + let normalError = null; + try { + const text = await fetchText(ctx, cacheBustedURL(url), freshHeaders); + if (extractMediaURL(text, url)) return text; + normalError = new Error('播放器页面未包含媒体配置'); + } catch (error) { + normalError = error; + } + throw normalError || new Error('播放器页面请求失败'); } async function fetchSignedManifestText(ctx, url, headers) { - // Navigating a browser to a signed playlist is not a reliable text fetch. - const text = await fetchText(ctx, url, headers, 4, 'manifest-http'); - if (!/^\s*#EXTM3U/i.test(text)) throw stageError('manifest-not-hls', '来源未返回 HLS 清单'); - if (!/#EXTINF|#EXT-X-STREAM-INF/i.test(text)) throw stageError('manifest-empty', 'HLS 清单没有媒体条目'); - return text; -} - -function capturedMediaHeaders(result, url) { - const arrays = [result && result.capturedRequests, result && result.mediaRequests, result && result.requests]; - for (let a = 0; a < arrays.length; a += 1) { - if (!Array.isArray(arrays[a])) continue; - const request = arrays[a].find(function (item) { return item && item.url === url; }); - const input = request && (request.requestHeaders || request.headers); - if (!input || typeof input !== 'object') continue; - const headers = { 'User-Agent': SEXBJCAM_UA }; - Object.keys(input).forEach(function (key) { - const canonical = { 'user-agent': 'User-Agent', referer: 'Referer', origin: 'Origin', cookie: 'Cookie' }[key.toLowerCase()]; - if (canonical) headers[canonical] = String(input[key]); - }); - return headers; + const widget = typeof Widget !== 'undefined' ? Widget : null; + if (widget && widget.browser && typeof widget.browser.fetch === 'function') { + try { + const result = await widget.browser.fetch(url, { + visible: false, + timeout: 30, + waitAfterLoad: 1, + waitForAny: true, + headers: headers + }); + const browserText = unwrapText(result); + if (/^\s*#EXTM3U/i.test(browserText)) return browserText; + } catch (_) {} } - return null; -} - -function minimalMediaHeaders() { - const headers = { 'User-Agent': SEXBJCAM_UA }; - // The verified static CDN flow works with this User-Agent; captured requests carry their own headers. - return headers; -} - -function isVerificationPage(text) { - return /Just a moment|Checking (?:your )?browser|cf-browser-verification|cf-chl-/i.test(String(text || '').slice(0, 30000)); + return fetchText(ctx, url, headers); } function mediaURLFromBrowserResult(result) { @@ -573,7 +467,7 @@ function mediaURLFromBrowserResult(result) { const direct = stringValue(result[directKeys[i]]); if (isMediaURL(direct) && !/^blob:/i.test(direct)) candidates.push(direct); } - const arrays = [result.capturedRequests, result.mediaSources, result.mediaRequests, result.requests, result.responses, result.urls]; + const arrays = [result.mediaSources, result.mediaRequests, result.requests, result.responses, result.urls]; for (let a = 0; a < arrays.length; a += 1) { const values = arrays[a]; if (!Array.isArray(values)) continue; @@ -596,14 +490,13 @@ function cacheBustedURL(url) { return String(url || '') + separator + '_dreamby_refresh=' + Date.now() + '-' + SEXBJCAM_REQUEST_NONCE; } -async function httpRequest(url, headers, timeout = 6) { +async function httpRequest(url, headers) { const widget = typeof Widget !== 'undefined' ? Widget : null; const dollar = typeof $http !== 'undefined' ? $http : null; const client = (widget && widget.http) || dollar; if (!client) throw new Error('当前环境没有可用的 HTTP 客户端'); - const options = { headers: headers, timeout: timeout, timeoutSeconds: timeout, useBrowserFallback: false, browserFallback: false, useBrowserCookie: true, attachBrowserCookie: true }; - if (typeof client.get === 'function') return client.get(url, options); - if (typeof client.request === 'function') return client.request(Object.assign({ url: url, method: 'GET' }, options)); + if (typeof client.get === 'function') return client.get(url, { headers: headers }); + if (typeof client.request === 'function') return client.request({ url: url, method: 'GET', headers: headers }); throw new Error('当前 HTTP 客户端不支持 GET'); } @@ -811,62 +704,3 @@ const SEXBJCAM_API = { }; if (typeof globalThis !== 'undefined') Object.keys(SEXBJCAM_API).forEach(function (key) { globalThis[key] = SEXBJCAM_API[key]; }); if (typeof module !== 'undefined' && module.exports) module.exports = SEXBJCAM_API; - -function stageError(stage, message) { - const error = new Error(message + ';stage=' + stage); - error.stage = stage; - return error; -} - -async function runStage(ctx, stage, seconds, operation) { - const budget = ctx && ctx.__playbackBudget; - if (budget && budget.expired) throw stageError('total-deadline', '解析超时'); - let timer; - try { - const timeout = new Promise(function (_, reject) { - timer = setTimeout(function () { reject(stageError(stage + '-timeout', '来源请求超时')); }, seconds * 1000); - }); - return await Promise.race([Promise.resolve().then(operation), timeout]); - } finally { clearTimeout(timer); } -} - -async function withPlaybackBudget(input, operation, label) { - const ctx = Object.assign({}, normalizeContext(input)); - const budget = { endAt: Date.now() + 28000, expired: false, browserCalls: 0 }; - ctx.__playbackBudget = budget; - let timer; - try { - const timeout = new Promise(function (_, reject) { - timer = setTimeout(function () { budget.expired = true; reject(stageError('total-deadline', (label || '播放解析') + '超时;请稍后重试')); }, 28000); - }); - return await Promise.race([Promise.resolve().then(function () { return operation(ctx); }), timeout]); - } finally { clearTimeout(timer); budget.expired = true; } -} - -function remainingPlaybackSeconds(ctx, limit) { - const budget = ctx && ctx.__playbackBudget; - if (!budget) return limit; - const remaining = (budget.endAt - Date.now()) / 1000; - if (budget.expired || remaining <= 0) throw new Error('播放解析超时;stage=total-deadline'); - return Math.max(0.1, Math.min(limit, remaining)); -} - -function playbackBrowserSeconds(ctx, limit, maxCalls) { - const timeout = remainingPlaybackSeconds(ctx, limit); - const budget = ctx && ctx.__playbackBudget; - if (budget && budget.browserCalls >= maxCalls) throw new Error('浏览器回退次数已用完;stage=browser-fallback-limit'); - if (budget) budget.browserCalls++; - return timeout; -} - -async function resolvePlayback(input) { - return withPlaybackBudget(input, resolvePlaybackWithinBudget); -} - -async function getDetail(input) { - return withPlaybackBudget(input, getDetailWithinBudget, '详情加载'); -} - -async function getResourceVersions(input) { - return withPlaybackBudget(input, getResourceVersionsWithinBudget, '画质发现'); -} diff --git a/sexbjcam-network-recovery.test.js b/sexbjcam-network-recovery.test.js index 79f30c7..ed0f4e8 100644 --- a/sexbjcam-network-recovery.test.js +++ b/sexbjcam-network-recovery.test.js @@ -2,6 +2,7 @@ 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'; @@ -35,7 +36,7 @@ global.Widget = { 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) throw new Error('不得用浏览器导航读取 HLS 正文'); + if (String(url).indexOf(masterURL) === 0) { manifestBrowserRequests++; return { html: masterManifest }; } throw new Error('unexpected browser URL: ' + url); } }, @@ -47,40 +48,47 @@ 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.deepStrictEqual(weakDetail.resourceGroups, [], '弱网失败不能被保存成默认线路'); - - await assert.rejects( - library.getResourceVersions({ itemId: detailURL, embedURL }), - (error) => error instanceof Error, - '弱网资源探测应失败并允许客户端稍后重试' + 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); + + 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); 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 cached = await library.getResourceVersions({ itemId: detailURL, embedURL }); + const weakAgain = await library.getResourceVersions({ itemId: detailURL, embedURL }); assert.deepStrictEqual( - cached.groups[0].versions.map((version) => version.name), - ['1080P', '720P', '480P'], - '成功发现后的稳定画质元数据应可跨网络波动复用' + weakAgain.groups[0].versions.map((version) => version.name), + ['默认线路'], + '初版没有持久画质缓存,再次弱网时返回动态默认线路' ); - - const serializedCache = JSON.stringify(Array.from(storageValues.values())); - assert(!serializedCache.includes('token=fresh'), '不得缓存签名播放 URL'); + 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, '保持播放器要求的原生请求头'); - console.log('SexBJCam network recovery test passed'); + 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'); } run().catch((error) => { diff --git a/taolusm-mini-library.js b/taolusm-mini-library.js index 9b14e1d..0492a2c 100644 --- a/taolusm-mini-library.js +++ b/taolusm-mini-library.js @@ -9,8 +9,8 @@ const WidgetMetadata = { id: 'taolusm-mini-library', name: '套路SM', title: '套路SM', - version: '1.0.2', - author: 'Alan huang', + version: '1.0.0', + author: 'EL', logo: TAOLUSM_LOGO, icon: TAOLUSM_LOGO, site: TAOLUSM_DEFAULT_BASE, @@ -71,7 +71,6 @@ function getManifest() { } async function getHome(ctx) { - ctx = normalizeRuntimeContext(ctx); let latestItems = []; try { const html = await fetchText(ctx, categoryURL(ctx, '/')); @@ -111,7 +110,6 @@ async function getHome(ctx) { } async function getHomeSection(ctx) { - ctx = normalizeRuntimeContext(ctx); const sectionId = stringValue(ctx && (ctx.sectionId || ctx.id)); const section = findSection(sectionId) || TAOLUSM_SECTIONS[0]; try { @@ -130,7 +128,6 @@ async function getHomeSection(ctx) { } async function getCategory(ctx) { - ctx = normalizeRuntimeContext(ctx); const page = positiveInt(contextValue(ctx, 'page'), 1); const pageId = normalizePageId(ctx && (ctx.pageId || ctx.id || ctx.category || ctx.genreId)); const section = findSection(pageId) || TAOLUSM_SECTIONS[0]; @@ -151,7 +148,6 @@ async function getCategory(ctx) { } async function getDetail(ctx) { - ctx = normalizeRuntimeContext(ctx); const id = itemIdFromContext(ctx); if (!id) throw new Error('套路SM 详情参数无效'); @@ -176,7 +172,6 @@ async function getDetail(ctx) { .filter(function (item) { return item.id !== id; }) .slice(0, 18); const headers = mediaHeaders(ctx); - const source = parsePlaybackSource(html, id); return { id: id, @@ -188,8 +183,8 @@ async function getDetail(ctx) { posterHeaders: headers, backdropHeaders: headers, detailImageAspectRatio: '16:9', - overview: [source.preview ? '公开预览;完整影片需要站点登录及观看权限。' : '', overview].filter(Boolean).join('\n'), - resourceGroups: resourceGroupsFor(ctx, id, title, source), + overview: overview, + resourceGroups: resourceGroupsFor(ctx, id, title), recommendations: recommendations.length ? [ { id: 'related', @@ -201,48 +196,27 @@ async function getDetail(ctx) { }; } -async function getResourceVersions(ctx) { - ctx = normalizeRuntimeContext(ctx); +function getResourceVersions(ctx) { const id = itemIdFromContext(ctx); if (!id) return []; - const html = await fetchText(ctx, baseURL(ctx) + '/v/' + encodeURIComponent(id)); - const source = parsePlaybackSource(html, id); - if (!source.url) throw new Error('套路SM 当前页面未提供可播放的影片,可能需要登录及观看权限'); - return resourceGroupsFor(ctx, id, stringValue(ctx.title) || '在线播放', source); + return resourceGroupsFor(ctx, id, stringValue(ctx && ctx.title) || '在线播放'); } -async function resolvePlayback(ctx) { - ctx = normalizeRuntimeContext(ctx); +function resolvePlayback(ctx) { const id = itemIdFromContext(ctx); - if (!id) throw new Error('套路SM 播放参数无效'); - const html = await fetchText(ctx, baseURL(ctx) + '/v/' + encodeURIComponent(id)); - const source = parsePlaybackSource(html, id); - if (!source.url) throw new Error('套路SM 当前页面未提供媒体地址;完整影片需站点登录及观看权限'); - if (source.preview && (/^download-/.test(stringValue(ctx.versionId)) || /\/download\//.test(stringValue(ctx.url)))) { - throw new Error('旧下载线路需要站点登录及观看权限;当前公开页面仅提供预览,请重新打开详情并选择“公开预览”'); - } - const hls = /\.m3u8(?:$|[?#])/i.test(source.url); - if (hls) { - const body = await fetchText(ctx, source.url); - if (!/^\s*#EXTM3U/i.test(body)) throw new Error('套路SM 当前播放线路没有返回有效 HLS'); - } - return { url: source.url, container: hls ? 'm3u8' : 'mp4', headers: mediaHeaders(ctx), startPositionSeconds: 0, isLive: false, streamKind: hls ? 'hls' : 'file' }; -} + const url = stringValue(ctx && ctx.url) || (id ? downloadURL(ctx, id) : ''); + if (!url) throw new Error('套路SM 播放参数无效'); -function parsePlaybackSource(html, id) { - const scripts = String(html || '').match(/<script\b[^>]*>[\s\S]*?<\/script>/gi) || []; - for (const script of scripts) { - const owner = firstMatch(script, /const\s+video_id\s*=\s*['"]([^'"]+)['"]/); - if (owner !== String(id)) continue; - const url = decodeEntities(firstMatch(script, /const\s+source\s*=\s*['"]([^'"]+)['"]/)); - if (!/^https?:\/\//i.test(url) || !/\.(?:mp4|m3u8)(?:$|[?#])/i.test(url)) continue; - return { url: url, preview: /\/preview_mp4\//i.test(url) || /正在播放预览/.test(html) }; - } - return { url: '', preview: false }; + return { + url: url, + container: 'mp4', + headers: mediaHeaders(ctx), + isLive: false, + streamKind: 'file' + }; } async function search(ctx) { - ctx = normalizeRuntimeContext(ctx); const query = cleanText(contextValue(ctx, 'query') || contextValue(ctx, 'keyword') || contextValue(ctx, 'text')); const page = positiveInt(contextValue(ctx, 'page'), 1); if (!query) { @@ -320,15 +294,25 @@ function parseListHtml(ctx, html) { return items; } -function resourceGroupsFor(ctx, id, title, source) { - if (!source || !source.url) return []; - const versionId = (source.preview ? 'preview-' : 'source-') + id; - return [{ id: source.preview ? 'preview' : 'online', title: source.preview ? '公开预览' : '网页播放线路', versions: [{ - id: versionId, name: source.preview ? '公开预览(非完整影片)' : '当前网页线路', title: title, - subtitle: source.preview ? '完整影片需要站点登录及观看权限' : '播放时重新读取网页地址', - container: /\.m3u8(?:$|[?#])/i.test(source.url) ? 'm3u8' : 'mp4', default: true, - action: { type: 'play', itemId: id, versionId: versionId, title: title } - }] }]; +function resourceGroupsFor(ctx, id, title) { + return [ + { + id: 'download', + title: '在线播放', + versions: [ + { + id: 'download-' + id, + name: '默认线路', + title: title || '默认线路', + subtitle: '站点下载直链', + url: downloadURL(ctx, id), + container: 'mp4', + default: true, + headers: mediaHeaders(ctx) + } + ] + } + ]; } function categoryCard(ctx, section) { @@ -390,28 +374,12 @@ function toWideItem(item) { } async function fetchText(ctx, url) { - let timer; - try { - return await Promise.race([Promise.resolve().then(async function () { - const options = { headers: mediaHeaders(ctx), timeout: 8, timeoutSeconds: 8, browserFallback: false }; - let response; - if (typeof Widget !== 'undefined' && Widget.http && typeof Widget.http.get === 'function') response = await Widget.http.get(url, options); - else if (typeof Widget !== 'undefined' && Widget.http && typeof Widget.http.request === 'function') response = await Widget.http.request(Object.assign({ url: url, method: 'GET' }, options)); - else if (typeof $http !== 'undefined' && typeof $http.get === 'function') response = await $http.get(url, options); - else throw new Error('当前环境没有 HTTP 客户端'); - const status = Number(response && (response.statusCode || response.status) || 200); - if (status >= 400) throw new Error('套路SM HTTP ' + status); - const final = response && (response.finalURL || response.urlEffective || response.responseURL || response.url); - if (/\/login(?:[/?#]|$)/i.test(String(final || ''))) throw new Error('套路SM 此入口需要站点登录及观看权限'); - let text = ''; - if (typeof response === 'string') text = response; - else if (response && typeof response.text === 'function') text = await response.text(); - else if (response) for (const value of [response.data, response.body, response.html, response.text]) { if (typeof value === 'string') { text = value; break; } if (value && typeof value.html === 'string') { text = value.html; break; } } - if (!text) throw new Error('套路SM 返回空内容'); - if (/Just a moment|Checking (?:your )?browser|cf-chl-/i.test(text)) throw new Error('套路SM 返回验证页'); - return String(text); - }), new Promise(function (_, reject) { timer = setTimeout(function () { reject(new Error('套路SM HTTP 等待超时(8 秒)')); }, 8000); })]); - } finally { clearTimeout(timer); } + const response = await Widget.http.get(url, { + headers: mediaHeaders(ctx) + }); + const data = response && (response.data || response.body || response.text); + if (!data) throw new Error('请求失败: ' + url); + return String(data); } function mediaHeaders(ctx) { @@ -456,13 +424,7 @@ function normalizePageId(id) { } function itemIdFromContext(ctx) { - for (const value of [ctx.itemId, ctx.id, ctx.link, ctx.vid, ctx.videoId, ctx.versionId, ctx.url]) { - const text = stringValue(value); - if (/^\d+$/.test(text)) return text; - const match = text.match(/^(?:download|preview|source)-(\d+)$/) || text.match(/\/(?:v|download)\/(\d+)(?:[/?#]|$)/); - if (match) return match[1]; - } - return ''; + return stringValue(ctx && (ctx.itemId || ctx.id || ctx.link || ctx.vid || ctx.videoId)).replace(/\D+/g, ''); } function hasNextPage(html, page) { @@ -562,16 +524,3 @@ function stringValue(value) { function escapeRegExp(text) { return stringValue(text).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } - -function normalizeRuntimeContext(input) { - let ctx = input; - if (typeof ctx === 'string') { try { ctx = JSON.parse(ctx); } catch (_) { ctx = {}; } } - if (!ctx || typeof ctx !== 'object' || Array.isArray(ctx)) return {}; - const nested = {}; - ['params', 'config', 'settings', 'parameters', 'pagination', 'pageInfo'].forEach(function (key) { - let bag = ctx[key]; - if (typeof bag === 'string') { try { bag = JSON.parse(bag); } catch (_) { bag = null; } } - if (bag && typeof bag === 'object' && !Array.isArray(bag)) Object.keys(bag).forEach(function (name) { if (nested[name] === undefined) nested[name] = bag[name]; }); - }); - return Object.assign(nested, ctx); -} diff --git a/tests/adapters.test.cjs b/tests/adapters.test.cjs index 677e1a0..4947cc3 100644 --- a/tests/adapters.test.cjs +++ b/tests/adapters.test.cjs @@ -3,6 +3,7 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); +const crypto = require('node:crypto'); const root = path.resolve(__dirname, '..'); function load(file, additions = {}) { const context = vm.createContext({ console: { log() {}, warn() {}, error() {} }, URL, URLSearchParams, Buffer, atob, btoa, @@ -15,7 +16,7 @@ function plain(value) { return JSON.parse(JSON.stringify(value)); } const master = '#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=2000000,RESOLUTION=1920x1080\n1080.m3u8\n#EXT-X-STREAM-INF:BANDWIDTH=900000,RESOLUTION=1280x720\n720.m3u8\n'; const media = '#EXTM3U\n#EXT-X-TARGETDURATION:4\n#EXTINF:4,\nsegment.ts\n'; -for (const file of ['123av-mini-library.js', 'girigirilove-mini-library.js', 'kbjfan-mini-library.js', 'taolusm-mini-library.js', 'xxxfollow-mini-library 5.js']) { +for (const file of ['123av-mini-library.js', 'girigirilove-mini-library.js', 'xxxfollow-mini-library 5.js']) { test(`${file}: object and JSON-string search contexts request the same page`, async () => { const c = load(file); const requests = []; c.Widget.http.get = async (url) => { requests.push(url); return { status: 200, data: '<html><body></body></html>' }; }; @@ -63,7 +64,7 @@ test('591AV home does not wait for six category previews', async () => { const home = await c.getHome({}); assert.equal(calls, 1); assert.equal(home.sections[0].lazy, true); }); -for (const file of ['madou8-domestic-mini-library.js', 'madou8-mini-library 5.js']) { +for (const file of ['madou8-domestic-mini-library.js']) { for (const requested of [1080, 0, 2160]) { test(`${file}: lower first line does not hide the later 1080P line (${requested})`, async () => { const c = load(file); const requestedURLs = []; @@ -115,98 +116,7 @@ test('Giri parses quoted > in card attributes and never caches HTTP errors', asy c.Widget.http.get = async () => ({ status: 200, data: html }); assert.equal(await c.fetchText({}, 'https://girigirilove.com/video/123/'), html); }); -test('MissAV does not invoke player/browser fallbacks after a complete master discovery', async () => { - const c = load('missav-mini-library-download-working 6.js'); let linked = 0, browser = 0; - c.fetchText = async () => '<script>var source="https://cdn.test/master.m3u8"</script>'; - c.discoverAvailableQualities = async () => [{ height: 1080, url: 'https://cdn.test/1080.m3u8' }, { height: 720, url: 'https://cdn.test/720.m3u8' }]; - c.extractPlayableFromLinkedPlayers = async () => { linked++; return ''; }; c.extractFromBrowser = async () => { browser++; return ''; }; - const result = await c.resolvePlayback({ itemId: c.makeItemId('https://missav.ws/cn/a', 'A', '') }); - assert.equal(result.url, 'https://cdn.test/1080.m3u8'); assert.equal(linked, 0); assert.equal(browser, 0); -}); -test('MissAV accepts the original 1.0.7 detail payload and all legacy parameter names', () => { - const c = load('missav-mini-library-download-working 6.js'); - const old = 'missav://detail?url=https%3A%2F%2Fmissav.ws%2Fdm247%2Fcn%2Fsample&title=Old'; - assert.equal(c.detailUrlFromContext({ itemId: old }), 'https://missav.ws/dm247/cn/sample'); - const names = c.getManifest().parameters.map(p => p.name); - for (const name of ['baseURL', 'entryPath', 'backupBaseURLs', 'enableBrowserFallback', 'browserVisible', 'requestTimeoutSeconds', 'cacheMinutes']) assert.ok(names.includes(name)); -}); - -const missavFile = 'missav-mini-library-download-working 6.js'; -const missavPage = '<html><title>Sample - MissAV' + - '' + - ''; -function missavInput(c) { return { itemId: c.makeItemId('https://missav.ws/cn/sample-001', 'Sample', '') }; } -function acceleratedTimers() { return { setTimeout: (fn, ms) => setTimeout(fn, ms / 100) }; } - -test('MissAV HTTP 200 passive Cloudflare detection is not a blocking challenge', async () => { - const c = load(missavFile); const requests = []; let browser = 0; - c.Widget.http.get = async url => { requests.push(url); return { statusCode: 200, data: url.includes('.m3u8') ? master : missavPage }; }; - c.Widget.browser = { async fetch() { browser++; throw new Error('browser must not be needed'); } }; - const result = await c.resolvePlayback(missavInput(c)); - assert.equal(result.url, 'https://cdn.test/1080.m3u8'); - assert.equal(browser, 0); - assert.deepEqual(requests, ['https://missav.ws/cn/sample-001', 'https://cdn.test/playlist.m3u8']); - assert.equal(c.isCloudflare(missavPage, 200, { server: 'cloudflare' }), false); - for (const [html, status, headers] of [ - ['Just a moment...Verify you are human', 200, {}], - [missavPage, 200, { 'Cf-Mitigated': 'challenge' }], [missavPage, 403, {}] - ]) assert.equal(c.isUsableHTML(html, status, headers), false); -}); -test('MissAV uses its one browser call for media capture after a real challenge', async () => { - const c = load(missavFile); const browserCalls = [], requests = []; - c.Widget.http.get = async url => { requests.push(url); return { statusCode: 200, data: url.includes('.m3u8') ? master : 'Just a moment...Enable JavaScript and cookies to continue' }; }; - c.Widget.browser = { async fetch(url, options) { browserCalls.push({ url, options }); return { mediaSources: [{ url: 'https://cdn.test/playlist.m3u8' }] }; } }; - assert.equal((await c.resolvePlayback(missavInput(c))).url, 'https://cdn.test/1080.m3u8'); - assert.equal(browserCalls.length, 1); assert.equal(browserCalls[0].options.captureMedia, true); - assert.equal(browserCalls[0].options.visible, false); assert.equal(requests.length, 2); -}); -test('MissAV a host that ignores HTTP timeout still reaches media capture before the total deadline', async () => { - const c = load(missavFile, acceleratedTimers()); let browser = 0, requests = 0; - c.Widget.http.get = async url => { requests++; return url.includes('.m3u8') ? { status: 200, data: master } : new Promise(() => {}); }; - c.Widget.browser = { async fetch() { browser++; return { mediaSources: [{ url: 'https://cdn.test/playlist.m3u8' }] }; } }; - assert.equal((await c.resolvePlayback(missavInput(c))).url, 'https://cdn.test/1080.m3u8'); - assert.equal(browser, 1); assert.equal(requests, 2); -}); -test('MissAV a hung browser reports the failing media stage without retrying mirrors', async () => { - const c = load(missavFile, acceleratedTimers()); let browser = 0, requests = 0; - c.Widget.http.get = async () => { requests++; throw new Error('offline'); }; - c.Widget.browser = { async fetch() { browser++; return new Promise(() => {}); } }; - await assert.rejects(c.resolvePlayback(missavInput(c)), error => /stage=browser-media/.test(error.message) && !/total-deadline/.test(error.message)); - assert.equal(browser, 1); assert.equal(requests, 1); -}); -test('MissAV a hung optional master probe hands off the highest HTML quality without another browser', async () => { - const c = load(missavFile, acceleratedTimers()); let browser = 0; - c.Widget.http.get = async url => url.includes('.m3u8') ? new Promise(() => {}) : { status: 200, data: missavPage }; - c.Widget.browser = { async fetch() { browser++; return new Promise(() => {}); } }; - assert.equal((await c.resolvePlayback(missavInput(c))).url, 'https://cdn.test/1080p/video.m3u8'); - assert.equal(browser, 0); -}); -test('MissAV playback refreshes detail HTML instead of using cached signed source URLs', async () => { - const c = load(missavFile); let refreshes = 0; - c.setCachedText({}, 'https://missav.ws/cn/sample-001', missavPage); - c.Widget.http.get = async url => { if (url.includes('.m3u8')) return { status: 200, data: media }; refreshes++; return { status: 200, data: missavPage.replaceAll('https://cdn.test/', 'https://fresh.test/' + refreshes + '/') }; }; - assert.equal((await c.resolvePlayback(missavInput(c))).url, 'https://fresh.test/1/1080p/video.m3u8'); - assert.equal((await c.resolvePlayback(missavInput(c))).url, 'https://fresh.test/2/1080p/video.m3u8'); - assert.equal(refreshes, 2); -}); -test('MissAV optional master discovery reserves time to hand off an already discovered stream', async () => { - let offset = 0, masterTimeout; - class PlaybackClock extends Date { static now() { return Date.now() + offset; } } - const c = load(missavFile, { ...acceleratedTimers(), Date: PlaybackClock }); - c.Widget.http.get = async (url, options) => { - if (url.includes('.m3u8')) { masterTimeout = options.timeoutSeconds; return new Promise(() => {}); } - offset = 26500; return { status: 200, data: missavPage }; - }; - assert.equal((await c.resolvePlayback(missavInput(c))).url, 'https://cdn.test/1080p/video.m3u8'); - assert.ok(masterTimeout > 0 && masterTimeout <= 1); -}); -test('MissAV resource discovery failure does not become a fake playable webpage', async () => { - const c = load('missav-mini-library-download-working 6.js'); - c.fetchText = async () => { throw new Error('503'); }; c.extractFromBrowser = async () => ''; - const groups = await c.getResourceVersions({ itemId: c.makeItemId('https://missav.ws/cn/a', 'A', '') }); - assert.equal(Array.isArray(groups) ? groups.length : groups.groups?.length || 0, 0); -}); -for (const file of ['missav-mini-library-download-working 6.js', 'sexbjcam-mini-library.js', 'javgg-mini-library.js', 'novipnoad-mini-library.js']) { +for (const file of ['novipnoad-mini-library.js']) { test(`${file}: a hung resolver has one overall deadline`, async () => { const c = load(file, { setTimeout: (fn) => setTimeout(fn, 15) }); c.resolvePlaybackWithinBudget = async () => new Promise(() => {}); @@ -237,78 +147,6 @@ test('Manko keeps successful navigation cards when one category fails', async () c.primaryNavCard = async definition => { active++; max = Math.max(max, active); await Promise.resolve(); active--; if (definition[0] === 'new') throw new Error('offline'); return { id: definition[0], poster: 'https://img.test/a.jpg' }; }; const section = await c.getHomeSection({ sectionId: 'primary-nav' }); assert.ok(section.items.length > 0); assert.ok(max <= 2); }); -test('ASMRLIB category previews come from their own category and load after home', async () => { - const c = load('asmrlib-mini-library.js'); c.asmrHTTP = async () => ''; - c.asmrParseList = () => [{ id: 'home', poster: 'https://img.test/home.jpg' }]; - const home = await c.getHome(); assert.equal(home.sections[1].lazy, true); - const requested = []; c.getCategory = async ({ pageId }) => { requested.push(pageId); return { items: [0,1,2].map(i => ({ id: `${pageId}-${i}`, poster: `https://img.test/${pageId}-${i}.jpg` })) }; }; - const section = await c.getHomeSection({ sectionId: 'categories' }); assert.ok(requested.length >= 3); - assert.ok(section.items.every(x => x.previewItems[0].id.startsWith(x.action.pageId + '-'))); -}); -const asmrPost = 'becd2651e7d56ca656d27766d036dcee'; -const asmrCurrentPage = '

ASMR sample

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

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

' }; } }; - await assert.rejects(c.resolvePlayback({ itemId: asmrPost, params: { browserVisible: false } }), /stage=verification-required/); - assert.equal(visible, false); assert.equal(c.getManifest().parameters[0].defaultValue, true); -}); -test('ASMRLIB bounds a hung host browser and preserves the stage without exposing native exceptions', async () => { - const c = load('asmrlib-mini-library.js', { setTimeout: (fn, ms) => setTimeout(fn, ms / 100) }); let calls = 0; - c.Widget.browser = { async fetch() { calls++; return new Promise(() => {}); } }; - await assert.rejects(c.resolvePlayback({ itemId: asmrPost }), /stage=page-media/); assert.equal(calls, 1); - c.Widget.browser.fetch = async () => { throw new Error('Cookie=session-secret https://cdn.test/?token=secret'); }; - await assert.rejects(c.resolvePlayback({ itemId: asmrPost }), error => /stage=page-media/.test(error.message) && !/secret/.test(error.message)); -}); -test('ASMRLIB old UP failure falls back once to the embedded page instead of skipping browser capture', async () => { - const c = load('asmrlib-mini-library.js', { $crypto: { aesDecrypt() { throw new Error('unsupported AES'); } } }); let browser = 0, timeout; - c.Widget.http.get = async (url, options) => { timeout = options.timeout; return { data: 'abcdef123456' }; }; - c.Widget.browser = { async fetch(url) { browser++; assert.equal(url, 'https://asmrlib.com/posts/' + asmrPost); return { mediaURL: 'https://cdn.test/current.mp4' }; } }; - const versionId = 'asmrlib-line://UP/' + encodeURIComponent('https://v.upn.one/#legacycode'); - const result = await c.resolvePlayback({ itemId: asmrPost, versionId }); - assert.equal(result.url, 'https://cdn.test/current.mp4'); assert.equal(browser, 1); assert.equal(timeout, 5); - assert.equal(result.headers.Origin, undefined); assert.equal(result.headers.Referer, undefined); -}); test('ASMRLIB retains direct media compatibility without requiring a browser', async () => { const c = load('asmrlib-mini-library.js'); assert.equal((await c.resolvePlayback({ url: 'https://cdn.test/legacy.mp4' })).url, 'https://cdn.test/legacy.mp4'); @@ -362,12 +200,6 @@ for (const mode of ['expired', 'rejected']) { }); } -test('MissAV playback budget owns browser fallback and preserves attached cookies', () => { - const c = load('missav-mini-library-download-working 6.js'); - const options = c.requestOptions({__playbackBudget:{endAt:Date.now()+2000,expired:false}},'https://missav.ws/'); - assert.ok(options.timeout > 0 && options.timeout <= 2); assert.equal(options.useBrowserFallback,false); assert.equal(options.attachBrowserCookie,true); -}); - test('Novip playback awaits native browser media and caps both HTTP and browser stages', async () => { const c = load('novipnoad-mini-library.js'); const calls=[]; c.parsePlayInfo = () => ({vid:'video123',pkey:'key'}); c.parseEpisodes = () => []; @@ -396,367 +228,78 @@ 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))); }); -const madouCard = id => `
${id}
`; -const taoluPage = (id, source, preview = true) => `

Film ${id}

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

Other site

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

Film

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

Forbidden

' }); - await assert.rejects(c.getHome({}), /HTTP 403/); - c.Widget.http.get = async () => ({ statusCode: 200, data: 'No cards' }); - await assert.rejects(c.getHome({}), /未收到影片列表/); - c.fetchStreamInfo = async () => ({ playlist: [{ url: 'https://invalid.test/stream.m3u8' }] }); c.fetchHlsText = async () => { throw new Error('HTTP 403'); }; - await assert.rejects(c.buildPlaybackGroups({}, 'https://madou.test/detail', 'uid', 'Film'), /没有可验证/); -}); -test('Madou one challenged page can return native browser HTML without media capture or retry stacks', async () => { - const c = load('madou8-mini-library 5.js'); let browsers = 0; - c.Widget.http.get = async () => ({ statusCode: 403, data: 'Just a moment' }); - c.Widget.browser = { async fetch(url, options) { browsers++; assert.equal(options.visible, false); assert.equal(options.waitForMediaSource, undefined); return { html: madouCard('verified') }; } }; - const home = await c.getHome({}); assert.equal(home.hero[0].id, 'verified'); assert.equal(browsers, 1); - await assert.rejects(c.fetchHlsText({}, 'https://cdn.test/master.m3u8', 'https://madou.test/'), /HTTP 403/); assert.equal(browsers, 1); -}); -test('Madou relative cards and signed HLS paths round-trip without URL global', () => { - const c = load('madou8-mini-library 5.js', { URL: undefined }); - const items = c.parseCards(madouCard('film'), {}); - assert.equal(items[0].action.detailUrl, 'https://madou8.pw/asian/zh-CN/video/cid/film'); - assert.equal(c.resolveURL('../720/video.m3u8?sig=a/b#part', 'https://cdn.test/token/master.m3u8'), 'https://cdn.test/720/video.m3u8?sig=a/b#part'); -}); -test('Madou evaluates a working line beyond the old first-four limit and skips web-only schemes', async () => { - const c = load('madou8-mini-library 5.js'); const calls = []; - const playlist = Array.from({length: 5}, (_, i) => ({ url: `https://cdn.test/${i}/master.m3u8` })); - playlist.unshift({ url: 'https://enc.test/stream.m3u8', playMode: 'streampipe' }, { url: 'https://web.test/stream.m3u8', native: false }); - c.fetchHlsText = async (ctx, url) => { calls.push(url); if (url.includes('/4/')) return url.endsWith('master.m3u8') ? master : media; throw new Error('HTTP 403'); }; - const chosen = await c.chooseStream({}, playlist, 1080, 'https://madou.test/detail'); - assert.equal(chosen.height, 1080); assert.match(chosen.url, /\/4\/1080.m3u8/); assert.ok(!calls.some(url => /enc.test|web.test/.test(url))); -}); -test('Madou retains public player Accept-sign headers for master, variant and native playback', async () => { - const c = load('madou8-mini-library 5.js'); const calls = []; - c.Widget.http.get = async (url, options) => { calls.push({url, headers: options.headers}); return {statusCode:200,data:url.endsWith('master.m3u8') ? master : media}; }; - const chosen = await c.chooseStream({}, [{url:'https://cdn.test/master.m3u8',urlSign:'opaque normal value'}], 720, 'https://madou.test/detail'); - assert.equal(chosen.height, 720); assert.equal(chosen.headers.Accept, '*/*;sign=opaque%20normal%20value'); - assert.ok(calls.every(x => x.headers.Accept === chosen.headers.Accept)); assert.equal(chosen.headers.Origin, undefined); - const q = await c.discoverVariants({}, [{url:'https://cdn.test/master.m3u8',urlSign:'private-now'}], 'https://madou.test/detail'); - assert.ok(!JSON.stringify(q).includes('private-now')); -}); -test('Madou original media playlists expose honest original quality and refresh before every play', async () => { - const c = load('madou8-mini-library 5.js'); let api = 0; - c.Widget.http.get = async url => ({statusCode:200,data:url.includes('/api/video/stream') ? JSON.stringify({playlist:[{url:`https://cdn.test/stream.m3u8?fresh=${++api}`,urlSign:`now-${api}`} ]}) : media}); - const detail = 'https://madou8.pw/asian/zh-CN/video/cid/film'; - const groups = await c.buildPlaybackGroups(c.madouContext({}), detail, 'uid', 'Film'); - assert.equal(groups[0].versions[0].name, '原始画质'); assert.ok(!JSON.stringify(groups).includes('now-')); - const action = groups[0].versions[0].action; - for (const height of [0, 720, 0]) { - const result = await c.resolvePlayback(JSON.stringify({...action,qualityId:height,url:'https://expired.test/file.m3u8'})); - assert.match(result.url, new RegExp(`fresh=${api}$`)); assert.equal(result.headers.Accept, `*/*;sign=now-${api}`); - } - assert.equal(api, 4); -}); -test('Madou media discovery caps concurrency and the overall deadline when native requests hang', async () => { - const c = load('madou8-mini-library 5.js', {setTimeout:(fn,ms)=>setTimeout(fn,Math.min(ms,20))}); let active=0,max=0; - c.Widget.http.get = async () => { active++; max=Math.max(max,active); return new Promise(()=>{}); }; - const playlist=Array.from({length:9},(_,i)=>({url:`https://cdn.test/${i}/master.m3u8`})); - const start=Date.now(); await assert.rejects(c.chooseStream({__deadline:Date.now()+25},playlist,0,'https://madou.test/detail'), /stage=/); - assert.ok(Date.now()-start<150); assert.ok(max<=6); // timed-out native calls cannot be cancelled; logical workers remain three -}); -test('Taolu detail and versions identify public previews, not download/login pages or recommendations', async () => { - const c=load('taolusm-mini-library.js'); const calls=[]; - c.Widget.http.get=async url=>{calls.push(url);return {statusCode:200,data:taoluPage('121576','https://tl.test/preview_mp4/121576.mp4')};}; - const detail=await c.getDetail({itemId:'121576'}); const version=detail.resourceGroups[0].versions[0]; - assert.equal(version.name,'公开预览(非完整影片)'); assert.equal(version.url,undefined); assert.match(detail.overview,/完整影片需要/); - const result=await c.resolvePlayback(JSON.stringify(version.action)); assert.equal(result.url,'https://tl.test/preview_mp4/121576.mp4'); assert.equal(result.container,'mp4'); assert.equal(result.headers.Origin,undefined); - assert.ok(calls.every(url=>url.endsWith('/v/121576'))); assert.equal(c.parsePlaybackSource(taoluPage('other','https://tl.test/preview_mp4/other.mp4'),'121576').url,''); -}); -test('Taolu old complete/download choices report access requirements instead of silently substituting previews', async () => { - const c=load('taolusm-mini-library.js');c.Widget.http.get=async()=>({statusCode:200,data:taoluPage('121576','https://tl.test/preview_mp4/121576.mp4')}); - await assert.rejects(c.resolvePlayback({versionId:'download-121576'}),/旧下载线路需要/); - await assert.rejects(c.resolvePlayback({url:'https://taolusm.com/download/121576'}),/旧下载线路需要/); - assert.equal(c.itemIdFromContext({itemId:'https://taolusm.com/v/121576?year=2026'}),'121576'); -}); -test('Taolu missing source, HTTP errors and login redirects never become MP4 playback results', async () => { - const c=load('taolusm-mini-library.js'); - for(const response of [{statusCode:200,data:''},{statusCode:403,data:'Forbidden'},{statusCode:200,finalURL:'https://taolusm.com/login',data:'Login'}]) { - c.Widget.http.get=async()=>response; - await assert.rejects(c.resolvePlayback({itemId:'121576'}),/未提供|HTTP 403|需要站点登录/); - } -}); - -test('Madou history refresh ignores an old media URL when only stable item/version is present', async () => { - const c=load('madou8-mini-library 5.js');const version=c.encodeVersionId({url:'https://madou8.pw/asian/zh-CN/video/cid/film',uid:'uid',height:720}); - assert.equal(c.detailURL({itemId:'film',url:'https://expired.test/stream.m3u8'}),'https://madou8.pw/asian/zh-CN/video/cid/film'); - assert.equal(c.detailURL({versionId:version,url:'https://expired.test/stream.m3u8'}),'https://madou8.pw/asian/zh-CN/video/cid/film'); -}); -test('Madou native response body timeouts and logical media concurrency remain bounded', async () => { - const c=load('madou8-mini-library 5.js',{setTimeout:(fn,ms)=>setTimeout(fn,Math.min(ms,15))}); - c.Widget.http.get=async()=>({statusCode:200,text:()=>new Promise(()=>{})}); - await assert.rejects(c.getHome({}),/stage=page-http/); - let active=0,max=0; - const results=await c.madouMap([1,2,3,4,5,6,7],3,async value=>{active++;max=Math.max(max,active);await new Promise(resolve=>setTimeout(resolve,3));active--;return value;}); - assert.deepEqual(plain(results),[1,2,3,4,5,6,7]);assert.equal(max,3); -}); -test('KBJ unrelated HTML cannot create fake playable lines or successful first categories', async () => { - const c=load('kbjfan-mini-library.js');c.Widget.http.get=async()=>({statusCode:200,data:'Other site'}); - await assert.rejects(c.getCategory({pageId:'dance'}),/没有原站影片/); - const detail=await c.getDetail({itemId:'https://www.kbjfan.com/old-film/'});assert.equal(detail.resourceGroups.length,0); - await assert.rejects(c.getResourceVersions({itemId:'https://www.kbjfan.com/old-film/'}),/没有可播放媒体/); -}); - -function javggDetail(lines = ['VH', 'playmate', 'luluvdoo', 'SW']) { - return '
    ' + lines.map((line, i) => - `
  • Server${line}
  • `).join('') + '
' + lines.map((line, i) => - `
`).join('') + '
'; -} -function packedJavgg(host = 'cdn', file = 'master', signature = 'fresh') { - return `eval(function(p,a,c,k,e,d){return p;}('0({1:[{2:"3://4.5/6.7?8=9"}]});',36,10,'setup|sources|file|https|${host}|test|${file}|m3u8|sign|${signature}'.split('|'),0,{}))`; -} - -test('JAVGG maps real server labels to numbered frames, despite missing/reordered frames', () => { - const c = load('javgg-mini-library.js'); - const source = javggDetail().replace(/
[\s\S]*?<\/div><\/div>/, ''); - const players = c.parsePlayers(source); - assert.deepEqual(plain(players.map(x => [x.lineId, x.line])), [['2','playmate'],['3','luluvdoo'],['4','SW']]); - assert.equal(players[0].url, 'https://player2.test/e/film'); - const groups = players.map(x => c.qualityGroup('https://javgg.net/jav/a/', 'A', x, [{name:'720p',height:720}])); - assert.equal(new Set(groups.map(x => x.id)).size, 3); -}); - -test('JAVGG discovers packed HLS qualities while hung and empty players fail independently', async () => { - const c = load('javgg-mini-library.js', { setTimeout: (fn, ms) => setTimeout(fn, ms / 100) }); - let active = 0, max = 0, browsers = 0; - c.Widget.browser = { async fetch() { browsers++; throw new Error('should not be needed'); } }; - c.Widget.http.get = async url => { - if (url.includes('/jav/')) return {statusCode:200,data:javggDetail()}; - if (url.includes('player1')) return new Promise(() => {}); - active++; max = Math.max(active, max); await Promise.resolve(); active--; - if (url.includes('player2')) return {statusCode:200,data:'dynamic player'}; - if (url.includes('player3')) return {statusCode:200,data:packedJavgg('lulu')}; - if (url.includes('player4')) return {statusCode:200,data:packedJavgg('sw')}; - return {statusCode:200,data:url.includes('lulu') ? master.split('#EXT-X-STREAM-INF:BANDWIDTH=900000')[0] : master}; - }; - const groups = await c.getResourceVersions(JSON.stringify({detailUrl:'https://javgg.net/jav/a/'})); - assert.deepEqual(plain(groups.map(x => x.title)), ['luluvdoo 线路','SW 线路']); - assert.deepEqual(plain(groups[1].versions.map(x => x.name)), ['1080p','720p']); - assert.equal(groups[1].versions.filter(x => x.default).length, 1); - assert.equal(browsers, 0); assert.ok(max <= 2); - const payload = c.decodePayload(groups[1].versions[1].id); - assert.equal(payload.height,720); assert.equal(payload.lineId,'4'); assert.equal(payload.playerUrl,'https://player4.test/e/film'); - assert.ok(!groups[1].versions[0].id.includes('sign')); -}); - -test('JAVGG refreshes signed packed URLs and selects highest / requested / missing quality', async () => { - const c = load('javgg-mini-library.js'); let refreshes = 0; - c.Widget.http.get = async url => url.includes('player.test') ? {status:200,data:packedJavgg('cdn','master','fresh'+(++refreshes))} : {status:200,data:master}; - for (const height of [0,720,2160,1080]) { - const result = await c.resolvePlayback({versionId:c.encodePayload({kind:'play',detailUrl:'https://javgg.net/jav/a/',playerUrl:'https://player.test/e/a',line:'Server',height})}); - assert.equal(result.url, 'https://cdn.test/' + (height === 720 ? '720' : '1080') + '.m3u8'); - } - assert.equal(refreshes,4); -}); - -test('JAVGG does not execute packed scripts or accept unbounded packing dictionaries', () => { - const c = load('javgg-mini-library.js'); - assert.equal(c.extractPlayableURL(packedJavgg()),'https://cdn.test/master.m3u8?sign=fresh'); - assert.equal(c.extractPlayableURL(packedJavgg().replace('return p;', 'globalThis.compromised=true;return p;')),'https://cdn.test/master.m3u8?sign=fresh'); - assert.equal(c.compromised,undefined); - assert.equal(c.extractPlayableURL(packedJavgg().replace(',36,10,', ',36,200000,')), ''); -}); - -test('JAVGG rejects expired 404 manifests, retries fresh discovery, and leaves detail resources empty', async () => { - const c = load('javgg-mini-library.js'); let available = false; - c.Widget.http.get = async url => url.includes('/jav/') ? {status:200,data:javggDetail(['SW'])} : url.includes('player1') ? {status:200,data:packedJavgg()} : {statusCode:available ? 200 : 404,data:available ? media : 'Not Found'}; - const ctx={detailUrl:'https://javgg.net/jav/a/'}; - assert.equal((await c.getDetail(ctx)).resourceGroups.length,0); - await assert.rejects(c.getResourceVersions(ctx), /manifest-http-404/); - available=true; - const groups=await c.getResourceVersions(ctx);assert.equal(groups[0].versions[0].name,'HLS 原始画质'); -}); - -test('JAVGG captures real browser request headers once, then resolves a lower quality', async () => { - const c=load('javgg-mini-library.js'); let browsers=0; const manifestHeaders=[]; - c.Widget.http.get=async (url, options)=>{ - if(url.includes('/jav/')) return {status:200,data:javggDetail(['playmate'])}; - if(url.includes('player1')) return {status:200,data:'dynamic'}; - manifestHeaders.push(options.headers);return {status:200,data:master}; - }; - c.Widget.browser={async fetch(url,options){browsers++;assert.equal(options.visible,false);assert.equal(options.waitForAny,undefined);assert.ok(options.timeout<=10);return {capturedRequests:[{url:'https://cdn.test/master.m3u8?fresh=1',requestHeaders:{referer:'https://actual-player.test/',origin:'https://actual-player.test','user-agent':'Actual Agent',cookie:'fixture-only','Authorization':'must-not-forward'}}]};}}; - const groups=await c.getResourceVersions({detailUrl:'https://javgg.net/jav/a/'}); - const result=await c.resolvePlayback(groups[0].versions[1].action); - assert.equal(result.url,'https://cdn.test/720.m3u8');assert.equal(browsers,2); - assert.deepEqual(plain(result.headers),{'User-Agent':'Actual Agent',Referer:'https://actual-player.test/',Origin:'https://actual-player.test',Cookie:'fixture-only'}); - assert.ok(manifestHeaders.every(h=>h.Referer==='https://actual-player.test/')); -}); - -test('JAVGG bounds a hung native browser and identifies capture failure without exposing tokens', async () => { - const c=load('javgg-mini-library.js',{setTimeout:(fn,ms)=>setTimeout(fn,ms/100)});let browsers=0; - c.Widget.http.get=async url=>({status:200,data:url.includes('/jav/')?javggDetail(): 'dynamic'}); - c.Widget.browser={async fetch(){browsers++;return new Promise(()=>{});}}; - const start=Date.now();await assert.rejects(c.getResourceVersions({detailUrl:'https://javgg.net/jav/a/'}),/browser-media-timeout/); - assert.equal(browsers,1);assert.ok(Date.now()-start<500); - c.Widget.browser.fetch=async()=>({mediaSources:['blob:https://player.test/id'],secret:'must-not-leak'}); - await assert.rejects(c.resolvePlayback({playerUrl:'https://player.test/e/a'}),e=>/browser-.*media|browser-blob/.test(e.message)&&!e.message.includes('must-not-leak')); -}); - -test('JAVGG quality discovery itself has an overall deadline', async () => { - const c=load('javgg-mini-library.js',{setTimeout:fn=>setTimeout(fn,15)}); - c.discoverResourceVersions=async()=>new Promise(()=>{}); - await assert.rejects(c.getResourceVersions({}),/画质发现超时.*total-deadline/); -}); - -test('JAVGG direct detail Play explores later lines and selects the highest real quality', async () => { - const c=load('javgg-mini-library.js'); - c.Widget.http.get=async url=>{ - if(url.includes('/jav/')) return {status:200,data:javggDetail(['luluvdoo','SW'])}; - if(url.includes('player1')) return {status:200,data:packedJavgg('low')}; - if(url.includes('player2')) return {status:200,data:packedJavgg('high')}; - return {status:200,data:url.includes('low')?master.replace(/1920x1080/g,'1280x720').replace(/1080.m3u8/g,'720.m3u8'):master}; - }; - const result=await c.resolvePlayback({itemId:c.encodePayload({kind:'detail',detailUrl:'https://javgg.net/jav/a/'})}); - assert.equal(result.url,'https://high.test/1080.m3u8'); -}); - -test('JAVGG playback fails explicitly when a fresh manifest is permanently unavailable', async () => { - const c=load('javgg-mini-library.js'); - c.Widget.http.get=async url=>url.includes('player.test')?{status:200,data:packedJavgg()}:{statusCode:404,data:'Not Found'}; - await assert.rejects(c.resolvePlayback({playerUrl:'https://player.test/e/a'}),/manifest-http-404/); -}); - -test('JAVGG uses observed luluvdoo headers and does not invent headers for other players', () => { - const c=load('javgg-mini-library.js'); - const known=c.playbackHeaders('https://luluvdoo.com/e/film'); - assert.equal(known.Referer,'https://luluvdoo.com/');assert.equal(known.Origin,'https://luluvdoo.com'); - const other=c.playbackHeaders('https://javstreamhq.xyz/e/film'); - assert.equal(other.Origin,undefined);assert.equal(other.Referer,undefined); -}); - -const sexDetailURL='https://sexbjcam.com/2026/09/30/sample/'; -const sexEmbedURL='https://player.test/embed/sample'; -const sexDetailHTML='

Film title

'; -function packedSexPlayer(token='fresh') { - return `eval(function(p,a,c,k,e,d){return p;}('0="1://2.3/4.5?6=7";',36,8,'source|https|cdn|test|master|m3u8|token|${token}'.split('|'),0,{}))`; +// 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')); +const restoredIds = { + 'missav-mini-library.js': 'missav-mini-library', + 'sexbjcam-mini-library.js': 'sexbjcam-mini-library', + 'asmrlib-mini-library.js': 'asmrlib', + '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' +}; +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')); + 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`, () => { + const c = load(file); + const manifest = c.getManifest(); + assert.equal(manifest.id, restoredIds[file]); + assert.equal(manifest.version, original.version); + assert.ok(manifest.name && manifest.title); + assert.equal(manifest.aggregation.search, true); + for (const entry of ['getHome', 'getHomeSection', 'getCategory', 'getDetail', 'getResourceVersions', 'resolvePlayback', 'search']) { + assert.equal(typeof c[entry], 'function', entry); + } + }); } - -test('SexBJCam reads the film title rather than nested uploader metadata', () => { - const c=load('sexbjcam-mini-library.js'); - const detail=c.parseDetailHtml({},sexDetailHTML,sexDetailURL); - assert.equal(detail.title,'Film title');assert.equal(detail.embedURL,sexEmbedURL); -}); - -test('SexBJCam static packed playback avoids a hung browser and refreshes every selection', async () => { - const c=load('sexbjcam-mini-library.js');let browsers=0,players=0;const urls=[]; - c.Widget.browser={async fetch(){browsers++;return new Promise(()=>{});}}; - c.Widget.http.get=async (url,options)=>{urls.push(url);assert.equal(options.browserFallback,false);return {statusCode:200,data:url.includes('player.test')?packedSexPlayer('fresh'+(++players)):master};}; - for(const [id,expected] of [[sexDetailURL,1080],['quality:720',720],['quality:1080',1080],['quality:540',1080]]) { - const result=await c.resolvePlayback(JSON.stringify({itemId:sexDetailURL,embedURL:sexEmbedURL,versionId:id})); - assert.equal(result.url,'https://cdn.test/'+expected+'.m3u8'); +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); } - assert.equal(browsers,0);assert.equal(players,4); - assert.ok(urls.filter(x=>x.includes('cdn.test')).every(x=>!x.includes('_dreamby_refresh'))); }); - -test('SexBJCam hung native HTTP falls back once and preserves captured media headers', async () => { - const c=load('sexbjcam-mini-library.js',{setTimeout:(fn,ms)=>setTimeout(fn,ms/100)});let browsers=0; - c.Widget.http.get=async (url,options)=>{ - if(url.includes('player.test'))return new Promise(()=>{}); - assert.equal(options.headers.Referer,'https://actual-player.test/');return {status:200,data:master}; - }; - c.Widget.browser={async fetch(url,options){browsers++;assert.ok(options.timeout<=12);assert.equal(options.waitForAny,undefined);return {capturedRequests:[{url:'https://cdn.test/master.m3u8?token=device',requestHeaders:{referer:'https://actual-player.test/',origin:'https://actual-player.test','user-agent':'Device Agent',cookie:'fixture-cookie',Authorization:'must-not-copy'}}]};}}; - const result=await c.resolvePlayback({itemId:sexDetailURL,embedURL:sexEmbedURL,versionId:'quality:720'}); - assert.equal(result.url,'https://cdn.test/720.m3u8');assert.equal(browsers,1); - assert.deepEqual(plain(result.headers),{'User-Agent':'Device Agent',Referer:'https://actual-player.test/',Origin:'https://actual-player.test',Cookie:'fixture-cookie'}); +test('MissAV first upload 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', ''); + assert.equal(c.detailUrlFromContext({ itemId }), 'https://missav.ws/cn/sample'); + const playback = await c.resolvePlayback({ itemId, url: 'https://cdn.test/original.m3u8' }); + assert.equal(playback.url, 'https://cdn.test/original.m3u8'); + assert.equal(playback.container, 'm3u8'); assert.equal(requests, 0); }); - -test('SexBJCam hung native browser reports its own stage instead of the overall deadline', async () => { - const c=load('sexbjcam-mini-library.js',{setTimeout:(fn,ms)=>setTimeout(fn,ms/100)});let browsers=0; - c.Widget.http.get=async()=>({statusCode:503,data:'unavailable'}); - c.Widget.browser={async fetch(){browsers++;return new Promise(()=>{});}}; - const start=Date.now();await assert.rejects(c.resolvePlayback({embedURL:sexEmbedURL}),/browser-media-timeout/); - assert.equal(browsers,1);assert.ok(Date.now()-start<500); -}); - -test('SexBJCam stalled quality probe preserves a fresh media URL but creates no fake resource', async () => { - const c=load('sexbjcam-mini-library.js',{setTimeout:(fn,ms)=>setTimeout(fn,ms/100)}); - c.Widget.http.get=async url=>url.includes('/2026/')?{status:200,data:sexDetailHTML}:url.includes('player.test')?{status:200,data:packedSexPlayer()}:new Promise(()=>{}); - const result=await c.resolvePlayback({itemId:sexDetailURL,embedURL:sexEmbedURL}); - assert.equal(result.url,'https://cdn.test/master.m3u8?token=fresh'); - const detail=await c.getDetail({itemId:sexDetailURL});assert.equal(detail.resourceGroups.length,0); - await assert.rejects(c.getResourceVersions({itemId:sexDetailURL,embedURL:sexEmbedURL}),/manifest-http-timeout/); -}); - -test('SexBJCam variant-only capture keeps its URL and exposes honest original HLS quality', async () => { - const c=load('sexbjcam-mini-library.js');const variant='https://cdn.test/index-f3-v1-a1.m3u8?token=exact'; - c.Widget.http.get=async url=>url.includes('player.test')?{status:200,data:'dynamic player'}:{status:200,data:media}; - c.Widget.browser={async fetch(){return {capturedRequests:[{url:variant,requestHeaders:{Referer:'https://actual-player.test/'}}]};}}; - assert.equal(c.mediaURLFromBrowserResult({capturedRequests:[{url:variant}]}),variant); - const groups=await c.getResourceVersions({itemId:sexDetailURL,embedURL:sexEmbedURL}); - assert.equal(groups.groups[0].versions[0].name,'HLS 原始画质');assert.equal(groups.groups[0].versions[0].id,'quality:original'); - const result=await c.resolvePlayback(groups.groups[0].versions[0].action);assert.equal(result.url,variant); -}); - -test('SexBJCam metadata cache never stores signed URLs or media request credentials', async () => { - const c=load('sexbjcam-mini-library.js');const stored=[]; - c.Widget.storage={get(){return null},set(key,value){stored.push(value)}}; - c.Widget.http.get=async url=>url.includes('player.test')?{status:200,data:'dynamic player'}:{status:200,data:master}; - c.Widget.browser={async fetch(){return {capturedRequests:[{url:'https://cdn.test/master.m3u8?token=ephemeral',requestHeaders:{Cookie:'private-fixture',Origin:'https://actual-player.test'}}]};}}; - const groups=await c.getResourceVersions({itemId:sexDetailURL,embedURL:sexEmbedURL}); - assert.deepEqual(plain(groups.groups[0].versions.map(x=>x.name)),['1080P','720P']); - const serialized=JSON.stringify(stored)+JSON.stringify(groups); - assert.ok(!serialized.includes('ephemeral'));assert.ok(!serialized.includes('private-fixture')); -}); - -test('SexBJCam rejects permanent manifest errors and can retry the same item after recovery', async () => { - const c=load('sexbjcam-mini-library.js');let status=404; - c.Widget.http.get=async url=>url.includes('player.test')?{status:200,data:packedSexPlayer()}:{statusCode:status,data:status===200?master:'Not Found'}; - const ctx={itemId:sexDetailURL,embedURL:sexEmbedURL}; - await assert.rejects(c.resolvePlayback(ctx),/manifest-http-404/); - await assert.rejects(c.getResourceVersions(ctx),/manifest-http-404/); - status=200;assert.equal((await c.getResourceVersions(ctx)).groups[0].versions[0].name,'1080P'); -}); - -test('SexBJCam browser failures distinguish blob-only results without leaking response values', async () => { - const c=load('sexbjcam-mini-library.js');c.Widget.http.get=async()=>({status:200,data:'dynamic'}); - c.Widget.browser={async fetch(){return {mediaSources:['blob:https://player.test/id'],secret:'must-not-leak'};}}; - await assert.rejects(c.resolvePlayback({embedURL:sexEmbedURL}),error=>/browser-blob-only/.test(error.message)&&!error.message.includes('must-not-leak')); -}); - -for(const [entry,operation,label] of [['getDetail','getDetailWithinBudget','详情加载'],['getResourceVersions','getResourceVersionsWithinBudget','画质发现']]) { - test(`SexBJCam ${entry} has an overall deadline`,async()=>{ - const c=load('sexbjcam-mini-library.js',{setTimeout:fn=>setTimeout(fn,15)});c[operation]=async()=>new Promise(()=>{}); - await assert.rejects(c[entry]({}),new RegExp(label+'超时.*total-deadline')); - }); -} - -test('SexBJCam rejects an empty HLS response rather than returning a fake playable URL', async () => { - const c=load('sexbjcam-mini-library.js'); - c.Widget.http.get=async url=>({status:200,data:url.includes('player.test')?packedSexPlayer():'#EXTM3U\n'}); - await assert.rejects(c.resolvePlayback({embedURL:sexEmbedURL}),/manifest-empty/); - await assert.rejects(c.getResourceVersions({itemId:sexDetailURL,embedURL:sexEmbedURL}),/manifest-empty/); +test('ASMRLIB first upload retains separate embedded player lines and their version identifiers', async () => { + const c = load('asmrlib-mini-library.js'); + const post = 'becd2651e7d56ca656d27766d036dcee'; + c.Widget.http.get = async () => ({ data: '

Sample

' }); + const detail = await c.getDetail({ itemId: 'asmrlib://post/' + post }); + const groups = await c.getResourceVersions({ itemId: 'asmrlib://post/' + post }); + assert.deepEqual(plain(groups), plain(detail.resourceGroups)); + const versions = groups[0].versions; + assert.deepEqual(plain(versions.map(x => x.name)), ['BI 线路', 'AB 线路']); + 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 () => { + const c = load('kbjfan-mini-library.js'); const requests = []; + c.Widget.http.get = async url => { requests.push(url); return { data: '' }; }; + const result = await c.search({ query: 'A&B #tag', page: 2, params: { baseURL: 'https://kbj.test' } }); + assert.deepEqual(requests, ['https://kbj.test/?s=A%26B%20%23tag&paged=2']); + assert.equal(result.pageType, 'search'); assert.equal(result.page, 2); +}); +test('Taolu first upload retains its download version and original playback gateway', () => { + const c = load('taolusm-mini-library.js'); + const groups = c.getResourceVersions({ itemId: '121576' }); + assert.equal(groups[0].versions[0].id, 'download-121576'); + assert.equal(groups[0].versions[0].url, 'https://taolusm.com/download/121576'); + const playback = c.resolvePlayback({ itemId: '121576' }); + assert.equal(playback.url, groups[0].versions[0].url); + assert.equal(playback.container, 'mp4'); }); diff --git a/tools/library-contracts.json b/tools/library-contracts.json index bd692d4..919990a 100644 --- a/tools/library-contracts.json +++ b/tools/library-contracts.json @@ -278,12 +278,7 @@ "id": "missav-mini-library", "parameters": [ "baseURL", - "entryPath", - "backupBaseURLs", - "enableBrowserFallback", - "browserVisible", - "requestTimeoutSeconds", - "cacheMinutes" + "entryPath" ], "entryPoints": [ "getManifest", @@ -301,15 +296,7 @@ "id": "missav-mini-library", "parameters": [ "baseURL", - "entryPath", - "backupBaseURLs", - "enableBrowserFallback", - "browserVisible", - "automaticBrowserFallback", - "verificationCooldownMinutes", - "requestTimeoutSeconds", - "cacheMinutes", - "categoryRestoreMinutes" + "entryPath" ], "entryPoints": [ "getManifest", @@ -327,15 +314,7 @@ "id": "missav-mini-library", "parameters": [ "baseURL", - "entryPath", - "backupBaseURLs", - "enableBrowserFallback", - "browserVisible", - "automaticBrowserFallback", - "verificationCooldownMinutes", - "requestTimeoutSeconds", - "cacheMinutes", - "categoryRestoreMinutes" + "entryPath" ], "entryPoints": [ "getManifest", diff --git a/tools/library-first-versions.json b/tools/library-first-versions.json new file mode 100644 index 0000000..5d6a64b --- /dev/null +++ b/tools/library-first-versions.json @@ -0,0 +1,44 @@ +{ + "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" + } +}