From e8330ec79cb586778b09c655adeece62c9ec8144 Mon Sep 17 00:00:00 2001 From: hzl <1803573449@qq.com> Date: Sat, 3 Oct 2026 17:50:45 +0800 Subject: [PATCH] Optimize media scripts and preserve imported download paths --- .github/workflows/validate-scripts.yml | 37 + .gitignore | 6 + 123av-mini-library.js | 22 +- 1808-mini-library.js | 151 +- 4kvm-dreamby-mini-library.js | 18 +- 591av-mini-library.js | 52 +- 91porny_int.js | 4 +- CHANGELOG.md | 35 + MissAV 3.0.js | 27 +- README.md | 75 + asmrlib-mini-library.js | 35 +- girigirilove-mini-library.js | 107 +- jable.js | 14 +- jable.media-library.js | 88 +- javgg-mini-library.js | 47 +- kbjfan-mini-library.js | 24 +- madou8-domestic-mini-library.js | 21 +- madou8-mini-library 5.js | 21 +- manko-fun-mini-library.js | 14 +- missav-mini-library-CloudFlare.js | 1581 +++++++++++++++-- missav-mini-library-download-working 6.js | 90 +- missav-mini-library-download-working.js | 90 +- missav-mini-library.js | 1916 +++++++++++++++++++-- novipnoad-mini-library.js | 67 +- pornhub.media-library.js | 10 +- pornhub_int.js | 4 +- sexbjcam-mini-library.js | 53 +- sexbjcam-network-recovery.test.js | 5 + stripchat-angellive/worker/README.md | 6 +- stripchat-angellive/worker/index.js | 130 +- stripchat-angellive/worker/test.js | 77 + stripchat-angellive/worker/wrangler.toml | 4 + taolusm-mini-library.js | 24 +- tests/adapters.test.cjs | 259 +++ tools/commit-library-aliases.cjs | 19 + tools/library-aliases.json | 9 + tools/library-contracts.json | 636 +++++++ tools/sync-library-aliases.cjs | 25 + tools/verify-release.py | 34 + tools/verify-scripts.cjs | 36 + xvideos_int.js | 3 +- xxxfollow-mini-library 5.js | 84 +- xxxfollow-mini-library.js | 84 +- 43 files changed, 5247 insertions(+), 797 deletions(-) create mode 100644 .github/workflows/validate-scripts.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 README.md create mode 100644 tests/adapters.test.cjs create mode 100644 tools/commit-library-aliases.cjs create mode 100644 tools/library-aliases.json create mode 100644 tools/library-contracts.json create mode 100644 tools/sync-library-aliases.cjs create mode 100644 tools/verify-release.py create mode 100644 tools/verify-scripts.cjs diff --git a/.github/workflows/validate-scripts.yml b/.github/workflows/validate-scripts.yml new file mode 100644 index 0000000..d7daf4f --- /dev/null +++ b/.github/workflows/validate-scripts.yml @@ -0,0 +1,37 @@ +name: Validate scripts and import compatibility +on: + push: + pull_request: +permissions: + contents: read +jobs: + validate: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + - if: github.event_name == 'push' + run: node tools/sync-library-aliases.cjs + - run: node tools/sync-library-aliases.cjs --check + - run: node tools/verify-scripts.cjs + - run: node --test tests/adapters.test.cjs + - run: node sexbjcam-network-recovery.test.js + - run: node stripchat-angellive/test.js + - run: node --test stripchat-angellive/worker/test.js + - run: python3 tools/verify-release.py + - run: git diff --check + sync-compatibility-paths: + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + needs: validate + runs-on: ubuntu-24.04 + permissions: + contents: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + - run: node tools/sync-library-aliases.cjs + - run: node tools/commit-library-aliases.cjs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4f7897a --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +.wrangler/ +.env +.env.* +.dev.vars +.DS_Store diff --git a/123av-mini-library.js b/123av-mini-library.js index 1aa277e..94c16f6 100644 --- a/123av-mini-library.js +++ b/123av-mini-library.js @@ -11,7 +11,7 @@ const WidgetMetadata = { id: '123av-mini-library', name: '123AV', title: '123AV', - version: '1.1.0', + version: '1.1.1', requiredVersion: '0.0.1', author: 'Alan huang', site: AV123_DEFAULT_BASE, @@ -93,6 +93,7 @@ function getManifest() { } async function getHome(ctx) { + ctx = normalizeRuntimeContext(ctx); const browseSection = { id: '123av-browse', title: '分类浏览', @@ -116,6 +117,7 @@ async function getHome(ctx) { } async function getHomeSection(ctx) { + ctx = normalizeRuntimeContext(ctx); const sectionId = stringValue(ctx && (ctx.sectionId || ctx.id || ctx.pageId)); const section = findSection(sectionId) || AV123_SECTIONS[0]; try { @@ -134,6 +136,7 @@ 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), 'new'); const section = findSection(pageId); @@ -160,6 +163,7 @@ async function getCategory(ctx) { } async function getDetail(ctx) { + ctx = normalizeRuntimeContext(ctx); const detailUrl = detailURLFromContext(ctx); if (!detailUrl) throw new Error('123AV 详情参数无效'); @@ -240,6 +244,7 @@ async function getDetail(ctx) { } async function getResourceVersions(ctx) { + ctx = normalizeRuntimeContext(ctx); const direct = stringValue(ctx && (ctx.playUrl || ctx.url || ctx.videoUrl)); if (isPlayableURL(direct)) { return playbackGroups('', ctx && ctx.title, ctx && ctx.surritCode, ctx && ctx.playerUrl, direct, ctx); @@ -256,6 +261,7 @@ async function getResourceVersions(ctx) { } async function resolvePlayback(ctx) { + ctx = normalizeRuntimeContext(ctx); const decoded = decodeDetailPayload(ctx && (ctx.versionId || ctx.itemId || ctx.id)); const direct = firstNonEmpty(ctx && ctx.playUrl, ctx && ctx.url, ctx && ctx.videoUrl, decoded.playUrl); if (isPlayableURL(direct) && !isDetailURL(direct)) { @@ -278,6 +284,7 @@ async function resolvePlayback(ctx) { } async function search(ctx) { + ctx = normalizeRuntimeContext(ctx); const query = stringValue(ctx && (ctx.query || ctx.keyword || ctx.text)); const page = positiveInt(contextValue(ctx, 'page'), 1); if (!query) { @@ -993,3 +1000,16 @@ if (typeof globalThis !== 'undefined') { if (typeof module !== 'undefined' && module.exports) { module.exports = exported; } + +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/1808-mini-library.js b/1808-mini-library.js index 4ca6686..6088a5c 100644 --- a/1808-mini-library.js +++ b/1808-mini-library.js @@ -1,6 +1,6 @@ /** * 1808.online - Dreamby / baiPlay 自定义媒体库 - * 电脑端验证版本:1.0.5 + * 脚本版本:1.0.6(原地址兼容更新) */ const SITE = "https://1808.online"; @@ -109,8 +109,8 @@ async function fetchText(ctx, path, referer) { else if (typeof $http !== "undefined" && typeof $http.request === "function") response = await $http.request({ url, method: "GET", ...options }); else if (typeof fetch === "function") response = await fetch(url, { headers }); else throw new Error("当前环境没有可用的 HTTP 客户端"); - if (response && typeof response.text === "function") return response.text(); - const html = responseText(response); + if (Number(response && (response.status || response.statusCode)) >= 400) throw new Error('源站 HTTP ' + (response.status || response.statusCode)); + const html = response && typeof response.text === 'function' ? await response.text() : responseText(response); if (!html || /Just a moment|cf-mitigated|Cloudflare Ray ID/i.test(html)) throw new Error("站点返回空内容或 Cloudflare 验证页"); return html; } @@ -121,7 +121,7 @@ function imageHeaders(ctx, referer) { function fallbackItems(ctx) { return HOME_FALLBACK.map(([id, title, poster, year]) => ({ - id, title, subtitle: String(year), type: "movie", poster, year, + id, title, subtitle: "历史推荐(2026-07-18)", badges: ["历史片单"], type: "movie", poster, year, imageHeaders: imageHeaders(ctx), posterHeaders: imageHeaders(ctx), action: { type: "detail", itemId: id } })); @@ -147,31 +147,9 @@ function movieSlug(id) { return movieId(id).match(/\/movies\/([^/]+)\.html$/i)?.[1] || ""; } -function syntheticPlaybackLines(ctx, id) { - const slug = movieSlug(id); - if (!slug) return []; - const referer = absoluteURL(ctx, id); - const definitions = [ - ["标清 360P", `https://ru.youxijiasu007.shop/movie/360/${slug}/360.m3u8`], - ["高清 720P", `https://ru.youxijiasu007.shop/movie/720/${slug}/720.m3u8`], - ["标清 360P 线路2", `https://ru.kuikuigo8923.space/movie/360/${slug}/360.m3u8`], - ["高清 720P 线路2", `https://ru.kuikuigo8923.space/movie/720/${slug}/720.m3u8`], - ["标清 360P 移动线路", `https://fmv.youxijiasu007.shop/movie/360/${slug}/360.m3u8`], - ["高清 720P 移动线路", `https://fmv.youxijiasu007.shop/movie/720/${slug}/720.m3u8`] - ]; - return definitions.map(([title, url]) => ({ id: url, title, url, referer })); -} -function fallbackDetail(ctx, id) { - const seed = HOME_FALLBACK.find(item => item[0] === id); - const title = seed?.[1] || movieSlug(id).replace(/_/g, " "); - const poster = seed?.[2] || `https://img.9bf85763.website/covers/${movieSlug(id)}.jpg`; - return { - title, poster, overview: "", - actorText: "", urls: syntheticPlaybackLines(ctx, id), related: fallbackItems(ctx).filter(item => item.id !== id).slice(0, 12), - referer: absoluteURL(ctx, id) - }; -} + + function parseMovieCards(ctx, html) { const cards = []; @@ -226,7 +204,7 @@ function parseDetailPage(ctx, html, id) { function getManifest() { return { - id: "1808-online", name: "1808在线", title: "1808在线", version: "1.0.5", author: "Codex", + id: "1808-online", name: "1808在线", title: "1808在线", version: "1.0.6", author: "Alan huang", logo: ICON, icon: ICON, site: SITE, adult: true, capabilities: { home: true, category: true, detail: true, search: false, resourceVersions: true, playback: true, resourceMatching: false }, aggregation: { search: false, playbackHistory: true, resourceMatching: false }, @@ -240,78 +218,53 @@ async function loadCategory(ctx, id) { } async function getHome(ctx) { - let latest = fallbackItems(ctx); - if (hasHTTPClient()) { - try { - const remote = (await loadCategory(ctx, "latest")).items.slice(0, 18); - if (remote.length) latest = remote; - } catch (_) {} - } + let latest = []; let error = ''; + try { latest = (await loadCategory(ctx, 'latest')).items.slice(0, 18); if (!latest.length) throw new Error('最新片单为空'); } + catch (e) { error = String(e && e.message || e); latest = fallbackItems(ctx); } return { - pageType: "home", id: "1808-home", title: "1808在线", heroAspectRatio: "2:3", hero: latest.slice(0, 6), items: latest, - sections: [{ ...CATEGORIES[0], lazy: false, items: latest, moreAction: categoryMoreAction(CATEGORIES[0]) }] - .concat(CATEGORIES.slice(1).map(v => ({ - ...v, lazy: true, items: [], moreAction: categoryMoreAction(v), - loadAction: { type: "custom", sectionId: v.id, id: v.id, title: v.title } - }))) + pageType: 'home', id: '1808-home', title: '1808在线', heroAspectRatio: '2:3', hero: latest.slice(0, 6), items: latest, + sections: [{ ...CATEGORIES[0], title: error ? '历史推荐(2026-07-18,实时加载失败)' : CATEGORIES[0].title, + lazy: false, items: latest, error: error || undefined, moreAction: categoryMoreAction(CATEGORIES[0]) }] + .concat(CATEGORIES.slice(1).map(v => ({ ...v, lazy: true, items: [], moreAction: categoryMoreAction(v), + loadAction: { type: 'custom', sectionId: v.id, id: v.id, title: v.title } }))) }; } async function getHomeSection(ctx) { - const id = String(value(ctx, ["sectionId", "pageId", "id"], "latest")); + const id = String(value(ctx, ['sectionId', 'pageId', 'id'], 'latest')); const category = CATEGORIES.find(v => v.id === id) || CATEGORIES[0]; - let items = fallbackItems(ctx); - if (hasHTTPClient()) { - try { - const remote = (await loadCategory(ctx, category.id)).items.slice(0, 18); - if (remote.length) items = remote; - } catch (_) {} - } - return { - id: category.id, title: category.title, style: category.style, lazy: false, items, - moreAction: categoryMoreAction(category) - }; + let items = []; let error = ''; + try { items = (await loadCategory(ctx, category.id)).items.slice(0, 18); } + catch (e) { error = String(e && e.message || e); if (category.id === 'latest') items = fallbackItems(ctx); } + return { id: category.id, title: error && items.length ? '历史推荐(2026-07-18,实时加载失败)' : category.title, + style: category.style, lazy: false, items, error: error || undefined, moreAction: categoryMoreAction(category) }; } async function getCategory(ctx) { - const id = String(value(ctx, ["pageId", "id"], "latest")); + const id = String(value(ctx, ['pageId', 'id'], 'latest')); const category = CATEGORIES.find(v => v.id === id) || CATEGORIES[0]; - let items = fallbackItems(ctx); - if (hasHTTPClient()) { - try { - const remote = (await loadCategory(ctx, category.id)).items; - if (remote.length) items = remote; - } catch (_) {} - } - return { pageType: "category", id: category.id, title: category.title, style: "media.posterGrid", itemAspectRatio: "2:3", page: 1, hasMore: false, items }; + let items = []; let error = ''; + try { items = (await loadCategory(ctx, category.id)).items; } catch (e) { error = String(e && e.message || e); } + return { pageType: 'category', id: category.id, title: category.title, style: 'media.posterGrid', itemAspectRatio: '2:3', page: 1, hasMore: false, items, error: error || undefined }; } async function getDetail(ctx) { - const id = movieId(value(ctx, ["itemId", "id", "sourceId", "path"], "")); - if (!id) throw new Error("缺少有效的电影详情地址"); - let detail = fallbackDetail(ctx, id); - if (hasHTTPClient()) { - try { - const remote = parseDetailPage(ctx, await fetchText(ctx, id), id); - if (!remote.urls.length) remote.urls = syntheticPlaybackLines(ctx, id); - detail = remote; - } catch (_) {} - } - if (!detail.urls.length) throw new Error("详情页没有解析到播放线路"); - const versions = detail.urls.map((v, index) => ({ id: v.url, title: v.title, name: v.title, url: v.url, container: "hls", default: index === 0, headers: playbackHeaders(v.referer), action: { type: "play", itemId: id, versionId: v.url, url: v.url, path: v.url, referer: v.referer, title: v.title } })); - return { - pageType: "detail", id, type: "movie", title: detail.title, poster: detail.poster, backdrop: detail.poster, - detailImageAspectRatio: "2:3", imageHeaders: imageHeaders(ctx, detail.referer), overview: detail.overview, - year: Number(detail.title.match(/(?:19|20)\d{2}/)?.[0]) || undefined, genres: ["剧情"], - cast: detail.actorText.split(/[,,、]/).filter(Boolean).slice(0, 20).map(name => ({ name: name.trim(), role: "演员" })), - resourceGroups: [{ id: "1808-lines", title: "播放线路", versions }], - recommendations: [{ id: "related", title: "相关推荐", style: "discover.posterCompact", items: detail.related }] - }; + const id = movieId(value(ctx, ['itemId', 'id', 'sourceId', 'path'], '')); + if (!id) throw new Error('缺少有效的电影详情地址'); + const detail = parseDetailPage(ctx, await fetchText(ctx, id), id); + if (!detail.urls.length) throw new Error('详情页没有返回真实播放线路,请刷新重试'); + const lines = detail.urls.slice().sort((a, b) => lineQuality(b) - lineQuality(a)); + const versions = lines.map((v, index) => ({ id: v.url, title: v.title, name: v.title, container: 'hls', default: index === 0, + headers: playbackHeaders(v.referer), action: { type: 'play', itemId: id, versionId: v.url, referer: v.referer, title: v.title } })); + return { pageType: 'detail', id, type: 'movie', title: detail.title, poster: detail.poster, backdrop: detail.poster, + detailImageAspectRatio: '2:3', imageHeaders: imageHeaders(ctx, detail.referer), overview: detail.overview, + year: Number(detail.title.match(/(?:19|20)\d{2}/)?.[0]) || undefined, genres: ['剧情'], + cast: detail.actorText.split(/[,,、]/).filter(Boolean).slice(0, 20).map(name => ({ name: name.trim(), role: '演员' })), + resourceGroups: [{ id: '1808-lines', title: '播放线路', versions }], + recommendations: [{ id: 'related', title: '相关推荐', style: 'discover.posterCompact', items: detail.related }] }; } async function getResourceVersions(ctx) { - const direct = String(value(ctx, ["versionId", "resourceId", "url", "playUrl", "videoUrl"], "")); - if (/^https?:\/\//i.test(direct)) return { groups: [{ id: "1808-lines", title: "播放线路", versions: [{ id: direct, title: "在线播放", url: direct, container: "hls" }] }] }; const detail = await getDetail(ctx); return { itemId: detail.id, groups: detail.resourceGroups }; } @@ -322,17 +275,25 @@ function playbackHeaders(referer) { async function resolvePlayback(ctx) { ctx = parseCtx(ctx); - let url = String(value(ctx, ["versionId", "resourceId", "url", "path", "playUrl", "videoUrl"], "")); - let referer = String(value(ctx, ["referer", "detailUrl"], "")); - if (!/^https?:\/\//i.test(url) || !/\.m3u8(?:\?|$)/i.test(url)) { - const id = movieId(value(ctx, ["itemId", "id", "sourceId"], "")); - if (!id) throw new Error("没有解析到电影 ID 或播放地址"); - const lines = syntheticPlaybackLines(ctx, id); - if (!lines.length) throw new Error("没有解析到播放地址"); - url = lines[0].url; - referer = lines[0].referer; + const id = movieId(value(ctx, ['itemId', 'id', 'sourceId'], '')); + const requested = String(value(ctx, ['versionId', 'resourceId', 'url', 'path', 'playUrl', 'videoUrl'], '')); + let url = requested; let referer = String(value(ctx, ['referer', 'detailUrl'], '')); + if (id) { + const detail = parseDetailPage(ctx, await fetchText(ctx, id), id); + const lines = detail.urls.slice().sort((a, b) => lineQuality(b) - lineQuality(a)); + const quality = lineQuality({ url: requested }); + const selected = lines.find(v => v.url === requested) || (quality && lines.find(v => lineQuality(v) === quality)) || lines[0]; + if (!selected) throw new Error('源站没有返回可播放线路,请刷新重试'); + url = selected.url; referer = selected.referer || detail.referer; } - return { url, container: "hls", headers: playbackHeaders(referer), startPositionSeconds: 0, isLive: false, streamKind: "vod" }; + if (!/^https?:\/\//i.test(url) || !/\.m3u8(?:[?#]|$)/i.test(url)) throw new Error('没有解析到真实 HLS 播放地址'); + if (!/#EXTM3U/.test(await fetchText(ctx, url, referer))) throw new Error('播放线路未返回有效 HLS 清单'); + return { url, container: 'hls', headers: playbackHeaders(referer), startPositionSeconds: 0, isLive: false, streamKind: 'vod' }; +} + +function lineQuality(line) { + const m = String(line.title || '').match(/(\d{3,4})\s*p/i) || String(line.url || '').match(/(?:^|\/)(\d{3,4})(?:p|\/)/i); + return m ? Number(m[1]) : 0; } const api = { diff --git a/4kvm-dreamby-mini-library.js b/4kvm-dreamby-mini-library.js index 512bc97..bb6b995 100644 --- a/4kvm-dreamby-mini-library.js +++ b/4kvm-dreamby-mini-library.js @@ -169,7 +169,7 @@ function kvmHomeGroupItems(html, id) { } function getManifest() { - return { id: '4kvm-dreamby', name: '4K影视', title: '4K影视', version: '1.10.0', author: 'Alan huang', logo: KVM_LOGO, + return { id: '4kvm-dreamby', name: '4K影视', title: '4K影视', version: '1.10.1', author: 'Alan huang', logo: KVM_LOGO, capabilities: { search: true, aggregation: true, playbackHistory: true }, aggregation: { search: true, playbackHistory: true } }; } const WidgetMetadata = getManifest(); @@ -225,8 +225,8 @@ function kvmHas4K(html) { return /(?:^|\s)4k\s*(?:加入片单|评分|收藏)/i.test(text) || /(?:badge|tag)[^>]*>\s*4k\s* !q.locked && /\.m3u8|\.mp4/i.test(q.url)); + const excluded = new Set(qualities.filter(q => q.locked).map(q => q.url)); + const isMain = url => !excluded.has(url) && !/(?:^|[\/_.-])(?:ads?|advert(?:isement)?|preroll|trailer|preview)(?:[\/_.-]|$)/i.test(url); + const playable = qualities.filter(q => !q.locked && /\.m3u8|\.mp4/i.test(q.url) && isMain(q.url)); const real1080 = playable.find(q => /1080|超清/i.test(q.title)); - const media1080 = media.find(url => /1080(?:p)?(?:[^0-9]|$)/i.test(url)); - const url = real1080 && real1080.url || media1080 || media[media.length - 1] || media[0]; + const validMedia = media.filter(isMain); + const media1080 = validMedia.find(url => /1080(?:p)?(?:[^0-9]|$)/i.test(url)); + const url = real1080 && real1080.url || media1080 || (playable.length === 1 ? playable[0].url : '') || (validMedia.length === 1 ? validMedia[0] : ''); + if (!url && validMedia.length > 1) throw new Error('4K影视播放:捕获到多个未明确归属的媒体地址,请重试;stage=media-selection'); if (!url) { const keys = result && typeof result === 'object' ? Object.keys(result).join(',') : typeof result; throw new Error('4K影视播放:浏览器未返回最终媒体地址;stage=media-capture;page=' + page + ';resultKeys=' + keys + ';mediaCandidates=0;blobOnly=true'); } const hls = /\.m3u8(?:[?#]|$)/i.test(url); // This source's HLS playlist and its segments live on different CDNs. The diff --git a/591av-mini-library.js b/591av-mini-library.js index fd168b8..4869b01 100644 --- a/591av-mini-library.js +++ b/591av-mini-library.js @@ -8,7 +8,7 @@ const WidgetMetadata = { id: '591av-mini-library', name: '591AV', title: '591AV', - version: '1.3.1', + version: '1.3.2', requiredVersion: '0.0.1', author: 'Alan huang', site: AV591_DEFAULT_BASE, @@ -81,8 +81,8 @@ async function getHome(ctx) { pageType: 'home', id: '591av-home', title: WidgetMetadata.title, heroAspectRatio: '16:9', hero: latest.slice(0, 6), sections: [{ - id: 'browse', title: '分类浏览', style: 'discover.annualPosterStack', lazy: false, - items: await buildBrowseCards(ctx, latest) + id: 'browse', title: '分类浏览', style: 'discover.annualPosterStack', lazy: true, + loadAction: { type: 'custom', id: 'browse', sectionId: 'browse', title: '分类浏览' }, items: [] }, { id: 'new', title: '最新上架', style: 'discover.spotlight', lazy: false, moreAction: categoryAction(AV591_SECTIONS[0]), items: latest, @@ -97,32 +97,32 @@ async function getHome(ctx) { }; } -async function buildBrowseCards(ctx, fallbackItems) { +async function buildBrowseCards(ctx) { const selected = AV591_BROWSE.slice(0, 6); - const cards = []; - for (let i = 0; i < selected.length; i += 1) { - const category = selected[i]; + const cards = await mapBrowseCards(selected, async function (category) { let previews = []; - try { - previews = parseCards(await fetchText(ctx, listingURL(ctx, category, 1, 'post_date')), ctx).slice(0, 3); - } catch (_) {} - if (!previews.length) continue; + try { previews = parseCards(await fetchText(ctx, listingURL(ctx, category, 1, 'post_date')), ctx).slice(0, 3); } catch (_) {} + if (!previews.length) return null; const lead = previews[0]; - cards.push({ - id: category.id, title: category.title, type: 'collection', - poster: lead.poster, backdrop: lead.backdrop || lead.poster, - imageHeaders: lead.imageHeaders, posterHeaders: lead.posterHeaders, - backdropHeaders: lead.backdropHeaders, previewItems: previews, - action: { - type: 'category', id: category.id, pageId: category.id, title: category.title, - page: 1, currentPage: 1, itemAspectRatio: '16:9' - } - }); - } - return cards; + return { id: category.id, title: category.title, type: 'collection', poster: lead.poster, backdrop: lead.backdrop || lead.poster, + imageHeaders: lead.imageHeaders, posterHeaders: lead.posterHeaders, backdropHeaders: lead.backdropHeaders, previewItems: previews, + action: { type: 'category', id: category.id, pageId: category.id, title: category.title, page: 1, currentPage: 1, itemAspectRatio: '16:9' } }; + }); + return cards.filter(Boolean); +} + +async function mapBrowseCards(items, load) { + const results = new Array(items.length); let next = 0; + await Promise.all(Array.from({ length: Math.min(2, items.length) }, async function () { + while (next < items.length) { const index = next++; results[index] = await load(items[index]); } + })); + return results; } async function getHomeSection(ctx) { + if (value(ctx, ['sectionId', 'pageId', 'id'], 'new') === 'browse') { + return { id: 'browse', title: '分类浏览', style: 'discover.annualPosterStack', lazy: false, items: await buildBrowseCards(ctx, []) }; + } const section = findSection(value(ctx, ['sectionId', 'pageId', 'id'], 'new')) || AV591_SECTIONS[0]; try { const items = parseCards(await fetchText(ctx, listingURL(ctx, section, 1, section.sort || 'post_date')), ctx).slice(0, 20); @@ -145,8 +145,8 @@ async function getCategory(ctx) { try { const sourcePage = (page - 1) * 2 + 1; const payloads = await Promise.all([ - fetchText(ctx, listingURL(ctx, category, sourcePage, sort, true)).catch(function () { return ''; }), - fetchText(ctx, listingURL(ctx, category, sourcePage + 1, sort, true)).catch(function () { return ''; }) + fetchText(ctx, listingURL(ctx, category, sourcePage, sort, true)), + fetchText(ctx, listingURL(ctx, category, sourcePage + 1, sort, true)) ]); const firstHTML = payloads[0]; const secondHTML = payloads[1]; @@ -178,7 +178,7 @@ async function getCategory(ctx) { } catch (error) { return { pageType: 'category', id: id, title: category.title, style: 'media.posterGrid', - itemAspectRatio: '16:9', page: page, hasMore: false, + itemAspectRatio: '16:9', page: page, hasMore: true, nextPage: page, selectedSortValue: sort, sort: AV591_SORTS, items: [], error: errorMessage(error) }; } diff --git a/91porny_int.js b/91porny_int.js index 7e4debb..18b046c 100644 --- a/91porny_int.js +++ b/91porny_int.js @@ -785,7 +785,7 @@ WidgetMetadata = { id: '91porny_int', title: '91Porny', description: '91Porny', - version: "0.9.6", + version: "0.9.7", requiredVersion: '0.0.1', author: "網路", site: 'https://github.com/baranwang/forward-widget', @@ -1090,7 +1090,7 @@ async function search(params) { (_params1 = params).page || (_params1.page = 1); (_params2 = params).base_url || (_params2.base_url = DEFAULT_BASE_URL); try { - const $ = await widgetAPI.getHtml(`${params.base_url}/search?keywords=${params.keyword}&page=${params.page}`); + const $ = await widgetAPI.getHtml(`${params.base_url}/search?keywords=${encodeURIComponent(params.keyword)}&page=${params.page}`); return getVideoList($, params.base_url); } catch (error) { console.error("视频列表加载失败", error); diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..552a6bf --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,35 @@ +# 2026-10-03 + +基于 `main` 的 `8e43017532ec81e3338046ba6e32dff7655bd3dd` 修复审查中 O01–O25;保留所有已发布文件路径。 + +## 加载与失败恢复 + +- MissAV 已发现完整画质时跳过关联播放器和浏览器回退;缺少地址时逐阶段解析。 +- Jable 与 591AV 首屏只请求第一组内容,其余分区懒加载。591AV 的两源页必须一起成功,任一失败后保留当前页供重试。 +- ASMRLIB 分类预览使用各自分类的内容;Manko 分类预览限并发、逐卡降级。 +- 1808 网络失败时明确标记历史推荐;详情和播放只使用实际返回的线路,不再拼造播放 URL。 +- 爱动漫只从完整的剧集区块取集数,排除历史/推荐链接;电影和单集剧分别呈现,父内容与选集 ID 独立保留。HTTP 错误页和验证页不写入内容缓存。 + +## 播放与输入兼容 + +- 麻豆两份媒体库继续探索后续线路,避免第一条 720P 隐藏可用 1080P。Pornhub 默认最高可用画质。 +- Jable 根据稳定内容 ID 刷新播放地址;XXXFollow 刷新当前条目,旧载荷只能经即时媒体验证后作为回退。MissAV Forward 的 URL 缓存限 60 秒/100 条,命中但不可用时清除并重新发现。 +- MissAV、SexBJCam、JAVGG、NO视频的播放解析共享 28 秒总时限,并限制各 HTTP/浏览器阶段及浏览器回退次数。宿主原生任务由其自身 timeout 管理,外层截止时间保证调用返回,不代表能够强制关闭原生浏览器。 +- 123AV、爱动漫、KBJ、套路SM、XXXFollow 支持对象和 JSON 字符串上下文,保留用户参数优先级及内容/集/版本 ID 的独立语义。旧 MissAV 路径兼容升级。 +- 91Porny/Pornhub 编码搜索关键词;Jable Forward 的排序和页码只添加一次。XVideos 删除原样输出会话凭证的日志。 +- 4K影视保留旧版本 ID `public-1080`,展示为自动画质;排除明显广告/预告和锁定流,多个无法确定归属的捕获流明确报错。此改动没有新增媒体元数据探测能力。 + +## 兼容维护与 Worker + +- 5 个旧下载入口同步完整 JS 副本。CI 保证主维护文件与副本一致,验证原有 39 个媒体库的 ID、参数和入口。 +- Stripchat Worker 首个有效 CDN 返回后取消其余探测;失败时仍区分全部 404/410 和暂时性故障。重试重新取得 master/pkey/pdkey;清单/JSON 的超时包含正文读取,并限制小响应大小,分片保持流式传输。 +- 增加不包含 Cookie/token/完整签名 URL 的阶段耗时日志,以及 5% traces 采样配置。 +- 新增离线回归、ZIP/索引/hash/版本一致性和 FWENC2/Forward 封装校验。 + +## 验证范围与待办 + +46 项媒体库回归、10 项 Worker 回归、原有 SexBJCam 网络恢复和 Angel Live 插件契约测试通过;普通脚本语法、39 个媒体库契约、兼容副本及已发布包一致性通过。 + +欧乐 O26 已对照一个公开网页样本(影片 24813):网页起播正常,master、变体与分片均返回 200;同一组 URL 在无 Origin、网页 Origin、现有 CDN Origin 三种设置下的范围请求全部返回 206,未复现拒绝。因此本轮保持既有请求头;仍需更多影片、跨 CDN 与 DreamBy 设备样本。浏览器网页起播不等于设备端能够回传最终媒体地址。设备上的首次起播、久置后播放、切画质及浏览器交回播放器仍需验证。 + +本次提交只更新 Worker 仓库源码和配置,未部署 Cloudflare,也未改 Stripchat 插件 ZIP 或订阅索引。 diff --git a/MissAV 3.0.js b/MissAV 3.0.js index 623d48c..060bf7c 100644 --- a/MissAV 3.0.js +++ b/MissAV 3.0.js @@ -3,7 +3,7 @@ WidgetMetadata = { title: "MissAV_ovo", author: "𝙈𝙖𝙠𝙠𝙖𝙋𝙖𝙠𝙠𝙖|CC|EL|Eric|墨白", description: "MissAV 视频聚合模块,支持其他模块聚合missav资源、支持高清海报、封面图、预告片、相似推荐、演员信息及头像", - version: "3.0", + version: "3.0.1", requiredVersion: "0.0.1", site: "https://missav.ai", modules: [ @@ -1794,7 +1794,26 @@ async function loadResource(params = {}) { const code = extractCodeFromParams(params); if (!code) return []; - let cached = VIDEO_URL_CACHE[code.toUpperCase()]; + const cacheKey = code.toUpperCase(); + let cached = VIDEO_URL_CACHE[cacheKey]; + if (cached && Date.now() - Number(cached.timestamp || 0) >= 60000) { + delete VIDEO_URL_CACHE[cacheKey]; + cached = null; + } + if (cached) { + try { + const isHLS = /\.m3u8(?:[?#]|$)/i.test(cached.url); + const response = await Widget.http.get(cached.url, { + headers: { ...HEADERS, Referer: cached.referer, ...(isHLS ? {} : { Range: 'bytes=0-0' }) }, + timeout: 5000 + }); + const status = Number(response && (response.statusCode || response.status)); + if (status < 200 || status >= 300 || !status || (isHLS && !/^\s*#EXTM3U/.test(String(response.data || '')))) throw new Error('cached media unavailable'); + } catch (_) { + delete VIDEO_URL_CACHE[cacheKey]; + cached = null; + } + } let videoUrl = cached ? cached.url : null; let currentReferer = cached?.referer || `${BASE_URL}/cn/search/${encodeURIComponent(code.trim())}`; let detailHtml = ""; @@ -1881,7 +1900,9 @@ async function loadResource(params = {}) { } if (videoUrl) { - VIDEO_URL_CACHE[code.toUpperCase()] = { url: videoUrl, timestamp: Date.now(), referer: currentReferer }; + VIDEO_URL_CACHE[cacheKey] = { url: videoUrl, timestamp: Date.now(), referer: currentReferer }; + const cacheKeys = Object.keys(VIDEO_URL_CACHE); + if (cacheKeys.length > 100) delete VIDEO_URL_CACHE[cacheKeys[0]]; } } diff --git a/README.md b/README.md new file mode 100644 index 0000000..7754c84 --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +# DreamBy / Forward 脚本 + +本仓库同时保存 DreamBy(baiPlay)自定义媒体库、Forward 脚本和 Angel Live 插件。不同协议的版本号不能互相比较;亚洲版、国产版等不同内容分支分别维护。 + +已经导入 DreamBy 的入口继续使用原来的下载地址。更新沿用原文件路径、媒体库 ID 和原参数名;不用删除后重新导入。每个下载入口都包含完整 JavaScript,不需要远程加载器。 + +## 主维护文件与兼容入口 + +| 主维护文件 | 版本 | 保留并同步的旧文件 | +| --- | --- | --- | +| [eporner-mini-library 2.js](eporner-mini-library%202.js) | 1.0.9 | `eporner-mini-library.js` | +| [xxxfollow-mini-library 5.js](xxxfollow-mini-library%205.js) | 1.0.1 | `xxxfollow-mini-library.js` | +| [missav-mini-library-download-working 6.js](missav-mini-library-download-working%206.js) | 1.5.10 | `missav-mini-library-download-working.js`、`missav-mini-library.js`、`missav-mini-library-CloudFlare.js` | + +Eporner、XXXFollow 的两份文件原本内容相同。MissAV 的两个旧 1.0.7 实现升级为 1.5.10 兼容副本,保留 `missav-mini-library` ID、旧参数名和 `missav://detail?` 历史条目格式。 + +日常只修改左侧主维护文件,再运行: + +```bash +node tools/sync-library-aliases.cjs +``` + +GitHub Actions 会先生成兼容副本并验证;`main` 的检查通过后,如果副本有变化,机器人只提交这 5 个兼容文件。PR 要提交同步后的副本,检查会拒绝遗漏更新。主维护文件的新实现因此能继续通过所有旧下载路径获得。 + +## 独立保留的入口 + +- 麻豆亚洲版 `madou8-mini-library 5.js` 与国产版 `madou8-domestic-mini-library.js` 内容范围和 ID 不同,分别保留。 +- Jable、Pornhub、Hanime1、MissAV 的 Forward 与 DreamBy 入口分别保留。 +- Hanime1 的 `.fwd` 包和加密的 `twitter视频.js` 保持原格式;Stripchat ZIP、两个订阅索引和插件主程序保持一致。 +- `ttdm-mini-library (1).js`、`pektino-mini-library 3.js` 保持现有文件名。 + +## 2026-10-03 更新版本 + +| 文件 | 原版本 | 新版本 | +| --- | --- | --- | +| [123av-mini-library.js](123av-mini-library.js) | 1.1.0 | 1.1.1 | +| [1808-mini-library.js](1808-mini-library.js) | 1.0.5 | 1.0.6 | +| [4kvm-dreamby-mini-library.js](4kvm-dreamby-mini-library.js) | 1.10.0 | 1.10.1 | +| [591av-mini-library.js](591av-mini-library.js) | 1.3.1 | 1.3.2 | +| [91porny_int.js](91porny_int.js) | 0.9.6 | 0.9.7 | +| [MissAV 3.0.js](MissAV%203.0.js) | 3.0 | 3.0.1 | +| [asmrlib-mini-library.js](asmrlib-mini-library.js) | 1.2.0 | 1.2.1 | +| [girigirilove-mini-library.js](girigirilove-mini-library.js) | 1.0.0 | 1.0.1 | +| [jable.js](jable.js) | 1.3.0 | 1.3.1 | +| [jable.media-library.js](jable.media-library.js) | 1.0.0 | 1.0.1 | +| [javgg-mini-library.js](javgg-mini-library.js) | 1.0.0 | 1.0.1 | +| [kbjfan-mini-library.js](kbjfan-mini-library.js) | 1.0.0 | 1.0.1 | +| [madou8-domestic-mini-library.js](madou8-domestic-mini-library.js) | 1.2.1 | 1.2.2 | +| [madou8-mini-library 5.js](madou8-mini-library%205.js) | 1.1.0 | 1.1.1 | +| [manko-fun-mini-library.js](manko-fun-mini-library.js) | 1.8.0 | 1.8.1 | +| [missav-mini-library-download-working 6.js](missav-mini-library-download-working%206.js) | 1.5.9 | 1.5.10 | +| [novipnoad-mini-library.js](novipnoad-mini-library.js) | 1.0.0 | 1.0.1 | +| [pornhub.media-library.js](pornhub.media-library.js) | 1.0.0 | 1.0.1 | +| [pornhub_int.js](pornhub_int.js) | 1.1.5 | 1.1.6 | +| [sexbjcam-mini-library.js](sexbjcam-mini-library.js) | 1.1.7 | 1.1.8 | +| [taolusm-mini-library.js](taolusm-mini-library.js) | 1.0.0 | 1.0.1 | +| [xvideos_int.js](xvideos_int.js) | 0.9.6 | 0.9.7 | +| [xxxfollow-mini-library 5.js](xxxfollow-mini-library%205.js) | 1.0.0 | 1.0.1 | + +具体行为和设备验证限制见 [CHANGELOG.md](CHANGELOG.md)。 + +## 本地验收 + +```bash +node tools/sync-library-aliases.cjs --check +node tools/verify-scripts.cjs +node --test tests/adapters.test.cjs +node sexbjcam-network-recovery.test.js +node stripchat-angellive/test.js +node --test stripchat-angellive/worker/test.js +python3 tools/verify-release.py +git diff --check +``` + +检查覆盖旧媒体库 ID/参数/入口、语法、分页失败恢复、画质选择、过期地址刷新、播放总时限,以及 ZIP/索引/版本/SHA-256 和特殊封装格式。离线测试不替代 DreamBy 设备上的浏览器媒体回传和实际起播验证。 diff --git a/asmrlib-mini-library.js b/asmrlib-mini-library.js index 6491719..7537e6f 100644 --- a/asmrlib-mini-library.js +++ b/asmrlib-mini-library.js @@ -191,7 +191,7 @@ function getManifest() { id: 'asmrlib', name: 'ASMRLIB', title: 'ASMRLIB', - version: '1.2.0', + version: '1.2.1', author: 'Alan huang', logo: ASMRLIB_LOGO, icon: ASMRLIB_LOGO, @@ -203,19 +203,6 @@ function getManifest() { 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', @@ -224,7 +211,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: false, items: categoryItems } + { id: 'categories', title: '热门标签', style: 'discover.annualListPreview', lazy: true, loadAction: { type: 'custom', id: 'categories', sectionId: 'categories', title: '热门标签' }, items: [] } ] }; } @@ -232,8 +219,22 @@ async function getHome() { async function getHomeSection(input) { const id = asmrText(asmrPick(input, ['sectionId', 'id'], 'latest')); if (id === 'categories') { - const home = await getHome(); - return home.sections[1]; + 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 page = await getCategory({ pageId: id, page: 1 }); return { id: id, title: page.title, style: 'discover.standard', lazy: false, items: page.items || [] }; diff --git a/girigirilove-mini-library.js b/girigirilove-mini-library.js index d4644a6..027e8b1 100644 --- a/girigirilove-mini-library.js +++ b/girigirilove-mini-library.js @@ -9,8 +9,8 @@ const WidgetMetadata = { id: 'girigirilove-mini-library', name: '爱动漫', title: '爱动漫', - version: '1.0.0', - author: 'EL / Codex', + version: '1.0.1', + author: 'Alan huang', logo: GIRI_ICON, icon: GIRI_ICON, site: GIRI_DEFAULT_SITE, @@ -77,6 +77,7 @@ function getManifest() { } async function getHome(ctx) { + ctx = normalizeRuntimeContext(ctx); const sections = GIRI_CATEGORIES.map(function (category) { return { id: category.id, @@ -99,6 +100,7 @@ async function getHome(ctx) { } async function getHomeSection(ctx) { + ctx = normalizeRuntimeContext(ctx); const section = findCategory(ctx && (ctx.sectionId || ctx.id)) || GIRI_CATEGORIES[0]; try { const page = await fetchCategoryPage(ctx, section, 1, 'latest'); @@ -116,6 +118,7 @@ async function getHomeSection(ctx) { } async function getCategory(ctx) { + ctx = normalizeRuntimeContext(ctx); const page = positiveInt(contextValue(ctx, 'page'), 1); const pageId = stringValue(ctx && (ctx.pageId || ctx.id)) || 'latest'; const section = findCategory(pageId) || GIRI_CATEGORIES[0]; @@ -136,6 +139,7 @@ async function getCategory(ctx) { } async function getDetail(ctx) { + ctx = normalizeRuntimeContext(ctx); const payload = decodePayload(ctx && (ctx.itemId || ctx.id || ctx.sourceId)); const id = firstNonEmpty(payload && payload.id, ctx && ctx.itemId, ctx && ctx.id); if (!id) throw new Error('爱动漫详情参数无效'); @@ -163,7 +167,8 @@ async function getDetail(ctx) { sourceName: '爱动漫', action: { type: 'play', - itemId: versionId, + itemId: encodePayload({ id: id, title: detail.title, poster: detail.poster }), + episodeId: versionId, versionId: versionId, url: episodePageURL, title: episode.title @@ -176,7 +181,7 @@ async function getDetail(ctx) { pageType: 'detail', id: encodePayload({ id: id, title: detail.title, poster: detail.poster }), title: detail.title, - type: detail.episodes.length > 1 ? 'series' : 'movie', + type: detail.isSeries ? 'series' : 'movie', poster: detail.poster, backdrop: detail.poster, detailImageAspectRatio: '2:3', @@ -186,7 +191,7 @@ async function getDetail(ctx) { overview: detail.overview, rating: detail.rating, remarks: detail.remarks, - seasons: detail.episodes.length ? [{ + seasons: detail.isSeries && detail.episodes.length ? [{ id: 'season-1', title: '播放列表', seasonNumber: 1, @@ -199,7 +204,7 @@ async function getDetail(ctx) { episodeNumber: index + 1, action: { type: 'play', - itemId: episodeId, + itemId: encodePayload({ id: id, title: detail.title, poster: detail.poster }), episodeId: episodeId, versionId: episodeId, url: episodePageURL, @@ -223,7 +228,11 @@ async function getDetail(ctx) { } async function getResourceVersions(ctx) { - const payload = decodePayload(ctx && (ctx.itemId || ctx.id || ctx.versionId || ctx.episodeId)); + ctx = normalizeRuntimeContext(ctx); + const selected = firstNonEmpty(ctx && ctx.episodeId, ctx && ctx.versionId); + const selectedPayload = decodePayload(selected); + const selectedId = firstNonEmpty(selectedPayload && selectedPayload.id, selected); + const payload = decodePayload(isPlayId(selectedId) ? selected : ctx && (ctx.itemId || ctx.id)); const id = firstNonEmpty(payload && payload.id, ctx && ctx.itemId, ctx && ctx.id); if (!id) return []; if (isPlayId(id)) { @@ -250,6 +259,7 @@ async function getResourceVersions(ctx) { } async function resolvePlayback(ctx) { + ctx = normalizeRuntimeContext(ctx); const direct = firstPlayable(ctx && ctx.playUrl, ctx && ctx.videoUrl, ctx && ctx.mediaUrl, ctx && ctx.src); const resolved = direct ? { url: direct, referer: ctx && ctx.referer } : await resolveFirstPlayable(ctx); const url = resolved && resolved.url; @@ -342,6 +352,7 @@ async function play(flagOrInput, id) { } async function search(ctx) { + ctx = normalizeRuntimeContext(ctx); const query = stringValue(ctx && (ctx.query || ctx.keyword || ctx.text)); const page = positiveInt(contextValue(ctx, 'page'), 1); if (!query) return { pageType: 'search', title: '搜索结果', items: [], page: page, hasMore: false }; @@ -366,6 +377,7 @@ async function getSearch(ctx) { } async function matchResources(ctx) { + ctx = normalizeRuntimeContext(ctx); const query = stringValue(ctx && (ctx.title || ctx.name || ctx.query || ctx.keyword)); if (!query) return { results: [] }; const results = await search({ query: query, params: ctx && ctx.params, config: ctx && ctx.config }); @@ -398,7 +410,9 @@ async function fetchText(ctx, url) { if (cached) return cached; const response = await httpGet(url, { headers: requestHeaders(ctx, url) }); const text = responseText(response); - if (!text) throw new Error('空响应'); + const status = Number(response && (response.status || response.statusCode)) || 200; + if (status >= 400) throw new Error('爱动漫源站 HTTP ' + status); + if (!text || /Just a moment|cf-mitigated|Cloudflare Ray ID|\s*(?:Service Unavailable|Bad Gateway|Error)/i.test(text)) throw new Error('空响应、错误页或浏览器验证页'); setCached(ctx, url, text); return text; } @@ -436,10 +450,11 @@ function parseCards(ctx, html) { const items = []; const seen = {}; const source = String(html || ''); - const pattern = /<a\b([^>]*class=["'][^"']*public-list-exp[^"']*["'][^>]*)>([\s\S]*?)<\/a>/gi; + const pattern = /<a\b((?:[^>"']|"[^"]*"|'[^']*')*)>([\s\S]*?)<\/a>/gi; let match; while ((match = pattern.exec(source))) { const attrs = match[1] || ''; + if (!/\bpublic-list-exp\b/.test(attr(attrs, 'class'))) continue; const block = match[0]; const href = attr(attrs, 'href'); const title = cleanText(attr(attrs, 'title') || firstMatch(block, /alt=["']([^"']+)["']/i)); @@ -498,7 +513,7 @@ function parseDetail(ctx, html, id) { const remarks = cleanText(firstMatch(source, /<[^>]*class=["'][^"']*slide-info-remarks[^"']*["'][^>]*>([\s\S]*?)<\/[^>]+>/i)); const rating = numericRating(cleanText(firstMatch(source, /<div[^>]*class=["'][^"']*fraction[^"']*["'][^>]*>([\s\S]*?)<\/div>/i))); const group = bestEpisodeGroup(source); - const episodes = parseEpisodes(group || source); + const episodes = parseEpisodes(group); const related = parseRelated(ctx, source); return { title: title, @@ -507,46 +522,31 @@ function parseDetail(ctx, html, id) { remarks: remarks, rating: rating, episodes: episodes, + isSeries: /(?:更新至|全)\s*\d+\s*集|连载|TV动画|电视剧/.test(remarks) || episodes.length > 1, related: related }; } function bestEpisodeGroup(html) { const source = String(html || ''); - const listIndex = source.indexOf('anthology-list top20'); - const tabSource = listIndex > 0 ? source.slice(Math.max(0, source.lastIndexOf('anthology-tab', listIndex) - 300), listIndex) : source; - const listSource = listIndex > 0 ? source.slice(listIndex) : source; - const groupPattern = /<div\b[^>]*class=["'][^"']*anthology-list-box[^"']*["'][^>]*>([\s\S]*?)(?=<div\b[^>]*class=["'][^"']*anthology-list-box|<\/div>\s*<\/div>\s*<script|$)/gi; - const groups = []; - let match; - while ((match = groupPattern.exec(listSource))) groups.push(match[1]); + const groups = giriElementBlocks(source, 'anthology-list-box'); if (!groups.length) return ''; - const tabPattern = /<a\b[^>]*class=["'][^"']*swiper-slide[^"']*["'][^>]*>([\s\S]*?)<\/a>/gi; - const tabs = []; - while ((match = tabPattern.exec(tabSource))) tabs.push(cleanText(match[1])); - for (let index = 0; index < tabs.length; index += 1) { - if (tabs[index].indexOf('简中') >= 0 && groups[index]) return groups[index]; - } - for (let index = 0; index < groups.length; index += 1) { - if (/\/play[^"']+-2-\d+\/?/i.test(groups[index])) return groups[index]; - } - return groups[0]; + const tabs = []; const pattern = /<a\b((?:[^>"']|"[^"]*"|'[^']*')*)>([\s\S]*?)<\/a>/gi; let match; + const tabBlock = giriElementBlocks(source, 'anthology-tab').join(''); + while ((match = pattern.exec(tabBlock))) if (/\bswiper-slide\b/.test(attr(match[1], 'class'))) tabs.push(cleanText(match[2])); + for (let index = 0; index < tabs.length; index++) if (tabs[index].indexOf('简中') >= 0 && groups[index]) return groups[index]; + return groups.find(group => /\/play[^"']+-2-\d+\/?/i.test(group)) || groups[0]; } function parseEpisodes(html) { - const episodes = []; - const pattern = /<a\b([^>]*href=["'][^"']*\/(?:play\/|play[^\/"']+)[^"']*["'][^>]*)>([\s\S]*?)<\/a>/gi; + const episodes = []; const seen = {}; + const pattern = /<a\b((?:[^>"']|"[^"]*"|'[^']*')*)>([\s\S]*?)<\/a>/gi; let match; while ((match = pattern.exec(String(html || '')))) { - const href = attr(match[1], 'href'); - const id = cleanId(href); - const raw = cleanText(match[2]); - if (!id || !raw) continue; - const epNum = Number(raw); - episodes.push({ - id: id, - title: /^\d+$/.test(raw) && epNum <= 100 ? '第' + epNum + '集' : raw - }); + const id = cleanId(attr(match[1], 'href')); const raw = cleanText(match[2]); + if (!id || !isPlayId(id) || !raw || seen[id]) continue; + seen[id] = true; + episodes.push({ id, title: /^\d+$/.test(raw) && Number(raw) <= 100 ? '第' + Number(raw) + '集' : raw }); } return episodes; } @@ -943,3 +943,34 @@ if (typeof globalThis !== 'undefined') { if (typeof module !== 'undefined' && module.exports) { module.exports = GirigiriLoveMiniLibrary; } + +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); +} + +function giriElementBlocks(html, className) { + const source = String(html || ''); const blocks = []; + const opening = /<([a-z][a-z0-9-]*)\b((?:[^>"']|"[^"]*"|'[^']*')*)>/gi; + let match; + while ((match = opening.exec(source))) { + if ((' ' + attr(match[2], 'class') + ' ').indexOf(' ' + className + ' ') < 0) continue; + const name = match[1].toLowerCase(); const start = opening.lastIndex; + const tags = /<\/?([a-z][a-z0-9-]*)\b((?:[^>"']|"[^"]*"|'[^']*')*)>/gi; + tags.lastIndex = start; let depth = 1; let tag; + while ((tag = tags.exec(source))) { + if (tag[1].toLowerCase() !== name) continue; + if (tag[0][1] === '/') depth--; else if (!/\/\s*>$/.test(tag[0])) depth++; + if (!depth) { blocks.push(source.slice(start, tag.index)); opening.lastIndex = tags.lastIndex; break; } + } + } + return blocks; +} diff --git a/jable.js b/jable.js index 42fc74f..6438a99 100644 --- a/jable.js +++ b/jable.js @@ -5,7 +5,7 @@ WidgetMetadata = { description: "修复声音&恢复大量分类", author: "nibiru|MakkaPakka|蝴蝶", site: "https://widgets-xd.vercel.app", - version: "1.3.0", + version: "1.3.1", requiredVersion: "0.0.2", detailCacheDuration: 60, modules: [ @@ -1165,14 +1165,6 @@ async function search(params = {}) { let url = `https://jable.tv/search/${keyword}/?mode=async&function=get_block&block_id=list_videos_videos_list_search_result&q=${keyword}`; - if (params.sort_by) { - url += `&sort_by=${params.sort_by}`; - } - - if (params.from) { - url += `&from=${params.from}`; - } - return await loadPage({ ...params, url }); } @@ -1189,10 +1181,10 @@ async function loadPageSections(params = {}) { throw new Error("\u5730\u5740\u4e0d\u80fd\u4e3a\u7a7a"); } if (params["sort_by"]) { - url += `&sort_by=${params.sort_by}`; + url += `&sort_by=${encodeURIComponent(params.sort_by)}`; } if (params["from"]) { - url += `&from=${params.from}`; + url += `&from=${encodeURIComponent(params.from)}`; } const response = await Widget.http.get(url, { headers: { diff --git a/jable.media-library.js b/jable.media-library.js index 6b0de27..1193ff1 100644 --- a/jable.media-library.js +++ b/jable.media-library.js @@ -301,9 +301,9 @@ var WidgetMetadata = { id: "baiplay_jable_media_library", title: "Jable", description: "Jable custom media library source for baiPlay", - author: "baiPlay", + author: 'Alan huang', site: JABLE_BASE_URL, - version: "1.0.0", + version: "1.0.1", requiredVersion: "0.0.2", detailCacheDuration: 60, modules: [ @@ -427,55 +427,42 @@ async function getHome(ctx = {}) { const ext = argsify(ctx); const page = normalizePage(ext.page || ext.pg || ext.from || 1); const categoryIds = Array.isArray(ext.categoryIds) && ext.categoryIds.length ? ext.categoryIds : JABLE_HOME_CATEGORY_IDS; - const sections = [ - { - id: "jable-categories", - title: "\u5206\u7c7b", - style: "discover.watchProviders", - items: categoryShortcutItems(), - }, - ]; + const sections = [{ id: 'jable-categories', title: '分类', style: 'discover.watchProviders', items: categoryShortcutItems() }]; let hero = []; - let firstError = null; - - for (const categoryId of categoryIds) { - const category = findCategory(categoryId); - try { - const items = await loadPosterWall({ - categoryId: category.id, - page, - sort_by: ext.sort_by || ext.sortBy || category.defaultSort, - }); - const mediaItems = items.map((item, index) => toMiniMediaItem(item, index + 1, category)).filter(Boolean); - if (!mediaItems.length) continue; - if (!hero.length) hero = mediaItems.slice(0, 6); - const action = { type: "category", id: category.id, pageId: category.id, title: category.title }; - sections.push({ - id: category.id, - title: category.title, - style: homeSectionStyle(category), - contentType: "movie", - more: action, - moreAction: action, - items: mediaItems.slice(0, SOURCE_PAGE_LIMIT), - }); - } catch (error) { - if (!firstError) firstError = error; - logInfo("Jable home section skipped: " + category.id + " - " + (error && error.message ? error.message : error)); + for (let index = 0; index < categoryIds.length; index++) { + const category = findCategory(categoryIds[index]); + const section = jableSectionShell(category); + if (index === 0) { + try { + const rows = await loadPosterWall({ categoryId: category.id, page, sort_by: ext.sort_by || ext.sortBy || category.defaultSort }); + section.items = rows.map((item, rank) => toMiniMediaItem(item, rank + 1, category)).filter(Boolean).slice(0, SOURCE_PAGE_LIMIT); + hero = section.items.slice(0, 6); + section.lazy = false; + } catch (error) { section.error = errorMessage(error); } } + sections.push(section); } + return { pageType: 'home', id: 'jable-home', title: 'Jable', hero, sections }; +} - if (!hero.length && firstError) { - throw firstError; - } +function jableSectionShell(category) { + const action = { type: 'category', id: category.id, pageId: category.id, title: category.title }; + return { id: category.id, title: category.title, style: homeSectionStyle(category), contentType: 'movie', lazy: true, + loadAction: { type: 'custom', id: category.id, sectionId: category.id, title: category.title }, more: action, moreAction: action, items: [] }; +} - const result = { - pageType: "home", - title: "Jable", - sections, - }; - if (hero.length) result.hero = hero; - return result; +async function getHomeSection(ctx = {}) { + const ext = argsify(ctx); + const id = ext.sectionId || ext.pageId || ext.id || JABLE_HOME_CATEGORY_IDS[0]; + if (id === 'jable-categories') return { id, title: '分类', style: 'discover.watchProviders', lazy: false, items: categoryShortcutItems() }; + const category = findCategory(id); + const section = jableSectionShell(category); + try { + const items = await loadPosterWall({ categoryId: category.id, page: 1, sort_by: ext.sort_by || category.defaultSort }); + section.items = items.map((item, index) => toMiniMediaItem(item, index + 1, category)).filter(Boolean).slice(0, SOURCE_PAGE_LIMIT); + } catch (error) { section.error = errorMessage(error); } + section.lazy = false; + return section; } async function getCategory(ctx = {}) { @@ -907,7 +894,8 @@ async function matchEpisode(ctx = {}) { async function getPlayback(input) { const ext = argsify(input); - if (ext.url || ext.playUrl || ext.videoUrl) { + const hasStableItem = !!(ext.itemId || ext.versionId || ext.id); + if (!hasStableItem && (ext.url || ext.playUrl || ext.videoUrl)) { return playbackFromDirectUrl(ext.url || ext.playUrl || ext.videoUrl, ext); } const detail = input && input.videoUrl ? input : await getDetail(Object.keys(ext).length ? ext : input); @@ -939,7 +927,8 @@ async function getResourceVersions(ctx = {}) { async function resolvePlayback(ctx = {}) { const ext = argsify(ctx); - if (ext.url || ext.playUrl || ext.videoUrl) { + const hasStableItem = !!(ext.itemId || ext.versionId || ext.id); + if (!hasStableItem && (ext.url || ext.playUrl || ext.videoUrl)) { return playbackFromDirectUrl(ext.url || ext.playUrl || ext.videoUrl, ext); } const playback = await getPlayback(ext); @@ -1151,7 +1140,6 @@ function buildMiniResourceGroups(detail) { type: "play", itemId, versionId, - url: detail.videoUrl, title, }, }, @@ -1672,6 +1660,7 @@ const JableMediaLibrary = { init, getManifest, getHome, + getHomeSection, getCategory, home, homeVod, @@ -1714,6 +1703,7 @@ if (typeof globalThis !== "undefined") { globalThis.init = init; globalThis.getManifest = getManifest; globalThis.getHome = getHome; + globalThis.getHomeSection = getHomeSection; globalThis.getCategory = getCategory; globalThis.getResourceVersions = getResourceVersions; globalThis.resolvePlayback = resolvePlayback; diff --git a/javgg-mini-library.js b/javgg-mini-library.js index 4cbc0f3..98a770f 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.0', + version: '1.0.1', requiredVersion: '0.0.1', author: 'Alan huang', site: JAVGG_DEFAULT_BASE, @@ -235,7 +235,7 @@ async function getResourceVersions(rawCtx) { return groups; } -async function resolvePlayback(rawCtx) { +async function resolvePlaybackWithinBudget(rawCtx) { const ctx = normalizeContext(rawCtx); const payload = decodePayload(firstNonEmpty(ctx.versionId, ctx.itemId, ctx.id)); let playerUrl = firstNonEmpty(ctx.playerUrl, payload.playerUrl); @@ -418,7 +418,7 @@ async function resolvePlayerMedia(ctx, playerUrl, detailUrl) { } catch (error) { playable = ''; } - if (!playable) playable = await extractFromBrowser(playerUrl, detailUrl); + if (!playable) playable = await extractFromBrowser(playerUrl, detailUrl, ctx); return playable; } @@ -453,6 +453,7 @@ function resolveRelativeURL(base, value) { async function fetchText(ctx, url, referer) { const response = await httpGet(url, { headers: requestHeaders(referer || url), + timeout: remainingPlaybackSeconds(ctx, 6), timeoutSeconds: remainingPlaybackSeconds(ctx, 6), useBrowserCookie: false, attachBrowserCookie: false, useBrowserFallback: false, @@ -482,12 +483,13 @@ async function httpGet(url, options) { throw new Error('当前环境没有可用的 HTTP 客户端'); } -async function extractFromBrowser(url, referer) { +async function extractFromBrowser(url, referer, ctx) { if (typeof Widget === 'undefined' || !Widget.browser || typeof Widget.browser.fetch !== 'function') return ''; try { const result = await Widget.browser.fetch(url, { visible: false, - timeout: 70, + timeout: playbackBrowserSeconds(ctx, 12, 1), + timeoutSeconds: remainingPlaybackSeconds(ctx, 12), waitAfterLoad: 4, waitForAny: true, waitForMediaSource: true, @@ -805,3 +807,38 @@ if (typeof globalThis !== 'undefined') { Object.keys(exported).forEach(function (key) { globalThis[key] = exported[key]; }); } if (typeof module !== 'undefined' && module.exports) module.exports = exported; + +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 }; + ctx.__playbackBudget = budget; + let timer; + try { + const timeout = new Promise(function (_, reject) { + timer = setTimeout(function () { budget.expired = true; reject(new Error('播放解析超时;stage=total-deadline;请稍后重试')); }, 28000); + }); + 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 61b6819..3d88485 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.0', - author: 'EL', + version: '1.0.1', + author: 'Alan huang', logo: KBJFAN_LOGO, icon: KBJFAN_LOGO, site: KBJFAN_DEFAULT_BASE, @@ -60,6 +60,7 @@ function getManifest() { } async function getHome(ctx) { + ctx = normalizeRuntimeContext(ctx); let hero = []; try { hero = (await loadSectionItems(ctx, KBJFAN_SECTIONS[0], 1)).slice(0, 8); @@ -87,6 +88,7 @@ async function getHome(ctx) { } async function getHomeSection(ctx) { + ctx = normalizeRuntimeContext(ctx); const section = findSection(ctx && (ctx.sectionId || ctx.id)) || KBJFAN_SECTIONS[0]; try { return { @@ -110,6 +112,7 @@ 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)); @@ -126,6 +129,7 @@ async function getCategory(ctx) { } async function getDetail(ctx) { + ctx = normalizeRuntimeContext(ctx); const detailURL = detailURLFromContext(ctx); if (!detailURL) throw new Error('KBJFan 详情参数无效'); const html = await fetchText(ctx, detailURL); @@ -149,6 +153,7 @@ async function getDetail(ctx) { } async function getResourceVersions(ctx) { + ctx = normalizeRuntimeContext(ctx); const detailURL = detailURLFromContext(ctx); if (!detailURL) return []; let videoURL = directMediaURLFromContext(ctx); @@ -163,6 +168,7 @@ async function getResourceVersions(ctx) { } async function resolvePlayback(ctx) { + ctx = normalizeRuntimeContext(ctx); let detailURL = detailURLFromContext(ctx); let videoURL = directMediaURLFromContext(ctx); if (!videoURL && detailURL) { @@ -182,6 +188,7 @@ 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: [] }; @@ -383,3 +390,16 @@ 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-domestic-mini-library.js b/madou8-domestic-mini-library.js index b373298..d28f19e 100644 --- a/madou8-domestic-mini-library.js +++ b/madou8-domestic-mini-library.js @@ -8,7 +8,7 @@ const WidgetMetadata = { id: 'madou8-domestic-mini-library', name: '麻豆视频·国产', title: '麻豆视频·国产', - version: '1.2.1', + version: '1.2.2', requiredVersion: '0.0.1', author: 'Alan huang', site: MADOU8_DEFAULT_BASE, @@ -176,7 +176,6 @@ async function discoverVariants(ctx, playlist, detailUrl) { const variants = parseMaster(body, url); if (variants.length) { discovered.push.apply(discovered, variants.slice(0, 6)); - break; } else if (/#EXTINF/i.test(body)) { const inferred = inferQuality(url); if (inferred) discovered.push(inferred); @@ -188,6 +187,7 @@ async function discoverVariants(ctx, playlist, detailUrl) { async function chooseStream(ctx, playlist, requestedHeight, detailUrl) { let fallback = null; + function remember(candidate) { if (!fallback || (candidate.height || 0) > (fallback.height || 0)) fallback = candidate; } for (let i = 0; i < Math.min(playlist.length, 4); i++) { const item = playlist[i]; if (!item || !/^https?:\/\//i.test(String(item.url || ''))) continue; @@ -197,19 +197,18 @@ async function chooseStream(ctx, playlist, requestedHeight, detailUrl) { if (!variants.length) { if (!/#EXTINF/i.test(body)) 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; + inferred.url = item.url; remember(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; + ? 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 (!requestedHeight && fallback && ordered[j].height <= fallback.height) continue; try { - const mediaBody = await fetchHlsText(ctx, ordered[j].url, detailUrl); - if (!/#EXTINF/i.test(mediaBody)) continue; - if (!fallback) fallback = ordered[j]; - return ordered[j]; + if (!/#EXTINF/i.test(await fetchHlsText(ctx, ordered[j].url, detailUrl))) continue; + remember(ordered[j]); + if (requestedHeight && ordered[j].height === requestedHeight) return ordered[j]; + break; } catch (_) {} } } diff --git a/madou8-mini-library 5.js b/madou8-mini-library 5.js index e4c8cf6..b108309 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.0', + version: '1.1.1', requiredVersion: '0.0.1', author: 'Alan huang', site: MADOU8_DEFAULT_BASE, @@ -176,7 +176,6 @@ async function discoverVariants(ctx, playlist, detailUrl) { const variants = parseMaster(body, url); if (variants.length) { discovered.push.apply(discovered, variants.slice(0, 6)); - break; } else if (/#EXTINF/i.test(body)) { const inferred = inferQuality(url); if (inferred) discovered.push(inferred); @@ -188,6 +187,7 @@ async function discoverVariants(ctx, playlist, detailUrl) { async function chooseStream(ctx, playlist, requestedHeight, detailUrl) { let fallback = null; + function remember(candidate) { if (!fallback || (candidate.height || 0) > (fallback.height || 0)) fallback = candidate; } for (let i = 0; i < Math.min(playlist.length, 4); i++) { const item = playlist[i]; if (!item || !/^https?:\/\//i.test(String(item.url || ''))) continue; @@ -197,19 +197,18 @@ async function chooseStream(ctx, playlist, requestedHeight, detailUrl) { if (!variants.length) { if (!/#EXTINF/i.test(body)) 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; + inferred.url = item.url; remember(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; + ? 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 (!requestedHeight && fallback && ordered[j].height <= fallback.height) continue; try { - const mediaBody = await fetchHlsText(ctx, ordered[j].url, detailUrl); - if (!/#EXTINF/i.test(mediaBody)) continue; - if (!fallback) fallback = ordered[j]; - return ordered[j]; + if (!/#EXTINF/i.test(await fetchHlsText(ctx, ordered[j].url, detailUrl))) continue; + remember(ordered[j]); + if (requestedHeight && ordered[j].height === requestedHeight) return ordered[j]; + break; } catch (_) {} } } diff --git a/manko-fun-mini-library.js b/manko-fun-mini-library.js index ee8b145..674812c 100644 --- a/manko-fun-mini-library.js +++ b/manko-fun-mini-library.js @@ -61,7 +61,7 @@ function searchPath(query, page, mode) { const key = mode === 'actor' ? 'actor' function codePrefix(query) { const m = String(query || '').trim().toUpperCase().match(/[A-Z]{2,}/); return m ? m[0] : ''; } function canonicalCode(value) { return String(value || '').toUpperCase().replace(/[^A-Z0-9]/g, '').replace(/^\d{2,4}(?=[A-Z])/, ''); } -function getManifest() { return { id: 'manko-fun', name: 'Manko.fun 中文版', title: 'Manko.fun 中文版', version: '1.8.0', author: MANKO_AUTHOR, logo: MANKO_BASE + '/images/metaog.png', capabilities: { search: true, aggregation: true, playbackHistory: true }, aggregation: { search: true, playbackHistory: true } }; } +function getManifest() { return { id: 'manko-fun', name: 'Manko.fun 中文版', title: 'Manko.fun 中文版', version: '1.8.1', author: MANKO_AUTHOR, logo: MANKO_BASE + '/images/metaog.png', capabilities: { search: true, aggregation: true, playbackHistory: true }, aggregation: { search: true, playbackHistory: true } }; } async function getCategory(ctx) { const id = String(pick(ctx, ['pageId','categoryId','id'], 'new')); const page = Math.max(1, Number(pick(ctx, ['page','pg','currentPage','pageNumber','pageIndex'], 1)) || 1); let title = '影片', result; if (id === 'genres') { const all = await apiGet('common/list-genre'); const list = Array.isArray(all) ? all : []; const limit = 48; const values = list.slice((page - 1) * limit, page * limit); const items = values.map(x => directoryTextCard('genre', x, genreZh(x), genreZh(x) === x ? '内容类型' : x)); return { pageType: 'category', id, title: '全部内容类型', style: 'discover.annualCategories', page, nextPage: page + 1, limit, total: list.length, totalPages: Math.ceil(list.length / limit), hasMore: page * limit < list.length, items }; } @@ -76,8 +76,8 @@ async function getCategory(ctx) { } async function getHome() { const first = await getCategory({ pageId: 'new', page: 1 }); const lazy = (id, title) => ({ id, title, style: 'discover.annualPosterStack', lazy: true, loadAction: { type: 'custom', id, sectionId: id, title }, items: [] }); return { pageType: 'home', id: 'manko-home-zh-v3', title: 'Manko.fun 中文版', heroAspectRatio: '16:9', hero: first.items.slice(0, 6), sections: [{ id: 'new', title: '✨ 最新作品', style: 'discover.standard', moreAction: { type: 'category', pageId: 'new', title: '✨ 最新作品' }, items: first.items.slice(0, 12) }, lazy('primary-nav', '快捷导航'), lazy('category-nav', '影片类别'), lazy('genre-nav', '内容类型'), lazy('maker-nav', '制作商'), lazy('actor-nav', '演员分类')] }; } async function getHomeSection(ctx) { const id = String(pick(ctx, ['sectionId','id'], 'new')); try { - if (id === 'primary-nav') { const items = await Promise.all(MANKO_PRIMARY_NAV.map(primaryNavCard)); return { id, title: '快捷导航', style: 'discover.annualPosterStack', lazy: false, items: items.filter(x => x.poster) }; } - if (id === 'category-nav') { const items = await Promise.all(MANKO_VIDEO_CATEGORIES.map(primaryNavCard)); return { id, title: '影片类别', style: 'discover.annualPosterStack', lazy: false, items: items.filter(x => x.poster) }; } + if (id === 'primary-nav') { const items = await loadNavCards(MANKO_PRIMARY_NAV); return { id, title: '快捷导航', style: 'discover.annualPosterStack', lazy: false, items: items.filter(x => x.poster) }; } + if (id === 'category-nav') { const items = await loadNavCards(MANKO_VIDEO_CATEGORIES); return { id, title: '影片类别', style: 'discover.annualPosterStack', lazy: false, items: items.filter(x => x.poster) }; } if (id === 'genre-nav') { const p = await getCategory({ pageId: 'genres', page: 1 }); return { id, title: '内容类型', style: 'discover.annualCategories', lazy: false, moreAction: { type: 'category', pageId: 'genres', title: '全部内容类型' }, items: p.items }; } if (id === 'maker-nav') { const p = await getCategory({ pageId: 'makers', page: 1 }); return { id, title: '制作商', style: 'discover.annualCategories', lazy: false, moreAction: { type: 'category', pageId: 'makers', title: '全部制作商' }, items: p.items }; } if (id === 'actor-nav') { const defs = [['Censored','有码演员'],['Uncensored','无码演员'],['Western','西方演员']]; const items = await Promise.all(defs.map(async d => { const rows = await apiGet('swx/movie/actor/' + d[0] + '?offset=0&limit=3'); const previews = (Array.isArray(rows) ? rows : []).map(actorCard); const lead = previews[0] || {}; return { id: 'actors:' + d[0], title: d[1], subtitle: '浏览全部演员', type: 'collection', poster: lead.poster || '', backdrop: lead.backdrop || lead.poster || '', imageHeaders: MANKO_HEADERS, previewItems: previews, action: { type: 'category', pageId: 'actors:' + d[0], title: d[1] } }; })); return { id, title: '演员分类', style: 'discover.annualPosterStack', lazy: false, items: items.filter(x => x.poster) }; } @@ -91,3 +91,11 @@ async function search(ctx) { const query = String(pick(ctx, ['query','keyword',' const api = { getManifest, getHome, getHomeSection, getCategory, getDetail, getResourceVersions, resolvePlayback, search }; Object.assign(globalThis, api); if (typeof module !== 'undefined') module.exports = Object.assign({ _test: { decodeBase91, apiMovie, categoryPath, genreZh, actorZh, actorCard, castMember, directoryCard, directoryTextCard, filteredMoviePath, primaryNavCard, searchMode, searchPath, codePrefix, canonicalCode, MANKO_AUTHOR, MANKO_PRIMARY_NAV, MANKO_VIDEO_CATEGORIES } }, api); + +async function loadNavCards(definitions) { + const cards = new Array(definitions.length); let next = 0; + await Promise.all(Array.from({ length: Math.min(2, definitions.length) }, async function () { + while (next < definitions.length) { const index = next++; try { cards[index] = await primaryNavCard(definitions[index]); } catch (_) {} } + })); + return cards.filter(Boolean); +} diff --git a/missav-mini-library-CloudFlare.js b/missav-mini-library-CloudFlare.js index f6cab42..3324627 100644 --- a/missav-mini-library-CloudFlare.js +++ b/missav-mini-library-CloudFlare.js @@ -4,6 +4,7 @@ const MISSAV_DEFAULT_BASE = 'https://missav.ws'; const MISSAV_DEFAULT_ENTRY = '/dm247/cn'; const MISSAV_LOGO = 'https://missav.ws/favicon.ico'; const MISSAV_DETAIL_PAYLOAD_PREFIX = 'missav://detail?'; +const MISSAV_VERIFY_PAYLOAD_PREFIX = 'missav://verify?'; const MISSAV_UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1'; @@ -11,8 +12,8 @@ const WidgetMetadata = { id: 'missav-mini-library', name: 'MissAV', title: 'MissAV', - version: '1.0.7', - author: 'alanhuang', + version: '1.5.10', + author: 'Alan huang', logo: MISSAV_LOGO, icon: MISSAV_LOGO, site: MISSAV_DEFAULT_BASE, @@ -20,43 +21,72 @@ const WidgetMetadata = { }; const MISSAV_SECTIONS = [ - { id: 'new-release', title: '新作上市', path: '/new', style: 'discover.spotlight' }, - { id: 'latest', title: '最近更新', path: '/new?sort=published_at', style: 'discover.ranked' }, - { id: 'chinese-subtitle', title: '中文字幕', path: '/chinese-subtitle', style: 'discover.posterCompact' }, - { id: 'uncensored-leak', title: '无码流出', aliases: ['无码影片'], path: '/uncensored-leak', style: 'discover.posterCompact' }, - { id: 'today-hot', title: '今日热门', path: '/today-hot', style: 'discover.ranked' }, - { id: 'weekly-hot', title: '本周热门', path: '/weekly-hot', style: 'discover.ranked' }, - { id: 'monthly-hot', title: '本月热门', path: '/monthly-hot', style: 'discover.ranked' }, - { id: 'fc2', title: 'FC2', path: '/fc2', style: 'discover.posterCompact' }, - { id: 'heyzo', title: 'HEYZO', path: '/heyzo', style: 'discover.posterCompact' }, - { id: 'tokyo-hot', title: '东京热', path: '/tokyo-hot', style: 'discover.posterCompact' }, - { id: '1pondo', title: '一本道', path: '/1pondo', style: 'discover.posterCompact' }, - { id: 'caribbeancom', title: 'Caribbeancom', path: '/caribbeancom', style: 'discover.posterCompact' }, - { id: 'caribbeancompr', title: 'Caribbeancompr', path: '/caribbeancompr', style: 'discover.posterCompact' }, - { id: '10musume', title: '10musume', path: '/10musume', style: 'discover.posterCompact' }, - { id: 'pacopacomama', title: 'pacopacomama', path: '/pacopacomama', style: 'discover.posterCompact' }, - { id: 'gachinco', title: 'Gachinco', path: '/gachinco', style: 'discover.posterCompact' }, - { id: 'xxx-av', title: 'XXX-AV', path: '/xxx-av', style: 'discover.posterCompact' }, - { id: 'siro', title: 'SIRO', path: '/siro', style: 'discover.posterCompact' }, - { id: 'luxu', title: 'LUXU', path: '/luxu', style: 'discover.posterCompact' }, - { id: 'gana', title: 'GANA', path: '/gana', style: 'discover.posterCompact' }, - { id: 'prestige-premium', title: 'PRESTIGE PREMIUM', path: '/prestige-premium', style: 'discover.posterCompact' }, - { id: 's-cute', title: 'S-CUTE', path: '/s-cute', style: 'discover.posterCompact' }, - { id: 'ara', title: 'ARA', path: '/ara', style: 'discover.posterCompact' }, - { id: 'madou', title: '麻豆传媒', path: '/madou', style: 'discover.posterCompact' }, - { id: 'twav', title: 'TWAV', path: '/twav', style: 'discover.posterCompact' }, - { id: 'vr', title: 'VR', path: '/vr', style: 'discover.posterCompact' } + { id: 'recommended', title: '推荐给你', path: '/', style: 'discover.spotlight', group: 'japanese', personalizedHome: true, randomCategory: true }, + { id: 'nakadashi', title: '中出', path: '/cn/search/%E4%B8%AD%E5%87%BA', style: 'discover.posterCompact', group: 'japanese', eagerPagination: true }, + { id: 'big-breasts', title: '巨乳', path: '/cn/search/%E5%B7%A8%E4%B9%B3', style: 'discover.posterCompact', group: 'japanese', eagerPagination: true }, + { id: 'married-women', title: '人妻', path: '/cn/search/%E4%BA%BA%E5%A6%BB', style: 'discover.posterCompact', group: 'japanese', eagerPagination: true }, + { id: 'chinese-subtitle', title: '中文字幕', path: '/dm278/cn/chinese-subtitle', style: 'discover.posterCompact', group: 'japanese' }, + { id: 'latest', title: '最近更新', path: '/dm539/cn/new', style: 'discover.ranked', group: 'japanese', eagerPagination: true }, + { id: 'new-release', title: '新作上市', path: '/dm635/cn/release', style: 'discover.spotlight', group: 'japanese', eagerPagination: true }, + { id: 'random', title: '随机', aliases: ['好手气'], path: '/random', style: 'discover.posterCompact', group: 'japanese', randomCategory: true }, + { id: 'uncensored-leak', title: '无码流出', aliases: ['无码影片'], path: '/dm817/cn/uncensored-leak', style: 'discover.posterCompact', group: 'uncensored' }, + { id: 'actresses', title: '女优一览', path: '/cn/actresses', style: 'discover.posterCompact', group: 'japanese', categoryIndex: true }, + { id: 'actress-ranking', title: '女优排行', path: '/cn/actresses/ranking', style: 'discover.ranked', group: 'japanese', categoryIndex: true }, + { id: 'genres', title: '类型', path: '/cn/genres', style: 'discover.posterCompact', group: 'japanese', categoryIndex: true }, + { id: 'makers', title: '发行商', path: '/cn/makers', style: 'discover.posterCompact', group: 'japanese', categoryIndex: true }, + { id: 'vr', title: 'VR', path: '/cn/genres/VR', style: 'discover.posterCompact', group: 'japanese' }, + { id: 'today-hot', title: '今日热门', path: '/dm301/cn/today-hot', style: 'discover.ranked', group: 'japanese', eagerPagination: true }, + { id: 'weekly-hot', title: '本周热门', aliases: ['本週热门'], path: '/dm170/cn/weekly-hot', style: 'discover.ranked', group: 'japanese', eagerPagination: true }, + { id: 'monthly-hot', title: '本月热门', path: '/dm273/cn/monthly-hot', style: 'discover.ranked', group: 'japanese', eagerPagination: true }, + + { id: 'siro', title: 'SIRO', path: '/dm36/cn/siro', style: 'discover.posterCompact', group: 'amateur' }, + { id: 'luxu', title: 'LUXU', path: '/dm34/cn/luxu', style: 'discover.posterCompact', group: 'amateur' }, + { id: 'gana', title: 'GANA', path: '/dm34/cn/gana', style: 'discover.posterCompact', group: 'amateur' }, + { id: 'prestige-premium', title: 'PRESTIGE PREMIUM', aliases: ['MAAN'], path: '/dm1004/cn/maan', style: 'discover.posterCompact', group: 'amateur' }, + { id: 's-cute', title: 'S-CUTE', aliases: ['SCUTE'], path: '/dm38/cn/scute', style: 'discover.posterCompact', group: 'amateur' }, + { id: 'ara', title: 'ARA', path: '/dm34/cn/ara', style: 'discover.posterCompact', group: 'amateur' }, + + { id: 'fc2', title: 'FC2', path: '/dm597/cn/fc2', style: 'discover.posterCompact', group: 'uncensored' }, + { id: 'heyzo', title: 'HEYZO', path: '/dm2208642/cn/heyzo', style: 'discover.posterCompact', group: 'uncensored' }, + { id: 'tokyo-hot', title: '东京热', aliases: ['TOKYOHOT'], path: '/dm42/cn/tokyohot', style: 'discover.posterCompact', group: 'uncensored' }, + { id: '1pondo', title: '一本道', path: '/dm5199603/cn/1pondo', style: 'discover.posterCompact', group: 'uncensored', artworkCodePrefixes: ['pondo-'], detailSlugPattern: '^pondo-\\d{6}_\\d{3}$' }, + { id: 'caribbeancom', title: 'Caribbeancom', path: '/dm7704788/cn/caribbeancom', style: 'discover.posterCompact', group: 'uncensored' }, + { id: 'caribbeancompr', title: 'Caribbeancompr', path: '/dm91887/cn/caribbeancompr', style: 'discover.posterCompact', group: 'uncensored', detailSlugPattern: '^\\d{6}_\\d{3}$' }, + { id: '10musume', title: '10musume', path: '/dm7208981/cn/10musume', style: 'discover.posterCompact', group: 'uncensored', artworkCodePrefixes: ['musume-'], detailSlugPattern: '^musume-\\d{6}_\\d{2}$' }, + { id: 'pacopacomama', title: 'pacopacomama', path: '/dm3600557/cn/pacopacomama', style: 'discover.posterCompact', group: 'uncensored', artworkCodePrefixes: ['pacopacomama-'], detailSlugPattern: '^pacopacomama-\\d{6}_\\d{3}$' }, + { id: 'gachinco', title: 'Gachinco', path: '/dm150/cn/gachinco', style: 'discover.posterCompact', group: 'uncensored', artworkCodePrefixes: ['gachi'] }, + { id: 'xxx-av', title: 'XXX-AV', aliases: ['XXXAV'], path: '/dm42/cn/xxxav', style: 'discover.posterCompact', group: 'uncensored' }, + { id: 'marriedslash', title: '人妻斩', path: '/dm37/cn/marriedslash', style: 'discover.posterCompact', group: 'uncensored', artworkCodePrefixes: ['c0930-'] }, + { id: 'naughty4610', title: '顽皮 4610', path: '/dm33/cn/naughty4610', style: 'discover.posterCompact', group: 'uncensored', artworkCodePrefixes: ['h4610-'] }, + { id: 'naughty0930', title: '顽皮 0930', path: '/dm37/cn/naughty0930', style: 'discover.posterCompact', group: 'uncensored', artworkCodePrefixes: ['h0930-'] }, + + { id: 'madou', title: '麻豆传媒', path: '/dm63/cn/madou', style: 'discover.posterCompact', group: 'asian' }, + { id: 'twav', title: 'TWAV', path: '/dm31/cn/twav', style: 'discover.posterCompact', group: 'asian' }, + { id: 'furuke', title: 'Furuke', path: '/dm15/cn/furuke', style: 'discover.posterCompact', group: 'asian' }, + { id: 'klive', title: '韩国直播', path: '/cn/klive', style: 'discover.posterCompact', group: 'asian' }, + { id: 'clive', title: '中国直播', path: '/cn/clive', style: 'discover.posterCompact', group: 'asian' } ]; -const MISSAV_PRIMARY_CATEGORIES = [ - { id: 'latest', title: '最近更新', path: '/new?sort=published_at', subtitle: '按发布时间更新' }, - { id: 'new-release', title: '新作上市', path: '/new', subtitle: '最新上架作品' }, - { id: 'uncensored-leak', title: '无码流出', path: '/uncensored-leak', subtitle: '无码流出专区' }, - { id: 'actresses', title: '女优一览', path: '/actresses', subtitle: '按女优浏览' }, - { id: 'actress-ranking', title: '女优排行', path: '/actresses/ranking', subtitle: '站内女优排行' }, - { id: 'genres', title: '类型', path: '/genres', subtitle: '按题材类型浏览' }, - { id: 'makers', title: '发行商', path: '/makers', subtitle: '按发行商浏览' }, - { id: 'vr', title: 'VR', path: '/genres/VR', subtitle: 'VR 作品专区' } +const MISSAV_CATEGORY_GROUPS = [ + { id: 'japanese', title: '日本 AV / 综合分类' }, + { id: 'amateur', title: '素人' }, + { id: 'uncensored', title: '无码影片' }, + { id: 'asian', title: '亚洲 AV' } +]; + +const MISSAV_HOME_MEDIA_SECTION_IDS = [ + 'recommended', + 'nakadashi', + 'big-breasts', + 'married-women', + 'new-release', + 'latest', + 'chinese-subtitle', + 'uncensored-leak', + 'random', + 'today-hot', + 'weekly-hot', + 'monthly-hot' ]; function getManifest() { @@ -133,11 +163,27 @@ function getManifest() { }, { name: 'browserVisible', - title: '显示验证窗口', + title: '旧版显示窗口开关', type: 'boolean', defaultValue: false, required: false, - description: '默认关闭,避免列表页自动跳出真人验证界面。需要时点击页面里的手动验证卡片。' + 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', @@ -154,16 +200,26 @@ function getManifest() { 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 hero = parseCards(sectionBlock(html, '推荐给你'), '推荐给你', ctx).slice(0, 10).map(toWideItem); const mediaSections = homeMediaSections(ctx, parseHomeSections(ctx, html)); - const sections = [primaryCategoriesSection(ctx)].concat(mediaSections); + const artwork = categoryArtworkFromSections(ctx, mediaSections); + const sections = categoryGroupSections(ctx, artwork).concat(mediaSections); return { pageType: 'home', @@ -183,7 +239,9 @@ function homeMediaSections(ctx, parsedSections) { seen[section.id] = true; sections.push(section); }); - MISSAV_SECTIONS.forEach(function (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)); @@ -191,26 +249,63 @@ function homeMediaSections(ctx, parsedSections) { 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 categoryGroupSections(ctx, artwork) { + const sections = []; + MISSAV_CATEGORY_GROUPS.forEach(function (group) { + const categories = MISSAV_SECTIONS.filter(function (category) { + return category.group === group.id; + }); + for (let offset = 0; offset < categories.length; offset += 6) { + const part = Math.floor(offset / 6); + const groupSectionId = 'missav-categories-' + group.id + (part ? '-' + (part + 1) : ''); + const groupCategories = categories.slice(offset, offset + 6); + const hasCompleteArtwork = groupCategories.every(function (category) { + return categoryPreviewItems( + artwork && artwork[category.id] && artwork[category.id].length + ? artwork[category.id] + : cachedCategoryArtwork(ctx, category.id), + category + ).length >= 3; + }); + sections.push({ + id: groupSectionId, + title: group.title + (part ? '(续 ' + part + ')' : ''), + style: 'discover.annualWidePreview', + lazy: !hasCompleteArtwork, + loadAction: !hasCompleteArtwork + ? { type: 'custom', id: groupSectionId, sectionId: groupSectionId, title: group.title } + : undefined, + items: hasCompleteArtwork + ? groupCategories.map(function (category) { + return categoryCard(ctx, category, artwork && artwork[category.id]); + }) + : [] + }); + } + }); + return sections; } -function categoryCard(ctx, category) { - return { +function categoryCard(ctx, category, livePreviewItems) { + const cachedPreviewItems = cachedCategoryArtwork(ctx, category.id); + const previewItems = categoryPreviewItems( + livePreviewItems && livePreviewItems.length + ? livePreviewItems + : cachedPreviewItems, + category + ); + const cover = previewItems[0] && firstNonEmpty( + previewItems[0].backdrop, + previewItems[0].poster, + previewItems[0].thumbnailURL + ); + const item = { id: 'category-' + category.id, title: category.title, - subtitle: category.subtitle || '', + subtitle: category.subtitle || '浏览' + category.title + '相关内容', type: 'category', aspectRatio: '16:9', - previewItems: [], + previewItems: previewItems, action: { type: 'category', id: category.id, @@ -220,43 +315,273 @@ function categoryCard(ctx, category) { itemAspectRatio: '16:9' } }; + if (cover) { + item.poster = cover; + item.backdrop = cover; + item.thumbnailURL = cover; + item.posterPath = cover; + item.backdropPath = cover; + item.image = cover; + item.imageURL = cover; + item.posterURL = cover; + item.backdropURL = cover; + item.cover = cover; + item.coverURL = cover; + item.imageFit = 'fill'; + item.imageHeaders = imageHeaders(ctx, categoryURL(ctx, category.path)); + item.posterHeaders = item.imageHeaders; + item.backdropHeaders = item.imageHeaders; + } + return item; +} + +function categoryArtworkFromSections(ctx, sections) { + const artwork = {}; + (sections || []).forEach(function (section) { + if (!section || !section.id || !section.items || !section.items.length) return; + const previews = categoryPreviewItems(section.items, findSection(section.id)); + if (!previews.length) return; + artwork[section.id] = previews; + rememberCategoryArtwork(ctx, section.id, previews); + }); + return artwork; +} + +function categoryPreviewItems(items, category) { + const prefixes = category && Array.isArray(category.artworkCodePrefixes) + ? category.artworkCodePrefixes.map(function (value) { + return String(value || '').toLowerCase(); + }).filter(Boolean) + : []; + return (items || []).filter(function (item) { + if (!item || !firstNonEmpty(item.backdrop, item.poster, item.thumbnailURL)) return false; + if (!prefixes.length && !(category && category.detailSlugPattern)) return true; + if (!categoryMatchesItem(item, category)) return false; + if (!prefixes.length) return true; + const target = stringValue(item.action && (item.action.url || item.action.itemId || item.action.id)).toLowerCase(); + const title = stringValue(item.title).toLowerCase(); + return prefixes.some(function (prefix) { + return target.indexOf('/' + prefix) >= 0 || title.indexOf(prefix) >= 0; + }); + }).slice(0, 3).map(function (item, index) { + const image = firstNonEmpty(item.backdrop, item.poster, item.thumbnailURL); + return { + id: item.id, + title: item.title, + type: item.type || 'movie', + rank: index + 1, + poster: image, + backdrop: image, + thumbnailURL: image, + aspectRatio: '16:9', + action: item.action + }; + }); +} + +function categoryMatchesItem(item, category) { + if (!category || !category.detailSlugPattern) return true; + const target = stringValue(item && item.action && (item.action.url || item.action.itemId || item.action.id)); + const slug = categorySlug(pathOf(target)).toLowerCase(); + try { + return new RegExp(category.detailSlugPattern, 'i').test(slug); + } catch (error) { + return true; + } +} + +function filterCategoryItems(items, category) { + if (!category || !category.detailSlugPattern) return items || []; + return (items || []).filter(function (item) { + return categoryMatchesItem(item, category); + }); } async function getHomeSection(ctx) { + ctx = normalizeContext(ctx); const sectionId = stringValue(ctx && (ctx.sectionId || ctx.id)); + const categoryGroup = categoryGroupFromSectionId(sectionId); + if (categoryGroup) return loadCategoryGroupSection(ctx, categoryGroup); const section = findSection(sectionId) || MISSAV_SECTIONS[0]; + if (section.personalizedHome) return personalizedRecommendationSection(ctx, section); const url = categoryURL(ctx, section.path); try { const html = await fetchText(ctx, url, entryURL(ctx)); + const items = filterCategoryItems( + parseCards(mediaListBlock(html), section.title, ctx), + section + ).slice(0, 18); + rememberCategoryArtwork(ctx, section.id, items); return { id: section.id, title: section.title, style: section.style, lazy: false, moreAction: categoryAction(ctx, section), - items: parseCards(html, section.title, ctx).slice(0, 18) + items: items }; } catch (error) { return verificationSection(ctx, section.id, section.title, section.style, url, error); } } +function categoryGroupFromSectionId(sectionId) { + const value = stringValue(sectionId); + const match = /^missav-categories-([a-z]+)(?:-(\d+))?$/.exec(value); + if (!match) return null; + const group = MISSAV_CATEGORY_GROUPS.find(function (item) { + return item.id === match[1]; + }); + if (!group) return null; + const partNumber = Math.max(1, Number(match[2] || 1)); + const categories = MISSAV_SECTIONS.filter(function (category) { + return category.group === group.id; + }).slice((partNumber - 1) * 6, partNumber * 6); + if (!categories.length) return null; + return { + id: value, + title: group.title + (partNumber > 1 ? '(续 ' + (partNumber - 1) + ')' : ''), + categories: categories + }; +} + +async function loadCategoryGroupSection(ctx, groupSection) { + const artwork = {}; + const categories = groupSection.categories || []; + for (let offset = 0; offset < categories.length; offset += 2) { + const batch = categories.slice(offset, offset + 2); + const results = await Promise.all(batch.map(function (category) { + return loadCategoryPreviewItems(ctx, category); + })); + batch.forEach(function (category, index) { + artwork[category.id] = results[index]; + }); + } + return { + id: groupSection.id, + title: groupSection.title, + style: 'discover.annualWidePreview', + lazy: false, + items: categories.map(function (category) { + return categoryCard(ctx, category, artwork[category.id]); + }) + }; +} + +async function loadCategoryPreviewItems(ctx, category) { + const cached = categoryPreviewItems(cachedCategoryArtwork(ctx, category.id), category); + if (cached.length >= 3) return cached; + try { + const url = categoryURL(ctx, category.path); + let html; + if (category.personalizedHome) { + html = await fetchText(ctx, entryURL(ctx), entryURL(ctx)); + } else if (category.categoryIndex && isActressIndexURL(url)) { + html = await fetchActressIndexText(ctx, url, entryURL(ctx)); + } else { + html = await fetchText(ctx, url, entryURL(ctx)); + } + let items; + if (category.personalizedHome) { + items = parseCards(sectionBlock(html, category.title), category.title, ctx); + if (!items.length) items = parseCards(mediaListBlock(html), category.title, ctx); + } else if (category.categoryIndex) { + items = parseCategoryCards(ctx, html, category.title); + if (!items.length) items = parseCards(mediaListBlock(html), category.title, ctx); + } else { + items = filterCategoryItems( + parseCards(mediaListBlock(html), category.title, ctx), + category + ); + } + const previews = categoryPreviewItems(items, category); + if (previews.length) rememberCategoryArtwork(ctx, category.id, previews); + return previews; + } catch (error) { + return cached; + } +} + +async function personalizedRecommendationSection(ctx, section) { + let items = []; + const homeURL = entryURL(ctx); + const browserText = await browserHTML(ctx, homeURL, homeURL); + if (browserText) { + items = parseCards(sectionBlock(browserText, section.title), section.title, ctx).slice(0, 18); + } + if (!items.length) { + try { + const fallbackHTML = await fetchText(ctx, randomPageURL(ctx, 1), homeURL); + items = parseCards(fallbackHTML, section.title, ctx).slice(0, 18); + } catch (error) { + return verificationSection(ctx, section.id, section.title, section.style, homeURL, error); + } + } + rememberCategoryArtwork(ctx, section.id, items); + return { + id: section.id, + title: section.title, + style: section.style, + lazy: false, + moreAction: categoryAction(ctx, section), + items: items + }; +} + async function getCategory(ctx) { - const page = positiveInt(contextValue(ctx, 'page'), 1); + ctx = normalizeContext(ctx); + const requestedPage = pageFromContext(ctx, 1); const pageId = normalizePageId(ctx, ctx && (ctx.pageId || ctx.id)); + const verificationURL = verificationUrlFromPageId(pageId); + if (verificationURL) return runVerificationCategory(ctx, pageId, verificationURL); const section = findSection(pageId); const primary = findPrimaryCategory(pageId); const path = section ? section.path : primary ? primary.path : pageId; - const url = pagedURL(categoryURL(ctx, path), page); + const page = categoryFetchPage(ctx, pageId, requestedPage); + const url = section && section.randomCategory + ? randomPageURL(ctx, page) + : pagedURL(categoryURL(ctx, path), page); let html = ''; try { - html = await fetchText(ctx, url, entryURL(ctx)); + html = isActressWorksURL(url) + ? await fetchActressCategoryText(ctx, url, entryURL(ctx)) + : isActressIndexURL(url) + ? await fetchActressIndexText(ctx, url, entryURL(ctx)) + : await fetchText(ctx, url, entryURL(ctx)); } catch (error) { return verificationCategory(ctx, pageId, section ? section.title : primary ? primary.title : '需要验证', url, error, page); } const title = section ? section.title : primary ? primary.title : pageTitle(html) || 'MissAV'; - const items = parseCards(html, title, ctx); - const categoryItems = items.length ? [] : parseCategoryCards(ctx, html, title); + const categoryIndex = !!(section && section.categoryIndex); + const categoryItems = categoryIndex ? parseCategoryCards(ctx, html, title) : []; + const items = categoryItems.length + ? [] + : filterCategoryItems(parseCards(mediaListBlock(html), title, ctx), section); + const fallbackCategoryItems = items.length || categoryItems.length ? [] : parseCategoryCards(ctx, html, title); + const listItems = items.length + ? items + : categoryItems.length + ? categoryItems + : fallbackCategoryItems; + if (section && items.length) rememberCategoryArtwork(ctx, section.id, items); + const pagination = section && section.randomCategory + ? { hasMore: true, totalPages: 99 } + : section && section.id === 'actress-ranking' + ? { hasMore: false, totalPages: 1 } + : paginationInfo(html, page, listItems.length); + if (items.length && requestedPage === 1 && section && section.eagerPagination) { + await prefetchNextCategoryPage(ctx, pageId, section, page, pagination); + } + const restored = listItems.length + ? rememberCategoryPage(ctx, pageId, page, listItems, pagination) + : null; + const responseItems = restored && restored.items.length + ? restored.items + : items.length + ? items + : listItems; + const responsePage = restored ? restored.page : page; + const responseHasMore = restored ? restored.hasMore : items.length ? pagination.hasMore : false; return { pageType: 'category', @@ -264,13 +589,19 @@ async function getCategory(ctx) { title: title, style: items.length ? 'media.posterGrid' : 'discover.annualCategories', itemAspectRatio: '16:9', - items: items.length ? items : categoryItems, - page: page, - hasMore: items.length ? hasNextPage(html, page) : false + items: responseItems, + page: responsePage, + currentPage: responsePage, + pageIndex: responsePage, + nextPage: responseHasMore ? responsePage + 1 : undefined, + totalPages: restored && restored.totalPages ? restored.totalPages : pagination.totalPages, + pagecount: restored && restored.totalPages ? restored.totalPages : pagination.totalPages, + hasMore: responseHasMore }; } async function getDetail(ctx) { + ctx = normalizeContext(ctx); const detailURL = detailUrlFromContext(ctx); if (!detailURL) throw new Error('MissAV 详情参数无效'); @@ -311,9 +642,21 @@ async function getDetail(ctx) { firstMatch(html, /(\d{1,2}:\d{2}:\d{2})/i) ); const releaseDate = firstMatch(html, /(\d{4}-\d{2}-\d{2})/); - const detailPlayableURL = absoluteURL(ctx, extractPlayableURL(html)); - const resourceGroups = playbackGroups(detailURL, title, detailPlayableURL, ctx); - const defaultVersion = resourceGroups[0] && resourceGroups[0].versions && resourceGroups[0].versions[0]; + let detailPlayableURL = absoluteURL(ctx, extractPlayableURL(html)); + if (!detailPlayableURL) detailPlayableURL = await extractPlayableFromLinkedPlayers(ctx, html, detailURL); + let resourceGroups = await playbackGroups(detailURL, title, detailPlayableURL, ctx, html); + if (resourceVersionCount(resourceGroups) <= 1) { + const browserPlayableURL = await extractFromBrowser(detailURL, detailURL); + if (browserPlayableURL && browserPlayableURL !== detailPlayableURL) { + const browserGroups = await playbackGroups(detailURL, title, browserPlayableURL, ctx, html); + if (resourceVersionCount(browserGroups) > resourceVersionCount(resourceGroups)) { + detailPlayableURL = browserPlayableURL; + resourceGroups = browserGroups; + } + } + } + const versions = resourceGroups[0] && resourceGroups[0].versions || []; + const defaultVersion = versions[0]; return { id: makeItemId(detailURL, title, poster), @@ -335,19 +678,19 @@ async function getDetail(ctx) { remarks: durationText || code, resourceGroups: resourceGroups, resourceSummary: { - versionCount: defaultVersion ? 1 : 0, + versionCount: versions.length, episodeCount: 0, defaultVersionId: defaultVersion ? defaultVersion.id : '' }, - mediaSources: detailPlayableURL ? [ + mediaSources: defaultVersion && defaultVersion.url ? [ { - id: encodePayload({ url: detailURL, title: title, playUrl: detailPlayableURL }), - name: 'MissAV HLS', - displayName: 'MissAV HLS', + id: defaultVersion.id, + name: defaultVersion.name, + displayName: defaultVersion.name, protocol: 'hls', container: 'm3u8', - url: detailPlayableURL, - path: detailPlayableURL, + url: defaultVersion.url, + path: defaultVersion.url, headers: playbackHeaders(ctx, detailURL), header: playbackHeaders(ctx, detailURL), Header: playbackHeaders(ctx, detailURL), @@ -372,32 +715,74 @@ async function getDetail(ctx) { } async function getResourceVersions(ctx) { + ctx = normalizeContext(ctx); const detailURL = detailUrlFromContext(ctx); const title = stringValue(ctx && (ctx.title || ctx.name)) || titleFromUrl(detailURL); const direct = playUrlFromContext(ctx); - if (direct) return playbackGroups(detailURL, title, direct, ctx); + if (direct) { + let directGroups = await playbackGroups(detailURL, title, direct, ctx); + if (resourceVersionCount(directGroups) <= 1 && detailURL) { + const browserPlayableURL = await extractFromBrowser(detailURL, detailURL); + if (browserPlayableURL && browserPlayableURL !== direct) { + const browserGroups = await playbackGroups(detailURL, title, browserPlayableURL, ctx); + if (resourceVersionCount(browserGroups) > resourceVersionCount(directGroups)) directGroups = browserGroups; + } + } + return directGroups; + } try { const html = await fetchText(ctx, detailURL, entryURL(ctx)); - return playbackGroups(detailURL, title, extractPlayableURL(html), ctx); + let playUrl = extractPlayableURL(html); + if (!playUrl) playUrl = await extractPlayableFromLinkedPlayers(ctx, html, detailURL); + let groups = await playbackGroups(detailURL, title, playUrl, ctx, html); + if (resourceVersionCount(groups) <= 1) { + const browserPlayableURL = await extractFromBrowser(detailURL, detailURL); + if (browserPlayableURL && browserPlayableURL !== playUrl) { + const browserGroups = await playbackGroups(detailURL, title, browserPlayableURL, ctx, html); + if (resourceVersionCount(browserGroups) > resourceVersionCount(groups)) groups = browserGroups; + } + } + return groups; } catch (error) { - return playbackGroups(detailURL, title, '', ctx); + return []; } } -async function resolvePlayback(ctx) { +async function resolvePlaybackWithinBudget(ctx) { + ctx = normalizeContext(ctx); + const requestedQuality = qualityFromContext(ctx); const direct = firstNonEmpty(playUrlFromContext(ctx), ctx && ctx.url, ctx && ctx.playUrl, ctx && ctx.videoUrl); - if (isPlayableURL(direct) && !isDetailPageURL(direct)) { + const stableDetailURL = detailUrlFromContext(ctx); + if (!isDetailPageURL(stableDetailURL) && !requestedQuality && isPlayableURL(direct) && !isDetailPageURL(direct)) { return playbackResult(ctx, direct, ctx && ctx.referer); } const detailURL = detailUrlFromContext(ctx); if (!detailURL) throw new Error('MissAV 播放参数无效'); - const html = await fetchText(ctx, detailURL, entryURL(ctx)); - const url = firstNonEmpty( - extractPlayableURL(html), - await extractPlayableFromLinkedPlayers(ctx, html, detailURL), - await extractFromBrowser(detailURL, detailURL) - ); + let html = ''; + try { + html = await fetchText(ctx, detailURL, entryURL(ctx)); + } catch (error) { + // Playback has a separate, narrowly-scoped hidden browser media fallback. + } + let masterURL = extractPlayableURL(html); + if (!masterURL) masterURL = await extractPlayableFromLinkedPlayers(ctx, html, detailURL); + if (!masterURL) masterURL = await extractFromBrowser(detailURL, detailURL, ctx); + let url = masterURL; + if (masterURL) { + let qualities = await discoverAvailableQualities(ctx, masterURL, detailURL, html); + const knownQuality = qualities.some(function (quality) { return quality.height > 0; }); + const missingRequested = requestedQuality && !qualities.some(function (quality) { return String(quality.height) === String(requestedQuality); }); + if ((!knownQuality || missingRequested) && ctx.__playbackBudget.browserCalls === 0) { + const browserMasterURL = await extractFromBrowser(detailURL, detailURL, ctx); + if (browserMasterURL && browserMasterURL !== masterURL) { + const browserQualities = await discoverAvailableQualities(ctx, browserMasterURL, detailURL, html); + if (browserQualities.length > qualities.length) qualities = browserQualities; + } + } + const selected = selectQuality(qualities, requestedQuality); + if (selected && selected.url) url = selected.url; + } if (!url) { throw new Error('未能解析到 MissAV 播放地址。站点可能启用了 Cloudflare 或更换了播放器脚本,请尝试更新入口路径或在 App 侧启用浏览器请求。'); } @@ -450,9 +835,10 @@ async function play(flagOrInput, id) { } async function search(ctx) { + ctx = normalizeContext(ctx); const query = stringValue(ctx && (ctx.query || ctx.keyword || ctx.text)); if (!query) return { pageType: 'search', title: '搜索结果', items: [] }; - const page = positiveInt(contextValue(ctx, 'page'), 1); + const page = pageFromContext(ctx, 1); const url = pagedURL(categoryURL(ctx, '/search/' + encodeURIComponent(query.replace(/\\/g, ''))), page); let html = ''; try { @@ -467,12 +853,18 @@ async function search(ctx) { }; } const items = parseCards(html, query, ctx); + const pagination = paginationInfo(html, page, items.length); return { pageType: 'search', title: '搜索结果', items: items, page: page, - hasMore: hasNextPage(html, page) + currentPage: page, + pageIndex: page, + nextPage: pagination.hasMore ? page + 1 : undefined, + totalPages: pagination.totalPages, + pagecount: pagination.totalPages, + hasMore: pagination.hasMore }; } @@ -492,11 +884,20 @@ async function matchResources(ctx) { } async function onAction(ctx) { - const name = stringValue(ctx && (ctx.name || ctx.action || ctx.id)); - const payload = (ctx && ctx.payload) || 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), @@ -544,6 +945,47 @@ function baseURL(ctx) { ) || MISSAV_DEFAULT_BASE).replace(/\/+$/, ''); } +function normalizeContext(ctx) { + if (ctx === undefined || ctx === null) return {}; + if (typeof ctx === 'string') { + const text = ctx.trim(); + if (!text) return {}; + if (text[0] === '{' && text[text.length - 1] === '}') { + try { + const parsed = JSON.parse(text); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}; + } catch (error) { + return { query: text }; + } + } + return { query: text }; + } + return typeof ctx === 'object' ? ctx : {}; +} + +function pageFromContext(ctx, fallback) { + const input = normalizeContext(ctx); + const pagination = input.pagination && typeof input.pagination === 'object' ? input.pagination : {}; + const pageInfo = input.pageInfo && typeof input.pageInfo === 'object' ? input.pageInfo : {}; + return positiveInt(firstNonEmpty( + contextValue(input, 'page'), + contextValue(input, 'pg'), + contextValue(input, 'currentPage'), + contextValue(input, 'pageNumber'), + contextValue(input, 'pageIndex'), + pagination.page, + pagination.pg, + pagination.currentPage, + pagination.pageNumber, + pagination.pageIndex, + pageInfo.page, + pageInfo.pg, + pageInfo.currentPage, + pageInfo.pageNumber, + pageInfo.pageIndex + ), fallback); +} + function entryPath(ctx) { let value = stringValue( contextValue(ctx, 'entryPath') || @@ -589,6 +1031,95 @@ 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 + }); + + 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; + } + + throw new Error( + '女优作品页读取失败。已短暂尝试复用刚才的验证状态,请点击一次真人验证后重试。' + + (lastError && lastError.message ? ' 原因:' + lastError.message : '') + ); +} + +async function fetchActressIndexText(ctx, url, referer) { + const cached = getCachedText(ctx, url); + if (cached && isVerifiedTargetHTML(ctx, url, cached)) return cached; + + const fastContext = Object.assign({}, ctx || {}, { + requestTimeoutSeconds: Math.min(numberParam(ctx, 'requestTimeoutSeconds', 45), 12), + suppressBrowserFailureCooldown: true + }); + let lastError = null; + try { + const options = requestOptions(fastContext, referer || url); + options.useBrowserFallback = false; + options.browserFallback = false; + options.allowBrowserFallback = false; + const response = await httpGet(url, options); + const text = responseText(response); + if (isVerifiedTargetHTML(fastContext, url, text)) { + setCachedText(ctx, url, text); + return text; + } + throw new Error('HTTP ' + (response && response.status ? response.status : 'empty')); + } catch (error) { + lastError = error; + } + + const browserText = await browserHTML(fastContext, url, referer || url); + if (isVerifiedTargetHTML(fastContext, url, browserText)) { + setCachedText(ctx, url, browserText); + return browserText; + } + + throw new Error( + '女优目录读取失败。已短暂尝试复用浏览器验证状态,请手动完成一次真人验证。' + + (lastError && lastError.message ? ' 原因:' + lastError.message : '') + ); +} + async function fetchText(ctx, url, referer) { const cached = getCachedText(ctx, url); if (cached) return cached; @@ -637,16 +1168,17 @@ async function safeFetch(ctx, url, referer) { } function requestOptions(ctx, referer) { - const timeout = numberParam(ctx, 'requestTimeoutSeconds', 45); + 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: boolParam(ctx, 'enableBrowserFallback', true), - browserFallback: boolParam(ctx, 'enableBrowserFallback', true), - allowBrowserFallback: boolParam(ctx, 'enableBrowserFallback', true) + useBrowserFallback: browserFallback, + browserFallback: browserFallback, + allowBrowserFallback: browserFallback }; } @@ -654,9 +1186,19 @@ 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 客户端。'); } @@ -688,43 +1230,84 @@ function playbackHeaders(ctx, referer) { function responseText(response) { if (typeof response === 'string') return response; if (!response) return ''; + if (typeof response.text === 'string') return response.text; if (typeof response.data === 'string') return response.data; if (typeof response.body === 'string') return response.body; + if (response.data && typeof response.data.html === 'string') return response.data.html; + if (response.body && typeof response.body.html === 'string') return response.body.html; + if (typeof response.html === 'string') return response.html; return String(response.data || response.body || ''); } async function browserHTML(ctx, url, referer, forceVisible) { if (!boolParam(ctx, 'enableBrowserFallback', true)) return ''; + if (forceVisible !== true && !boolParam(ctx, 'automaticBrowserFallback', true)) return ''; + if (forceVisible !== true && browserRetryBlocked(ctx, url)) return ''; if (typeof Widget === 'undefined' || !Widget.browser || typeof Widget.browser.fetch !== 'function') return ''; try { - const timeout = numberParam(ctx, 'requestTimeoutSeconds', 45); + 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: forceVisible === true ? true : boolParam(ctx, 'browserVisible', false), + visible: visible, timeout: timeout, timeoutSeconds: timeout, - waitAfterLoad: 4, - waitForAny: true, - waitForMediaSource: true, + waitAfterLoad: visible ? 1.5 : 3, headers: { 'User-Agent': MISSAV_UA, Referer: referer || url } }); - return responseText(result.html ? { data: result.html } : result); + const html = responseText(result.html ? { data: result.html } : result); + if (isUsableHTML(html)) { + clearBrowserFailure(url); + return html; + } + rememberBrowserFailure(ctx, url); + return ''; } catch (error) { + rememberBrowserFailure(ctx, url); return ''; } } -async function extractFromBrowser(url, referer) { +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 (typeof Widget === 'undefined' || !Widget.browser || typeof Widget.browser.fetch !== 'function') return ''; try { const result = await Widget.browser.fetch(url, { visible: false, - timeout: 70, + timeout: playbackBrowserSeconds(ctx, 12, 1), + timeoutSeconds: remainingPlaybackSeconds(ctx, 12), waitAfterLoad: 3, waitForMediaSource: true, waitForAny: true, + captureRequests: true, + captureMedia: true, headers: { 'User-Agent': MISSAV_UA, Referer: referer || url @@ -783,8 +1366,8 @@ function candidatePathVariants(ctx, path) { function isKnownCategoryPath(path) { const clean = String(path || '').split(/[?#]/)[0].replace(/^\/(?:dm\d+\/)?cn/i, '') || '/'; - return MISSAV_SECTIONS.concat(MISSAV_PRIMARY_CATEGORIES).some(function (item) { - return clean === item.path || clean === item.path.split('?')[0]; + return MISSAV_SECTIONS.some(function (item) { + return categorySlug(clean) === categorySlug(item.path); }); } @@ -801,12 +1384,162 @@ 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 ''; @@ -821,7 +1554,9 @@ function getCachedText(ctx, url) { function setCachedText(ctx, url, text) { if (!isUsableHTML(text)) return; - const ttl = numberParam(ctx, 'cacheMinutes', 20) * 60 * 1000; + 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); @@ -870,22 +1605,42 @@ function numberParam(ctx, key, fallback) { function playableFromBrowserResult(result) { if (!result) return ''; + const candidates = []; const keys = ['url', 'mediaURL', 'mediaUrl', 'videoURL', 'videoUrl', 'playURL', 'playUrl', 'src']; for (let index = 0; index < keys.length; index += 1) { const value = result[keys[index]]; - if (isPlayableURL(value)) return value; + if (isPlayableURL(value)) candidates.push(value); } - const arrays = [result.mediaSources, result.mediaRequests, result.requests, result.responses, result.urls]; + const arrays = [ + result.mediaSources, + result.mediaRequests, + result.capturedRequests, + result.requests, + result.responses, + result.urls + ]; for (let a = 0; a < arrays.length; a += 1) { const array = arrays[a]; if (!Array.isArray(array)) continue; for (let i = 0; i < array.length; i += 1) { const item = array[i]; const value = typeof item === 'string' ? item : firstNonEmpty(item && item.url, item && item.src, item && item.responseURL); - if (isPlayableURL(value)) return value; + if (isPlayableURL(value)) candidates.push(value); } } - return ''; + return unique(candidates).sort(function (left, right) { + return playableCandidateScore(right) - playableCandidateScore(left); + })[0] || ''; +} + +function playableCandidateScore(url) { + const value = String(url || ''); + let score = 0; + if (/\.m3u8(?:[?#]|$)/i.test(value)) score += 20; + if (/(?:master|playlist|index)\.m3u8(?:[?#]|$)/i.test(value)) score += 40; + if (/\/\d{3,4}p(?:\/|[?#]|$)/i.test(value)) score -= 30; + if (/\.(?:ts|m2ts)(?:[?#]|$)/i.test(value)) score -= 100; + return score; } function parseHomeSections(ctx, html) { @@ -899,7 +1654,7 @@ function parseHomeSections(ctx, html) { const predefined = findSection(title); sections.push({ id: predefined ? predefined.id : 'missav-home-' + index, - title: predefined ? predefined.title : title, + title: title, style: predefined ? predefined.style : sectionStyle(title), moreAction: predefined ? categoryAction(ctx, predefined) : undefined, items: items @@ -966,11 +1721,20 @@ function verificationDetail(ctx, url, error) { return { pageType: 'detail', id: makeItemId(url, title, ''), - title: title, - type: 'movie', + title: '需要真人验证', + type: 'collection', + mediaType: 'collection', overview: cleanText(error && error.message) || '站点触发了 Cloudflare 真人验证。请返回列表页点击手动验证卡片,完成后刷新。', detailImageAspectRatio: '16:9', - resourceGroups: playbackGroups(url, title, '', ctx), + playable: false, + isPlayable: false, + resourceGroups: [], + mediaSources: [], + playbackSummary: { + versionCount: 0, + episodeCount: 0, + defaultVersionId: '' + }, recommendations: [ { id: 'verify', @@ -983,23 +1747,145 @@ function verificationDetail(ctx, url, error) { } function verificationCard(ctx, title, url, error) { + const targetURL = url || entryURL(ctx); + const pageId = verificationPageId(targetURL); return { - id: 'verify-cloudflare-' + encodeURIComponent(url || entryURL(ctx)).slice(0, 120), + id: pageId, title: title || '手动完成验证', subtitle: '点击后再打开验证界面,完成后返回刷新', overview: cleanText(error && error.message) || '当前网络触发了 Cloudflare 真人验证。', - type: 'collection', + type: 'category', + mediaType: 'category', + playable: false, + isPlayable: false, aspectRatio: '16:9', action: { - type: 'custom', - name: 'verifyCloudflare', - id: 'verifyCloudflare', + type: 'category', + id: pageId, + pageId: pageId, title: '手动完成验证', - payload: { url: url || entryURL(ctx) } + 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', @@ -1012,32 +1898,65 @@ function categoryAction(ctx, section) { function parseCategoryCards(ctx, html, fallbackTitle) { const source = categoryListBlock(html, fallbackTitle); - const items = []; - const seen = {}; - anchorsIn(source).forEach(function (anchor) { - const href = anchor.href; - const title = cleanText(anchor.text || anchor.title); - if (!title || seen[href + title] || isJunkCategoryTitle(title)) return; - if (!isCategoryHref(href)) return; + const records = {}; + const order = []; + const pattern = /<a\b([^>]*)>([\s\S]*?)<\/a>/gi; + let match; + while ((match = pattern.exec(source))) { + const href = attr(match[1], 'href'); + if (!isCategoryHref(href)) continue; const url = absoluteURL(ctx, href); - seen[href + title] = true; - items.push({ - id: 'category-link-' + items.length + '-' + encodeURIComponent(title), - title: title, + const rawTitle = cleanText(firstNonEmpty( + attr(match[1], 'title'), + attr(match[2], 'alt'), + match[2] + )); + const title = categoryCardTitle(rawTitle); + const image = absoluteURL(ctx, pickImage(match[0])); + if (!records[url]) { + records[url] = { url: url, title: '', image: '' }; + order.push(url); + } + if (title && !isJunkCategoryTitle(title)) records[url].title = records[url].title || title; + if (image) records[url].image = records[url].image || image; + } + + return order.map(function (url, index) { + const record = records[url]; + if (!record || !record.title) return null; + const item = { + id: 'category-link-' + index + '-' + encodeURIComponent(record.title), + title: record.title, subtitle: fallbackTitle || '分类', type: 'category', - aspectRatio: '16:9', + mediaType: 'category', + playable: false, + isPlayable: false, + aspectRatio: record.image ? '2:3' : '16:9', action: { type: 'category', - id: url, - pageId: url, - title: title, - url: url, + id: record.url, + pageId: record.url, + title: record.title, + url: record.url, itemAspectRatio: '16:9' } - }); - }); - return items.slice(0, 80); + }; + if (record.image) { + item.poster = record.image; + item.thumbnailURL = record.image; + item.imageHeaders = imageHeaders(ctx, record.url); + } + return item; + }).filter(Boolean).slice(0, 120); +} + +function categoryCardTitle(value) { + return cleanText(value) + .replace(/\s+\d+\s*(?:条|部)影片[\s\S]*$/i, '') + .replace(/\s+\d{4}\s*出道[\s\S]*$/i, '') + .replace(/\s+第\s*\d+\s*名[\s\S]*$/i, '') + .trim(); } function categoryListBlock(html, title) { @@ -1045,9 +1964,8 @@ function categoryListBlock(html, title) { const heading = cleanText(title || ''); const index = heading ? source.search(new RegExp('<h1[^>]*>\\s*' + escapeRegExp(heading), 'i')) : -1; if (index < 0) return source; - const nextScript = source.indexOf('<script', index); const nextFooter = source.indexOf('<footer', index); - const endCandidates = [nextScript, nextFooter].filter(function (value) { return value > index; }).sort(function (a, b) { return a - b; }); + const endCandidates = [nextFooter].filter(function (value) { return value > index; }); return source.slice(index, endCandidates[0] || undefined); } @@ -1083,6 +2001,25 @@ function parseCards(html, fallbackTitle, ctx) { return items; } +function mediaListBlock(html) { + const source = String(html || ''); + if (!source) return source; + const anchorPattern = /<a\b([^>]*)>([\s\S]*?)<\/a>/gi; + let match; + let firstMediaIndex = -1; + while ((match = anchorPattern.exec(source))) { + if (!isDetailHref(attr(match[1], 'href'))) continue; + firstMediaIndex = match.index; + break; + } + if (firstMediaIndex < 0) return source; + const headingIndex = source.lastIndexOf('<h1', firstMediaIndex); + const mainIndex = source.lastIndexOf('<main', firstMediaIndex); + const start = Math.max(headingIndex, mainIndex, 0); + const footerIndex = source.indexOf('<footer', firstMediaIndex); + return source.slice(start, footerIndex > firstMediaIndex ? footerIndex : undefined); +} + function mediaBlocks(html) { const source = String(html || ''); const blocks = []; @@ -1114,7 +2051,9 @@ function expandBlock(source, start, end, fallback) { .sort(function (a, b) { return a - b; })[0]; const realEnd = close || (nextDetail >= 0 ? end + nextDetail : Math.min(source.length, end + 1800)); const block = source.slice(realStart, realEnd); - return isDetailHref(block) ? block : fallback; + return anchorsIn(block).some(function (anchor) { + return isDetailHref(anchor.href); + }) ? block : fallback; } function parseCard(block, fallbackTitle, rank, ctx) { @@ -1188,42 +2127,241 @@ function toWideItem(item) { return item; } -function playbackGroups(detailURL, title, playUrl, ctx) { - const id = encodePayload({ url: detailURL, title: title, playUrl: playUrl || '' }); +async function playbackGroups(detailURL, title, playUrl, ctx, html) { + if (!isPlayableURL(playUrl)) return []; const headers = playbackHeaders(ctx || {}, detailURL); + const qualities = await discoverAvailableQualities(ctx || {}, playUrl, detailURL, html); + if (!qualities.length && containerOf(playUrl) === 'm3u8' && !qualityHeightFromURL(playUrl)) return []; + const sourceQualities = qualities.length ? qualities : [ + { + height: qualityHeightFromURL(playUrl), + label: qualityHeightFromURL(playUrl) ? qualityHeightFromURL(playUrl) + 'P' : '自动画质', + url: playUrl + } + ]; + const versions = sourceQualities.map(function (quality, index) { + const id = encodePayload({ + url: detailURL, + title: title, + quality: quality.height || 'auto' + }); + return { + id: id, + title: quality.label, + name: quality.label, + subtitle: index === 0 ? '最高可用画质' : '切换时请稍候', + url: quality.url, + playUrl: quality.url, + path: quality.url, + default: index === 0, + availability: 'playable', + container: containerOf(quality.url), + headers: headers, + header: headers, + Header: headers, + customHeaders: headers, + sourceName: 'MissAV', + action: { + type: 'play', + itemId: id, + versionId: id, + title: title || 'MissAV' + } + }; + }); return [ { id: 'missav-online', title: '在线播放', - versions: [ - { - id: id, - title: 'MissAV HLS', - name: '默认线路', - subtitle: 'MissAV', - url: playUrl || detailURL, - playUrl: playUrl || '', - path: playUrl || detailURL, - default: true, - availability: 'playable', - container: 'm3u8', - headers: headers, - header: headers, - Header: headers, - customHeaders: headers, - sourceName: 'MissAV', - action: { - type: 'play', - itemId: id, - versionId: id, - title: title || 'MissAV' - } - } - ] + versions: versions } ]; } +function resourceVersionCount(groups) { + return (groups || []).reduce(function (total, group) { + return total + (Array.isArray(group && group.versions) ? group.versions.length : 0); + }, 0); +} + +async function discoverAvailableQualities(ctx, playlistURL, referer, html) { + const candidates = extractHTMLQualities(html, ctx); + if (isPlayableURL(playlistURL)) { + const height = qualityHeightFromURL(playlistURL); + candidates.push({ + height: height, + label: height ? height + 'P' : '自动画质', + url: playlistURL + }); + } + + const urls = unique(candidates.map(function (item) { return item.url; })).slice(0, 8); + 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 response = await httpGet(playlistURL, { + headers: playbackHeaders(ctx || {}, referer || playlistURL), + timeout: remainingPlaybackSeconds(ctx, 5), + timeoutSeconds: remainingPlaybackSeconds(ctx, 5), + useBrowserCookie: true, + attachBrowserCookie: true + }); + const playlist = responseText(response); + if (playlist.indexOf('#EXT-X-STREAM-INF') < 0) return []; + return parseMasterQualities(playlist, playlistURL); + } catch (error) { + return []; + } +} + +function parseMasterQualities(playlist, playlistURL) { + const lines = String(playlist || '').split(/\r?\n/); + const qualities = []; + const seen = {}; + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index].trim(); + if (line.indexOf('#EXT-X-STREAM-INF:') !== 0) continue; + const resolution = line.match(/\bRESOLUTION=(\d+)x(\d+)/i); + const bandwidth = Number(firstMatch(line, /\b(?:AVERAGE-)?BANDWIDTH=(\d+)/i)) || 0; + let uri = ''; + for (let next = index + 1; next < lines.length; next += 1) { + const candidate = lines[next].trim(); + if (!candidate) continue; + if (candidate[0] === '#') break; + uri = candidate; + break; + } + if (!uri) continue; + const height = resolution ? Number(resolution[2]) : qualityHeightFromURL(uri); + const url = resolveRelativeURL(playlistURL, uri); + const key = String(height || 0) + '|' + url; + if (!url || seen[key]) continue; + seen[key] = true; + qualities.push({ + height: height, + width: resolution ? Number(resolution[1]) : 0, + bandwidth: bandwidth, + label: height ? height + 'P' : '自动画质', + url: url + }); + } + return qualities.sort(function (a, b) { + return (b.height || 0) - (a.height || 0) || (b.bandwidth || 0) - (a.bandwidth || 0); + }); +} + +function selectQuality(qualities, requestedQuality) { + const list = Array.isArray(qualities) ? qualities : []; + if (!list.length) return null; + const requested = Number(requestedQuality); + if (requested > 0) { + const exact = list.find(function (item) { return Number(item.height) === requested; }); + if (exact) return exact; + } + return list[0]; +} + +function qualityHeightFromURL(url) { + const match = String(url || '').match(/(?:^|[\/_.-])(\d{3,4})p(?:[\/_.?#-]|$)/i); + return match ? Number(match[1]) : 0; +} + +function resolveRelativeURL(base, value) { + const target = decodeEscapes(String(value || '').trim()); + if (!target) return ''; + if (/^https?:\/\//i.test(target)) return target; + if (target.indexOf('//') === 0) return 'https:' + target; + const origin = originOf(base); + if (target[0] === '/') return origin + target; + const directory = String(base || '').replace(/[?#].*$/, '').replace(/\/[^/]*$/, '/'); + const combined = directory + target; + const prefix = firstMatch(combined, /^(https?:\/\/[^/]+)/i); + const path = combined.slice(prefix.length).split('/'); + const normalized = []; + path.forEach(function (part) { + if (!part || part === '.') return; + if (part === '..') normalized.pop(); + else normalized.push(part); + }); + return prefix + '/' + normalized.join('/'); +} + function playbackResult(ctx, url, referer) { const link = absoluteURL(ctx, url); const container = containerOf(link); @@ -1254,9 +2392,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), @@ -1336,6 +2474,16 @@ function playUrlFromContext(ctx) { ); } +function qualityFromContext(ctx) { + const payload = decodePayload(ctx && (ctx.versionId || ctx.sourceId || ctx.id || ctx.itemId)); + return firstNonEmpty( + payload && payload.quality, + ctx && ctx.quality, + ctx && ctx.height, + ctx && ctx.resolution + ); +} + function makeItemId(url, title, poster) { return encodePayload({ url: absoluteURL(null, url), title: title || '', poster: poster || '' }); } @@ -1358,6 +2506,29 @@ function encodePayload(data) { return MISSAV_DETAIL_PAYLOAD_PREFIX + parts.join('&'); } +function verificationPageId(url) { + return MISSAV_VERIFY_PAYLOAD_PREFIX + 'url=' + encodeURIComponent(String(url || MISSAV_DEFAULT_BASE)); +} + +function verificationUrlFromPageId(value) { + const text = stringValue(value); + if (text.indexOf(MISSAV_VERIFY_PAYLOAD_PREFIX) !== 0) return ''; + const parts = text.slice(MISSAV_VERIFY_PAYLOAD_PREFIX.length).split('&'); + for (let index = 0; index < parts.length; index += 1) { + const part = parts[index]; + const separator = part.indexOf('='); + const key = separator >= 0 ? part.slice(0, separator) : part; + if (key !== 'url') continue; + const raw = separator >= 0 ? part.slice(separator + 1) : ''; + try { + return decodeURIComponent(raw); + } catch (error) { + return raw; + } + } + return ''; +} + function decodePayload(value) { const text = stringValue(value); if (text.indexOf(MISSAV_DETAIL_PAYLOAD_PREFIX) !== 0) return null; @@ -1377,6 +2548,7 @@ function decodePayload(value) { function normalizePageId(ctx, value) { const id = stringValue(value) || 'new-release'; + if (id.indexOf(MISSAV_VERIFY_PAYLOAD_PREFIX) === 0) return id; const section = findSection(id); if (section) return section.id; const primary = findPrimaryCategory(id); @@ -1400,7 +2572,7 @@ function findSection(id) { function findPrimaryCategory(id) { const value = stringValue(id); - return MISSAV_PRIMARY_CATEGORIES.find(function (item) { + return MISSAV_SECTIONS.find(function (item) { return item.id === value || item.title === value || item.path === value || @@ -1411,7 +2583,26 @@ function findPrimaryCategory(id) { function isDetailHref(href) { const value = String(href || ''); - return /\/(?:dm\d+\/)?cn\/(?!new(?:[/?#]|$)|search(?:[/?#]|$)|genres?(?:[/?#]|$)|makers?(?:[/?#]|$)|actresses?(?:[/?#]|$)|actors?(?:[/?#]|$)|today-hot|weekly-hot|monthly-hot|uncensored-leak|chinese-subtitle|fc2(?:[/?#]|$)|heyzo(?:[/?#]|$)|tokyo-hot(?:[/?#]|$)|1pondo(?:[/?#]|$)|caribbeancom(?:[/?#]|$)|caribbeancompr(?:[/?#]|$)|10musume(?:[/?#]|$)|pacopacomama(?:[/?#]|$)|gachinco(?:[/?#]|$)|xxx-av(?:[/?#]|$)|siro(?:[/?#]|$)|luxu(?:[/?#]|$)|gana(?:[/?#]|$)|prestige-premium(?:[/?#]|$)|s-cute(?:[/?#]|$)|ara(?:[/?#]|$)|madou(?:[/?#]|$)|twav(?:[/?#]|$)|vr(?:[/?#]|$)|history|playlist|favorite|login|register)[^"'#?\/]+/i.test(value); + if (findSection(value)) return false; + if (isCategoryHref(value)) return false; + const path = value + .replace(/^https?:\/\/[^/]+/i, '') + .split(/[?#]/)[0] + .replace(/^\/+|\/+$/g, ''); + const parts = path.split('/').filter(Boolean); + if (/^dm\d+$/i.test(parts[0] || '')) parts.shift(); + if (/^(?:cn|en|ja|ko|ms|th|de|fr|vi|id)$/i.test(parts[0] || '')) parts.shift(); + if (parts.length !== 1) return false; + const slug = parts[0] || ''; + if (/^(?:new|release|random|search|history|playlists?|saved|favorite|login|register|vip)$/i.test(slug)) return false; + // Most MissAV codes use a hyphen (for example abc-123), while the + // 1pondo/Caribbeancompr/10musume/pacopacomama feeds use date-based + // underscore IDs such as pondo-072526_001 and 051726_001. + const standardCode = /^[a-z0-9]+(?:[-_][a-z0-9]+)*$/i.test(slug) && + /[a-z]/i.test(slug) && + /\d/.test(slug); + const dateOnlyCode = /^\d{6}_\d{2,3}$/i.test(slug); + return standardCode || dateOnlyCode; } function categorySlug(value) { @@ -1427,6 +2618,21 @@ function hasNextPage(html, page) { return total ? page < total : false; } +function paginationInfo(html, page, itemCount) { + const source = String(html || ''); + let totalPages = 0; + const pagePattern = /[?&]page=(\d+)/gi; + let match; + while ((match = pagePattern.exec(source))) { + totalPages = Math.max(totalPages, Number(match[1]) || 0); + } + const hasMore = hasNextPage(source, page) || (!totalPages && Number(itemCount) >= 12); + return { + hasMore: hasMore, + totalPages: totalPages || undefined + }; +} + function sectionBlock(html, title) { const source = String(html || ''); const index = source.search(new RegExp('<h[12][^>]*>\\s*' + escapeRegExp(title), 'i')); @@ -1857,3 +3063,38 @@ 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 }; + ctx.__playbackBudget = budget; + let timer; + try { + const timeout = new Promise(function (_, reject) { + timer = setTimeout(function () { budget.expired = true; reject(new Error('播放解析超时;stage=total-deadline;请稍后重试')); }, 28000); + }); + 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/missav-mini-library-download-working 6.js b/missav-mini-library-download-working 6.js index 5eee709..3324627 100644 --- a/missav-mini-library-download-working 6.js +++ b/missav-mini-library-download-working 6.js @@ -12,7 +12,7 @@ const WidgetMetadata = { id: 'missav-mini-library', name: 'MissAV', title: 'MissAV', - version: '1.5.9', + version: '1.5.10', author: 'Alan huang', logo: MISSAV_LOGO, icon: MISSAV_LOGO, @@ -748,11 +748,12 @@ async function getResourceVersions(ctx) { } } -async function resolvePlayback(ctx) { +async function resolvePlaybackWithinBudget(ctx) { ctx = normalizeContext(ctx); const requestedQuality = qualityFromContext(ctx); const direct = firstNonEmpty(playUrlFromContext(ctx), ctx && ctx.url, ctx && ctx.playUrl, ctx && ctx.videoUrl); - if (!requestedQuality && isPlayableURL(direct) && !isDetailPageURL(direct)) { + const stableDetailURL = detailUrlFromContext(ctx); + if (!isDetailPageURL(stableDetailURL) && !requestedQuality && isPlayableURL(direct) && !isDetailPageURL(direct)) { return playbackResult(ctx, direct, ctx && ctx.referer); } @@ -764,16 +765,16 @@ async function resolvePlayback(ctx) { } catch (error) { // Playback has a separate, narrowly-scoped hidden browser media fallback. } - const masterURL = firstNonEmpty( - extractPlayableURL(html), - await extractPlayableFromLinkedPlayers(ctx, html, detailURL), - await extractFromBrowser(detailURL, detailURL) - ); + let masterURL = extractPlayableURL(html); + if (!masterURL) masterURL = await extractPlayableFromLinkedPlayers(ctx, html, detailURL); + if (!masterURL) masterURL = await extractFromBrowser(detailURL, detailURL, ctx); let url = masterURL; if (masterURL) { let qualities = await discoverAvailableQualities(ctx, masterURL, detailURL, html); - if (qualities.length <= 1) { - const browserMasterURL = await extractFromBrowser(detailURL, detailURL); + const knownQuality = qualities.some(function (quality) { return quality.height > 0; }); + const missingRequested = requestedQuality && !qualities.some(function (quality) { return String(quality.height) === String(requestedQuality); }); + if ((!knownQuality || missingRequested) && ctx.__playbackBudget.browserCalls === 0) { + const browserMasterURL = await extractFromBrowser(detailURL, detailURL, ctx); if (browserMasterURL && browserMasterURL !== masterURL) { const browserQualities = await discoverAvailableQualities(ctx, browserMasterURL, detailURL, html); if (browserQualities.length > qualities.length) qualities = browserQualities; @@ -1167,16 +1168,17 @@ async function safeFetch(ctx, url, referer) { } function requestOptions(ctx, referer) { - const timeout = numberParam(ctx, 'requestTimeoutSeconds', 45); + 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: boolParam(ctx, 'enableBrowserFallback', true), - browserFallback: boolParam(ctx, 'enableBrowserFallback', true), - allowBrowserFallback: boolParam(ctx, 'enableBrowserFallback', true) + useBrowserFallback: browserFallback, + browserFallback: browserFallback, + allowBrowserFallback: browserFallback }; } @@ -1245,7 +1247,8 @@ async function browserHTML(ctx, url, referer, forceVisible) { try { const visible = forceVisible === true; const configuredTimeout = numberParam(ctx, 'requestTimeoutSeconds', 45); - const timeout = visible ? Math.min(configuredTimeout, 20) : configuredTimeout; + 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, @@ -1292,12 +1295,14 @@ function clearBrowserFailure(url) { cacheSet(browserFailureKey(url), { retryAfter: 0 }); } -async function extractFromBrowser(url, referer) { +async function extractFromBrowser(url, referer, ctx) { + if (ctx && ctx.__playbackBudget && ctx.__playbackBudget.browserCalls >= 1) return ''; if (typeof Widget === 'undefined' || !Widget.browser || typeof Widget.browser.fetch !== 'function') return ''; try { const result = await Widget.browser.fetch(url, { visible: false, - timeout: 70, + timeout: playbackBrowserSeconds(ctx, 12, 1), + timeoutSeconds: remainingPlaybackSeconds(ctx, 12), waitAfterLoad: 3, waitForMediaSource: true, waitForAny: true, @@ -2191,10 +2196,14 @@ async function discoverAvailableQualities(ctx, playlistURL, referer, html) { } const urls = unique(candidates.map(function (item) { return item.url; })).slice(0, 8); - for (let index = 0; index < urls.length; index += 1) { - const variants = await discoverQualities(ctx, urls[index], referer); - variants.forEach(function (quality) { candidates.push(quality); }); - } + 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); } @@ -2268,8 +2277,8 @@ async function discoverQualities(ctx, playlistURL, referer) { try { const response = await httpGet(playlistURL, { headers: playbackHeaders(ctx || {}, referer || playlistURL), - timeout: numberParam(ctx, 'requestTimeoutSeconds', 45), - timeoutSeconds: numberParam(ctx, 'requestTimeoutSeconds', 45), + timeout: remainingPlaybackSeconds(ctx, 5), + timeoutSeconds: remainingPlaybackSeconds(ctx, 5), useBrowserCookie: true, attachBrowserCookie: true }); @@ -3054,3 +3063,38 @@ 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 }; + ctx.__playbackBudget = budget; + let timer; + try { + const timeout = new Promise(function (_, reject) { + timer = setTimeout(function () { budget.expired = true; reject(new Error('播放解析超时;stage=total-deadline;请稍后重试')); }, 28000); + }); + 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/missav-mini-library-download-working.js b/missav-mini-library-download-working.js index 5eee709..3324627 100644 --- a/missav-mini-library-download-working.js +++ b/missav-mini-library-download-working.js @@ -12,7 +12,7 @@ const WidgetMetadata = { id: 'missav-mini-library', name: 'MissAV', title: 'MissAV', - version: '1.5.9', + version: '1.5.10', author: 'Alan huang', logo: MISSAV_LOGO, icon: MISSAV_LOGO, @@ -748,11 +748,12 @@ async function getResourceVersions(ctx) { } } -async function resolvePlayback(ctx) { +async function resolvePlaybackWithinBudget(ctx) { ctx = normalizeContext(ctx); const requestedQuality = qualityFromContext(ctx); const direct = firstNonEmpty(playUrlFromContext(ctx), ctx && ctx.url, ctx && ctx.playUrl, ctx && ctx.videoUrl); - if (!requestedQuality && isPlayableURL(direct) && !isDetailPageURL(direct)) { + const stableDetailURL = detailUrlFromContext(ctx); + if (!isDetailPageURL(stableDetailURL) && !requestedQuality && isPlayableURL(direct) && !isDetailPageURL(direct)) { return playbackResult(ctx, direct, ctx && ctx.referer); } @@ -764,16 +765,16 @@ async function resolvePlayback(ctx) { } catch (error) { // Playback has a separate, narrowly-scoped hidden browser media fallback. } - const masterURL = firstNonEmpty( - extractPlayableURL(html), - await extractPlayableFromLinkedPlayers(ctx, html, detailURL), - await extractFromBrowser(detailURL, detailURL) - ); + let masterURL = extractPlayableURL(html); + if (!masterURL) masterURL = await extractPlayableFromLinkedPlayers(ctx, html, detailURL); + if (!masterURL) masterURL = await extractFromBrowser(detailURL, detailURL, ctx); let url = masterURL; if (masterURL) { let qualities = await discoverAvailableQualities(ctx, masterURL, detailURL, html); - if (qualities.length <= 1) { - const browserMasterURL = await extractFromBrowser(detailURL, detailURL); + const knownQuality = qualities.some(function (quality) { return quality.height > 0; }); + const missingRequested = requestedQuality && !qualities.some(function (quality) { return String(quality.height) === String(requestedQuality); }); + if ((!knownQuality || missingRequested) && ctx.__playbackBudget.browserCalls === 0) { + const browserMasterURL = await extractFromBrowser(detailURL, detailURL, ctx); if (browserMasterURL && browserMasterURL !== masterURL) { const browserQualities = await discoverAvailableQualities(ctx, browserMasterURL, detailURL, html); if (browserQualities.length > qualities.length) qualities = browserQualities; @@ -1167,16 +1168,17 @@ async function safeFetch(ctx, url, referer) { } function requestOptions(ctx, referer) { - const timeout = numberParam(ctx, 'requestTimeoutSeconds', 45); + 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: boolParam(ctx, 'enableBrowserFallback', true), - browserFallback: boolParam(ctx, 'enableBrowserFallback', true), - allowBrowserFallback: boolParam(ctx, 'enableBrowserFallback', true) + useBrowserFallback: browserFallback, + browserFallback: browserFallback, + allowBrowserFallback: browserFallback }; } @@ -1245,7 +1247,8 @@ async function browserHTML(ctx, url, referer, forceVisible) { try { const visible = forceVisible === true; const configuredTimeout = numberParam(ctx, 'requestTimeoutSeconds', 45); - const timeout = visible ? Math.min(configuredTimeout, 20) : configuredTimeout; + 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, @@ -1292,12 +1295,14 @@ function clearBrowserFailure(url) { cacheSet(browserFailureKey(url), { retryAfter: 0 }); } -async function extractFromBrowser(url, referer) { +async function extractFromBrowser(url, referer, ctx) { + if (ctx && ctx.__playbackBudget && ctx.__playbackBudget.browserCalls >= 1) return ''; if (typeof Widget === 'undefined' || !Widget.browser || typeof Widget.browser.fetch !== 'function') return ''; try { const result = await Widget.browser.fetch(url, { visible: false, - timeout: 70, + timeout: playbackBrowserSeconds(ctx, 12, 1), + timeoutSeconds: remainingPlaybackSeconds(ctx, 12), waitAfterLoad: 3, waitForMediaSource: true, waitForAny: true, @@ -2191,10 +2196,14 @@ async function discoverAvailableQualities(ctx, playlistURL, referer, html) { } const urls = unique(candidates.map(function (item) { return item.url; })).slice(0, 8); - for (let index = 0; index < urls.length; index += 1) { - const variants = await discoverQualities(ctx, urls[index], referer); - variants.forEach(function (quality) { candidates.push(quality); }); - } + 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); } @@ -2268,8 +2277,8 @@ async function discoverQualities(ctx, playlistURL, referer) { try { const response = await httpGet(playlistURL, { headers: playbackHeaders(ctx || {}, referer || playlistURL), - timeout: numberParam(ctx, 'requestTimeoutSeconds', 45), - timeoutSeconds: numberParam(ctx, 'requestTimeoutSeconds', 45), + timeout: remainingPlaybackSeconds(ctx, 5), + timeoutSeconds: remainingPlaybackSeconds(ctx, 5), useBrowserCookie: true, attachBrowserCookie: true }); @@ -3054,3 +3063,38 @@ 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 }; + ctx.__playbackBudget = budget; + let timer; + try { + const timeout = new Promise(function (_, reject) { + timer = setTimeout(function () { budget.expired = true; reject(new Error('播放解析超时;stage=total-deadline;请稍后重试')); }, 28000); + }); + 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/missav-mini-library.js b/missav-mini-library.js index 44025a6..3324627 100644 --- a/missav-mini-library.js +++ b/missav-mini-library.js @@ -4,6 +4,7 @@ const MISSAV_DEFAULT_BASE = 'https://missav.ws'; const MISSAV_DEFAULT_ENTRY = '/dm247/cn'; const MISSAV_LOGO = 'https://missav.ws/favicon.ico'; const MISSAV_DETAIL_PAYLOAD_PREFIX = 'missav://detail?'; +const MISSAV_VERIFY_PAYLOAD_PREFIX = 'missav://verify?'; const MISSAV_UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1'; @@ -11,8 +12,8 @@ const WidgetMetadata = { id: 'missav-mini-library', name: 'MissAV', title: 'MissAV', - version: '1.0.7', - author: 'alanhuang', + version: '1.5.10', + author: 'Alan huang', logo: MISSAV_LOGO, icon: MISSAV_LOGO, site: MISSAV_DEFAULT_BASE, @@ -20,43 +21,72 @@ const WidgetMetadata = { }; const MISSAV_SECTIONS = [ - { id: 'new-release', title: '新作上市', path: '/new', style: 'discover.spotlight' }, - { id: 'latest', title: '最近更新', path: '/new?sort=published_at', style: 'discover.ranked' }, - { id: 'chinese-subtitle', title: '中文字幕', path: '/chinese-subtitle', style: 'discover.posterCompact' }, - { id: 'uncensored-leak', title: '无码流出', aliases: ['无码影片'], path: '/uncensored-leak', style: 'discover.posterCompact' }, - { id: 'today-hot', title: '今日热门', path: '/today-hot', style: 'discover.ranked' }, - { id: 'weekly-hot', title: '本周热门', path: '/weekly-hot', style: 'discover.ranked' }, - { id: 'monthly-hot', title: '本月热门', path: '/monthly-hot', style: 'discover.ranked' }, - { id: 'fc2', title: 'FC2', path: '/fc2', style: 'discover.posterCompact' }, - { id: 'heyzo', title: 'HEYZO', path: '/heyzo', style: 'discover.posterCompact' }, - { id: 'tokyo-hot', title: '东京热', path: '/tokyo-hot', style: 'discover.posterCompact' }, - { id: '1pondo', title: '一本道', path: '/1pondo', style: 'discover.posterCompact' }, - { id: 'caribbeancom', title: 'Caribbeancom', path: '/caribbeancom', style: 'discover.posterCompact' }, - { id: 'caribbeancompr', title: 'Caribbeancompr', path: '/caribbeancompr', style: 'discover.posterCompact' }, - { id: '10musume', title: '10musume', path: '/10musume', style: 'discover.posterCompact' }, - { id: 'pacopacomama', title: 'pacopacomama', path: '/pacopacomama', style: 'discover.posterCompact' }, - { id: 'gachinco', title: 'Gachinco', path: '/gachinco', style: 'discover.posterCompact' }, - { id: 'xxx-av', title: 'XXX-AV', path: '/xxx-av', style: 'discover.posterCompact' }, - { id: 'siro', title: 'SIRO', path: '/siro', style: 'discover.posterCompact' }, - { id: 'luxu', title: 'LUXU', path: '/luxu', style: 'discover.posterCompact' }, - { id: 'gana', title: 'GANA', path: '/gana', style: 'discover.posterCompact' }, - { id: 'prestige-premium', title: 'PRESTIGE PREMIUM', path: '/prestige-premium', style: 'discover.posterCompact' }, - { id: 's-cute', title: 'S-CUTE', path: '/s-cute', style: 'discover.posterCompact' }, - { id: 'ara', title: 'ARA', path: '/ara', style: 'discover.posterCompact' }, - { id: 'madou', title: '麻豆传媒', path: '/madou', style: 'discover.posterCompact' }, - { id: 'twav', title: 'TWAV', path: '/twav', style: 'discover.posterCompact' }, - { id: 'vr', title: 'VR', path: '/vr', style: 'discover.posterCompact' } + { id: 'recommended', title: '推荐给你', path: '/', style: 'discover.spotlight', group: 'japanese', personalizedHome: true, randomCategory: true }, + { id: 'nakadashi', title: '中出', path: '/cn/search/%E4%B8%AD%E5%87%BA', style: 'discover.posterCompact', group: 'japanese', eagerPagination: true }, + { id: 'big-breasts', title: '巨乳', path: '/cn/search/%E5%B7%A8%E4%B9%B3', style: 'discover.posterCompact', group: 'japanese', eagerPagination: true }, + { id: 'married-women', title: '人妻', path: '/cn/search/%E4%BA%BA%E5%A6%BB', style: 'discover.posterCompact', group: 'japanese', eagerPagination: true }, + { id: 'chinese-subtitle', title: '中文字幕', path: '/dm278/cn/chinese-subtitle', style: 'discover.posterCompact', group: 'japanese' }, + { id: 'latest', title: '最近更新', path: '/dm539/cn/new', style: 'discover.ranked', group: 'japanese', eagerPagination: true }, + { id: 'new-release', title: '新作上市', path: '/dm635/cn/release', style: 'discover.spotlight', group: 'japanese', eagerPagination: true }, + { id: 'random', title: '随机', aliases: ['好手气'], path: '/random', style: 'discover.posterCompact', group: 'japanese', randomCategory: true }, + { id: 'uncensored-leak', title: '无码流出', aliases: ['无码影片'], path: '/dm817/cn/uncensored-leak', style: 'discover.posterCompact', group: 'uncensored' }, + { id: 'actresses', title: '女优一览', path: '/cn/actresses', style: 'discover.posterCompact', group: 'japanese', categoryIndex: true }, + { id: 'actress-ranking', title: '女优排行', path: '/cn/actresses/ranking', style: 'discover.ranked', group: 'japanese', categoryIndex: true }, + { id: 'genres', title: '类型', path: '/cn/genres', style: 'discover.posterCompact', group: 'japanese', categoryIndex: true }, + { id: 'makers', title: '发行商', path: '/cn/makers', style: 'discover.posterCompact', group: 'japanese', categoryIndex: true }, + { id: 'vr', title: 'VR', path: '/cn/genres/VR', style: 'discover.posterCompact', group: 'japanese' }, + { id: 'today-hot', title: '今日热门', path: '/dm301/cn/today-hot', style: 'discover.ranked', group: 'japanese', eagerPagination: true }, + { id: 'weekly-hot', title: '本周热门', aliases: ['本週热门'], path: '/dm170/cn/weekly-hot', style: 'discover.ranked', group: 'japanese', eagerPagination: true }, + { id: 'monthly-hot', title: '本月热门', path: '/dm273/cn/monthly-hot', style: 'discover.ranked', group: 'japanese', eagerPagination: true }, + + { id: 'siro', title: 'SIRO', path: '/dm36/cn/siro', style: 'discover.posterCompact', group: 'amateur' }, + { id: 'luxu', title: 'LUXU', path: '/dm34/cn/luxu', style: 'discover.posterCompact', group: 'amateur' }, + { id: 'gana', title: 'GANA', path: '/dm34/cn/gana', style: 'discover.posterCompact', group: 'amateur' }, + { id: 'prestige-premium', title: 'PRESTIGE PREMIUM', aliases: ['MAAN'], path: '/dm1004/cn/maan', style: 'discover.posterCompact', group: 'amateur' }, + { id: 's-cute', title: 'S-CUTE', aliases: ['SCUTE'], path: '/dm38/cn/scute', style: 'discover.posterCompact', group: 'amateur' }, + { id: 'ara', title: 'ARA', path: '/dm34/cn/ara', style: 'discover.posterCompact', group: 'amateur' }, + + { id: 'fc2', title: 'FC2', path: '/dm597/cn/fc2', style: 'discover.posterCompact', group: 'uncensored' }, + { id: 'heyzo', title: 'HEYZO', path: '/dm2208642/cn/heyzo', style: 'discover.posterCompact', group: 'uncensored' }, + { id: 'tokyo-hot', title: '东京热', aliases: ['TOKYOHOT'], path: '/dm42/cn/tokyohot', style: 'discover.posterCompact', group: 'uncensored' }, + { id: '1pondo', title: '一本道', path: '/dm5199603/cn/1pondo', style: 'discover.posterCompact', group: 'uncensored', artworkCodePrefixes: ['pondo-'], detailSlugPattern: '^pondo-\\d{6}_\\d{3}$' }, + { id: 'caribbeancom', title: 'Caribbeancom', path: '/dm7704788/cn/caribbeancom', style: 'discover.posterCompact', group: 'uncensored' }, + { id: 'caribbeancompr', title: 'Caribbeancompr', path: '/dm91887/cn/caribbeancompr', style: 'discover.posterCompact', group: 'uncensored', detailSlugPattern: '^\\d{6}_\\d{3}$' }, + { id: '10musume', title: '10musume', path: '/dm7208981/cn/10musume', style: 'discover.posterCompact', group: 'uncensored', artworkCodePrefixes: ['musume-'], detailSlugPattern: '^musume-\\d{6}_\\d{2}$' }, + { id: 'pacopacomama', title: 'pacopacomama', path: '/dm3600557/cn/pacopacomama', style: 'discover.posterCompact', group: 'uncensored', artworkCodePrefixes: ['pacopacomama-'], detailSlugPattern: '^pacopacomama-\\d{6}_\\d{3}$' }, + { id: 'gachinco', title: 'Gachinco', path: '/dm150/cn/gachinco', style: 'discover.posterCompact', group: 'uncensored', artworkCodePrefixes: ['gachi'] }, + { id: 'xxx-av', title: 'XXX-AV', aliases: ['XXXAV'], path: '/dm42/cn/xxxav', style: 'discover.posterCompact', group: 'uncensored' }, + { id: 'marriedslash', title: '人妻斩', path: '/dm37/cn/marriedslash', style: 'discover.posterCompact', group: 'uncensored', artworkCodePrefixes: ['c0930-'] }, + { id: 'naughty4610', title: '顽皮 4610', path: '/dm33/cn/naughty4610', style: 'discover.posterCompact', group: 'uncensored', artworkCodePrefixes: ['h4610-'] }, + { id: 'naughty0930', title: '顽皮 0930', path: '/dm37/cn/naughty0930', style: 'discover.posterCompact', group: 'uncensored', artworkCodePrefixes: ['h0930-'] }, + + { id: 'madou', title: '麻豆传媒', path: '/dm63/cn/madou', style: 'discover.posterCompact', group: 'asian' }, + { id: 'twav', title: 'TWAV', path: '/dm31/cn/twav', style: 'discover.posterCompact', group: 'asian' }, + { id: 'furuke', title: 'Furuke', path: '/dm15/cn/furuke', style: 'discover.posterCompact', group: 'asian' }, + { id: 'klive', title: '韩国直播', path: '/cn/klive', style: 'discover.posterCompact', group: 'asian' }, + { id: 'clive', title: '中国直播', path: '/cn/clive', style: 'discover.posterCompact', group: 'asian' } ]; -const MISSAV_PRIMARY_CATEGORIES = [ - { id: 'latest', title: '最近更新', path: '/new?sort=published_at', subtitle: '按发布时间更新' }, - { id: 'new-release', title: '新作上市', path: '/new', subtitle: '最新上架作品' }, - { id: 'uncensored-leak', title: '无码流出', path: '/uncensored-leak', subtitle: '无码流出专区' }, - { id: 'actresses', title: '女优一览', path: '/actresses', subtitle: '按女优浏览' }, - { id: 'actress-ranking', title: '女优排行', path: '/actresses/ranking', subtitle: '站内女优排行' }, - { id: 'genres', title: '类型', path: '/genres', subtitle: '按题材类型浏览' }, - { id: 'makers', title: '发行商', path: '/makers', subtitle: '按发行商浏览' }, - { id: 'vr', title: 'VR', path: '/genres/VR', subtitle: 'VR 作品专区' } +const MISSAV_CATEGORY_GROUPS = [ + { id: 'japanese', title: '日本 AV / 综合分类' }, + { id: 'amateur', title: '素人' }, + { id: 'uncensored', title: '无码影片' }, + { id: 'asian', title: '亚洲 AV' } +]; + +const MISSAV_HOME_MEDIA_SECTION_IDS = [ + 'recommended', + 'nakadashi', + 'big-breasts', + 'married-women', + 'new-release', + 'latest', + 'chinese-subtitle', + 'uncensored-leak', + 'random', + 'today-hot', + 'weekly-hot', + 'monthly-hot' ]; function getManifest() { @@ -114,19 +144,82 @@ function getManifest() { defaultValue: MISSAV_DEFAULT_ENTRY, required: true, description: '例如 /dm247/cn。MissAV 的 dm 前缀会变化,失效时可替换为当前可访问入口。' + }, + { + name: 'backupBaseURLs', + title: '备用站点地址', + type: 'input', + defaultValue: 'https://missav.ai,https://missav123.com', + required: false, + description: '多个域名用逗号或换行分隔。网络不稳或主域名被拦截时会自动尝试备用域名。' + }, + { + name: 'enableBrowserFallback', + title: 'Cloudflare 浏览器兜底', + type: 'boolean', + defaultValue: true, + required: false, + description: '遇到 Cloudflare 或普通 HTTP 失败时,使用 App 浏览器请求一次并复用浏览器 Cookie。' + }, + { + name: 'browserVisible', + title: '旧版显示窗口开关', + type: 'boolean', + defaultValue: false, + required: false, + description: '为兼容旧设置保留。自动请求始终隐藏;需要验证时请点击“手动完成验证”卡片。' + }, + { + name: 'automaticBrowserFallback', + title: '自动静默浏览器兜底', + type: 'boolean', + defaultValue: true, + required: false, + description: '普通 HTTP 命中 Cloudflare 时静默尝试一次;关闭后只显示手动验证卡片。' + }, + { + name: 'verificationCooldownMinutes', + title: '验证重试间隔分钟', + type: 'number', + defaultValue: 10, + required: false, + description: '静默验证失败后暂停自动重试,避免浏览时连续触发 Cloudflare。' + }, + { + name: 'requestTimeoutSeconds', + title: '请求超时秒数', + type: 'number', + defaultValue: 45, + required: false, + description: '网络差时可调大到 60-90 秒。' + }, + { + name: 'cacheMinutes', + title: '页面缓存分钟', + type: 'number', + defaultValue: 20, + required: false, + description: '短期缓存首页、分类、详情 HTML,减少重复触发 Cloudflare。' + }, + { + name: 'categoryRestoreMinutes', + title: '分类浏览进度保留分钟', + type: 'number', + defaultValue: 60, + required: false, + description: '从影片详情返回分类时,恢复已经加载的分页项目,避免列表退回并卡在 12 项。' } ] }; } async function getHome(ctx) { - const html = await safeFetch(entryURL(ctx), baseURL(ctx) + '/'); + ctx = normalizeContext(ctx); + const html = await safeFetch(ctx, entryURL(ctx), baseURL(ctx) + '/'); const hero = parseCards(sectionBlock(html, '推荐给你'), '推荐给你', ctx).slice(0, 10).map(toWideItem); - const parsedSections = parseHomeSections(ctx, html); - const mediaSections = parsedSections.length ? parsedSections : MISSAV_SECTIONS.map(function (section) { - return sectionShell(ctx, section); - }); - const sections = [primaryCategoriesSection(ctx)].concat(mediaSections); + const mediaSections = homeMediaSections(ctx, parseHomeSections(ctx, html)); + const artwork = categoryArtworkFromSections(ctx, mediaSections); + const sections = categoryGroupSections(ctx, artwork).concat(mediaSections); return { pageType: 'home', @@ -138,26 +231,81 @@ async function getHome(ctx) { }; } -function primaryCategoriesSection(ctx) { - return { - id: 'missav-primary-categories', - title: '分类', - style: 'discover.annualCategories', - lazy: false, - items: MISSAV_PRIMARY_CATEGORIES.map(function (category) { - return categoryCard(ctx, category); - }) - }; +function homeMediaSections(ctx, parsedSections) { + const sections = []; + const seen = {}; + (parsedSections || []).forEach(function (section) { + if (!section || !section.id || seen[section.id]) return; + seen[section.id] = true; + sections.push(section); + }); + MISSAV_SECTIONS.filter(function (section) { + return MISSAV_HOME_MEDIA_SECTION_IDS.indexOf(section.id) >= 0; + }).forEach(function (section) { + if (seen[section.id]) return; + seen[section.id] = true; + sections.push(sectionShell(ctx, section)); + }); + return sections; } -function categoryCard(ctx, category) { - return { +function categoryGroupSections(ctx, artwork) { + const sections = []; + MISSAV_CATEGORY_GROUPS.forEach(function (group) { + const categories = MISSAV_SECTIONS.filter(function (category) { + return category.group === group.id; + }); + for (let offset = 0; offset < categories.length; offset += 6) { + const part = Math.floor(offset / 6); + const groupSectionId = 'missav-categories-' + group.id + (part ? '-' + (part + 1) : ''); + const groupCategories = categories.slice(offset, offset + 6); + const hasCompleteArtwork = groupCategories.every(function (category) { + return categoryPreviewItems( + artwork && artwork[category.id] && artwork[category.id].length + ? artwork[category.id] + : cachedCategoryArtwork(ctx, category.id), + category + ).length >= 3; + }); + sections.push({ + id: groupSectionId, + title: group.title + (part ? '(续 ' + part + ')' : ''), + style: 'discover.annualWidePreview', + lazy: !hasCompleteArtwork, + loadAction: !hasCompleteArtwork + ? { type: 'custom', id: groupSectionId, sectionId: groupSectionId, title: group.title } + : undefined, + items: hasCompleteArtwork + ? groupCategories.map(function (category) { + return categoryCard(ctx, category, artwork && artwork[category.id]); + }) + : [] + }); + } + }); + return sections; +} + +function categoryCard(ctx, category, livePreviewItems) { + const cachedPreviewItems = cachedCategoryArtwork(ctx, category.id); + const previewItems = categoryPreviewItems( + livePreviewItems && livePreviewItems.length + ? livePreviewItems + : cachedPreviewItems, + category + ); + const cover = previewItems[0] && firstNonEmpty( + previewItems[0].backdrop, + previewItems[0].poster, + previewItems[0].thumbnailURL + ); + const item = { id: 'category-' + category.id, title: category.title, - subtitle: category.subtitle || '', + subtitle: category.subtitle || '浏览' + category.title + '相关内容', type: 'category', aspectRatio: '16:9', - previewItems: [], + previewItems: previewItems, action: { type: 'category', id: category.id, @@ -167,37 +315,273 @@ function categoryCard(ctx, category) { itemAspectRatio: '16:9' } }; + if (cover) { + item.poster = cover; + item.backdrop = cover; + item.thumbnailURL = cover; + item.posterPath = cover; + item.backdropPath = cover; + item.image = cover; + item.imageURL = cover; + item.posterURL = cover; + item.backdropURL = cover; + item.cover = cover; + item.coverURL = cover; + item.imageFit = 'fill'; + item.imageHeaders = imageHeaders(ctx, categoryURL(ctx, category.path)); + item.posterHeaders = item.imageHeaders; + item.backdropHeaders = item.imageHeaders; + } + return item; +} + +function categoryArtworkFromSections(ctx, sections) { + const artwork = {}; + (sections || []).forEach(function (section) { + if (!section || !section.id || !section.items || !section.items.length) return; + const previews = categoryPreviewItems(section.items, findSection(section.id)); + if (!previews.length) return; + artwork[section.id] = previews; + rememberCategoryArtwork(ctx, section.id, previews); + }); + return artwork; +} + +function categoryPreviewItems(items, category) { + const prefixes = category && Array.isArray(category.artworkCodePrefixes) + ? category.artworkCodePrefixes.map(function (value) { + return String(value || '').toLowerCase(); + }).filter(Boolean) + : []; + return (items || []).filter(function (item) { + if (!item || !firstNonEmpty(item.backdrop, item.poster, item.thumbnailURL)) return false; + if (!prefixes.length && !(category && category.detailSlugPattern)) return true; + if (!categoryMatchesItem(item, category)) return false; + if (!prefixes.length) return true; + const target = stringValue(item.action && (item.action.url || item.action.itemId || item.action.id)).toLowerCase(); + const title = stringValue(item.title).toLowerCase(); + return prefixes.some(function (prefix) { + return target.indexOf('/' + prefix) >= 0 || title.indexOf(prefix) >= 0; + }); + }).slice(0, 3).map(function (item, index) { + const image = firstNonEmpty(item.backdrop, item.poster, item.thumbnailURL); + return { + id: item.id, + title: item.title, + type: item.type || 'movie', + rank: index + 1, + poster: image, + backdrop: image, + thumbnailURL: image, + aspectRatio: '16:9', + action: item.action + }; + }); +} + +function categoryMatchesItem(item, category) { + if (!category || !category.detailSlugPattern) return true; + const target = stringValue(item && item.action && (item.action.url || item.action.itemId || item.action.id)); + const slug = categorySlug(pathOf(target)).toLowerCase(); + try { + return new RegExp(category.detailSlugPattern, 'i').test(slug); + } catch (error) { + return true; + } +} + +function filterCategoryItems(items, category) { + if (!category || !category.detailSlugPattern) return items || []; + return (items || []).filter(function (item) { + return categoryMatchesItem(item, category); + }); } async function getHomeSection(ctx) { + ctx = normalizeContext(ctx); const sectionId = stringValue(ctx && (ctx.sectionId || ctx.id)); + const categoryGroup = categoryGroupFromSectionId(sectionId); + if (categoryGroup) return loadCategoryGroupSection(ctx, categoryGroup); const section = findSection(sectionId) || MISSAV_SECTIONS[0]; + if (section.personalizedHome) return personalizedRecommendationSection(ctx, section); + const url = categoryURL(ctx, section.path); try { - const html = await fetchText(ctx, categoryURL(ctx, section.path)); + const html = await fetchText(ctx, url, entryURL(ctx)); + const items = filterCategoryItems( + parseCards(mediaListBlock(html), section.title, ctx), + section + ).slice(0, 18); + rememberCategoryArtwork(ctx, section.id, items); return { id: section.id, title: section.title, style: section.style, lazy: false, moreAction: categoryAction(ctx, section), - items: parseCards(html, section.title, ctx).slice(0, 18) + items: items }; } catch (error) { - return emptySection(section.id, section.title, section.style, error); + return verificationSection(ctx, section.id, section.title, section.style, url, error); } } +function categoryGroupFromSectionId(sectionId) { + const value = stringValue(sectionId); + const match = /^missav-categories-([a-z]+)(?:-(\d+))?$/.exec(value); + if (!match) return null; + const group = MISSAV_CATEGORY_GROUPS.find(function (item) { + return item.id === match[1]; + }); + if (!group) return null; + const partNumber = Math.max(1, Number(match[2] || 1)); + const categories = MISSAV_SECTIONS.filter(function (category) { + return category.group === group.id; + }).slice((partNumber - 1) * 6, partNumber * 6); + if (!categories.length) return null; + return { + id: value, + title: group.title + (partNumber > 1 ? '(续 ' + (partNumber - 1) + ')' : ''), + categories: categories + }; +} + +async function loadCategoryGroupSection(ctx, groupSection) { + const artwork = {}; + const categories = groupSection.categories || []; + for (let offset = 0; offset < categories.length; offset += 2) { + const batch = categories.slice(offset, offset + 2); + const results = await Promise.all(batch.map(function (category) { + return loadCategoryPreviewItems(ctx, category); + })); + batch.forEach(function (category, index) { + artwork[category.id] = results[index]; + }); + } + return { + id: groupSection.id, + title: groupSection.title, + style: 'discover.annualWidePreview', + lazy: false, + items: categories.map(function (category) { + return categoryCard(ctx, category, artwork[category.id]); + }) + }; +} + +async function loadCategoryPreviewItems(ctx, category) { + const cached = categoryPreviewItems(cachedCategoryArtwork(ctx, category.id), category); + if (cached.length >= 3) return cached; + try { + const url = categoryURL(ctx, category.path); + let html; + if (category.personalizedHome) { + html = await fetchText(ctx, entryURL(ctx), entryURL(ctx)); + } else if (category.categoryIndex && isActressIndexURL(url)) { + html = await fetchActressIndexText(ctx, url, entryURL(ctx)); + } else { + html = await fetchText(ctx, url, entryURL(ctx)); + } + let items; + if (category.personalizedHome) { + items = parseCards(sectionBlock(html, category.title), category.title, ctx); + if (!items.length) items = parseCards(mediaListBlock(html), category.title, ctx); + } else if (category.categoryIndex) { + items = parseCategoryCards(ctx, html, category.title); + if (!items.length) items = parseCards(mediaListBlock(html), category.title, ctx); + } else { + items = filterCategoryItems( + parseCards(mediaListBlock(html), category.title, ctx), + category + ); + } + const previews = categoryPreviewItems(items, category); + if (previews.length) rememberCategoryArtwork(ctx, category.id, previews); + return previews; + } catch (error) { + return cached; + } +} + +async function personalizedRecommendationSection(ctx, section) { + let items = []; + const homeURL = entryURL(ctx); + const browserText = await browserHTML(ctx, homeURL, homeURL); + if (browserText) { + items = parseCards(sectionBlock(browserText, section.title), section.title, ctx).slice(0, 18); + } + if (!items.length) { + try { + const fallbackHTML = await fetchText(ctx, randomPageURL(ctx, 1), homeURL); + items = parseCards(fallbackHTML, section.title, ctx).slice(0, 18); + } catch (error) { + return verificationSection(ctx, section.id, section.title, section.style, homeURL, error); + } + } + rememberCategoryArtwork(ctx, section.id, items); + return { + id: section.id, + title: section.title, + style: section.style, + lazy: false, + moreAction: categoryAction(ctx, section), + items: items + }; +} + async function getCategory(ctx) { - const page = positiveInt(contextValue(ctx, 'page'), 1); + ctx = normalizeContext(ctx); + const requestedPage = pageFromContext(ctx, 1); const pageId = normalizePageId(ctx, ctx && (ctx.pageId || ctx.id)); + const verificationURL = verificationUrlFromPageId(pageId); + if (verificationURL) return runVerificationCategory(ctx, pageId, verificationURL); const section = findSection(pageId); const primary = findPrimaryCategory(pageId); const path = section ? section.path : primary ? primary.path : pageId; - const url = pagedURL(categoryURL(ctx, path), page); - const html = await fetchText(ctx, url); + const page = categoryFetchPage(ctx, pageId, requestedPage); + const url = section && section.randomCategory + ? randomPageURL(ctx, page) + : pagedURL(categoryURL(ctx, path), page); + let html = ''; + try { + html = isActressWorksURL(url) + ? await fetchActressCategoryText(ctx, url, entryURL(ctx)) + : isActressIndexURL(url) + ? await fetchActressIndexText(ctx, url, entryURL(ctx)) + : await fetchText(ctx, url, entryURL(ctx)); + } catch (error) { + return verificationCategory(ctx, pageId, section ? section.title : primary ? primary.title : '需要验证', url, error, page); + } const title = section ? section.title : primary ? primary.title : pageTitle(html) || 'MissAV'; - const items = parseCards(html, title, ctx); - const categoryItems = items.length ? [] : parseCategoryCards(ctx, html, title); + const categoryIndex = !!(section && section.categoryIndex); + const categoryItems = categoryIndex ? parseCategoryCards(ctx, html, title) : []; + const items = categoryItems.length + ? [] + : filterCategoryItems(parseCards(mediaListBlock(html), title, ctx), section); + const fallbackCategoryItems = items.length || categoryItems.length ? [] : parseCategoryCards(ctx, html, title); + const listItems = items.length + ? items + : categoryItems.length + ? categoryItems + : fallbackCategoryItems; + if (section && items.length) rememberCategoryArtwork(ctx, section.id, items); + const pagination = section && section.randomCategory + ? { hasMore: true, totalPages: 99 } + : section && section.id === 'actress-ranking' + ? { hasMore: false, totalPages: 1 } + : paginationInfo(html, page, listItems.length); + if (items.length && requestedPage === 1 && section && section.eagerPagination) { + await prefetchNextCategoryPage(ctx, pageId, section, page, pagination); + } + const restored = listItems.length + ? rememberCategoryPage(ctx, pageId, page, listItems, pagination) + : null; + const responseItems = restored && restored.items.length + ? restored.items + : items.length + ? items + : listItems; + const responsePage = restored ? restored.page : page; + const responseHasMore = restored ? restored.hasMore : items.length ? pagination.hasMore : false; return { pageType: 'category', @@ -205,17 +589,28 @@ async function getCategory(ctx) { title: title, style: items.length ? 'media.posterGrid' : 'discover.annualCategories', itemAspectRatio: '16:9', - items: items.length ? items : categoryItems, - page: page, - hasMore: items.length ? hasNextPage(html, page) : false + items: responseItems, + page: responsePage, + currentPage: responsePage, + pageIndex: responsePage, + nextPage: responseHasMore ? responsePage + 1 : undefined, + totalPages: restored && restored.totalPages ? restored.totalPages : pagination.totalPages, + pagecount: restored && restored.totalPages ? restored.totalPages : pagination.totalPages, + hasMore: responseHasMore }; } async function getDetail(ctx) { + ctx = normalizeContext(ctx); const detailURL = detailUrlFromContext(ctx); if (!detailURL) throw new Error('MissAV 详情参数无效'); - const html = await fetchText(ctx, detailURL); + let html = ''; + try { + html = await fetchText(ctx, detailURL, entryURL(ctx)); + } catch (error) { + return verificationDetail(ctx, detailURL, error); + } const fallbackTitle = titleFromUrl(detailURL); const title = cleanText( firstNonEmpty( @@ -247,9 +642,21 @@ async function getDetail(ctx) { firstMatch(html, /(\d{1,2}:\d{2}:\d{2})/i) ); const releaseDate = firstMatch(html, /(\d{4}-\d{2}-\d{2})/); - const detailPlayableURL = absoluteURL(ctx, extractPlayableURL(html)); - const resourceGroups = playbackGroups(detailURL, title, detailPlayableURL, ctx); - const defaultVersion = resourceGroups[0] && resourceGroups[0].versions && resourceGroups[0].versions[0]; + let detailPlayableURL = absoluteURL(ctx, extractPlayableURL(html)); + if (!detailPlayableURL) detailPlayableURL = await extractPlayableFromLinkedPlayers(ctx, html, detailURL); + let resourceGroups = await playbackGroups(detailURL, title, detailPlayableURL, ctx, html); + if (resourceVersionCount(resourceGroups) <= 1) { + const browserPlayableURL = await extractFromBrowser(detailURL, detailURL); + if (browserPlayableURL && browserPlayableURL !== detailPlayableURL) { + const browserGroups = await playbackGroups(detailURL, title, browserPlayableURL, ctx, html); + if (resourceVersionCount(browserGroups) > resourceVersionCount(resourceGroups)) { + detailPlayableURL = browserPlayableURL; + resourceGroups = browserGroups; + } + } + } + const versions = resourceGroups[0] && resourceGroups[0].versions || []; + const defaultVersion = versions[0]; return { id: makeItemId(detailURL, title, poster), @@ -271,19 +678,19 @@ async function getDetail(ctx) { remarks: durationText || code, resourceGroups: resourceGroups, resourceSummary: { - versionCount: defaultVersion ? 1 : 0, + versionCount: versions.length, episodeCount: 0, defaultVersionId: defaultVersion ? defaultVersion.id : '' }, - mediaSources: detailPlayableURL ? [ + mediaSources: defaultVersion && defaultVersion.url ? [ { - id: encodePayload({ url: detailURL, title: title, playUrl: detailPlayableURL }), - name: 'MissAV HLS', - displayName: 'MissAV HLS', + id: defaultVersion.id, + name: defaultVersion.name, + displayName: defaultVersion.name, protocol: 'hls', container: 'm3u8', - url: detailPlayableURL, - path: detailPlayableURL, + url: defaultVersion.url, + path: defaultVersion.url, headers: playbackHeaders(ctx, detailURL), header: playbackHeaders(ctx, detailURL), Header: playbackHeaders(ctx, detailURL), @@ -308,32 +715,74 @@ async function getDetail(ctx) { } async function getResourceVersions(ctx) { + ctx = normalizeContext(ctx); const detailURL = detailUrlFromContext(ctx); const title = stringValue(ctx && (ctx.title || ctx.name)) || titleFromUrl(detailURL); const direct = playUrlFromContext(ctx); - if (direct) return playbackGroups(detailURL, title, direct, ctx); + if (direct) { + let directGroups = await playbackGroups(detailURL, title, direct, ctx); + if (resourceVersionCount(directGroups) <= 1 && detailURL) { + const browserPlayableURL = await extractFromBrowser(detailURL, detailURL); + if (browserPlayableURL && browserPlayableURL !== direct) { + const browserGroups = await playbackGroups(detailURL, title, browserPlayableURL, ctx); + if (resourceVersionCount(browserGroups) > resourceVersionCount(directGroups)) directGroups = browserGroups; + } + } + return directGroups; + } try { - const html = await fetchText(ctx, detailURL); - return playbackGroups(detailURL, title, extractPlayableURL(html), ctx); + const html = await fetchText(ctx, detailURL, entryURL(ctx)); + let playUrl = extractPlayableURL(html); + if (!playUrl) playUrl = await extractPlayableFromLinkedPlayers(ctx, html, detailURL); + let groups = await playbackGroups(detailURL, title, playUrl, ctx, html); + if (resourceVersionCount(groups) <= 1) { + const browserPlayableURL = await extractFromBrowser(detailURL, detailURL); + if (browserPlayableURL && browserPlayableURL !== playUrl) { + const browserGroups = await playbackGroups(detailURL, title, browserPlayableURL, ctx, html); + if (resourceVersionCount(browserGroups) > resourceVersionCount(groups)) groups = browserGroups; + } + } + return groups; } catch (error) { - return playbackGroups(detailURL, title, '', ctx); + return []; } } -async function resolvePlayback(ctx) { +async function resolvePlaybackWithinBudget(ctx) { + ctx = normalizeContext(ctx); + const requestedQuality = qualityFromContext(ctx); const direct = firstNonEmpty(playUrlFromContext(ctx), ctx && ctx.url, ctx && ctx.playUrl, ctx && ctx.videoUrl); - if (isPlayableURL(direct) && !isDetailPageURL(direct)) { + const stableDetailURL = detailUrlFromContext(ctx); + if (!isDetailPageURL(stableDetailURL) && !requestedQuality && isPlayableURL(direct) && !isDetailPageURL(direct)) { return playbackResult(ctx, direct, ctx && ctx.referer); } const detailURL = detailUrlFromContext(ctx); if (!detailURL) throw new Error('MissAV 播放参数无效'); - const html = await fetchText(ctx, detailURL); - const url = firstNonEmpty( - extractPlayableURL(html), - await extractPlayableFromLinkedPlayers(ctx, html, detailURL), - await extractFromBrowser(detailURL, detailURL) - ); + let html = ''; + try { + html = await fetchText(ctx, detailURL, entryURL(ctx)); + } catch (error) { + // Playback has a separate, narrowly-scoped hidden browser media fallback. + } + let masterURL = extractPlayableURL(html); + if (!masterURL) masterURL = await extractPlayableFromLinkedPlayers(ctx, html, detailURL); + if (!masterURL) masterURL = await extractFromBrowser(detailURL, detailURL, ctx); + let url = masterURL; + if (masterURL) { + let qualities = await discoverAvailableQualities(ctx, masterURL, detailURL, html); + const knownQuality = qualities.some(function (quality) { return quality.height > 0; }); + const missingRequested = requestedQuality && !qualities.some(function (quality) { return String(quality.height) === String(requestedQuality); }); + if ((!knownQuality || missingRequested) && ctx.__playbackBudget.browserCalls === 0) { + const browserMasterURL = await extractFromBrowser(detailURL, detailURL, ctx); + if (browserMasterURL && browserMasterURL !== masterURL) { + const browserQualities = await discoverAvailableQualities(ctx, browserMasterURL, detailURL, html); + if (browserQualities.length > qualities.length) qualities = browserQualities; + } + } + const selected = selectQuality(qualities, requestedQuality); + if (selected && selected.url) url = selected.url; + } if (!url) { throw new Error('未能解析到 MissAV 播放地址。站点可能启用了 Cloudflare 或更换了播放器脚本,请尝试更新入口路径或在 App 侧启用浏览器请求。'); } @@ -386,18 +835,36 @@ async function play(flagOrInput, id) { } async function search(ctx) { + ctx = normalizeContext(ctx); const query = stringValue(ctx && (ctx.query || ctx.keyword || ctx.text)); if (!query) return { pageType: 'search', title: '搜索结果', items: [] }; - const page = positiveInt(contextValue(ctx, 'page'), 1); + const page = pageFromContext(ctx, 1); const url = pagedURL(categoryURL(ctx, '/search/' + encodeURIComponent(query.replace(/\\/g, ''))), page); - const html = await fetchText(ctx, url); + let html = ''; + try { + html = await fetchText(ctx, url, entryURL(ctx)); + } catch (error) { + return { + pageType: 'search', + title: '搜索结果', + items: [verificationCard(ctx, '需要真人验证', url, error)], + page: page, + hasMore: false + }; + } const items = parseCards(html, query, ctx); + const pagination = paginationInfo(html, page, items.length); return { pageType: 'search', title: '搜索结果', items: items, page: page, - hasMore: hasNextPage(html, page) + currentPage: page, + pageIndex: page, + nextPage: pagination.hasMore ? page + 1 : undefined, + totalPages: pagination.totalPages, + pagecount: pagination.totalPages, + hasMore: pagination.hasMore }; } @@ -416,11 +883,33 @@ async function matchResources(ctx) { return { results: (results.items || []).slice(0, 8) }; } +async function onAction(ctx) { + ctx = normalizeContext(ctx); + const nestedAction = ctx && ctx.action && typeof ctx.action === 'object' ? ctx.action : {}; + const name = stringValue(firstNonEmpty( + ctx && ctx.name, + nestedAction.name, + ctx && ctx.actionName, + nestedAction.id, + ctx && ctx.id + )); + const payload = nestedAction.payload || (ctx && ctx.payload) || nestedAction || ctx || {}; + if (name !== 'verifyCloudflare') return { handled: false }; + const url = stringValue(payload.url || ctx.url) || entryURL(ctx); + const html = await browserHTML(ctx, url, url, true); + if (isUsableHTML(html)) setCachedText(ctx, url, html); + return { + handled: true, + ok: isUsableHTML(html), + message: isUsableHTML(html) ? '验证完成,可以返回刷新当前页面。' : '验证没有完成,请确认页面已加载并通过真人验证。' + }; +} + async function extractPlayableFromLinkedPlayers(ctx, html, referer) { const urls = extractPlayerURLs(ctx, html, referer); for (let index = 0; index < urls.length; index += 1) { try { - const playerHTML = await fetchText(ctx, urls[index]); + const playerHTML = await fetchText(ctx, urls[index], referer); const playable = extractPlayableURL(playerHTML); if (playable) return playable; } catch (error) { @@ -456,6 +945,47 @@ function baseURL(ctx) { ) || MISSAV_DEFAULT_BASE).replace(/\/+$/, ''); } +function normalizeContext(ctx) { + if (ctx === undefined || ctx === null) return {}; + if (typeof ctx === 'string') { + const text = ctx.trim(); + if (!text) return {}; + if (text[0] === '{' && text[text.length - 1] === '}') { + try { + const parsed = JSON.parse(text); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}; + } catch (error) { + return { query: text }; + } + } + return { query: text }; + } + return typeof ctx === 'object' ? ctx : {}; +} + +function pageFromContext(ctx, fallback) { + const input = normalizeContext(ctx); + const pagination = input.pagination && typeof input.pagination === 'object' ? input.pagination : {}; + const pageInfo = input.pageInfo && typeof input.pageInfo === 'object' ? input.pageInfo : {}; + return positiveInt(firstNonEmpty( + contextValue(input, 'page'), + contextValue(input, 'pg'), + contextValue(input, 'currentPage'), + contextValue(input, 'pageNumber'), + contextValue(input, 'pageIndex'), + pagination.page, + pagination.pg, + pagination.currentPage, + pagination.pageNumber, + pagination.pageIndex, + pageInfo.page, + pageInfo.pg, + pageInfo.currentPage, + pageInfo.pageNumber, + pageInfo.pageIndex + ), fallback); +} + function entryPath(ctx) { let value = stringValue( contextValue(ctx, 'entryPath') || @@ -501,39 +1031,174 @@ function pagedURL(url, page) { return url + (url.indexOf('?') >= 0 ? '&' : '?') + 'page=' + page; } -async function fetchText(ctx, url) { - const response = await httpGet(url, { - headers: requestHeaders(ctx, url), - useBrowserCookie: true, - attachBrowserCookie: true, - useBrowserFallback: true, - browserFallback: true, - allowBrowserFallback: true +function isActressWorksURL(url) { + const path = pathOf(url).split('?')[0].replace(/\/+$/, ''); + return /\/(?:dm\d+\/)?cn\/actresses\/[^/]+$/i.test(path) && + !/\/actresses\/ranking$/i.test(path); +} + +function isActressIndexURL(url) { + const path = pathOf(url).split('?')[0].replace(/\/+$/, ''); + return /\/(?:dm\d+\/)?cn\/actresses(?:\/ranking)?$/i.test(path); +} + +async function fetchActressCategoryText(ctx, url, referer) { + const cached = getCachedText(ctx, url); + if (cached) return cached; + + const fastContext = Object.assign({}, ctx || {}, { + requestTimeoutSeconds: Math.min(numberParam(ctx, 'requestTimeoutSeconds', 45), 12), + suppressBrowserFailureCooldown: true }); - const text = responseText(response); - if (isCloudflare(text, response && response.status, response && response.headers)) { - const browserText = await browserHTML(url, url); - if (browserText && !isCloudflare(browserText)) return browserText; - throw new Error('MissAV 启用了 Cloudflare 浏览器校验,当前 HTTP 环境无法直接读取页面。'); + + let lastError = null; + try { + const options = requestOptions(fastContext, referer || url); + options.useBrowserFallback = false; + options.browserFallback = false; + options.allowBrowserFallback = false; + const response = await httpGet(url, options); + const text = responseText(response); + if (isUsableHTML(text, response && response.status, response && response.headers)) { + setCachedText(ctx, url, text); + return text; + } + throw new Error('HTTP ' + (response && response.status ? response.status : 'empty')); + } catch (error) { + lastError = error; + } + + // 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; } - return text; + + throw new Error( + '女优作品页读取失败。已短暂尝试复用刚才的验证状态,请点击一次真人验证后重试。' + + (lastError && lastError.message ? ' 原因:' + lastError.message : '') + ); } -async function safeFetch(url, referer) { +async function fetchActressIndexText(ctx, url, referer) { + const cached = getCachedText(ctx, url); + if (cached && isVerifiedTargetHTML(ctx, url, cached)) return cached; + + const fastContext = Object.assign({}, ctx || {}, { + requestTimeoutSeconds: Math.min(numberParam(ctx, 'requestTimeoutSeconds', 45), 12), + suppressBrowserFailureCooldown: true + }); + let lastError = null; try { - return await fetchText({ baseURL: originOf(url), entryPath: pathOf(url) }, url, referer); + const options = requestOptions(fastContext, referer || url); + options.useBrowserFallback = false; + options.browserFallback = false; + options.allowBrowserFallback = false; + const response = await httpGet(url, options); + const text = responseText(response); + if (isVerifiedTargetHTML(fastContext, url, text)) { + setCachedText(ctx, url, text); + return text; + } + throw new Error('HTTP ' + (response && response.status ? response.status : 'empty')); + } catch (error) { + lastError = error; + } + + const browserText = await browserHTML(fastContext, url, referer || url); + if (isVerifiedTargetHTML(fastContext, url, browserText)) { + setCachedText(ctx, url, browserText); + return browserText; + } + + throw new Error( + '女优目录读取失败。已短暂尝试复用浏览器验证状态,请手动完成一次真人验证。' + + (lastError && lastError.message ? ' 原因:' + lastError.message : '') + ); +} + +async function fetchText(ctx, url, referer) { + const cached = getCachedText(ctx, url); + if (cached) return cached; + + const urls = candidateURLs(ctx, url); + let lastError = null; + for (let index = 0; index < urls.length; index += 1) { + const currentURL = urls[index]; + const requestReferer = referer || entryURL(ctx); + try { + const response = await httpGet(currentURL, requestOptions(ctx, requestReferer)); + const text = responseText(response); + if (isUsableHTML(text, response && response.status, response && response.headers)) { + setCachedText(ctx, currentURL, text); + if (currentURL !== url) setCachedText(ctx, url, text); + return text; + } + if (isCloudflare(text, response && response.status, response && response.headers)) { + const browserText = await browserHTML(ctx, currentURL, requestReferer); + if (isUsableHTML(browserText)) { + setCachedText(ctx, currentURL, browserText); + if (currentURL !== url) setCachedText(ctx, url, browserText); + return browserText; + } + } + lastError = new Error('HTTP ' + (response && response.status ? response.status : 'empty') + ' ' + currentURL); + } catch (error) { + lastError = error; + const browserText = await browserHTML(ctx, currentURL, requestReferer); + if (isUsableHTML(browserText)) { + setCachedText(ctx, currentURL, browserText); + if (currentURL !== url) setCachedText(ctx, url, browserText); + return browserText; + } + } + } + throw new Error('MissAV 页面读取失败,可能是 Cloudflare 验证、网络超时或当前域名不可达。可点击“手动完成验证”,或在“备用站点地址”里填写当前能打开的域名。' + (lastError && lastError.message ? ' 原因:' + lastError.message : '')); +} + +async function safeFetch(ctx, url, referer) { + try { + return await fetchText(ctx || { 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 客户端。'); } @@ -565,40 +1230,84 @@ function playbackHeaders(ctx, referer) { function responseText(response) { if (typeof response === 'string') return response; if (!response) return ''; + if (typeof response.text === 'string') return response.text; if (typeof response.data === 'string') return response.data; if (typeof response.body === 'string') return response.body; + if (response.data && typeof response.data.html === 'string') return response.data.html; + if (response.body && typeof response.body.html === 'string') return response.body.html; + if (typeof response.html === 'string') return response.html; return String(response.data || response.body || ''); } -async function browserHTML(url, referer) { +async function browserHTML(ctx, url, referer, forceVisible) { + if (!boolParam(ctx, 'enableBrowserFallback', true)) return ''; + if (forceVisible !== true && !boolParam(ctx, 'automaticBrowserFallback', true)) return ''; + if (forceVisible !== true && browserRetryBlocked(ctx, url)) return ''; if (typeof Widget === 'undefined' || !Widget.browser || typeof Widget.browser.fetch !== 'function') return ''; try { + const visible = forceVisible === true; + const configuredTimeout = numberParam(ctx, 'requestTimeoutSeconds', 45); + const timeout = 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: false, - timeout: 60, - waitAfterLoad: 2.5, - waitForAny: true, - waitForMediaSource: true, + visible: visible, + timeout: timeout, + timeoutSeconds: timeout, + waitAfterLoad: visible ? 1.5 : 3, headers: { 'User-Agent': MISSAV_UA, Referer: referer || url } }); - return responseText(result.html ? { data: result.html } : result); + const html = responseText(result.html ? { data: result.html } : result); + if (isUsableHTML(html)) { + clearBrowserFailure(url); + return html; + } + rememberBrowserFailure(ctx, url); + return ''; } catch (error) { + rememberBrowserFailure(ctx, url); return ''; } } -async function extractFromBrowser(url, referer) { +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 (typeof Widget === 'undefined' || !Widget.browser || typeof Widget.browser.fetch !== 'function') return ''; try { const result = await Widget.browser.fetch(url, { visible: false, - timeout: 70, + timeout: playbackBrowserSeconds(ctx, 12, 1), + timeoutSeconds: remainingPlaybackSeconds(ctx, 12), waitAfterLoad: 3, waitForMediaSource: true, waitForAny: true, + captureRequests: true, + captureMedia: true, headers: { 'User-Agent': MISSAV_UA, Referer: referer || url @@ -614,24 +1323,324 @@ async function extractFromBrowser(url, referer) { } } +function candidateURLs(ctx, url) { + const input = stringValue(url); + const urls = [input]; + const origin = originOf(input); + const path = input.replace(/^https?:\/\/[^/]+/i, ''); + candidatePathVariants(ctx, path).forEach(function (variant) { + const next = origin + variant; + if (urls.indexOf(next) < 0) urls.push(next); + }); + backupBaseURLs(ctx).forEach(function (base) { + const root = base.replace(/\/+$/, ''); + if (!root || root === origin) return; + candidatePathVariants(ctx, path).forEach(function (variant) { + const next = root + variant; + if (urls.indexOf(next) < 0) urls.push(next); + }); + }); + return urls.filter(Boolean); +} + +function candidatePathVariants(ctx, path) { + const value = path && path[0] === '/' ? path : '/' + stringValue(path); + const variants = [value]; + if (!isKnownCategoryPath(value)) return variants; + const match = value.match(/^\/(?:dm\d+\/)?cn(\/[^?#]*)([?#].*)?$/i); + if (match) { + const suffix = match[1] || ''; + const query = match[2] || ''; + const cnPath = '/cn' + suffix + query; + const rootPath = suffix + query; + if (variants.indexOf(cnPath) < 0) variants.push(cnPath); + if (variants.indexOf(rootPath) < 0) variants.push(rootPath); + } else { + const localePath = localePrefix(ctx) + value; + const cnPath = '/cn' + value; + if (variants.indexOf(localePath) < 0) variants.push(localePath); + if (variants.indexOf(cnPath) < 0) variants.push(cnPath); + } + return variants; +} + +function isKnownCategoryPath(path) { + const clean = String(path || '').split(/[?#]/)[0].replace(/^\/(?:dm\d+\/)?cn/i, '') || '/'; + return MISSAV_SECTIONS.some(function (item) { + return categorySlug(clean) === categorySlug(item.path); + }); +} + +function backupBaseURLs(ctx) { + const raw = stringValue(contextValue(ctx, 'backupBaseURLs') || contextValue(ctx, 'backupBaseUrls') || contextValue(ctx, 'backup_base_urls')); + return raw.split(/[\n,,\s]+/).map(function (value) { + return value.replace(/\/+$/, ''); + }).filter(function (value) { + return /^https?:\/\//i.test(value); + }); +} + +function cacheKey(url) { + return 'missav:html:' + String(url || ''); +} + +function categoryArtworkKey(ctx, pageId) { + return 'missav:category-artwork:v4:' + baseURL(ctx) + ':' + String(pageId || ''); +} + +function rememberCategoryArtwork(ctx, pageId, items) { + const previews = categoryPreviewItems(items, findSection(pageId)); + if (!previews.length) return; + const key = categoryArtworkKey(ctx, pageId); + const value = { + items: previews, + expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000 + }; + memoryCache()[key] = value; + cacheSet(key, value); +} + +function cachedCategoryArtwork(ctx, pageId) { + const key = categoryArtworkKey(ctx, pageId); + const now = Date.now(); + let value = memoryCache()[key] || cacheGet(key); + if (typeof value === 'string') { + try { + value = JSON.parse(value); + } catch (error) { + value = null; + } + } + if (!value || value.expiresAt <= now || !Array.isArray(value.items)) return []; + memoryCache()[key] = value; + return value.items; +} + +function categoryStateKey(ctx, pageId) { + return 'missav:category-state:v6:' + baseURL(ctx) + ':' + String(pageId || ''); +} + +function memoryCache() { + if (typeof globalThis === 'undefined') return {}; + if (!globalThis.__MISSAV_HTML_CACHE__) globalThis.__MISSAV_HTML_CACHE__ = {}; + return globalThis.__MISSAV_HTML_CACHE__; +} + +function categoryStateFromCache(ctx, pageId) { + const key = categoryStateKey(ctx, pageId); + const now = Date.now(); + let state = memoryCache()[key] || cacheGet(key); + if (typeof state === 'string') { + try { + state = JSON.parse(state); + } catch (error) { + state = null; + } + } + if (!state || state.expiresAt <= now || !state.pages) return null; + memoryCache()[key] = state; + return state; +} + +function randomPageURL(ctx, page) { + const bucket = ((Math.max(1, Number(page) || 1) - 1) % 99) + 2; + return baseURL(ctx) + '/random/' + bucket; +} + +async function prefetchNextCategoryPage(ctx, pageId, section, page, pagination) { + if (!pagination || !pagination.hasMore) return; + const nextPage = page + 1; + const state = categoryStateFromCache(ctx, pageId); + if (state && state.pages && state.pages[String(nextPage)]) return; + try { + const url = pagedURL(categoryURL(ctx, section.path), nextPage); + const html = await fetchText(ctx, url, entryURL(ctx)); + const items = parseCards(html, section.title, ctx); + if (!items.length) return; + rememberCategoryPage(ctx, pageId, nextPage, items, paginationInfo(html, nextPage, items.length)); + } catch (error) { + // Page 1 remains usable; ordinary scrolling can retry the next page later. + } +} + +function contiguousCategoryPage(state) { + if (!state || !state.pages || !state.pages['1']) return 0; + let page = 1; + while (state.pages[String(page + 1)]) page += 1; + return page; +} + +function categoryFetchPage(ctx, pageId, requestedPage) { + if (requestedPage <= 1) return 1; + const state = categoryStateFromCache(ctx, pageId); + if (!state || !state.pages[String(requestedPage)]) return requestedPage; + const lastLoadedPage = contiguousCategoryPage(state); + return lastLoadedPage >= requestedPage ? lastLoadedPage + 1 : requestedPage; +} + +function rememberCategoryPage(ctx, pageId, page, items, pagination) { + const key = categoryStateKey(ctx, pageId); + const ttl = numberParam(ctx, 'categoryRestoreMinutes', 60) * 60 * 1000; + const existing = categoryStateFromCache(ctx, pageId); + const state = existing || { pages: {} }; + state.pages[String(page)] = (items || []).slice(0, 120); + state.hasMoreByPage = state.hasMoreByPage || {}; + state.hasMoreByPage[String(page)] = !!(pagination && pagination.hasMore); + state.totalPages = Math.max(Number(state.totalPages) || 0, Number(pagination && pagination.totalPages) || 0); + state.expiresAt = Date.now() + ttl; + + const pageNumbers = Object.keys(state.pages).map(Number).filter(function (value) { + return Number.isFinite(value) && value > 0; + }).sort(function (a, b) { return a - b; }); + while (pageNumbers.length > 20) { + const removed = pageNumbers.shift(); + delete state.pages[String(removed)]; + delete state.hasMoreByPage[String(removed)]; + } + memoryCache()[key] = state; + cacheSet(key, state); + + if (page !== 1) { + return { + items: items, + page: page, + hasMore: !!(pagination && pagination.hasMore), + totalPages: Number(pagination && pagination.totalPages) || state.totalPages + }; + } + + const lastContiguousPage = contiguousCategoryPage(state); + if (lastContiguousPage === 1) { + return { + items: items, + page: 1, + hasMore: !!(pagination && pagination.hasMore), + totalPages: Number(pagination && pagination.totalPages) || state.totalPages + }; + } + + const merged = []; + const seen = {}; + for (let current = 1; current <= lastContiguousPage; current += 1) { + (state.pages[String(current)] || []).forEach(function (item) { + const identity = stringValue(item && (item.id || item.url || item.title)); + if (identity && seen[identity]) return; + if (identity) seen[identity] = true; + merged.push(item); + }); + } + const latestHasMore = state.hasMoreByPage[String(lastContiguousPage)]; + return { + items: merged.slice(0, 240), + page: lastContiguousPage, + hasMore: latestHasMore !== undefined + ? latestHasMore + : state.totalPages > lastContiguousPage, + totalPages: state.totalPages + }; +} + +function getCachedText(ctx, url) { + const ttl = numberParam(ctx, 'cacheMinutes', 20) * 60 * 1000; + if (ttl <= 0) return ''; + const key = cacheKey(url); + const now = Date.now(); + const memory = memoryCache()[key]; + if (memory && memory.expiresAt > now && memory.text) return memory.text; + const stored = cacheGet(key); + if (stored && stored.expiresAt > now && stored.text) return stored.text; + return ''; +} + +function setCachedText(ctx, url, text) { + if (!isUsableHTML(text)) return; + const configuredMinutes = numberParam(ctx, 'cacheMinutes', 20); + const minimumMinutes = isActressIndexURL(url) ? 60 : isActressWorksURL(url) ? 30 : 0; + const ttl = Math.max(configuredMinutes, minimumMinutes) * 60 * 1000; + if (ttl <= 0) return; + const value = { text: text, expiresAt: Date.now() + ttl }; + const key = cacheKey(url); + memoryCache()[key] = value; + cacheSet(key, value); +} + +function cacheGet(key) { + try { + if (typeof Widget !== 'undefined' && Widget.cache && typeof Widget.cache.get === 'function') return Widget.cache.get(key); + if (typeof $cache !== 'undefined' && typeof $cache.get === 'function') return $cache.get(key); + } catch (error) { + return null; + } + return null; +} + +function cacheSet(key, value) { + try { + if (typeof Widget !== 'undefined' && Widget.cache && typeof Widget.cache.set === 'function') Widget.cache.set(key, value); + if (typeof $cache !== 'undefined' && typeof $cache.set === 'function') $cache.set(key, value); + } catch (error) { + // Cache is opportunistic; ignore unsupported host APIs. + } +} + +function isUsableHTML(html, status, headers) { + const text = String(html || ''); + if (!text || text.length < 80) return false; + if (Number(status) >= 400) return false; + if (isCloudflare(text, status, headers)) return false; + return /<html|<body|<a\b|<video\b|m3u8|mp4/i.test(text); +} + +function boolParam(ctx, key, fallback) { + const value = contextValue(ctx, key); + if (value === undefined || value === null || value === '') return fallback; + if (typeof value === 'boolean') return value; + return !/^(false|0|no|off|否|关闭)$/i.test(String(value).trim()); +} + +function numberParam(ctx, key, fallback) { + const value = Number(contextValue(ctx, key)); + return Number.isFinite(value) && value > 0 ? value : fallback; +} + function playableFromBrowserResult(result) { if (!result) return ''; + const candidates = []; const keys = ['url', 'mediaURL', 'mediaUrl', 'videoURL', 'videoUrl', 'playURL', 'playUrl', 'src']; for (let index = 0; index < keys.length; index += 1) { const value = result[keys[index]]; - if (isPlayableURL(value)) return value; + if (isPlayableURL(value)) candidates.push(value); } - const arrays = [result.mediaSources, result.mediaRequests, result.requests, result.responses, result.urls]; + const arrays = [ + result.mediaSources, + result.mediaRequests, + result.capturedRequests, + result.requests, + result.responses, + result.urls + ]; for (let a = 0; a < arrays.length; a += 1) { const array = arrays[a]; if (!Array.isArray(array)) continue; for (let i = 0; i < array.length; i += 1) { const item = array[i]; const value = typeof item === 'string' ? item : firstNonEmpty(item && item.url, item && item.src, item && item.responseURL); - if (isPlayableURL(value)) return value; + if (isPlayableURL(value)) candidates.push(value); } } - return ''; + return unique(candidates).sort(function (left, right) { + return playableCandidateScore(right) - playableCandidateScore(left); + })[0] || ''; +} + +function playableCandidateScore(url) { + const value = String(url || ''); + let score = 0; + if (/\.m3u8(?:[?#]|$)/i.test(value)) score += 20; + if (/(?:master|playlist|index)\.m3u8(?:[?#]|$)/i.test(value)) score += 40; + if (/\/\d{3,4}p(?:\/|[?#]|$)/i.test(value)) score -= 30; + if (/\.(?:ts|m2ts)(?:[?#]|$)/i.test(value)) score -= 100; + return score; } function parseHomeSections(ctx, html) { @@ -684,6 +1693,199 @@ function emptySection(id, title, style, error) { }; } +function verificationCategory(ctx, id, title, url, error, page) { + return { + pageType: 'category', + id: id, + title: title || '需要验证', + style: 'media.posterGrid', + itemAspectRatio: '16:9', + items: [verificationCard(ctx, '需要真人验证', url, error)], + page: page || 1, + hasMore: false + }; +} + +function verificationSection(ctx, id, title, style, url, error) { + return { + id: id, + title: title, + style: style || 'discover.posterCompact', + lazy: false, + items: [verificationCard(ctx, '需要真人验证', url, error)] + }; +} + +function verificationDetail(ctx, url, error) { + const title = titleFromUrl(url) || '需要真人验证'; + return { + pageType: 'detail', + id: makeItemId(url, title, ''), + title: '需要真人验证', + type: 'collection', + mediaType: 'collection', + overview: cleanText(error && error.message) || '站点触发了 Cloudflare 真人验证。请返回列表页点击手动验证卡片,完成后刷新。', + detailImageAspectRatio: '16:9', + playable: false, + isPlayable: false, + resourceGroups: [], + mediaSources: [], + playbackSummary: { + versionCount: 0, + episodeCount: 0, + defaultVersionId: '' + }, + recommendations: [ + { + id: 'verify', + title: '访问受限', + style: 'discover.posterCompact', + items: [verificationCard(ctx, '手动完成验证', url, error)] + } + ] + }; +} + +function verificationCard(ctx, title, url, error) { + const targetURL = url || entryURL(ctx); + const pageId = verificationPageId(targetURL); + return { + id: pageId, + title: title || '手动完成验证', + subtitle: '点击后再打开验证界面,完成后返回刷新', + overview: cleanText(error && error.message) || '当前网络触发了 Cloudflare 真人验证。', + type: 'category', + mediaType: 'category', + playable: false, + isPlayable: false, + aspectRatio: '16:9', + action: { + type: 'category', + id: pageId, + pageId: pageId, + title: '手动完成验证', + url: targetURL, + itemAspectRatio: '16:9' + } + }; +} + +async function runVerificationCategory(ctx, pageId, url) { + const requestedPage = Math.max(pageFromContext(ctx, 1), pageFromURL(url, 1)); + if (requestedPage > 1 || isVerifiedTargetHTML(ctx, url, getCachedText(ctx, url))) { + return resumeVerifiedCategory(ctx, pageId, url, requestedPage); + } + let html = await browserHTML(ctx, url, url, true); + if (!isVerifiedTargetHTML(ctx, url, html)) { + const refreshedHTML = await fetchAfterVerification(ctx, url); + if (isVerifiedTargetHTML(ctx, url, refreshedHTML)) html = refreshedHTML; + } + const ok = isVerifiedTargetHTML(ctx, url, html); + if (ok) { + setCachedText(ctx, url, html); + if (isActressWorksURL(url) || isActressIndexURL(url)) { + resetCategoryState(ctx, normalizePageId(ctx, url)); + return resumeVerifiedCategory(ctx, pageId, url, 1); + } + } + const retryCard = verificationCard( + ctx, + '验证未完成,点击重新验证', + url, + new Error('浏览器尚未返回可读取的影片页面') + ); + return { + pageType: 'category', + id: pageId, + title: ok ? '验证完成' : '验证未完成', + style: 'media.posterGrid', + itemAspectRatio: '16:9', + page: 1, + hasMore: false, + items: ok + ? [{ + id: pageId + '-success', + title: '验证完成', + subtitle: '请返回原页面继续浏览', + type: 'collection', + mediaType: 'collection', + playable: false, + isPlayable: false, + aspectRatio: '16:9', + action: { type: 'none' } + }] + : [retryCard] + }; +} + +async function resumeVerifiedCategory(ctx, verificationPageId, targetURL, page) { + const stablePageId = normalizePageId(ctx, targetURL); + const resumedContext = Object.assign({}, ctx || {}, { + id: stablePageId, + pageId: stablePageId, + page: page, + currentPage: page, + pageIndex: page + }); + let result = await getCategory(resumedContext); + result.id = stablePageId; + result.verificationTarget = targetURL; + return result; +} + +function resetCategoryState(ctx, pageId) { + const key = categoryStateKey(ctx, pageId); + delete memoryCache()[key]; + cacheSet(key, { pages: {}, expiresAt: 0 }); +} + +function isVerifiedTargetHTML(ctx, url, html) { + if (!isUsableHTML(html)) return false; + if (isActressWorksURL(url)) { + return parseCards(html, pageTitle(html) || '女优作品', ctx).length > 0; + } + if (isActressIndexURL(url)) { + return parseCategoryCards(ctx, html, /\/ranking$/i.test(pathOf(url)) ? '女优排行' : '女优一览').length > 0; + } + return true; +} + +async function fetchAfterVerification(ctx, url) { + const retryContext = Object.assign({}, ctx || {}, { + requestTimeoutSeconds: Math.min(numberParam(ctx, 'requestTimeoutSeconds', 45), 6) + }); + for (let attempt = 0; attempt < 1; attempt += 1) { + try { + await verificationCookieDelay(500); + const options = requestOptions(retryContext, url); + options.useBrowserFallback = false; + options.browserFallback = false; + options.allowBrowserFallback = false; + options.useBrowserCookie = true; + options.attachBrowserCookie = true; + const response = await httpGet(url, options); + const html = responseText(response); + if (isVerifiedTargetHTML(ctx, url, html)) return html; + } catch (error) { + // Return a retry card quickly instead of leaving the verification page spinning. + } + } + return ''; +} + +function verificationCookieDelay(milliseconds) { + if (typeof Promise === 'undefined') return null; + if (typeof setTimeout !== 'function') return Promise.resolve(); + return new Promise(function (resolve) { + setTimeout(resolve, milliseconds); + }); +} + +function pageFromURL(url, fallback) { + const match = String(url || '').match(/[?&]page=(\d+)/i); + return positiveInt(match && match[1], fallback); +} + function categoryAction(ctx, section) { return { type: 'category', @@ -696,32 +1898,65 @@ function categoryAction(ctx, section) { function parseCategoryCards(ctx, html, fallbackTitle) { const source = categoryListBlock(html, fallbackTitle); - const items = []; - const seen = {}; - anchorsIn(source).forEach(function (anchor) { - const href = anchor.href; - const title = cleanText(anchor.text || anchor.title); - if (!title || seen[href + title] || isJunkCategoryTitle(title)) return; - if (!isCategoryHref(href)) return; + const records = {}; + const order = []; + const pattern = /<a\b([^>]*)>([\s\S]*?)<\/a>/gi; + let match; + while ((match = pattern.exec(source))) { + const href = attr(match[1], 'href'); + if (!isCategoryHref(href)) continue; const url = absoluteURL(ctx, href); - seen[href + title] = true; - items.push({ - id: 'category-link-' + items.length + '-' + encodeURIComponent(title), - title: title, + const rawTitle = cleanText(firstNonEmpty( + attr(match[1], 'title'), + attr(match[2], 'alt'), + match[2] + )); + const title = categoryCardTitle(rawTitle); + const image = absoluteURL(ctx, pickImage(match[0])); + if (!records[url]) { + records[url] = { url: url, title: '', image: '' }; + order.push(url); + } + if (title && !isJunkCategoryTitle(title)) records[url].title = records[url].title || title; + if (image) records[url].image = records[url].image || image; + } + + return order.map(function (url, index) { + const record = records[url]; + if (!record || !record.title) return null; + const item = { + id: 'category-link-' + index + '-' + encodeURIComponent(record.title), + title: record.title, subtitle: fallbackTitle || '分类', type: 'category', - aspectRatio: '16:9', + mediaType: 'category', + playable: false, + isPlayable: false, + aspectRatio: record.image ? '2:3' : '16:9', action: { type: 'category', - id: url, - pageId: url, - title: title, - url: url, + id: record.url, + pageId: record.url, + title: record.title, + url: record.url, itemAspectRatio: '16:9' } - }); - }); - return items.slice(0, 80); + }; + if (record.image) { + item.poster = record.image; + item.thumbnailURL = record.image; + item.imageHeaders = imageHeaders(ctx, record.url); + } + return item; + }).filter(Boolean).slice(0, 120); +} + +function categoryCardTitle(value) { + return cleanText(value) + .replace(/\s+\d+\s*(?:条|部)影片[\s\S]*$/i, '') + .replace(/\s+\d{4}\s*出道[\s\S]*$/i, '') + .replace(/\s+第\s*\d+\s*名[\s\S]*$/i, '') + .trim(); } function categoryListBlock(html, title) { @@ -729,9 +1964,8 @@ function categoryListBlock(html, title) { const heading = cleanText(title || ''); const index = heading ? source.search(new RegExp('<h1[^>]*>\\s*' + escapeRegExp(heading), 'i')) : -1; if (index < 0) return source; - const nextScript = source.indexOf('<script', index); const nextFooter = source.indexOf('<footer', index); - const endCandidates = [nextScript, nextFooter].filter(function (value) { return value > index; }).sort(function (a, b) { return a - b; }); + const endCandidates = [nextFooter].filter(function (value) { return value > index; }); return source.slice(index, endCandidates[0] || undefined); } @@ -767,6 +2001,25 @@ function parseCards(html, fallbackTitle, ctx) { return items; } +function mediaListBlock(html) { + const source = String(html || ''); + if (!source) return source; + const anchorPattern = /<a\b([^>]*)>([\s\S]*?)<\/a>/gi; + let match; + let firstMediaIndex = -1; + while ((match = anchorPattern.exec(source))) { + if (!isDetailHref(attr(match[1], 'href'))) continue; + firstMediaIndex = match.index; + break; + } + if (firstMediaIndex < 0) return source; + const headingIndex = source.lastIndexOf('<h1', firstMediaIndex); + const mainIndex = source.lastIndexOf('<main', firstMediaIndex); + const start = Math.max(headingIndex, mainIndex, 0); + const footerIndex = source.indexOf('<footer', firstMediaIndex); + return source.slice(start, footerIndex > firstMediaIndex ? footerIndex : undefined); +} + function mediaBlocks(html) { const source = String(html || ''); const blocks = []; @@ -798,7 +2051,9 @@ function expandBlock(source, start, end, fallback) { .sort(function (a, b) { return a - b; })[0]; const realEnd = close || (nextDetail >= 0 ? end + nextDetail : Math.min(source.length, end + 1800)); const block = source.slice(realStart, realEnd); - return isDetailHref(block) ? block : fallback; + return anchorsIn(block).some(function (anchor) { + return isDetailHref(anchor.href); + }) ? block : fallback; } function parseCard(block, fallbackTitle, rank, ctx) { @@ -872,42 +2127,241 @@ function toWideItem(item) { return item; } -function playbackGroups(detailURL, title, playUrl, ctx) { - const id = encodePayload({ url: detailURL, title: title, playUrl: playUrl || '' }); +async function playbackGroups(detailURL, title, playUrl, ctx, html) { + if (!isPlayableURL(playUrl)) return []; const headers = playbackHeaders(ctx || {}, detailURL); + const qualities = await discoverAvailableQualities(ctx || {}, playUrl, detailURL, html); + if (!qualities.length && containerOf(playUrl) === 'm3u8' && !qualityHeightFromURL(playUrl)) return []; + const sourceQualities = qualities.length ? qualities : [ + { + height: qualityHeightFromURL(playUrl), + label: qualityHeightFromURL(playUrl) ? qualityHeightFromURL(playUrl) + 'P' : '自动画质', + url: playUrl + } + ]; + const versions = sourceQualities.map(function (quality, index) { + const id = encodePayload({ + url: detailURL, + title: title, + quality: quality.height || 'auto' + }); + return { + id: id, + title: quality.label, + name: quality.label, + subtitle: index === 0 ? '最高可用画质' : '切换时请稍候', + url: quality.url, + playUrl: quality.url, + path: quality.url, + default: index === 0, + availability: 'playable', + container: containerOf(quality.url), + headers: headers, + header: headers, + Header: headers, + customHeaders: headers, + sourceName: 'MissAV', + action: { + type: 'play', + itemId: id, + versionId: id, + title: title || 'MissAV' + } + }; + }); return [ { id: 'missav-online', title: '在线播放', - versions: [ - { - id: id, - title: 'MissAV HLS', - name: '默认线路', - subtitle: 'MissAV', - url: playUrl || detailURL, - playUrl: playUrl || '', - path: playUrl || detailURL, - default: true, - availability: 'playable', - container: 'm3u8', - headers: headers, - header: headers, - Header: headers, - customHeaders: headers, - sourceName: 'MissAV', - action: { - type: 'play', - itemId: id, - versionId: id, - title: title || 'MissAV' - } - } - ] + versions: versions } ]; } +function resourceVersionCount(groups) { + return (groups || []).reduce(function (total, group) { + return total + (Array.isArray(group && group.versions) ? group.versions.length : 0); + }, 0); +} + +async function discoverAvailableQualities(ctx, playlistURL, referer, html) { + const candidates = extractHTMLQualities(html, ctx); + if (isPlayableURL(playlistURL)) { + const height = qualityHeightFromURL(playlistURL); + candidates.push({ + height: height, + label: height ? height + 'P' : '自动画质', + url: playlistURL + }); + } + + const urls = unique(candidates.map(function (item) { return item.url; })).slice(0, 8); + 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 response = await httpGet(playlistURL, { + headers: playbackHeaders(ctx || {}, referer || playlistURL), + timeout: remainingPlaybackSeconds(ctx, 5), + timeoutSeconds: remainingPlaybackSeconds(ctx, 5), + useBrowserCookie: true, + attachBrowserCookie: true + }); + const playlist = responseText(response); + if (playlist.indexOf('#EXT-X-STREAM-INF') < 0) return []; + return parseMasterQualities(playlist, playlistURL); + } catch (error) { + return []; + } +} + +function parseMasterQualities(playlist, playlistURL) { + const lines = String(playlist || '').split(/\r?\n/); + const qualities = []; + const seen = {}; + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index].trim(); + if (line.indexOf('#EXT-X-STREAM-INF:') !== 0) continue; + const resolution = line.match(/\bRESOLUTION=(\d+)x(\d+)/i); + const bandwidth = Number(firstMatch(line, /\b(?:AVERAGE-)?BANDWIDTH=(\d+)/i)) || 0; + let uri = ''; + for (let next = index + 1; next < lines.length; next += 1) { + const candidate = lines[next].trim(); + if (!candidate) continue; + if (candidate[0] === '#') break; + uri = candidate; + break; + } + if (!uri) continue; + const height = resolution ? Number(resolution[2]) : qualityHeightFromURL(uri); + const url = resolveRelativeURL(playlistURL, uri); + const key = String(height || 0) + '|' + url; + if (!url || seen[key]) continue; + seen[key] = true; + qualities.push({ + height: height, + width: resolution ? Number(resolution[1]) : 0, + bandwidth: bandwidth, + label: height ? height + 'P' : '自动画质', + url: url + }); + } + return qualities.sort(function (a, b) { + return (b.height || 0) - (a.height || 0) || (b.bandwidth || 0) - (a.bandwidth || 0); + }); +} + +function selectQuality(qualities, requestedQuality) { + const list = Array.isArray(qualities) ? qualities : []; + if (!list.length) return null; + const requested = Number(requestedQuality); + if (requested > 0) { + const exact = list.find(function (item) { return Number(item.height) === requested; }); + if (exact) return exact; + } + return list[0]; +} + +function qualityHeightFromURL(url) { + const match = String(url || '').match(/(?:^|[\/_.-])(\d{3,4})p(?:[\/_.?#-]|$)/i); + return match ? Number(match[1]) : 0; +} + +function resolveRelativeURL(base, value) { + const target = decodeEscapes(String(value || '').trim()); + if (!target) return ''; + if (/^https?:\/\//i.test(target)) return target; + if (target.indexOf('//') === 0) return 'https:' + target; + const origin = originOf(base); + if (target[0] === '/') return origin + target; + const directory = String(base || '').replace(/[?#].*$/, '').replace(/\/[^/]*$/, '/'); + const combined = directory + target; + const prefix = firstMatch(combined, /^(https?:\/\/[^/]+)/i); + const path = combined.slice(prefix.length).split('/'); + const normalized = []; + path.forEach(function (part) { + if (!part || part === '.') return; + if (part === '..') normalized.pop(); + else normalized.push(part); + }); + return prefix + '/' + normalized.join('/'); +} + function playbackResult(ctx, url, referer) { const link = absoluteURL(ctx, url); const container = containerOf(link); @@ -938,9 +2392,9 @@ function playbackResult(ctx, url, referer) { function extractPlayableURL(html) { const source = decodeEscapes(String(html || '') + '\n' + unpackPackedScripts(html).join('\n')); return firstPlayable( + firstMatch(source, /\bsource\s*=\s*["']([^"']+\.(?:m3u8|mp4|mpd)[^"']*)["']/i), firstMatch(source, /source1280\s*=\s*["']([^"']+\.(?:m3u8|mp4|mpd)[^"']*)["']/i), firstMatch(source, /source842\s*=\s*["']([^"']+\.(?:m3u8|mp4|mpd)[^"']*)["']/i), - firstMatch(source, /source\s*=\s*["']([^"']+\.(?:m3u8|mp4|mpd)[^"']*)["']/i), firstMatch(source, /var\s+hlsUrl\s*=\s*["']([^"']+\.(?:m3u8|mp4|mpd)[^"']*)["']/i), firstMatch(source, /<video\b[^>]*\b(?:data-src|src)\s*=\s*["']([^"']+\.(?:m3u8|mp4|mpd)[^"']*)["']/i), firstMatch(source, /source\s+src\s*=\s*["']([^"']+\.(?:m3u8|mp4|mpd)[^"']*)["']/i), @@ -1020,6 +2474,16 @@ function playUrlFromContext(ctx) { ); } +function qualityFromContext(ctx) { + const payload = decodePayload(ctx && (ctx.versionId || ctx.sourceId || ctx.id || ctx.itemId)); + return firstNonEmpty( + payload && payload.quality, + ctx && ctx.quality, + ctx && ctx.height, + ctx && ctx.resolution + ); +} + function makeItemId(url, title, poster) { return encodePayload({ url: absoluteURL(null, url), title: title || '', poster: poster || '' }); } @@ -1042,6 +2506,29 @@ function encodePayload(data) { return MISSAV_DETAIL_PAYLOAD_PREFIX + parts.join('&'); } +function verificationPageId(url) { + return MISSAV_VERIFY_PAYLOAD_PREFIX + 'url=' + encodeURIComponent(String(url || MISSAV_DEFAULT_BASE)); +} + +function verificationUrlFromPageId(value) { + const text = stringValue(value); + if (text.indexOf(MISSAV_VERIFY_PAYLOAD_PREFIX) !== 0) return ''; + const parts = text.slice(MISSAV_VERIFY_PAYLOAD_PREFIX.length).split('&'); + for (let index = 0; index < parts.length; index += 1) { + const part = parts[index]; + const separator = part.indexOf('='); + const key = separator >= 0 ? part.slice(0, separator) : part; + if (key !== 'url') continue; + const raw = separator >= 0 ? part.slice(separator + 1) : ''; + try { + return decodeURIComponent(raw); + } catch (error) { + return raw; + } + } + return ''; +} + function decodePayload(value) { const text = stringValue(value); if (text.indexOf(MISSAV_DETAIL_PAYLOAD_PREFIX) !== 0) return null; @@ -1061,6 +2548,7 @@ function decodePayload(value) { function normalizePageId(ctx, value) { const id = stringValue(value) || 'new-release'; + if (id.indexOf(MISSAV_VERIFY_PAYLOAD_PREFIX) === 0) return id; const section = findSection(id); if (section) return section.id; const primary = findPrimaryCategory(id); @@ -1084,7 +2572,7 @@ function findSection(id) { function findPrimaryCategory(id) { const value = stringValue(id); - return MISSAV_PRIMARY_CATEGORIES.find(function (item) { + return MISSAV_SECTIONS.find(function (item) { return item.id === value || item.title === value || item.path === value || @@ -1095,7 +2583,26 @@ function findPrimaryCategory(id) { function isDetailHref(href) { const value = String(href || ''); - return /\/(?:dm\d+\/)?cn\/(?!new(?:[/?#]|$)|search(?:[/?#]|$)|genres?(?:[/?#]|$)|makers?(?:[/?#]|$)|actresses?(?:[/?#]|$)|actors?(?:[/?#]|$)|today-hot|weekly-hot|monthly-hot|uncensored-leak|chinese-subtitle|fc2(?:[/?#]|$)|heyzo(?:[/?#]|$)|tokyo-hot(?:[/?#]|$)|1pondo(?:[/?#]|$)|caribbeancom(?:[/?#]|$)|caribbeancompr(?:[/?#]|$)|10musume(?:[/?#]|$)|pacopacomama(?:[/?#]|$)|gachinco(?:[/?#]|$)|xxx-av(?:[/?#]|$)|siro(?:[/?#]|$)|luxu(?:[/?#]|$)|gana(?:[/?#]|$)|prestige-premium(?:[/?#]|$)|s-cute(?:[/?#]|$)|ara(?:[/?#]|$)|madou(?:[/?#]|$)|twav(?:[/?#]|$)|vr(?:[/?#]|$)|history|playlist|favorite|login|register)[^"'#?\/]+/i.test(value); + if (findSection(value)) return false; + if (isCategoryHref(value)) return false; + const path = value + .replace(/^https?:\/\/[^/]+/i, '') + .split(/[?#]/)[0] + .replace(/^\/+|\/+$/g, ''); + const parts = path.split('/').filter(Boolean); + if (/^dm\d+$/i.test(parts[0] || '')) parts.shift(); + if (/^(?:cn|en|ja|ko|ms|th|de|fr|vi|id)$/i.test(parts[0] || '')) parts.shift(); + if (parts.length !== 1) return false; + const slug = parts[0] || ''; + if (/^(?:new|release|random|search|history|playlists?|saved|favorite|login|register|vip)$/i.test(slug)) return false; + // Most MissAV codes use a hyphen (for example abc-123), while the + // 1pondo/Caribbeancompr/10musume/pacopacomama feeds use date-based + // underscore IDs such as pondo-072526_001 and 051726_001. + const standardCode = /^[a-z0-9]+(?:[-_][a-z0-9]+)*$/i.test(slug) && + /[a-z]/i.test(slug) && + /\d/.test(slug); + const dateOnlyCode = /^\d{6}_\d{2,3}$/i.test(slug); + return standardCode || dateOnlyCode; } function categorySlug(value) { @@ -1111,6 +2618,21 @@ function hasNextPage(html, page) { return total ? page < total : false; } +function paginationInfo(html, page, itemCount) { + const source = String(html || ''); + let totalPages = 0; + const pagePattern = /[?&]page=(\d+)/gi; + let match; + while ((match = pagePattern.exec(source))) { + totalPages = Math.max(totalPages, Number(match[1]) || 0); + } + const hasMore = hasNextPage(source, page) || (!totalPages && Number(itemCount) >= 12); + return { + hasMore: hasMore, + totalPages: totalPages || undefined + }; +} + function sectionBlock(html, title) { const source = String(html || ''); const index = source.search(new RegExp('<h[12][^>]*>\\s*' + escapeRegExp(title), 'i')); @@ -1390,9 +2912,12 @@ function isDetailPageURL(url) { function isCloudflare(html, status, headers) { const text = String(html || '') + ' ' + JSON.stringify(headers || {}); return Number(status) === 403 || + Number(status) === 429 || + Number(status) === 503 || /<title>\s*Just a moment/i.test(text) || + /Checking your browser|Verifying you are human|Verify you are human|Ray ID/i.test(text) || /Enable JavaScript and cookies to continue/i.test(text) || - /cf-mitigated|cf-browser-verification/i.test(text); + /cf-mitigated|cf-browser-verification|cf-chl-|challenge-platform|turnstile/i.test(text); } function decodeEscapes(value) { @@ -1505,6 +3030,7 @@ const MissAVMiniLibrary = { search: search, onSearch: onSearch, getSearch: getSearch, + onAction: onAction, matchResources: matchResources, matchMovie: matchResources }; @@ -1528,6 +3054,7 @@ if (typeof globalThis !== 'undefined') { globalThis.search = search; globalThis.onSearch = onSearch; globalThis.getSearch = getSearch; + globalThis.onAction = onAction; globalThis.matchResources = matchResources; globalThis.matchMovie = matchResources; globalThis.__jsEvalReturn = __jsEvalReturn; @@ -1536,3 +3063,38 @@ 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 }; + ctx.__playbackBudget = budget; + let timer; + try { + const timeout = new Promise(function (_, reject) { + timer = setTimeout(function () { budget.expired = true; reject(new Error('播放解析超时;stage=total-deadline;请稍后重试')); }, 28000); + }); + 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/novipnoad-mini-library.js b/novipnoad-mini-library.js index a8e7928..eb1e586 100644 --- a/novipnoad-mini-library.js +++ b/novipnoad-mini-library.js @@ -32,8 +32,8 @@ function getManifest() { return { id: 'novipnoad', name: 'NO视频', - version: '1.0.0', - author: 'baiPlay', + version: '1.0.1', + author: 'Alan huang', logo: NOVIPNOAD_LOGO, description: 'NO视频自定义媒体库示例。站点主页启用了 Cloudflare 浏览器校验,纯 HTTP 运行环境可能需要写入 novipnoad.cf.cookie 或由 App 提供浏览器代理请求。', @@ -630,10 +630,10 @@ function getResourceVersions(ext) { return buildResourceGroups(itemId, vid, episode ? episode.title : ''); } -function resolvePlayback(ext) { +async function resolvePlaybackWithinBudget(ext) { const itemId = ext.itemId || ext.id || ''; const detailURL = siteURL(itemId); - const html = fetchText(itemId); + const html = await requestPlaybackURL(detailURL, BASE + "/", ext); const playInfo = parsePlayInfo(html); const episodes = parseEpisodes(html, itemId, ''); const requested = ext.versionId || ext.episodeId || playInfo.vid || ''; @@ -649,13 +649,13 @@ function resolvePlayback(ext) { const playerURL = PLAYER_BASE + '/v1/?url=' + encodeURIComponent(vid) + '&pkey=' + pkey + '&ref=' + encodeURIComponent(refPath); const playerFrameURL = PLAYER_BASE + '/v1/player.php?id=' + encodeURIComponent(vid); - const browserResult = browserFetchPlayer(playerURL, detailURL); + const browserResult = await browserFetchPlayer(playerURL, detailURL, ext); const directPlayback = playbackFromBrowserResult(browserResult, playerFrameURL); if (directPlayback) return directPlayback; let vkey = extractBrowserVkey(browserResult); if (!vkey || !vkey.ckey) { - const playerHTML = fetchPlayer(playerURL, detailURL); + const playerHTML = await requestPlaybackURL(playerURL, detailURL, ext); vkey = extractVkey(playerHTML); } if (!vkey || !vkey.ckey) throw new Error('NO视频播放器校验未通过,无法生成播放密钥'); @@ -673,7 +673,7 @@ function resolvePlayback(ext) { encodeURIComponent(vkey.ip || '') + '&time=' + encodeURIComponent(vkey.time || ''); - const encJS = fetchPlayer(encURL, playerFrameURL); + const encJS = await requestPlaybackURL(encURL, playerFrameURL, ext); const payload = firstMatch(encJS, /JSON\.decrypt\(["']([^"']+)["']\)/i); if (!payload) throw new Error('NO视频没有返回可解密播放信息'); @@ -707,12 +707,13 @@ function playbackHeaders(referer) { }; } -function browserFetchPlayer(url, referer) { +async function browserFetchPlayer(url, referer, ctx) { if (!Widget.browser || typeof Widget.browser.fetch !== 'function') return null; try { - return Widget.browser.fetch(url, { + return await Widget.browser.fetch(url, { visible: false, - timeout: 60, + timeout: playbackBrowserSeconds(ctx, 12, 1), + timeoutSeconds: remainingPlaybackSeconds(ctx, 12), waitAfterLoad: 1.0, waitForSessionStorageKey: 'vkey', waitForMediaSource: true, @@ -1075,3 +1076,49 @@ function matchMovie(ext) { function matchEpisode(ext) { return matchResources(ext); } + +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 }; + ctx.__playbackBudget = budget; + let timer; + try { + const timeout = new Promise(function (_, reject) { + timer = setTimeout(function () { budget.expired = true; reject(new Error('播放解析超时;stage=total-deadline;请稍后重试')); }, 28000); + }); + 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 requestPlaybackURL(url, referer, ctx) { + const headers = { 'User-Agent': UA, Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', Referer: referer || BASE + '/' }; + const cookie = getCookieHeader(); if (cookie) headers.Cookie = cookie; + const timeout = remainingPlaybackSeconds(ctx, 6); + const response = await Widget.http.get(url, { headers, timeout, timeoutSeconds: timeout }); + if (Number(response && (response.status || response.statusCode)) >= 400) throw new Error('NO视频源站请求失败'); + const data = response && typeof response.data === 'string' ? response.data : response && typeof response.body === 'string' ? response.body : ''; + if (!data || isCloudflareChallenge(data, response && response.status, response && response.headers || {})) throw new Error('NO视频播放器页面为空或需要浏览器验证'); + return data; +} diff --git a/pornhub.media-library.js b/pornhub.media-library.js index e4b4f77..5482981 100644 --- a/pornhub.media-library.js +++ b/pornhub.media-library.js @@ -48,9 +48,9 @@ var WidgetMetadata = { id: "baiplay_pornhub_media_library", title: "Pornhub", description: "Pornhub custom media library source for baiPlay", - author: "community", + author: 'Alan huang', site: PORNHUB_BASE_URL, - version: "1.0.0", + version: "1.0.1", requiredVersion: "0.0.2", detailCacheDuration: 60, }; @@ -792,10 +792,8 @@ function normalizeQuality(value) { } function sortMediaSources(sources) { - return (sources || []).slice().sort((a, b) => { - if (!!a.default !== !!b.default) return a.default ? -1 : 1; - return (b.quality || 0) - (a.quality || 0); - }); + return (sources || []).slice().sort((a, b) => (b.quality || 0) - (a.quality || 0)) + .map((source, index) => ({ ...source, default: index === 0 })); } function mergeMediaSources(left, right) { diff --git a/pornhub_int.js b/pornhub_int.js index a0fb83f..8994a12 100644 --- a/pornhub_int.js +++ b/pornhub_int.js @@ -1,7 +1,7 @@ WidgetMetadata = { id: "Pornhub_int", title: "Pornhub", - version: "1.1.5", + version: "1.1.6", requiredVersion: "0.0.1", description: "網路收集到的模塊", author: "海带", @@ -938,7 +938,7 @@ async function getSearchResults(params) { const sortBy = params.sort_by || ""; const searchType = params.search_type || "no"; // 获取是否开启精准搜索的设置 - const formattedQuery = searchQuery.trim().replace(/[\s\-]+/g, '+').toLowerCase();; + const formattedQuery = searchQuery.trim().toLowerCase().split(/[\s\-]+/).map(encodeURIComponent).join('+'); let baseUrl = `https://cn.pornhub.com/video/search?search=${formattedQuery}`; if (sortBy === 'new') baseUrl += '&o=mr'; else if (sortBy === 'views') baseUrl += '&o=mv'; diff --git a/sexbjcam-mini-library.js b/sexbjcam-mini-library.js index f04b569..7738b12 100644 --- a/sexbjcam-mini-library.js +++ b/sexbjcam-mini-library.js @@ -9,7 +9,7 @@ const WidgetMetadata = { id: 'sexbjcam-mini-library', name: 'SexBJCam', title: 'SexBJCam', - version: '1.1.7', + version: '1.1.8', author: 'Alan huang', logo: SEXBJCAM_LOGO, icon: SEXBJCAM_LOGO, @@ -170,7 +170,7 @@ async function getResourceVersions(rawCtx) { }; } -async function resolvePlayback(rawCtx) { +async function resolvePlaybackWithinBudget(rawCtx) { const ctx = normalizeContext(rawCtx); const detailURL = detailURLFromContext(ctx); let embedURL = embedURLFromContext(ctx); @@ -501,7 +501,7 @@ async function loadSectionItems(ctx, section, page) { } async function fetchText(ctx, url, headers) { - const response = await httpRequest(url, headers || requestHeaders(ctx, url)); + const response = await httpRequest(url, headers || requestHeaders(ctx, url), remainingPlaybackSeconds(ctx, 6)); const text = unwrapText(response); if (!text) throw new Error('请求失败: ' + url); return text; @@ -521,7 +521,8 @@ async function fetchPlayerText(ctx, url) { try { const result = await widget.browser.fetch(cacheBustedURL(url), { visible: false, - timeout: 60, + timeout: playbackBrowserSeconds(ctx, 12, 2), + timeoutSeconds: remainingPlaybackSeconds(ctx, 12), waitAfterLoad: 3, waitForAny: true, waitForMediaSource: true, @@ -550,7 +551,8 @@ async function fetchSignedManifestText(ctx, url, headers) { try { const result = await widget.browser.fetch(url, { visible: false, - timeout: 30, + timeout: playbackBrowserSeconds(ctx, 6, 2), + timeoutSeconds: remainingPlaybackSeconds(ctx, 6), waitAfterLoad: 1, waitForAny: true, headers: headers @@ -603,13 +605,13 @@ function cacheBustedURL(url) { return String(url || '') + separator + '_dreamby_refresh=' + Date.now() + '-' + SEXBJCAM_REQUEST_NONCE; } -async function httpRequest(url, headers) { +async function httpRequest(url, headers, timeout = 6) { 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 客户端'); - 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 }); + if (typeof client.get === 'function') return client.get(url, { headers: headers, timeout, timeoutSeconds: timeout }); + if (typeof client.request === 'function') return client.request({ url: url, method: 'GET', headers: headers, timeout, timeoutSeconds: timeout }); throw new Error('当前 HTTP 客户端不支持 GET'); } @@ -817,3 +819,38 @@ 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; + +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 }; + ctx.__playbackBudget = budget; + let timer; + try { + const timeout = new Promise(function (_, reject) { + timer = setTimeout(function () { budget.expired = true; reject(new Error('播放解析超时;stage=total-deadline;请稍后重试')); }, 28000); + }); + 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/sexbjcam-network-recovery.test.js b/sexbjcam-network-recovery.test.js index 630ebb9..49a9e58 100644 --- a/sexbjcam-network-recovery.test.js +++ b/sexbjcam-network-recovery.test.js @@ -74,6 +74,11 @@ async function run() { const serializedCache = JSON.stringify(Array.from(storageValues.values())); assert(!serializedCache.includes('token=fresh'), '不得缓存签名播放 URL'); + + 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'); } diff --git a/stripchat-angellive/worker/README.md b/stripchat-angellive/worker/README.md index ba88a06..3e5b8b2 100644 --- a/stripchat-angellive/worker/README.md +++ b/stripchat-angellive/worker/README.md @@ -1,6 +1,6 @@ # stripchat-mouflon-proxy(Cloudflare Worker 版) -云端常驻版的 Mouflon 解密代理。逻辑与 `~/stripchat-mouflon-proxy/proxy.js` 完全一致, +云端常驻版的 Mouflon 解密代理。采用与本地 Node 版相同的解析、解密流程, 只是跑在 Cloudflare 边缘,因此**任何设备、任何网络都能用,不需要自己的机器开机**。 线上地址: @@ -21,6 +21,10 @@ npx wrangler deploy `~/stripchat-mouflon-proxy/worker/src/index.js` 是实际部署的那份,本目录与它保持一致, 改动后请同步过去再 deploy。 +2026-10-03 的仓库更新加入首个有效 CDN 返回、重试刷新 master/密钥和覆盖清单正文的超时; +清单/JSON 限 1 MiB,视频分片仍流式传输。新增日志仅记录阶段、耗时和成功状态,traces 按 5% 采样。 +这些改动需要部署到同一个 Worker 后才会影响线上服务;本次脚本修复未执行部署。 + ## 路由 | 路径 | 说明 | diff --git a/stripchat-angellive/worker/index.js b/stripchat-angellive/worker/index.js index 13734f5..de66591 100644 --- a/stripchat-angellive/worker/index.js +++ b/stripchat-angellive/worker/index.js @@ -66,23 +66,78 @@ const masterFailCache = new Map(); /* -------------------------------- 工具 -------------------------------- */ -async function upstreamFetch(url, timeoutMs = 12000) { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); +async function upstreamFetch(url, timeoutMs = 12000, options = {}) { + const controller = new AbortController(); let timer; let rejectDeadline; + const deadline = new Promise((_, reject) => { rejectDeadline = reject; }); + function cancel(reason) { controller.abort(); rejectDeadline(new Error(reason)); } + const externalAbort = () => cancel('upstream probe cancelled'); + if (options.signal) { + if (options.signal.aborted) throw new Error('upstream probe cancelled'); + options.signal.addEventListener('abort', externalAbort, { once: true }); + } + timer = setTimeout(() => cancel('upstream request timed out'), timeoutMs); try { - return await fetch(url, { - headers: { - "User-Agent": UA, - "Referer": "https://stripchat.com/", - "Accept": "*/*", - "Accept-Encoding": "identity" - }, - redirect: "manual", - signal: controller.signal - }); + const operation = (async () => { + const response = await fetch(url, { headers: { 'User-Agent': UA, Referer: 'https://stripchat.com/', Accept: '*/*', 'Accept-Encoding': 'identity' }, redirect: 'manual', signal: controller.signal }); + if (!options.read) return response; // Media segments stay streaming. + if (!response.ok) { if (response.body) await response.body.cancel().catch(() => {}); return { response, body: '' }; } + const body = await readSmallBody(response, controller.signal); + return { response, body: options.read === 'json' ? JSON.parse(body) : body }; + })(); + return await Promise.race([operation, deadline]); } finally { clearTimeout(timer); + if (options.signal) options.signal.removeEventListener('abort', externalAbort); + } +} + +async function readSmallBody(response, signal) { + const limit = 1024 * 1024; + if (!response.body || typeof response.body.getReader !== 'function') { + const text = await response.text(); + if (new TextEncoder().encode(text).length > limit) throw new Error('upstream playlist/JSON exceeds size limit'); + return text; } + const reader = response.body.getReader(); const chunks = []; let size = 0; + const cancel = () => { reader.cancel().catch(() => {}); }; + signal.addEventListener('abort', cancel, { once: true }); + try { + while (true) { + const part = await reader.read(); + if (signal.aborted) throw new Error('upstream body read cancelled'); + if (part.done) break; + size += part.value.byteLength; + if (size > limit) { await reader.cancel(); throw new Error('upstream playlist/JSON exceeds size limit'); } + chunks.push(part.value); + } + const bytes = new Uint8Array(size); let offset = 0; + for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; } + return new TextDecoder().decode(bytes); + } finally { signal.removeEventListener('abort', cancel); reader.releaseLock(); } +} + +async function firstSuccessful(values, probe) { + const controllers = values.map(() => new AbortController()); + const results = new Array(values.length); + if (!values.length) return { hit: null, results: [] }; + return new Promise(resolve => { + let completed = 0; let settled = false; + values.forEach((value, index) => { + Promise.resolve().then(() => probe(value, controllers[index].signal)).catch(() => ({ ok: false, why: 'upstream probe failed' })).then(result => { + results[index] = result; completed++; + if (settled) return; + if (result.ok) { + settled = true; + controllers.forEach((controller, other) => { if (other !== index) controller.abort(); }); + resolve({ hit: result, results }); + } else if (completed === values.length) { settled = true; resolve({ hit: null, results }); } + }); + }); + }); +} + +function logStage(stage, startedAt, ok) { + console.log(JSON.stringify({ stage, durationMs: Date.now() - startedAt, ok })); } function json(body, status = 200) { @@ -109,9 +164,8 @@ async function getKeys(env) { keyInflight = (async () => { try { - const res = await upstreamFetch(KEY_URL, 8000); + const { response: res, body: parsed } = await upstreamFetch(KEY_URL, 8000, { read: 'json' }); if (res.ok) { - const parsed = await res.json(); if (parsed && parsed.keys && Object.keys(parsed.keys).length) { keyCache = { at: Date.now(), keys: { ...bundled, ...parsed.keys } }; return keyCache.keys; @@ -188,12 +242,12 @@ function parseMaster(bodyText, baseURL) { return { pkeys, variants }; } -async function probeMaster(streamId, tld, keys) { +async function probeMaster(streamId, tld, keys, signal) { const url = `https://edge-hls.doppiocdn.${tld}/hls/${streamId}/master/${streamId}_auto.m3u8`; try { - const res = await upstreamFetch(url, MASTER_FETCH_TIMEOUT_MS); + const { response: res, body } = await upstreamFetch(url, MASTER_FETCH_TIMEOUT_MS, { read: 'text', signal }); if (!res.ok) return { ok: false, why: `${tld}: HTTP ${res.status}` }; - const parsed = parseMaster(await res.text(), url); + const parsed = parseMaster(body, url); if (!parsed.variants.length) return { ok: false, why: `${tld}: 无画质变体` }; const known = parsed.pkeys.find((pair) => keys[pair.key]); if (!known) return { ok: false, why: `${tld}: master 给出的 pkey 无匹配 pdkey` }; @@ -220,8 +274,9 @@ async function loadMaster(streamId, env) { const keys = await getKeys(env); // 4 个 CDN 域名并发探,最坏耗时从 36s 降到单个超时。 - const results = await Promise.all(EDGE_TLDS.map((tld) => probeMaster(streamId, tld, keys))); - const hit = results.find((item) => item.ok); + const startedAt = Date.now(); + const { hit, results } = await firstSuccessful(EDGE_TLDS, (tld, signal) => probeMaster(streamId, tld, keys, signal)); + logStage('master', startedAt, !!hit); if (hit) { masterCache.set(streamId, { at: Date.now(), data: hit.data }); masterFailCache.delete(streamId); @@ -385,12 +440,11 @@ function alternateHosts(url) { return list; } -async function tryVariantCandidate(candidate, attempts) { +async function tryVariantCandidate(candidate, attempts, signal) { try { - const res = await upstreamFetch(candidate, 6000); - const body = res.ok ? await res.text() : ""; + const { response: res, body } = await upstreamFetch(candidate, 6000, { read: 'text', signal }); const decoy = MOUFLON_ADVERT.test(body); - if (res.ok && !decoy) return { ok: true, body, url: candidate }; + if (res.ok && /^\s*#EXTM3U/.test(body) && !decoy) return { ok: true, body, url: candidate }; attempts.push(`${new URL(candidate).host} -> ${res.status}${decoy ? " (广告诱饵清单)" : ""}`); } catch (error) { attempts.push(`${new URL(candidate).host} -> ${error && error.message}`); @@ -399,22 +453,20 @@ async function tryVariantCandidate(candidate, attempts) { } async function fetchVariantPlaylist(master, variant, env) { - const attempts = []; - // 第一轮并发探所有 CDN 域名:最常见的情况是第一个就命中,最坏也只有一个超时周期。 - const first = await Promise.all(alternateHosts(variantURL(master, variant)).map((candidate) => tryVariantCandidate(candidate, attempts))); - const hit = first.find((item) => item.ok); - if (hit) return hit; - + const attempts = []; const startedAt = Date.now(); + const first = await firstSuccessful(alternateHosts(variantURL(master, variant)), (candidate, signal) => tryVariantCandidate(candidate, attempts, signal)); + if (first.hit) { logStage('variant', startedAt, true); return { ...first.hit, master }; } masterCache.delete(master.streamId); - await new Promise((resolve) => setTimeout(resolve, 300)); - const second = await Promise.all(alternateHosts(variantURL(master, variant)).map((candidate) => tryVariantCandidate(candidate, attempts))); - const retry = second.find((item) => item.ok); - if (retry) return retry; - return { ok: false, attempts }; + let fresh; + try { fresh = await loadMaster(master.streamId, env); } + catch (_) { logStage('variant', startedAt, false); return { ok: false, attempts: attempts.concat('master refresh failed') }; } + const selected = fresh.variants.find(item => item.name === variant.name) || fresh.variants.find(item => item.height === variant.height); + if (!selected) { logStage('variant', startedAt, false); return { ok: false, attempts: attempts.concat('refreshed master has no matching quality') }; } + const second = await firstSuccessful(alternateHosts(variantURL(fresh, selected)), (candidate, signal) => tryVariantCandidate(candidate, attempts, signal)); + logStage('variant', startedAt, !!second.hit); + return second.hit ? { ...second.hit, master: fresh } : { ok: false, attempts }; } -/* -------------------------------- 路由 -------------------------------- */ - async function handleIndex(request, streamId, env, ctx) { const origin = new URL(request.url).origin; const cache = typeof caches !== "undefined" ? caches.default : null; @@ -479,7 +531,8 @@ async function handleVariantPlaylist(streamId, name, env, ctx, request) { "上游媒体清单不可用(已重试多个 CDN 节点):\n" + outcome.attempts.join("\n") + "\n\n403/302 通常是该 CDN 节点暂时不可用或对本出口限流,换一个主播或过几分钟再试。", 502); } - const rewritten = await rewriteMediaPlaylist(outcome.body, outcome.url, master.pkey, master.pdkey, segmentMode(env)); + const activeMaster = outcome.master || master; + const rewritten = await rewriteMediaPlaylist(outcome.body, outcome.url, activeMaster.pkey, activeMaster.pdkey, segmentMode(env)); if (!rewritten.segmentCount) return text("上游清单里没有可解密的 Mouflon 分片", 502); const response = new Response(rewritten.playlist, { status: 200, @@ -701,6 +754,7 @@ export default { // 仅供本地单元测试导入;不会成为 Worker 的 HTTP 路由或公开接口。 export const __test = { + upstreamFetch, firstSuccessful, loadMaster, fetchVariantPlaylist, handleVariantPlaylist, masterCache, masterFailCache, compactSegmentTarget, decodeSegmentParam, decryptSegmentURL, diff --git a/stripchat-angellive/worker/test.js b/stripchat-angellive/worker/test.js index 1065a10..c120202 100644 --- a/stripchat-angellive/worker/test.js +++ b/stripchat-angellive/worker/test.js @@ -48,3 +48,80 @@ test("rewriteMediaPlaylist emits standard direct HLS segment URLs", async () => assert.match(result.playlist, /123_real-segment_7\.mp4/); assert.doesNotMatch(result.playlist, /EXT-X-MOUFLON|media\.mp4/); }); + +function masterFixture(pkey = '1Dzcc6OjP73LKbtI', variant = '1080.m3u8') { + return `#EXTM3U\n#EXT-X-MOUFLON:PSCH:v2:${pkey}\n#EXT-X-STREAM-INF:BANDWIDTH=2000000,RESOLUTION=1920x1080,NAME="1080p"\n${variant}\n`; +} + +async function withFetch(replacement, operation) { + const original = globalThis.fetch; + globalThis.fetch = replacement; + __test.masterCache.clear(); __test.masterFailCache.clear(); + try { return await operation(); } + finally { globalThis.fetch = original; __test.masterCache.clear(); __test.masterFailCache.clear(); } +} + +test('loadMaster returns the first valid CDN and cancels pending probes', async () => { + let cancelled = 0; + await withFetch(async (url, options) => { + if (String(url).includes('/api/keys')) return new Response('', { status: 503 }); + if (String(url).includes('doppiocdn.org')) return new Response(masterFixture()); + return new Promise((_, reject) => options.signal.addEventListener('abort', () => { cancelled++; reject(new Error('cancelled')); }, { once: true })); + }, async () => { + const started = Date.now(); const master = await __test.loadMaster('fast-cdn', {}); + assert.equal(master.variants[0].height, 1080); assert.ok(Date.now() - started < 500); assert.equal(cancelled, 3); + }); +}); + +test('all 404/410 masters mean offline; mixed upstream failures remain unknown', async () => { + for (const mixed of [false, true]) { + await withFetch(async url => new Response('', { status: mixed && String(url).includes('doppiocdn.com') ? 403 : 404 }), async () => { + await assert.rejects(__test.loadMaster(mixed ? 'unknown' : 'offline', {}), error => error.offline === !mixed); + }); + } +}); + +test('the upstream deadline includes a stalled playlist body', async () => { + let cancelled = false; + await withFetch(async () => new Response(new ReadableStream({ cancel() { cancelled = true; } })), async () => { + const started = Date.now(); + await assert.rejects(__test.upstreamFetch('https://edge.test/stalled.m3u8', 20, { read: 'text' }), /timed out|cancelled/); + assert.ok(Date.now() - started < 500); assert.equal(cancelled, true); + }); +}); + +test('oversized playlists are rejected without unbounded buffering', async () => { + await withFetch(async () => new Response(new Uint8Array(1024 * 1024 + 1)), async () => { + await assert.rejects(__test.upstreamFetch('https://edge.test/oversized.m3u8', 100, { read: 'text' }), /size limit/); + }); +}); + +test('media segments remain streaming rather than being buffered as playlists', async () => { + const body = new ReadableStream({}); + await withFetch(async () => new Response(body), async () => { + const response = await __test.upstreamFetch('https://media.test/segment.mp4', 100); + assert.ok(response.body); assert.equal(response.bodyUsed, false); await response.body.cancel(); + }); +}); + +test('variant retry reloads master and uses the refreshed pkey and pdkey', async () => { + const streamId = 'rotating-key'; + const newPkey = '7uUnbD0jMCB9GH32'; const newPdkey = 'lzCQ6QBTnLpB0zMF'; + const token = encryptedToken('fresh-segment', newPdkey); + const body = `#EXTM3U\n#EXT-X-TARGETDURATION:2\n#EXT-X-MOUFLON:URI:https://media-hls.doppiocdn.org/b-hls-14/123_${token}_7.mp4\nmedia.mp4\n`; + const requests = []; + await withFetch(async (url, options) => { + requests.push(String(url)); + if (new URL(url).pathname.endsWith('_auto.m3u8')) return new Response(masterFixture(newPkey, 'refreshed.m3u8')); + if (String(url).includes('pkey=old-key')) return new Response('', { status: 403 }); + assert.ok(String(url).includes(`pkey=${newPkey}`)); assert.ok(String(url).includes('refreshed.m3u8')); + return new Response(body); + }, async () => { + const old = { streamId, scheme: 'v1', pkey: 'old-key', pdkey: 'old-pdkey', variants: [{ name: '1080p', height: 1080, bandwidth: 2000000, url: 'https://media-hls.doppiocdn.org/old.m3u8' }] }; + __test.masterCache.set(streamId, { at: Date.now(), data: old }); + const response = await __test.handleVariantPlaylist(streamId, '1080p', { SEGMENT_MODE: 'direct' }, {}, new Request('https://worker.test/play/rotating-key/1080p.m3u8')); + const playlist = await response.text(); + assert.equal(response.status, 200, playlist); assert.match(playlist, /123_fresh-segment_7\.mp4/); + assert.ok(requests.some(url => url.includes('/master/'))); assert.ok(requests.some(url => url.includes(`pkey=${newPkey}`))); + }); +}); diff --git a/stripchat-angellive/worker/wrangler.toml b/stripchat-angellive/worker/wrangler.toml index cd36d5e..2bd58b7 100644 --- a/stripchat-angellive/worker/wrangler.toml +++ b/stripchat-angellive/worker/wrangler.toml @@ -4,3 +4,7 @@ compatibility_date = "2026-09-01" [observability] enabled = true + +[observability.traces] +enabled = true +head_sampling_rate = 0.05 diff --git a/taolusm-mini-library.js b/taolusm-mini-library.js index 0492a2c..33a205f 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.0', - author: 'EL', + version: '1.0.1', + author: 'Alan huang', logo: TAOLUSM_LOGO, icon: TAOLUSM_LOGO, site: TAOLUSM_DEFAULT_BASE, @@ -71,6 +71,7 @@ function getManifest() { } async function getHome(ctx) { + ctx = normalizeRuntimeContext(ctx); let latestItems = []; try { const html = await fetchText(ctx, categoryURL(ctx, '/')); @@ -110,6 +111,7 @@ 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 { @@ -128,6 +130,7 @@ 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]; @@ -148,6 +151,7 @@ async function getCategory(ctx) { } async function getDetail(ctx) { + ctx = normalizeRuntimeContext(ctx); const id = itemIdFromContext(ctx); if (!id) throw new Error('套路SM 详情参数无效'); @@ -197,12 +201,14 @@ async function getDetail(ctx) { } function getResourceVersions(ctx) { + ctx = normalizeRuntimeContext(ctx); const id = itemIdFromContext(ctx); if (!id) return []; return resourceGroupsFor(ctx, id, stringValue(ctx && ctx.title) || '在线播放'); } function resolvePlayback(ctx) { + ctx = normalizeRuntimeContext(ctx); const id = itemIdFromContext(ctx); const url = stringValue(ctx && ctx.url) || (id ? downloadURL(ctx, id) : ''); if (!url) throw new Error('套路SM 播放参数无效'); @@ -217,6 +223,7 @@ 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) { @@ -524,3 +531,16 @@ 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 new file mode 100644 index 0000000..eba6a24 --- /dev/null +++ b/tests/adapters.test.cjs @@ -0,0 +1,259 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const root = path.resolve(__dirname, '..'); +function load(file, additions = {}) { + const context = vm.createContext({ console: { log() {}, warn() {}, error() {} }, URL, URLSearchParams, Buffer, atob, btoa, + TextDecoder, TextEncoder, setTimeout, clearTimeout, module: { exports: {} }, + Widget: { http: { async get() { return { status: 200, statusCode: 200, data: '<html><body></body></html>' }; } }, storage: { get() { return null; } } }, ...additions }); + vm.runInContext(fs.readFileSync(path.join(root, file), 'utf8'), context, { filename: file, timeout: 2000 }); + return context; +} +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']) { + 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>' }; }; + c.Widget.http.post = async (url, body) => { requests.push(url + '|' + body); return { status: 200, data: '<html><body></body></html>' }; }; + const input = { query: 'A&B #tag', page: 2 }; + await c.search(input).catch(() => {}); const objectRequests = requests.splice(0); + await c.search(JSON.stringify(input)).catch(() => {}); + assert.ok(objectRequests.length > 0); assert.deepEqual(requests, objectRequests); + }); +} + +test('Jable home waits for one category; all remaining categories load lazily', async () => { + const c = load('jable.media-library.js'); let calls = 0; + c.loadPosterWall = async () => { calls++; return [{ id: 'fresh', title: 'Fresh', poster: 'https://img.test/fresh.jpg' }]; }; + const home = await c.getHome({}); + assert.equal(calls, 1); assert.equal(home.hero.length, 1); + assert.ok(home.sections.filter(s => s.lazy).length > 20); + const section = await c.getHomeSection({ sectionId: 'hot' }); + assert.equal(section.lazy, false); assert.equal(section.items.length, 1); assert.equal(calls, 2); +}); +test('Jable refreshes a saved detail playback action even when it contains an old URL', async () => { + const c = load('jable.media-library.js'); let calls = 0; + c.getDetail = async () => { calls++; return { id: 'https://jable.tv/videos/a/', link: 'https://jable.tv/videos/a/', videoUrl: 'https://cdn.test/fresh.m3u8' }; }; + const result = await c.resolvePlayback({ itemId: 'https://jable.tv/videos/a/', versionId: 'https://jable.tv/videos/a/#hls', url: 'https://cdn.test/expired.m3u8' }); + assert.equal(result.url, 'https://cdn.test/fresh.m3u8'); assert.equal(calls, 1); +}); +test('591AV fails an incomplete two-page batch and keeps its page available for retry', async () => { + const c = load('591av-mini-library.js'); let calls = 0; + c.fetchText = async () => { if (++calls === 1) throw new Error('temporary failure'); return '<html></html>'; }; + const result = await c.getCategory({ pageId: 'new', page: 1 }); + assert.ok(result.error); assert.equal(result.nextPage, 1); assert.equal(result.hasMore, true); assert.equal(result.items.length, 0); +}); +test('591AV recovers the same batch without skipping source page one', async () => { + const c = load('591av-mini-library.js'); const requests = []; + c.fetchText = async (ctx, url) => { requests.push(url); return url; }; + c.parseCards = html => [{ id: new URL(html).searchParams.get('from') === '2' ? 'source-two' : 'source-one', title: 'Video' }]; + const first = await c.getCategory({ pageId: 'new', page: 1 }); + const retry = await c.getCategory({ pageId: 'new', page: 1 }); + assert.deepEqual(plain(retry.items), plain(first.items)); assert.equal(requests[0], requests[2]); assert.equal(requests[1], requests[3]); + assert.deepEqual(plain(first.items.map(item => item.id)), ['source-one', 'source-two']); +}); +test('591AV home does not wait for six category previews', async () => { + const c = load('591av-mini-library.js'); let calls = 0; + c.fetchText = async () => { calls++; return 'latest'; }; c.parseCards = () => [{ id: 'latest', poster: 'https://img.test/a.jpg' }]; + 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 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 = []; + c.fetchHlsText = async (ctx, url) => { requestedURLs.push(url); return media; }; + const result = await c.chooseStream({}, [{ url: 'https://cdn.test/720p/stream.m3u8' }, { url: 'https://cdn.test/1080p/stream.m3u8' }], requested, 'https://madou8.pw/detail'); + assert.equal(result.height, 1080); assert.equal(requestedURLs.length, 2); + }); + } + test(`${file}: master-discovered lower fallback still explores another line`, async () => { + const c = load(file); + c.fetchHlsText = async (ctx, url) => url.includes('master720') ? master.replace(/1920x1080/g, '1280x720').replace(/1080\.m3u8/g, '720.m3u8') : media; + const result = await c.chooseStream({}, [{ url: 'https://cdn.test/master720.m3u8' }, { url: 'https://cdn.test/1080p/stream.m3u8' }], 1080, 'https://madou8.pw/detail'); + assert.equal(result.height, 1080); + }); +} +test('1808 network failure is labeled historical and cannot generate playable lines', async () => { + const c = load('1808-mini-library.js'); c.Widget.http.get = async () => { throw new Error('offline'); }; + const home = await c.getHome({}); assert.ok(home.sections[0].error); assert.match(home.sections[0].title, /历史/); assert.match(home.hero[0].subtitle, /历史/); + await assert.rejects(c.getDetail({ itemId: '/movies/new_slug.html' }), /offline/); + await assert.rejects(c.resolvePlayback({ itemId: '/movies/new_slug.html' }), /offline/); + assert.equal(c.syntheticPlaybackLines, undefined); +}); +test('1808 defaults to the highest real source line and validates the current HLS', async () => { + const c = load('1808-mini-library.js'); + c.fetchText = async (ctx, url) => url.includes('.m3u8') ? media : '<html>current</html>'; + c.parseDetailPage = () => ({ title: 'Film', poster: '', overview: '', actorText: '', related: [], referer: 'https://1808.online/movies/a.html', urls: [ + { title: '360P', url: 'https://cdn.test/360/a.m3u8' }, { title: '720P', url: 'https://cdn.test/720/a.m3u8' }] }); + const detail = await c.getDetail({ itemId: '/movies/a.html' }); assert.equal(detail.resourceGroups[0].versions[0].name, '720P'); + const result = await c.resolvePlayback({ itemId: '/movies/a.html' }); assert.equal(result.url, 'https://cdn.test/720/a.m3u8'); +}); +test('Giri excludes history links, bounds episode groups and preserves parent item IDs', async () => { + const c = load('girigirilove-mini-library.js'); + const history = '<a href="/play/999-1-1/">2026</a>'; + assert.equal(c.parseDetail({}, history, 'film').episodes.length, 0); + const html = '<h1>Series</h1><span class="slide-info-remarks">更新至1集</span><div class="anthology-list-box"><div><a x-effect="visible => show()" href="/play/123-1-1/">1</a></div></div>' + history; + c.fetchText = async () => html; + const result = await c.getDetail({ itemId: 'series' }); assert.equal(result.type, 'series'); assert.equal(result.seasons[0].episodes.length, 1); + const action = result.seasons[0].episodes[0].action; + assert.equal(c.decodePayload(action.itemId).id, 'series'); assert.equal(c.decodePayload(action.episodeId).id, 'play/123-1-1'); + c.fetchText = async () => '<h1>Film</h1><div class="anthology-list-box"><a href="/play/456-1-1/">正片</a></div>'; + const film = await c.getDetail({ itemId: 'film' }); assert.equal(film.type, 'movie'); assert.equal(film.seasons.length, 0); assert.equal(film.resourceGroups.length, 1); +}); +test('Giri parses quoted > in card attributes and never caches HTTP errors', async () => { + const c = load('girigirilove-mini-library.js'); + const html = '<a x-effect="visible => show()" class="public-list-exp" href="/video/123/" title="Good"><img src="https://img.test/a.jpg"></a>'; + assert.equal(c.parseCards({}, html).length, 1); + c.Widget.http.get = async () => ({ status: 503, data: '<html><title>Service Unavailable' }); + await assert.rejects(c.fetchText({}, 'https://girigirilove.com/video/123/'), /503/); + 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 () => ''; + 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 1.5.10 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)); +}); +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']) { + test(`${file}: a hung resolver has one overall deadline`, async () => { + const c = load(file, { setTimeout: (fn) => setTimeout(fn, 15) }); + c.resolvePlaybackWithinBudget = async () => new Promise(() => {}); + const start = Date.now(); await assert.rejects(c.resolvePlayback({}), /total-deadline/); assert.ok(Date.now() - start < 500); + }); +} +test('XXXFollow refreshes a saved payload instead of trusting its expired cached URL', async () => { + const c = load('xxxfollow-mini-library 5.js'); + const old = c.makePlaybackPayload('fresh', 'https://cdn.test/old.mp4', '', 'Saved'); + c.findById = () => ({ id: 'fresh', videoUrl: 'https://cdn.test/new.mp4', title: 'New' }); + const result = await c.resolvePlayback({ itemId: old, url: 'https://cdn.test/old.mp4' }); assert.equal(result.url, 'https://cdn.test/new.mp4'); +}); +test('4KVM retains the historical version ID without claiming every captured stream is 1080P', async () => { + const c = load('4kvm-dreamby-mini-library.js'); + assert.equal(c.kvmVersions('1')[0].id, 'public-1080'); assert.match(c.kvmVersions('1')[0].name, /自动/); + c.Widget.browser = { async fetch() { return { mediaSources: [{ url: 'https://cdn.test/360.mp4' }] }; } }; + assert.equal((await c.resolvePlayback({ itemId: '1' })).url, 'https://cdn.test/360.mp4'); + c.Widget.browser.fetch = async () => ({ mediaSources: [{ url: 'https://cdn.test/ads/1080.mp4' }, { url: 'https://cdn.test/main/360.mp4' }] }); + assert.equal((await c.resolvePlayback({ itemId: '1' })).url, 'https://cdn.test/main/360.mp4'); +}); +test('Pornhub sorts by actual quality and marks only the highest as default', () => { + const c = load('pornhub.media-library.js'); + const result = c.sortMediaSources([{ id: '360', quality: 360, default: true }, { id: '1080', quality: 1080, default: false }]); + assert.deepEqual(plain(result.map(x => x.quality)), [1080, 360]); assert.equal(result.filter(x => x.default).length, 1); +}); +test('Manko keeps successful navigation cards when one category fails', async () => { + const c = load('manko-fun-mini-library.js'); let active = 0, max = 0; + 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 + '-'))); +}); +test('Jable Forward adds sorting and page parameters once', async () => { + const c = load('jable.js'); let captured; + c.Widget.http.get = async url => { captured = new URL(url); throw new Error('fixture stop'); }; + await assert.rejects(c.search({ keyword: 'A&B #tag', sort_by: 'video_viewed', from: 2 }), /fixture stop/); + assert.deepEqual(captured.searchParams.getAll('sort_by'), ['video_viewed']); assert.deepEqual(captured.searchParams.getAll('from'), ['2']); + assert.equal(captured.searchParams.get('q'), 'A&B #tag'); +}); + +for (const keyword of ['中文 空格', 'A&B #tag', 'C++ 100%', '"quoted"']) { + test(`Forward search retains special characters: ${keyword}`, async () => { + const c91 = load('91porny_int.js'); let url91; + vm.runInContext('widgetAPI.getHtml = async function(url) { globalThis.capturedSearchURL = url; throw new Error("stop"); }', c91); + await c91.search({ keyword, page: 2 }); url91 = new URL(c91.capturedSearchURL); + assert.equal(url91.searchParams.get('keywords'), keyword); assert.equal(url91.searchParams.get('page'), '2'); assert.equal(url91.hash, ''); + const cPH = load('pornhub_int.js'); let urlPH; + cPH.Widget.http.get = async url => { urlPH = new URL(url); throw new Error('stop'); }; + await assert.rejects(cPH.getSearchResults({ search_query: keyword, page: 2 }), /stop/); + assert.equal(urlPH.searchParams.get('search'), keyword.trim().toLowerCase().replace(/[\s\-]+/g, ' ')); + assert.equal(urlPH.searchParams.get('page'), '2'); assert.equal(urlPH.hash, ''); + }); +} + +test('Giri selected episode keeps its version and resolves its own play page', async () => { + const c = load('girigirilove-mini-library.js'); + const parent = c.encodePayload({id:'GV123'}); const selected = c.encodePayload({id:'play/123-1-2',episodeTitle:'第2集'}); + const groups = await c.getResourceVersions({itemId:parent, episodeId:selected}); + assert.equal(groups[0].versions.length, 1); assert.equal(c.decodePayload(groups[0].versions[0].id).id,'play/123-1-2'); + const pages = []; c.resolvePlayPage = async (ctx,id) => {pages.push(id);return {url:'https://cdn.test/episode2.m3u8'};}; + const result = await c.resolvePlayback({itemId:parent, episodeId:selected,versionId:groups[0].versions[0].id}); + assert.equal(result.url,'https://cdn.test/episode2.m3u8'); assert.deepEqual(pages,['play/123-1-2']); +}); + +for (const mode of ['expired', 'rejected']) { + test(`MissAV Forward refreshes a ${mode} URL and can recover after failure`, async () => { + const c = load('MissAV 3.0.js'); const requests = []; + c.Widget.html = { load: () => selector => ({length: selector === '#videodetails' ? 1 : 0,attr: () => 'https://missav.ai/cn/ABP-123'}) }; + c.extractVideoUrlFromHtml = () => 'https://cdn.test/new.m3u8'; + vm.runInContext(`VIDEO_URL_CACHE['ABP-123'] = {url:'https://cdn.test/old.m3u8',timestamp:Date.now()-${mode === 'expired' ? 61000 : 1000},referer:'https://missav.ai/cn/ABP-123'}`,c); + c.Widget.http.get = async url => {requests.push(url);return url.includes('old.m3u8') ? {statusCode:403,data:''} : {statusCode:200,data:'detail'};}; + const result = await c.loadResource({code:'ABP-123'}); + assert.equal(result[0].url,'https://cdn.test/new.m3u8'); assert.ok(requests.some(url => url.includes('/cn/search/'))); + assert.equal(requests.some(url => url.includes('old.m3u8')),mode === 'rejected'); + c.Widget.http.get = async () => {throw new Error('temporary');}; + assert.equal((await c.loadResource({code:'ABP-123'})).length,0); + c.Widget.http.get = async () => ({statusCode:200,data:'detail'}); + assert.equal((await c.loadResource({code:'ABP-123'}))[0].url,'https://cdn.test/new.m3u8'); + }); +} + +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 = () => []; + c.Widget.http.get = async (url,options) => {calls.push(options);return {status:200,data:'detail'};}; + c.Widget.browser = {async fetch(url,options){calls.push(options);return {mediaSources:['https://cdn.test/final.m3u8']};}}; + const result = await c.resolvePlayback({itemId:'/movie/123.html'}); + assert.equal(result.url,'https://cdn.test/final.m3u8'); assert.equal(calls.length,2); + assert.ok(calls[0].timeout <= 6 && calls[1].timeout <= 12); +}); + +test('Context normalization keeps user parameter precedence and distinct content/episode IDs', async () => { + const c = load('girigirilove-mini-library.js'); const requests=[]; + c.Widget.http.get = async url => {requests.push(url);return {status:200,data:''};}; + await c.getCategory(JSON.stringify({pageId:'latest',parameters:JSON.stringify({page:2}),params:{baseURL:'https://custom.test'},config:{baseURL:'https://ignored.test'}})); + assert.ok(requests.length > 0); assert.ok(requests.every(url=>url.startsWith('https://custom.test/'))); + const parent=c.encodePayload({id:'GV123'}); const episode=c.encodePayload({id:'play/123-1-2'}); + c.resolvePlayPage = async (ctx,id) => ({url:'https://cdn.test/'+id.split('-').pop()+'.m3u8'}); + assert.equal((await c.resolvePlayback(JSON.stringify({itemId:parent,episodeId:episode}))).url,'https://cdn.test/2.m3u8'); +}); + +test('XVideos attaches the configured session token without printing it', async () => { + const logs=[]; const token='fixture-session-secret'; + const c=load('xvideos_int.js',{console:{log:(...args)=>logs.push(args.join(' ')),error:(...args)=>logs.push(args.join(' '))},setTimeout:()=>0}); + c.Widget.storage.getItem=async()=>token; + const options=await vm.runInContext('widgetAPI.getDefaultOptions()',c); + assert.equal(options.headers.Cookie,'session_token='+token); assert.ok(logs.every(line=>!line.includes(token))); +}); diff --git a/tools/commit-library-aliases.cjs b/tools/commit-library-aliases.cjs new file mode 100644 index 0000000..66f4e72 --- /dev/null +++ b/tools/commit-library-aliases.cjs @@ -0,0 +1,19 @@ +// Run only after validation in the main-branch compatibility sync job. +const { execFileSync, spawnSync } = require('node:child_process'); +const targets = Object.values(require('./library-aliases.json')).flat(); +function git(...args) { return execFileSync('git', args, { stdio: 'inherit' }); } +if (process.env.GITHUB_EVENT_NAME !== 'push' || process.env.GITHUB_REF !== 'refs/heads/main') { + throw new Error('Compatibility commits are limited to main-branch push workflows'); +} +const diff = spawnSync('git', ['diff', '--quiet', '--', ...targets]); +if (diff.status === 0) { + console.log('Compatibility paths already synchronized.'); +} else { + if (diff.status !== 1) throw new Error('Could not inspect compatibility copies'); + git('diff', '--check'); + git('config', 'user.name', 'github-actions[bot]'); + git('config', 'user.email', '41898282+github-actions[bot]@users.noreply.github.com'); + git('add', '--', ...targets); + git('commit', '-m', 'Sync compatibility library download paths'); + git('push', 'origin', 'HEAD:refs/heads/main'); +} diff --git a/tools/library-aliases.json b/tools/library-aliases.json new file mode 100644 index 0000000..93b0c8e --- /dev/null +++ b/tools/library-aliases.json @@ -0,0 +1,9 @@ +{ + "eporner-mini-library 2.js": ["eporner-mini-library.js"], + "xxxfollow-mini-library 5.js": ["xxxfollow-mini-library.js"], + "missav-mini-library-download-working 6.js": [ + "missav-mini-library-download-working.js", + "missav-mini-library.js", + "missav-mini-library-CloudFlare.js" + ] +} diff --git a/tools/library-contracts.json b/tools/library-contracts.json new file mode 100644 index 0000000..bd692d4 --- /dev/null +++ b/tools/library-contracts.json @@ -0,0 +1,636 @@ +{ + "123av-mini-library.js": { + "id": "123av-mini-library", + "parameters": [ + "baseUrl", + "surritBaseUrl" + ], + "entryPoints": [ + "getManifest", + "getHome", + "getHomeSection", + "getCategory", + "getDetail", + "getResourceVersions", + "resolvePlayback", + "search" + ] + }, + "1808-mini-library.js": { + "id": "1808-online", + "parameters": [ + "baseURL" + ], + "entryPoints": [ + "getManifest", + "getHome", + "getHomeSection", + "getCategory", + "getDetail", + "getResourceVersions", + "resolvePlayback" + ] + }, + "4kvm-dreamby-mini-library.js": { + "id": "4kvm-dreamby", + "parameters": [], + "entryPoints": [ + "getManifest", + "getHome", + "getHomeSection", + "getCategory", + "getDetail", + "getResourceVersions", + "resolvePlayback", + "search" + ] + }, + "591av-mini-library.js": { + "id": "591av-mini-library", + "parameters": [ + "baseUrl" + ], + "entryPoints": [ + "getManifest", + "getHome", + "getHomeSection", + "getCategory", + "getDetail", + "getResourceVersions", + "resolvePlayback", + "search" + ] + }, + "asmrlib-mini-library.js": { + "id": "asmrlib", + "parameters": [], + "entryPoints": [ + "getManifest", + "getHome", + "getHomeSection", + "getCategory", + "getDetail", + "getResourceVersions", + "resolvePlayback", + "search" + ] + }, + "bad-news-mini-library.js": { + "id": "bad-news-media", + "parameters": [], + "entryPoints": [ + "getManifest", + "getHome", + "getHomeSection", + "getCategory", + "getDetail", + "getResourceVersions", + "resolvePlayback", + "search" + ] + }, + "eporner-mini-library 2.js": { + "id": "eporner-mini-library", + "parameters": [], + "entryPoints": [ + "getManifest", + "getHome", + "getHomeSection", + "getCategory", + "getDetail", + "getResourceVersions", + "resolvePlayback", + "search" + ] + }, + "eporner-mini-library.js": { + "id": "eporner-mini-library", + "parameters": [], + "entryPoints": [ + "getManifest", + "getHome", + "getHomeSection", + "getCategory", + "getDetail", + "getResourceVersions", + "resolvePlayback", + "search" + ] + }, + "forward.stripchat.dreamby-mini-library.js": { + "id": "forward.stripchat", + "parameters": [ + "cookie" + ], + "entryPoints": [ + "getManifest", + "getHome", + "getHomeSection", + "getCategory", + "getDetail", + "getResourceVersions", + "resolvePlayback" + ] + }, + "girigirilove-mini-library.js": { + "id": "girigirilove-mini-library", + "parameters": [ + "baseURL", + "cacheMinutes" + ], + "entryPoints": [ + "getManifest", + "getHome", + "getHomeSection", + "getCategory", + "getDetail", + "getResourceVersions", + "resolvePlayback", + "search", + "matchResources" + ] + }, + "hanime1-mini-library.js": { + "id": "hanime1-mini-library", + "parameters": [ + "baseUrl" + ], + "entryPoints": [ + "getManifest", + "getHome", + "getHomeSection", + "getCategory", + "getDetail", + "getResourceVersions", + "resolvePlayback", + "search" + ] + }, + "hsex-mini-library.js": { + "id": "hsex-mini-library", + "parameters": [ + "baseUrl" + ], + "entryPoints": [ + "getManifest", + "getHome", + "getHomeSection", + "getCategory", + "getDetail", + "getResourceVersions", + "resolvePlayback", + "search" + ] + }, + "jable.media-library.js": { + "id": "baiplay_jable_media_library", + "parameters": [], + "entryPoints": [ + "getManifest", + "getHome", + "getCategory", + "getDetail", + "getResourceVersions", + "resolvePlayback", + "search", + "matchResources" + ] + }, + "javgg-mini-library.js": { + "id": "javgg-mini-library", + "parameters": [ + "baseUrl" + ], + "entryPoints": [ + "getManifest", + "getHome", + "getHomeSection", + "getCategory", + "getDetail", + "getResourceVersions", + "resolvePlayback", + "search" + ] + }, + "kbjfan-mini-library.js": { + "id": "kbjfan-mini-library", + "parameters": [ + "baseURL" + ], + "entryPoints": [ + "getManifest", + "getHome", + "getHomeSection", + "getCategory", + "getDetail", + "getResourceVersions", + "resolvePlayback", + "search" + ] + }, + "madou8-domestic-mini-library.js": { + "id": "madou8-domestic-mini-library", + "parameters": [ + "baseUrl" + ], + "entryPoints": [ + "getManifest", + "getHome", + "getHomeSection", + "getCategory", + "getDetail", + "getResourceVersions", + "resolvePlayback", + "search" + ] + }, + "madou8-mini-library 5.js": { + "id": "madou8-mini-library", + "parameters": [ + "baseUrl" + ], + "entryPoints": [ + "getManifest", + "getHome", + "getHomeSection", + "getCategory", + "getDetail", + "getResourceVersions", + "resolvePlayback", + "search" + ] + }, + "manko-fun-mini-library.js": { + "id": "manko-fun", + "parameters": [], + "entryPoints": [ + "getManifest", + "getHome", + "getHomeSection", + "getCategory", + "getDetail", + "getResourceVersions", + "resolvePlayback", + "search" + ] + }, + "missav-mini-library-CloudFlare.js": { + "id": "missav-mini-library", + "parameters": [ + "baseURL", + "entryPath", + "backupBaseURLs", + "enableBrowserFallback", + "browserVisible", + "requestTimeoutSeconds", + "cacheMinutes" + ], + "entryPoints": [ + "getManifest", + "getHome", + "getHomeSection", + "getCategory", + "getDetail", + "getResourceVersions", + "resolvePlayback", + "search", + "matchResources" + ] + }, + "missav-mini-library-download-working 6.js": { + "id": "missav-mini-library", + "parameters": [ + "baseURL", + "entryPath", + "backupBaseURLs", + "enableBrowserFallback", + "browserVisible", + "automaticBrowserFallback", + "verificationCooldownMinutes", + "requestTimeoutSeconds", + "cacheMinutes", + "categoryRestoreMinutes" + ], + "entryPoints": [ + "getManifest", + "getHome", + "getHomeSection", + "getCategory", + "getDetail", + "getResourceVersions", + "resolvePlayback", + "search", + "matchResources" + ] + }, + "missav-mini-library-download-working.js": { + "id": "missav-mini-library", + "parameters": [ + "baseURL", + "entryPath", + "backupBaseURLs", + "enableBrowserFallback", + "browserVisible", + "automaticBrowserFallback", + "verificationCooldownMinutes", + "requestTimeoutSeconds", + "cacheMinutes", + "categoryRestoreMinutes" + ], + "entryPoints": [ + "getManifest", + "getHome", + "getHomeSection", + "getCategory", + "getDetail", + "getResourceVersions", + "resolvePlayback", + "search", + "matchResources" + ] + }, + "missav-mini-library.js": { + "id": "missav-mini-library", + "parameters": [ + "baseURL", + "entryPath" + ], + "entryPoints": [ + "getManifest", + "getHome", + "getHomeSection", + "getCategory", + "getDetail", + "getResourceVersions", + "resolvePlayback", + "search", + "matchResources" + ] + }, + "novipnoad-mini-library.js": { + "id": "novipnoad", + "parameters": [], + "entryPoints": [ + "getManifest", + "getHome", + "getCategory", + "getDetail", + "getResourceVersions", + "resolvePlayback", + "search", + "matchResources" + ] + }, + "olevod-mini-library.js": { + "id": "baiplay_olevod_media_library", + "parameters": [], + "entryPoints": [ + "getManifest", + "getHome", + "getHomeSection", + "getCategory", + "getDetail", + "getResourceVersions", + "resolvePlayback", + "search" + ] + }, + "pb365-mini-library.js": { + "id": "pb365-mini-library", + "parameters": [ + "baseUrl" + ], + "entryPoints": [ + "getManifest", + "getHome", + "getHomeSection", + "getCategory", + "getDetail", + "getResourceVersions", + "resolvePlayback", + "search" + ] + }, + "pektino-mini-library 3.js": { + "id": "pektino-twitter-video", + "parameters": [ + "baseURL", + "cookie" + ], + "entryPoints": [ + "getManifest", + "getHome", + "getHomeSection", + "getCategory", + "getDetail", + "getResourceVersions", + "resolvePlayback" + ] + }, + "pimpbunny-mini-library.js": { + "id": "pimpbunny-mini-library", + "parameters": [ + "baseURL" + ], + "entryPoints": [ + "getManifest", + "getHome", + "getHomeSection", + "getCategory", + "getDetail", + "getResourceVersions", + "resolvePlayback", + "search" + ] + }, + "pornhub.media-library.js": { + "id": "baiplay_pornhub_media_library", + "parameters": [], + "entryPoints": [ + "getManifest", + "getHome", + "getCategory", + "getDetail", + "getResourceVersions", + "resolvePlayback", + "search", + "matchResources" + ] + }, + "rou666-mini-library.js": { + "id": "rou666-mini-library", + "parameters": [ + "baseUrl", + "proxyBaseUrl" + ], + "entryPoints": [ + "getManifest", + "getHome", + "getHomeSection", + "getCategory", + "getDetail", + "getResourceVersions", + "resolvePlayback", + "search" + ] + }, + "sexbjcam-mini-library.js": { + "id": "sexbjcam-mini-library", + "parameters": [ + "baseURL" + ], + "entryPoints": [ + "getManifest", + "getHome", + "getHomeSection", + "getCategory", + "getDetail", + "getResourceVersions", + "resolvePlayback", + "search" + ] + }, + "spankbang-mini-library.js": { + "id": "spankbang-dreamby", + "parameters": [ + "baseUrl", + "visibleVerification" + ], + "entryPoints": [ + "getManifest", + "getHome", + "getHomeSection", + "getCategory", + "getDetail", + "getResourceVersions", + "resolvePlayback", + "search" + ] + }, + "superporn-mini-library.js": { + "id": "superporn-mini-library", + "parameters": [ + "baseUrl" + ], + "entryPoints": [ + "getManifest", + "getHome", + "getHomeSection", + "getCategory", + "getDetail", + "getResourceVersions", + "resolvePlayback", + "search" + ] + }, + "taolusm-mini-library.js": { + "id": "taolusm-mini-library", + "parameters": [ + "baseURL" + ], + "entryPoints": [ + "getManifest", + "getHome", + "getHomeSection", + "getCategory", + "getDetail", + "getResourceVersions", + "resolvePlayback", + "search" + ] + }, + "ttdm-mini-library (1).js": { + "id": "ttdm-mini-library", + "parameters": [ + "baseUrl" + ], + "entryPoints": [ + "getManifest", + "getHome", + "getHomeSection", + "getCategory", + "getDetail", + "getResourceVersions", + "resolvePlayback", + "search" + ] + }, + "twiidol-mini-library.js": { + "id": "twiidol-twitter-video", + "parameters": [], + "entryPoints": [ + "getManifest", + "getHome", + "getHomeSection", + "getCategory", + "getDetail", + "getResourceVersions", + "resolvePlayback", + "search" + ] + }, + "xiaohuangniao-mini-library.js": { + "id": "xiaohuangniao-mini-library", + "parameters": [ + "baseUrl" + ], + "entryPoints": [ + "getManifest", + "getHome", + "getHomeSection", + "getCategory", + "getDetail", + "getResourceVersions", + "resolvePlayback", + "search" + ] + }, + "xxxfollow-mini-library 5.js": { + "id": "xxxfollow-mini-library", + "parameters": [ + "baseURL" + ], + "entryPoints": [ + "getManifest", + "getHome", + "getHomeSection", + "getCategory", + "getDetail", + "getResourceVersions", + "resolvePlayback", + "search" + ] + }, + "xxxfollow-mini-library.js": { + "id": "xxxfollow-mini-library", + "parameters": [ + "baseURL" + ], + "entryPoints": [ + "getManifest", + "getHome", + "getHomeSection", + "getCategory", + "getDetail", + "getResourceVersions", + "resolvePlayback", + "search" + ] + }, + "yesporn-mini-library.js": { + "id": "yesporn-mini-library", + "parameters": [ + "baseURL" + ], + "entryPoints": [ + "getManifest", + "getHome", + "getHomeSection", + "getCategory", + "getDetail", + "getResourceVersions", + "resolvePlayback", + "search" + ] + } +} diff --git a/tools/sync-library-aliases.cjs b/tools/sync-library-aliases.cjs new file mode 100644 index 0000000..93d208d --- /dev/null +++ b/tools/sync-library-aliases.cjs @@ -0,0 +1,25 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const root = path.resolve(__dirname, '..'); +const aliases = require('./library-aliases.json'); +const check = process.argv.includes('--check'); +let failures = 0; +for (const [source, targets] of Object.entries(aliases)) { + if (path.basename(source) !== source || !source.endsWith('.js')) throw new Error('Invalid maintained source path'); + const content = fs.readFileSync(path.join(root, source)); + for (const target of targets) { + if (path.basename(target) !== target || !target.endsWith('.js')) throw new Error('Invalid compatibility path'); + const file = path.join(root, target); + if (check) { + if (!fs.existsSync(file) || !content.equals(fs.readFileSync(file))) { + console.error(`Compatibility copy is stale: ${target} (source: ${source})`); + failures++; + } + } else { + fs.writeFileSync(file, content); + console.log(`Synced: ${target} <- ${source}`); + } + } +} +if (failures) process.exitCode = 1; +else if (check) console.log('All compatibility copies match their maintained source.'); diff --git a/tools/verify-release.py b/tools/verify-release.py new file mode 100644 index 0000000..180522b --- /dev/null +++ b/tools/verify-release.py @@ -0,0 +1,34 @@ +from pathlib import Path +import base64, hashlib, json, subprocess, tempfile, zipfile +from urllib.parse import urlparse, unquote + +root = Path(__file__).resolve().parent.parent +index = json.loads((root / 'source-index.json').read_text()) +assert index == json.loads((root / 'stripchat-angellive/source-index.json').read_text()), 'Plugin indexes differ' +for plugin in index['plugins']: + name = unquote(urlparse(plugin['zipURLs'][0]).path.rsplit('/', 1)[-1]) + package = root / name + assert package.parent == root and package.exists(), 'Missing published package' + assert hashlib.sha256(package.read_bytes()).hexdigest() == plugin['sha256'], 'Package SHA-256 differs from index' + with zipfile.ZipFile(package) as archive: + assert archive.testzip() is None, 'Corrupt published package' + for file in ['main.js', 'manifest.json']: + assert archive.read(file) == (root / 'stripchat-angellive' / file).read_bytes(), f'Published {file} differs from source' + manifest = json.loads(archive.read('manifest.json')) + assert manifest['version'] == plugin['version'], 'Package version differs from index' + +encrypted = (root / 'twitter视频.js').read_text().splitlines() +assert encrypted[0] == 'FWENC2' and len(encrypted) >= 3, 'Invalid encrypted Forward envelope' +header = json.loads(encrypted[1]) +assert header['v'] == 2 and header['alg'] == 'A256GCM', 'Unexpected Forward encryption metadata' +assert base64.b64decode(encrypted[2], validate=True), 'Empty Forward payload' +for package in root.glob('*.fwd'): + with zipfile.ZipFile(package) as archive: + assert archive.testzip() is None, f'Corrupt {package.name}' + scripts = {archive.read(info) for info in archive.infolist() if info.filename.endswith('.js')} + assert scripts, f'No script in {package.name}' + for script in scripts: + with tempfile.NamedTemporaryFile(suffix='.js') as temporary: + temporary.write(script); temporary.flush() + subprocess.run(['node', '--check', temporary.name], check=True) +print('Published ZIP/index/version, FWENC2 and Forward package checks passed.') diff --git a/tools/verify-scripts.cjs b/tools/verify-scripts.cjs new file mode 100644 index 0000000..fd57dc4 --- /dev/null +++ b/tools/verify-scripts.cjs @@ -0,0 +1,36 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const { spawnSync } = require('node:child_process'); +const root = path.resolve(__dirname, '..'); +let checked = 0; +function walk(folder) { + for (const entry of fs.readdirSync(folder, { withFileTypes: true })) { + if (entry.name === '.git' || entry.name === 'node_modules' || entry.name === '.wrangler') continue; + const file = path.join(folder, entry.name); + if (entry.isDirectory()) walk(file); + else if (/\.(?:js|cjs)$/.test(file)) { + if (fs.readFileSync(file, 'utf8').startsWith('FWENC2\n')) continue; + const result = spawnSync(process.execPath, ['--check', file], { encoding: 'utf8' }); + if (result.status !== 0) throw new Error(result.stderr); + checked++; + } + } +} +walk(root); +const contracts = require('./library-contracts.json'); +for (const [file, expected] of Object.entries(contracts)) { + const context = vm.createContext({ console: { log() {}, warn() {}, error() {} }, URL, URLSearchParams, TextDecoder, TextEncoder, Buffer, atob, btoa, + module: { exports: {} }, Widget: {}, setTimeout, clearTimeout }); + vm.runInContext(fs.readFileSync(path.join(root, file), 'utf8'), context, { filename: file, timeout: 2000 }); + const manifest = vm.runInContext('getManifest()', context, { timeout: 2000 }); + if (manifest.id !== expected.id) throw new Error(`${file}: imported library ID changed`); + for (const name of expected.parameters) { + if (!(manifest.parameters || []).some(parameter => parameter.name === name)) throw new Error(`${file}: existing parameter ${name} disappeared`); + } + if (!manifest.version) throw new Error(`${file}: missing version`); + for (const name of expected.entryPoints) { + if (typeof context[name] !== 'function') throw new Error(`${file}: missing ${name}`); + } +} +console.log(`Syntax checked ${checked} ordinary scripts; ${Object.keys(contracts).length} library contracts passed.`); diff --git a/xvideos_int.js b/xvideos_int.js index c32a5ed..32c987a 100644 --- a/xvideos_int.js +++ b/xvideos_int.js @@ -811,7 +811,6 @@ const BASE_URL = 'https://www.xvideos.com'; const widgetAPI = new WidgetAPI(async ()=>{ try { const sessionToken = await getStorageItem('xvideos.session_token'); - console.log('sessionToken', sessionToken); return { headers: { Cookie: `session_token=${sessionToken}` @@ -826,7 +825,7 @@ WidgetMetadata = { id: 'xvideos_int', title: 'XVideos', description: '網路收集到的模塊', - version: "0.9.6", + version: "0.9.7", requiredVersion: '0.0.1', author: "網路", site: 'https://github.com/baranwang/forward-widget', diff --git a/xxxfollow-mini-library 5.js b/xxxfollow-mini-library 5.js index 8962334..f214914 100644 --- a/xxxfollow-mini-library 5.js +++ b/xxxfollow-mini-library 5.js @@ -16,7 +16,7 @@ const XXXFOLLOW_TAGS = [ ]; const WidgetMetadata = { - id: 'xxxfollow-mini-library', name: 'XXXFollow', title: 'XXXFollow', version: '1.0.0', author: 'EL', + id: 'xxxfollow-mini-library', name: 'XXXFollow', title: 'XXXFollow', version: '1.0.1', author: 'Alan huang', logo: XXXFOLLOW_BASE + '/favicon.ico', icon: XXXFOLLOW_BASE + '/favicon.ico', site: XXXFOLLOW_BASE, description: 'XXXFollow 短视频聚合自定义媒体库。' }; @@ -35,6 +35,7 @@ function getManifest() { } async function getHome(ctx) { + ctx = normalizeRuntimeContext(ctx); let items = []; try { items = await loadFeed(ctx, '', 1); } catch (_) {} return { @@ -49,6 +50,7 @@ async function getHome(ctx) { } async function getHomeSection(ctx) { + ctx = normalizeRuntimeContext(ctx); const id = stringValue(ctx && (ctx.sectionId || ctx.id)); if (id === 'tags') return { id: 'tags', title: '标签', style: 'discover.annualCategories', lazy: false, items: XXXFOLLOW_TAGS.map(function (tag) { return { id: 'tag-' + tag[0], title: tag[1], type: 'category', action: { type: 'category', pageId: 'tag:' + tag[0], title: tag[1] } }; }) }; try { return { id: 'for-you', title: '推荐', style: 'discover.spotlight', lazy: false, items: await loadFeed(ctx, '', 1) }; } @@ -56,6 +58,7 @@ async function getHomeSection(ctx) { } async function getCategory(ctx) { + ctx = normalizeRuntimeContext(ctx); const raw = stringValue(ctx && (ctx.pageId || ctx.id || ctx.category || ctx.genreId)); const page = positiveInt(contextValue(ctx, 'page'), 1); const tag = raw.indexOf('tag:') === 0 ? raw.slice(4) : ''; @@ -64,30 +67,29 @@ async function getCategory(ctx) { } async function getDetail(ctx) { + ctx = normalizeRuntimeContext(ctx); const item = await findItem(ctx); if (!item) throw new Error('XXXFollow 找不到视频详情'); return detailObject(ctx, item); } async function getResourceVersions(ctx) { + ctx = normalizeRuntimeContext(ctx); const item = await findItem(ctx); if (!item) return []; return resourceGroups(ctx, item); } async function resolvePlayback(ctx) { - let url = directURL(ctx); - let item; - if (!url) { - const cachedId = normalizeItemId(ctx); - url = cachedId && XXXFOLLOW_VIDEO_CACHE[cachedId]; - } - if (!url) { item = await findItem(ctx); url = item && item.videoUrl; } - if (!url) throw new Error('没有解析到 XXXFollow 播放地址'); + ctx = normalizeRuntimeContext(ctx); + const item = await findItem(ctx, true); + const url = item && item.videoUrl; + if (!url) throw new Error('没有解析到 XXXFollow 播放地址,请重新打开视频或所属标签'); return { url: decodeURL(url), container: /\.m3u8(?:$|[?#])/i.test(url) ? 'm3u8' : 'mp4', headers: mediaHeaders(ctx), startPositionSeconds: 0, isLive: false, streamKind: /\.m3u8(?:$|[?#])/i.test(url) ? 'hls' : 'file' }; } async function search(ctx) { + ctx = normalizeRuntimeContext(ctx); const query = clean(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: [] }; @@ -99,28 +101,39 @@ function getSearch(ctx) { return search(ctx); } function play(ctx) { return resolvePlayback(ctx); } function getPlayback(ctx) { return resolvePlayback(ctx); } -async function findItem(ctx) { - const givenURL = directURL(ctx); +async function findItem(ctx, refresh) { const id = normalizeItemId(ctx); const embedded = playbackPayload(rawPlaybackId(ctx)); - if (embedded.url) return { - id: embedded.id || id, - sourceId: embedded.id || id, - videoUrl: embedded.url, - poster: embedded.poster || stringValue(ctx && (ctx.poster || ctx.backdrop)), - backdrop: embedded.poster || stringValue(ctx && (ctx.backdrop || ctx.poster)), - title: embedded.title || stringValue(ctx && ctx.title) || id - }; - if (id && XXXFOLLOW_VIDEO_CACHE[id]) return { id: id, videoUrl: XXXFOLLOW_VIDEO_CACHE[id], title: stringValue(ctx && ctx.title) || id }; - if (givenURL && id && !/^https?:\/\//i.test(id)) return { id: id, videoUrl: givenURL, title: stringValue(ctx.title) || id }; - const html = await fetchText(ctx, baseURL(ctx)); - const state = extractPreloadState(html); - const found = findById(state, id); - if (found) return found; - if (givenURL) return { id: id || givenURL, videoUrl: givenURL, title: stringValue(ctx.title) || id || 'XXXFollow' }; + const cached = id && XXXFOLLOW_VIDEO_CACHE[id]; + if (!refresh && cached && cached.at && Date.now() - cached.at < 30000) return cached.item; + const pages = [baseURL(ctx)]; + const hint = cached && cached.tag || stringValue(embedded.tag); + if (hint) pages.unshift(feedURL(ctx, hint, 1)); + for (const url of pages) { + try { + const item = findById(extractPreloadState(await fetchText(ctx, url)), id); + if (item) return item; + } catch (error) { if (!refresh && !embedded.url) throw error; } + } + if (refresh) { + // Old payloads may describe videos outside the current feed. Validate that + // fallback URL now, so an expired cached address cannot masquerade as success. + const fallback = embedded.url || directURL(ctx); + if (fallback && await validateDirectMedia(ctx, fallback)) return { id, videoUrl: fallback, title: embedded.title || ctx.title || id }; + return null; + } + if (embedded.url) return { id: embedded.id || id, videoUrl: embedded.url, poster: embedded.poster, title: embedded.title || id }; return null; } +async function validateDirectMedia(ctx, url) { + try { + const response = await Widget.http.get(url, { headers: Object.assign({}, mediaHeaders(ctx), { Range: 'bytes=0-0' }), timeout: 5, timeoutSeconds: 5 }); + const status = Number(response && (response.status || response.statusCode)); + return status >= 200 && status < 300; + } catch (_) { return false; } +} + function detailObject(ctx, item) { const related = []; return { pageType: 'detail', id: item.id, title: item.title, type: 'movie', poster: item.poster, backdrop: item.backdrop, @@ -154,7 +167,11 @@ function parseItem(ctx, item) { const poster = absolute(ctx, first.thumb_url || first.start_url || ''); const tags = Array.isArray(post.tags) ? post.tags : []; const genres = tags.map(function (t) { return clean(t && (t.display || t.tag)); }).filter(Boolean); const videoUrl = decodeURL(video); - if (id && videoUrl) XXXFOLLOW_VIDEO_CACHE[id] = videoUrl; + if (id && videoUrl) { + const cachedItem = { id, sourceId: id, videoUrl, poster, title: clean(post.text || id), genres }; + XXXFOLLOW_VIDEO_CACHE[id] = { item: cachedItem, at: Date.now(), tag: tags[0] && (tags[0].tag || tags[0]) || '' }; + const keys = Object.keys(XXXFOLLOW_VIDEO_CACHE); if (keys.length > 100) delete XXXFOLLOW_VIDEO_CACHE[keys[0]]; + } const payload = makePlaybackPayload(id, videoUrl, poster, clean(post.text || id)); return { id: payload, sourceId: id, title: clean(post.text || 'Video ' + id).slice(0, 120), poster: poster, backdrop: poster, videoUrl: videoUrl, duration: Number(first.duration_in_second || 0), rating: Math.min(10, Math.round(Number(item.like_count || 0) / 2000)), genres: genres, overview: clean(post.text || ''), action: { type: 'detail', itemId: payload, id: payload, title: clean(post.text || id), url: videoUrl, playUrl: videoUrl, videoUrl: videoUrl } }; } @@ -224,3 +241,16 @@ function clean(value) { return stringValue(value).replace(/<[^>]+>/g, ' ').repla const XXXFOLLOW_API = { getManifest, getHome, getHomeSection, getCategory, getDetail, getResourceVersions, resolvePlayback, search, onSearch, getSearch, play, getPlayback }; if (typeof globalThis !== 'undefined') Object.keys(XXXFOLLOW_API).forEach(function (key) { globalThis[key] = XXXFOLLOW_API[key]; }); if (typeof module !== 'undefined' && module.exports) module.exports = XXXFOLLOW_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/xxxfollow-mini-library.js b/xxxfollow-mini-library.js index 8962334..f214914 100644 --- a/xxxfollow-mini-library.js +++ b/xxxfollow-mini-library.js @@ -16,7 +16,7 @@ const XXXFOLLOW_TAGS = [ ]; const WidgetMetadata = { - id: 'xxxfollow-mini-library', name: 'XXXFollow', title: 'XXXFollow', version: '1.0.0', author: 'EL', + id: 'xxxfollow-mini-library', name: 'XXXFollow', title: 'XXXFollow', version: '1.0.1', author: 'Alan huang', logo: XXXFOLLOW_BASE + '/favicon.ico', icon: XXXFOLLOW_BASE + '/favicon.ico', site: XXXFOLLOW_BASE, description: 'XXXFollow 短视频聚合自定义媒体库。' }; @@ -35,6 +35,7 @@ function getManifest() { } async function getHome(ctx) { + ctx = normalizeRuntimeContext(ctx); let items = []; try { items = await loadFeed(ctx, '', 1); } catch (_) {} return { @@ -49,6 +50,7 @@ async function getHome(ctx) { } async function getHomeSection(ctx) { + ctx = normalizeRuntimeContext(ctx); const id = stringValue(ctx && (ctx.sectionId || ctx.id)); if (id === 'tags') return { id: 'tags', title: '标签', style: 'discover.annualCategories', lazy: false, items: XXXFOLLOW_TAGS.map(function (tag) { return { id: 'tag-' + tag[0], title: tag[1], type: 'category', action: { type: 'category', pageId: 'tag:' + tag[0], title: tag[1] } }; }) }; try { return { id: 'for-you', title: '推荐', style: 'discover.spotlight', lazy: false, items: await loadFeed(ctx, '', 1) }; } @@ -56,6 +58,7 @@ async function getHomeSection(ctx) { } async function getCategory(ctx) { + ctx = normalizeRuntimeContext(ctx); const raw = stringValue(ctx && (ctx.pageId || ctx.id || ctx.category || ctx.genreId)); const page = positiveInt(contextValue(ctx, 'page'), 1); const tag = raw.indexOf('tag:') === 0 ? raw.slice(4) : ''; @@ -64,30 +67,29 @@ async function getCategory(ctx) { } async function getDetail(ctx) { + ctx = normalizeRuntimeContext(ctx); const item = await findItem(ctx); if (!item) throw new Error('XXXFollow 找不到视频详情'); return detailObject(ctx, item); } async function getResourceVersions(ctx) { + ctx = normalizeRuntimeContext(ctx); const item = await findItem(ctx); if (!item) return []; return resourceGroups(ctx, item); } async function resolvePlayback(ctx) { - let url = directURL(ctx); - let item; - if (!url) { - const cachedId = normalizeItemId(ctx); - url = cachedId && XXXFOLLOW_VIDEO_CACHE[cachedId]; - } - if (!url) { item = await findItem(ctx); url = item && item.videoUrl; } - if (!url) throw new Error('没有解析到 XXXFollow 播放地址'); + ctx = normalizeRuntimeContext(ctx); + const item = await findItem(ctx, true); + const url = item && item.videoUrl; + if (!url) throw new Error('没有解析到 XXXFollow 播放地址,请重新打开视频或所属标签'); return { url: decodeURL(url), container: /\.m3u8(?:$|[?#])/i.test(url) ? 'm3u8' : 'mp4', headers: mediaHeaders(ctx), startPositionSeconds: 0, isLive: false, streamKind: /\.m3u8(?:$|[?#])/i.test(url) ? 'hls' : 'file' }; } async function search(ctx) { + ctx = normalizeRuntimeContext(ctx); const query = clean(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: [] }; @@ -99,28 +101,39 @@ function getSearch(ctx) { return search(ctx); } function play(ctx) { return resolvePlayback(ctx); } function getPlayback(ctx) { return resolvePlayback(ctx); } -async function findItem(ctx) { - const givenURL = directURL(ctx); +async function findItem(ctx, refresh) { const id = normalizeItemId(ctx); const embedded = playbackPayload(rawPlaybackId(ctx)); - if (embedded.url) return { - id: embedded.id || id, - sourceId: embedded.id || id, - videoUrl: embedded.url, - poster: embedded.poster || stringValue(ctx && (ctx.poster || ctx.backdrop)), - backdrop: embedded.poster || stringValue(ctx && (ctx.backdrop || ctx.poster)), - title: embedded.title || stringValue(ctx && ctx.title) || id - }; - if (id && XXXFOLLOW_VIDEO_CACHE[id]) return { id: id, videoUrl: XXXFOLLOW_VIDEO_CACHE[id], title: stringValue(ctx && ctx.title) || id }; - if (givenURL && id && !/^https?:\/\//i.test(id)) return { id: id, videoUrl: givenURL, title: stringValue(ctx.title) || id }; - const html = await fetchText(ctx, baseURL(ctx)); - const state = extractPreloadState(html); - const found = findById(state, id); - if (found) return found; - if (givenURL) return { id: id || givenURL, videoUrl: givenURL, title: stringValue(ctx.title) || id || 'XXXFollow' }; + const cached = id && XXXFOLLOW_VIDEO_CACHE[id]; + if (!refresh && cached && cached.at && Date.now() - cached.at < 30000) return cached.item; + const pages = [baseURL(ctx)]; + const hint = cached && cached.tag || stringValue(embedded.tag); + if (hint) pages.unshift(feedURL(ctx, hint, 1)); + for (const url of pages) { + try { + const item = findById(extractPreloadState(await fetchText(ctx, url)), id); + if (item) return item; + } catch (error) { if (!refresh && !embedded.url) throw error; } + } + if (refresh) { + // Old payloads may describe videos outside the current feed. Validate that + // fallback URL now, so an expired cached address cannot masquerade as success. + const fallback = embedded.url || directURL(ctx); + if (fallback && await validateDirectMedia(ctx, fallback)) return { id, videoUrl: fallback, title: embedded.title || ctx.title || id }; + return null; + } + if (embedded.url) return { id: embedded.id || id, videoUrl: embedded.url, poster: embedded.poster, title: embedded.title || id }; return null; } +async function validateDirectMedia(ctx, url) { + try { + const response = await Widget.http.get(url, { headers: Object.assign({}, mediaHeaders(ctx), { Range: 'bytes=0-0' }), timeout: 5, timeoutSeconds: 5 }); + const status = Number(response && (response.status || response.statusCode)); + return status >= 200 && status < 300; + } catch (_) { return false; } +} + function detailObject(ctx, item) { const related = []; return { pageType: 'detail', id: item.id, title: item.title, type: 'movie', poster: item.poster, backdrop: item.backdrop, @@ -154,7 +167,11 @@ function parseItem(ctx, item) { const poster = absolute(ctx, first.thumb_url || first.start_url || ''); const tags = Array.isArray(post.tags) ? post.tags : []; const genres = tags.map(function (t) { return clean(t && (t.display || t.tag)); }).filter(Boolean); const videoUrl = decodeURL(video); - if (id && videoUrl) XXXFOLLOW_VIDEO_CACHE[id] = videoUrl; + if (id && videoUrl) { + const cachedItem = { id, sourceId: id, videoUrl, poster, title: clean(post.text || id), genres }; + XXXFOLLOW_VIDEO_CACHE[id] = { item: cachedItem, at: Date.now(), tag: tags[0] && (tags[0].tag || tags[0]) || '' }; + const keys = Object.keys(XXXFOLLOW_VIDEO_CACHE); if (keys.length > 100) delete XXXFOLLOW_VIDEO_CACHE[keys[0]]; + } const payload = makePlaybackPayload(id, videoUrl, poster, clean(post.text || id)); return { id: payload, sourceId: id, title: clean(post.text || 'Video ' + id).slice(0, 120), poster: poster, backdrop: poster, videoUrl: videoUrl, duration: Number(first.duration_in_second || 0), rating: Math.min(10, Math.round(Number(item.like_count || 0) / 2000)), genres: genres, overview: clean(post.text || ''), action: { type: 'detail', itemId: payload, id: payload, title: clean(post.text || id), url: videoUrl, playUrl: videoUrl, videoUrl: videoUrl } }; } @@ -224,3 +241,16 @@ function clean(value) { return stringValue(value).replace(/<[^>]+>/g, ' ').repla const XXXFOLLOW_API = { getManifest, getHome, getHomeSection, getCategory, getDetail, getResourceVersions, resolvePlayback, search, onSearch, getSearch, play, getPlayback }; if (typeof globalThis !== 'undefined') Object.keys(XXXFOLLOW_API).forEach(function (key) { globalThis[key] = XXXFOLLOW_API[key]; }); if (typeof module !== 'undefined' && module.exports) module.exports = XXXFOLLOW_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); +}