From fc933d1882eaf772403c847a4089a9387a3bc6e6 Mon Sep 17 00:00:00 2001 From: hzl <1803573449@qq.com> Date: Sun, 4 Oct 2026 09:35:22 +0800 Subject: [PATCH] Repair JAVGG playback using the user working display version --- CHANGELOG.md | 10 +- README.md | 16 +- javgg-mini-library.js | 562 +++++++++++++++++++-------------- tests/adapters.test.cjs | 298 ++++++++++------- tools/library-restoration.json | 4 +- 5 files changed, 545 insertions(+), 345 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0809a48..f47d729 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,12 @@ -# JAVGG 1.0.3 列表加载修复 — 2026-10-04 +# JAVGG 1.0.4 基于用户正常显示文件修复播放 — 2026-10-04 + +- 用户本地 1.0.0 可正常显示,1.0.3 列表修复未通过设备测试。发布副本以用户文件为基础,22 个显示、详情、普通 HTTP 与载荷相关函数逐字保留;撤回 1.0.3 列表改动。用户本地文件不修改。 +- 播放配置按公开打包格式作为数据读取,不执行来源脚本;线路按真实 server 名和编号对应,空 iframe 容器不误取下一条线路。 +- 静态线路三路并发,播放器/清单及异步正文各最多 4 秒;仅一次短网页媒体回退;资源发现及播放各限 28 秒,移除原串行每条 70 秒等待。动态刷新真实画质地址,保留实际请求头与失败阶段。 +- 75 项回归、60 个脚本语法、39 个库契约、兼容副本、既有网络及包装检查通过。三个真实详情结构配合匿名播放器请求可发现真实画质,约 4.5–4.7 秒;streamwish 最高/最低/最高/重开四次清单 200、分片范围 206。部分源线路仍会独立超时/失效。iPhone 媒体回传、原生播放待用户测试。 +- 原下载路径、ID、参数和历史载荷保留,其他九个已恢复入口内容不变;恢复账本更新为用户要求的 1.0.4。 + +# JAVGG 1.0.3 列表加载修复历史(设备未通过,列表改动已撤回) - 针对 iPhone DreamBy 首页和分类 `stage=http-timeout`:列表普通 HTTP 使用 6 秒截止时间,包含异步正文;失败、验证页或不完整列表时,最多一次 12 秒网页 HTML 回退。浏览器只读取网页,不等待视频媒体,正常列表继续直接用 HTTP。 - 兼容宿主嵌套对象/JSON、异步 `text()` 和 Promise 正文。普通 Cloudflare jsd 脚本不误作验证页;真实验证、404/410、缺失内容和超时保留明确阶段。 diff --git a/README.md b/README.md index 2e55279..c2767c6 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,17 @@ 已经导入 DreamBy 的入口继续使用原来的下载地址和媒体库 ID。每个下载入口都包含完整 JavaScript,不需要远程加载器。 -## JAVGG 1.0.3:首页与分类加载修复 — 2026-10-04 +## JAVGG 1.0.4:使用用户正常显示的版本修复播放 — 2026-10-04 + +用户确认提供的本地 1.0.0 文件可以正常显示,而上次 1.0.3 的列表改动仍失败。此次以这份文件为基础,在独立目录修复并发布;用户本地文件保持原样。首页、分区、分类、搜索、详情和原 HTTP 读取等 22 个相关函数与用户文件逐字相同,撤回 1.0.3 的列表回退改动。原文件路径、媒体库 ID、参数及 `javgg://` 历史载荷保留。 + +播放部分读取公开播放器的打包配置,不执行远程脚本;按编号对应真实 server 名和 iframe,避免多个线路都叫 `Server` 或缺失 iframe 后错位。五条当前线路的静态解析最多三路并行,每个播放器/清单请求最多 4 秒,包含异步正文;仅在需要时做一次短网页媒体捕获。画质发现和播放各有 28 秒总截止时间,撤掉逐条最多 70 秒的等待。只返回来源真实画质,默认最高,切换/重开时重新读取播放地址;失败保留具体阶段,不生成虚假的可播放资源。媒体请求头沿用实际来源观察和宿主回传。 + +75 项媒体库回归、60 个脚本语法、39 个库契约、兼容路径、既有网络测试与发布包装通过。三部真实详情结构配合匿名播放器/清单请求,分别在约 4.5–4.7 秒发现可用画质。streamwish 的 1080p → 480p → 1080p → 重开四次探测均为清单 HTTP 200、分片范围 HTTP 206(188 字节,MPEG-TS 同步字节有效);使用 resolver 返回的请求头,每次重新读取播放器地址。详情结构来自当前浏览器会话,不能称为隔离匿名详情验证。部分播放器有超时或无效清单,会独立失败。DreamBy iPhone 的媒体回传、实际起播和切画质仍需用户更新测试。 + +其余九个已恢复下载入口继续与任务前快照一致。请沿用 [JAVGG 原 raw 地址](https://raw.githubusercontent.com/bbnotcode/ph_js/main/javgg-mini-library.js),确认导入版本为 1.0.4。 + +## JAVGG 1.0.3 列表修复历史(用户设备未通过,列表改动已撤回) 用户在 iPhone DreamBy 中报告首页及各分类显示站点 Logo 的失败卡,错误为 `stage=http-timeout`。JAVGG 原版列表请求禁用浏览器回退,HTTP 失败后直接返回错误卡。此次仅修列表读取和失败显示:普通 HTTP 最多 6 秒,包含正文读取;失败或收到验证/不完整页面时,最多一次 12 秒隐藏网页 HTML 回退,不等待视频媒体。正常 HTTP 列表不打开浏览器,可使用宿主提供的正常浏览器会话;不提取或保存 Cookie。 @@ -25,14 +35,14 @@ | [MissAV download-working](missav-mini-library-download-working.js) / [working 6](missav-mini-library-download-working%206.js) | 1.5.9 | | [SexBJCam](sexbjcam-mini-library.js) | 1.1.7 | | [ASMRLIB](asmrlib-mini-library.js) | 1.2.0 | -| [JAVGG](javgg-mini-library.js) | 1.0.3(随后按用户要求修复列表加载) | +| [JAVGG](javgg-mini-library.js) | 1.0.4(基于用户正常显示的 1.0.0 修复播放) | | [麻豆亚洲版](madou8-mini-library%205.js) | 1.1.0 | | [套路 SM](taolusm-mini-library.js) | 1.0.0 | | [KBJ fan](kbjfan-mini-library.js) | 1.0.0 | 每个文件均恢复该时间点的原始内容、版本号和参数,原 raw 地址与库 ID 保留。MissAV 普通版和 CloudFlare 版虽然版本号相同,内容和参数不同,分别保留;只有内容相同的两个 download-working 文件继续同步。 -时间截止点、基线提交及历史文件 SHA-256 记录在 [tools/library-restoration.json](tools/library-restoration.json)。JAVGG 的旧快照移入 `supersededFiles` 并标明此后按用户要求更新到 1.0.3;其余九个入口继续校验历史内容。恢复检查校验每个路径的历史内容、真实 manifest、原版公共入口和对应版本行为。此前恢复最早上传版的操作已更正,下文播放修复为撤回的历史记录。 +时间截止点、基线提交及历史文件 SHA-256 记录在 [tools/library-restoration.json](tools/library-restoration.json)。JAVGG 的旧快照移入 `supersededFiles` 并标明此次基于用户文件更新到 1.0.4;其余九个入口继续校验历史内容。恢复检查校验每个路径的历史内容、真实 manifest、原版公共入口和对应版本行为。此前恢复最早上传版的操作已更正,下文旧版播放修复为历史记录。 ## 主维护文件与兼容入口 diff --git a/javgg-mini-library.js b/javgg-mini-library.js index 5020588..28fadcf 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.3', + version: '1.0.4', requiredVersion: '0.0.1', author: 'Alan huang', site: JAVGG_DEFAULT_BASE, @@ -64,11 +64,10 @@ function getManifest() { async function getHome(rawCtx) { const ctx = normalizeContext(rawCtx); let immediate = []; - let loadError = ''; try { - immediate = (await fetchListingPage(ctx, sectionURL(ctx, JAVGG_SECTIONS[0], 1))).items.slice(0, 18); + immediate = parseCards(ctx, await fetchText(ctx, sectionURL(ctx, JAVGG_SECTIONS[0], 1))).slice(0, 18); } catch (error) { - loadError = error.message || '首页加载失败'; + immediate = [diagnosticItem('首页加载失败', error)]; } const browse = { id: 'javgg-browse', @@ -77,12 +76,11 @@ async function getHome(rawCtx) { lazy: false, items: JAVGG_SECTIONS.map(categoryCard) }; - const first = sectionResult(JAVGG_SECTIONS[0], immediate, loadError); + const first = sectionResult(JAVGG_SECTIONS[0], immediate); return { pageType: 'home', id: 'javgg-home', title: 'JAVGG', - error: loadError || undefined, heroAspectRatio: '2:3', hero: immediate.filter(isMediaItem).slice(0, 5), sections: [browse, first].concat(JAVGG_SECTIONS.slice(1).map(sectionShell)) @@ -93,10 +91,10 @@ async function getHomeSection(rawCtx) { const ctx = normalizeContext(rawCtx); const section = findSection(firstNonEmpty(ctx.sectionId, ctx.id, ctx.pageId)) || JAVGG_SECTIONS[0]; try { - const items = (await fetchListingPage(ctx, sectionURL(ctx, section, 1))).items.slice(0, 18); - return sectionResult(section, items); + const items = parseCards(ctx, await fetchText(ctx, sectionURL(ctx, section, 1))).slice(0, 18); + return sectionResult(section, items.length ? items : [diagnosticItem(section.title + '暂无内容')]); } catch (error) { - return sectionResult(section, [], error.message || section.title + '加载失败'); + return sectionResult(section, [diagnosticItem(section.title + '加载失败', error)]); } } @@ -109,9 +107,8 @@ async function getCategory(rawCtx) { const title = cleanText(firstNonEmpty(ctx.title, section && section.title, dynamicPath && dynamicPath.title, 'JAVGG')); const url = section ? sectionURL(ctx, section, page) : pagedURL(baseURL(ctx) + (dynamicPath ? dynamicPath.path : '/new-post/'), page); try { - const listing = await fetchListingPage(ctx, url); - const html = listing.html; - const items = listing.items; + const html = await fetchText(ctx, url); + const items = parseCards(ctx, html); return { pageType: 'category', id: pageId, @@ -126,8 +123,8 @@ async function getCategory(rawCtx) { } catch (error) { return { pageType: 'category', id: pageId, title: title, style: 'media.posterGrid', - itemAspectRatio: '2:3', page: page, nextPage: page, hasMore: false, - error: error.message || title + '加载失败', items: [] + itemAspectRatio: '2:3', page: page, hasMore: false, + items: [diagnosticItem(title + '加载失败', error)] }; } } @@ -139,9 +136,8 @@ async function search(rawCtx) { if (!query) return { pageType: 'search', title: '搜索', keyword: '', page: page, hasMore: false, items: [] }; const url = searchURL(ctx, query, page); try { - const listing = await fetchListingPage(ctx, url); - const html = listing.html; - const items = listing.items; + const html = await fetchText(ctx, url); + const items = parseCards(ctx, html); return { pageType: 'search', title: '搜索:' + query, keyword: query, style: 'media.posterGrid', itemAspectRatio: '2:3', @@ -150,8 +146,7 @@ async function search(rawCtx) { } catch (error) { return { pageType: 'search', title: '搜索:' + query, keyword: query, - page: page, nextPage: page, hasMore: false, - error: error.message || '搜索失败', items: [] + page: page, hasMore: false, items: [diagnosticItem('搜索失败', error)] }; } } @@ -223,58 +218,11 @@ async function getDetail(rawCtx) { } async function getResourceVersions(rawCtx) { - const ctx = normalizeContext(rawCtx); - const directPayload = decodePayload(firstNonEmpty(ctx.versionId, ctx.itemId, ctx.id)); - const detailUrl = firstNonEmpty(directPayload.detailUrl, detailURLFromContext(ctx)); - if (!detailUrl) return []; - const html = await fetchText(ctx, detailUrl); - const title = cleanText(firstNonEmpty(ctx.title, metaContent(html, 'property', 'og:title'), pageTitle(html))); - const players = parsePlayers(html); - if (!players.length) throw new Error('源站详情页当前没有可用播放线路'); - const groups = []; - for (let index = 0; index < players.length; index += 1) { - const qualities = await discoverQualities(ctx, detailUrl, players[index]); - if (qualities.length) groups.push(qualityGroup(detailUrl, title, players[index], qualities)); - } - if (!groups.length) throw new Error('暂时无法发现可用画质,请稍后重试'); - return groups; + return withPlaybackBudget(rawCtx, discoverResourceVersions, '画质发现'); } -async function resolvePlayback(rawCtx) { - const ctx = normalizeContext(rawCtx); - const payload = decodePayload(firstNonEmpty(ctx.versionId, ctx.itemId, ctx.id)); - let playerUrl = firstNonEmpty(ctx.playerUrl, payload.playerUrl); - let detailUrl = firstNonEmpty(payload.detailUrl, detailURLFromContext(ctx)); - let line = firstNonEmpty(payload.line, ctx.line); - if (!playerUrl && detailUrl) { - const players = parsePlayers(await fetchText(ctx, detailUrl)); - const selected = players.filter(function (item) { return !line || item.line === line; })[0] || players[0]; - playerUrl = selected && selected.url; - line = selected && selected.line; - } - if (!playerUrl) throw new Error('未找到 JAVGG 播放器地址'); - - let playable = await resolvePlayerMedia(ctx, playerUrl, detailUrl); - if (!playable) throw new Error('线路 ' + (line || '') + ' 暂时无法解析,请切换其他线路'); - const requestedHeight = positiveInt(firstNonEmpty(payload.height, ctx.height), 0); - if (requestedHeight && /\.m3u8(?:$|[?#])/i.test(playable)) { - try { - const playlist = await fetchText(ctx, playable, playerUrl); - const variants = parseMasterPlaylist(playable, playlist); - const selected = variants.filter(function (item) { return item.height === requestedHeight; })[0]; - if (selected) playable = selected.url; - } catch (error) { - // The master itself remains a valid highest/automatic fallback. - } - } - return { - url: playable, - container: /\.m3u8(?:$|[?#])/i.test(playable) ? 'm3u8' : 'mp4', - headers: playbackHeaders(playerUrl), - startPositionSeconds: 0, - isLive: false, - streamKind: /\.m3u8(?:$|[?#])/i.test(playable) ? 'hls' : 'file' - }; +async function resolvePlayback(input) { + return withPlaybackBudget(input, resolvePlaybackWithinBudget); } function sectionShell(section) { @@ -284,14 +232,10 @@ function sectionShell(section) { }; } -function sectionResult(section, items, error) { +function sectionResult(section, items) { return { id: section.id, title: section.title, style: section.style, - lazy: false, moreAction: categoryAction(section), - error: error || undefined, subtitle: error || undefined, - items: section.style === 'discover.ranked' ? items.map(function (item, index) { - return Object.assign({}, item, { rank: index + 1 }); - }) : items + lazy: false, moreAction: categoryAction(section), items: items }; } @@ -359,32 +303,47 @@ function parseCards(ctx, html) { function parsePlayers(html) { const text = String(html || ''); - const results = []; - const iframeURLs = []; - const iframePattern = /]*(?:data-lazy-src|data-src|src)=["']([^"']+)["'][^>]*>/gi; + const options = {}; + const optionPattern = /]*)>([\s\S]*?)<\/li>/gi; let match; - while ((match = iframePattern.exec(text))) { - const url = htmlDecode(match[1]); - if (/earnvidjav|javggvideo|streamhgjav|\/embed\/|\/e\/|\/t\//i.test(url)) iframeURLs.push(url); + while ((match = optionPattern.exec(text))) { + if (!/\bdooplay_player_option\b/i.test(attributeValue(match[1], 'class'))) continue; + const number = attributeValue(match[1], 'data-nume'); + const server = firstMatch(match[2], /]*class=["'][^"']*\bserver\b[^"']*["'][^>]*>([\s\S]*?)<\/span>/i); + const title = firstMatch(match[2], /]*class=["'][^"']*\btitle\b[^"']*["'][^>]*>([\s\S]*?)<\/span>/i); + options[number] = cleanText(server || title) || ('线路 ' + number); } - const optionPattern = /]*class=["'][^"']*\bdooplay_player_option\b[^"']*["'][^>]*data-post=["']([^"']+)["'][^>]*data-nume=["']([^"']+)["'][^>]*>[\s\S]*?(?:]*class=["'][^"']*\btitle\b[^"']*["'][^>]*>([\s\S]*?)<\/span>|Server[\s\S]*?([A-Z]{1,5}))[\s\S]*?<\/li>/gi; - const labels = []; - while ((match = optionPattern.exec(text))) labels.push(cleanText(match[3] || match[4])); - iframeURLs.forEach(function (url, index) { - const line = labels[index] || ['VH', 'TB', 'SW'][index] || ('线路 ' + (index + 1)); - results.push({ line: line, url: url }); - }); - return uniqueBy(results, function (x) { return x.url; }); + const results = []; + // source-player-N matches data-nume=N, even if a frame is missing or reordered. + const sourcePattern = /]*\bid=["']source-player-\d+["'][^>]*)>([\s\S]*?)<\/div>/gi; + while ((match = sourcePattern.exec(text))) { + const id = attributeValue(match[1], 'id').match(/^source-player-(\d+)$/); + if (!id) continue; + const frame = firstMatch(match[2], /]*)>/i); + const url = iframeURL(frame); + if (url) results.push({ lineId: id[1], line: options[id[1]] || ('线路 ' + id[1]), url: url }); + } + if (!results.length) { + const frames = text.match(/]*>/gi) || []; + const numbers = Object.keys(options); + frames.forEach(function (frame) { + const url = iframeURL(frame); + if (!url) return; + const number = numbers[results.length] || String(results.length + 1); + results.push({ lineId: number, line: options[number] || ('线路 ' + number), url: url }); + }); + } + return uniqueBy(results, function (player) { return player.url; }); } function qualityGroup(detailUrl, title, player, qualities) { return { - id: 'line-' + player.line, + id: 'line-' + (player.lineId || player.line), title: player.line + ' 线路', versions: qualities.map(function (quality, index) { const payload = encodePayload({ kind: 'play', detailUrl: detailUrl, playerUrl: player.url, - line: player.line, height: quality.height || 0, title: title + line: player.line, lineId: player.lineId, height: quality.height || 0, title: title }); return { id: payload, @@ -401,34 +360,37 @@ function qualityGroup(detailUrl, title, player, qualities) { }; } -async function discoverQualities(ctx, detailUrl, player) { - const mediaURL = await resolvePlayerMedia(ctx, player.url, detailUrl); - if (!mediaURL) return []; - if (!/\.m3u8(?:$|[?#])/i.test(mediaURL)) return [{ name: 'MP4', height: 0 }]; - try { - const playlist = await fetchText(ctx, mediaURL, player.url); - const variants = parseMasterPlaylist(mediaURL, playlist); - if (variants.length) { - return variants.map(function (variant) { - return { name: variant.height ? variant.height + 'p' : 'HLS', height: variant.height || 0 }; - }); - } - if (/#EXTINF/i.test(playlist)) return [{ name: 'HLS 原始画质', height: 0 }]; - } catch (error) { - return []; +async function discoverQualities(ctx, detailUrl, player, allowBrowser, captured) { + const media = captured || await resolvePlayerMedia(ctx, player.url, detailUrl, allowBrowser); + const result = { player: player, qualities: [], media: media.url ? media : null, stage: media.stage || 'player-media' }; + if (!media.url) return result; + if (!/\.m3u8(?:$|[?#])/i.test(media.url)) { + result.qualities = [{ name: 'MP4', height: 0 }]; + return result; } - return []; + try { + const playlist = await fetchPlaybackText(ctx, media.url, player.url, media.headers, 4, 'manifest-http'); + const variants = parseMasterPlaylist(media.url, playlist); + if (variants.length) result.qualities = variants.map(function (variant) { + return { name: variant.height ? variant.height + 'p' : 'HLS', height: variant.height || 0 }; + }); + else if (/#EXTINF/i.test(playlist)) result.qualities = [{ name: 'HLS 原始画质', height: 0 }]; + else result.stage = 'manifest-not-hls'; + } catch (error) { result.stage = error.stage || 'manifest-http'; } + return result; } -async function resolvePlayerMedia(ctx, playerUrl, detailUrl) { - let playable = ''; +async function resolvePlayerMedia(ctx, playerUrl, detailUrl, allowBrowser) { + let stage = 'player-media'; try { - playable = extractPlayableURL(await fetchText(ctx, playerUrl, detailUrl || playerUrl)); - } catch (error) { - playable = ''; + const playable = extractPlayableURL(await fetchPlaybackText(ctx, playerUrl, detailUrl || playerUrl, null, 4, 'player-http')); + if (playable) return { url: playable, headers: playbackHeaders(playerUrl) }; + } catch (error) { stage = error.stage || 'player-http'; } + if (allowBrowser) { + const captured = await extractFromBrowser(playerUrl, detailUrl, ctx, 10); + if (captured) return captured; } - if (!playable) playable = await extractFromBrowser(playerUrl, detailUrl); - return playable; + return { url: '', stage: stage }; } function parseMasterPlaylist(masterURL, text) { @@ -459,95 +421,6 @@ function resolveRelativeURL(base, value) { return String(base || '').replace(/[?#].*$/, '').replace(/\/[^/]*$/, '/') + value; } -// Only list pages use this bounded HTML fallback. Player/detail requests retain -// their existing implementation and never enter this list-page browser path. -async function fetchListingPage(ctx, url) { - let httpError; - try { - return await listingDeadline(async function () { - const response = await httpGet(url, { - headers: requestHeaders(url), timeout: 6, timeoutSeconds: 6, - useBrowserCookie: true, attachBrowserCookie: true, - useBrowserFallback: false, browserFallback: false, allowBrowserFallback: false - }); - return readListingResponse(ctx, response, 'http'); - }, 6, 'http-timeout'); - } catch (error) { - httpError = error || new Error('HTTP 请求失败'); - if (httpError.status === 404 || httpError.status === 410) throw httpError; - } - const httpStage = httpError.stage || (/timeout|超时/i.test(String(httpError.message)) ? 'http-timeout' : 'http-error'); - if (typeof Widget === 'undefined' || !Widget.browser || typeof Widget.browser.fetch !== 'function') { - throw listingError(httpStage, '列表读取失败,当前环境没有可用的网页回退'); - } - try { - return await listingDeadline(async function () { - const response = await Widget.browser.fetch(url, { - visible: false, timeout: 12, timeoutSeconds: 12, - waitAfterLoad: 1, headers: requestHeaders(url) - }); - return readListingResponse(ctx, response, 'browser'); - }, 12, 'browser-timeout'); - } catch (error) { - const stage = (error && error.stage) || 'browser-error'; - throw listingError(stage, '列表读取失败(HTTP阶段:' + httpStage + ')' + - (stage === 'browser-verification' ? ',请在原站完成验证后重试' : ',请稍后重试')); - } -} - -async function readListingResponse(ctx, response, stage) { - const status = Number(response && firstNonEmpty(response.statusCode, response.status)) || 0; - const html = await listingResponseText(response, 0); - if (/Just a moment|Checking(?:\s+your)?\s+browser|cf-browser-verification|cf-chl-/i.test(html.slice(0, 30000))) { - throw listingError(stage + '-verification', '来源需要浏览器验证'); - } - if (status >= 400) throw listingError(stage + '-status', '来源返回 HTTP ' + status, status); - const items = parseCards(ctx, html); - const emptyList = /<[^>]+class=["'][^"']*\b(?:no-results|noresults|not-found)\b/i.test(html) || - /(?:No results found|Nothing found|No posts found)/i.test(cleanText(html)); - if (!items.length && !emptyList) throw listingError(stage + '-content', '来源没有返回影片列表'); - return { html: html, items: items }; -} - -async function listingResponseText(response, depth) { - if (depth > 4 || response === undefined || response === null) return ''; - response = await response; - if (response === undefined || response === null) return ''; - if (typeof response === 'string') { - if (/^\s*\{/.test(response)) { - try { return await listingResponseText(JSON.parse(response), depth + 1); } catch (error) { /* Plain HTML below. */ } - } - return response; - } - if (typeof response.text === 'function') return String(await response.text()); - const keys = ['html', 'data', 'body', 'text', 'content']; - for (let index = 0; index < keys.length; index += 1) { - if (response[keys[index]] === undefined || response[keys[index]] === null) continue; - const text = await listingResponseText(response[keys[index]], depth + 1); - if (text) return text; - } - return ''; -} - -function listingError(stage, message, status) { - const error = new Error(message + ';stage=' + stage); - error.stage = stage; - if (status) error.status = status; - return error; -} - -async function listingDeadline(operation, seconds, stage) { - let timer; - try { - return await Promise.race([ - Promise.resolve().then(operation), - new Promise(function (_, reject) { - timer = setTimeout(function () { reject(listingError(stage, '来源请求超时')); }, seconds * 1000); - }) - ]); - } finally { clearTimeout(timer); } -} - async function fetchText(ctx, url, referer) { const response = await httpGet(url, { headers: requestHeaders(referer || url), @@ -580,45 +453,87 @@ async function httpGet(url, options) { throw new Error('当前环境没有可用的 HTTP 客户端'); } -async function extractFromBrowser(url, referer) { - if (typeof Widget === 'undefined' || !Widget.browser || typeof Widget.browser.fetch !== 'function') return ''; +// The user's working list/detail HTTP path above stays unchanged. +// Only resource discovery and playback use these bounded requests. +async function fetchPlaybackText(ctx, url, referer, headers, limit, stage) { + const seconds = remainingPlaybackSeconds(ctx, limit || 4); + stage = stage || 'playback-http'; + return runStage(ctx, stage, seconds, async function () { + const response = await httpGet(url, { + headers: headers || requestHeaders(referer || url), + timeout: seconds, timeoutSeconds: seconds, + useBrowserCookie: false, attachBrowserCookie: false, + useBrowserFallback: false, browserFallback: false + }); + const text = await playbackResponseText(response, 0); + const status = Number(response && (response.statusCode || response.status)); + if (status >= 400) throw stageError(stage + '-' + status, '来源请求失败(HTTP ' + status + ')'); + if (/Just a moment|Checking(?:\s+your)?\s+browser|cf-browser-verification|cf-chl-/i.test(text.slice(0, 30000))) { + throw stageError(stage + '-verification', '源站要求浏览器验证'); + } + if (!text) throw stageError(stage + '-empty', '来源返回空内容'); + return text; + }); +} + +async function playbackResponseText(response, depth) { + if (depth > 4 || response == null) return ''; + response = await response; + if (response == null) return ''; + if (typeof response === 'string') { + if (/^\s*\{/.test(response)) { + try { return await playbackResponseText(JSON.parse(response), depth + 1); } catch (error) { /* Not a JSON wrapper. */ } + } + return response; + } + if (typeof response.text === 'function') return String(await response.text()); + const keys = ['html', 'data', 'body', 'text', 'content']; + for (let index = 0; index < keys.length; index++) { + const text = await playbackResponseText(response[keys[index]], depth + 1); + if (text) return text; + } + return ''; +} + +async function extractFromBrowser(url, referer, ctx, limit) { + if (typeof Widget === 'undefined' || !Widget.browser || typeof Widget.browser.fetch !== 'function') return null; + const seconds = playbackBrowserSeconds(ctx, limit || 10, 1); try { - const result = await Widget.browser.fetch(url, { - visible: false, - timeout: 70, - waitAfterLoad: 4, - waitForAny: true, - waitForMediaSource: true, - headers: requestHeaders(referer || url) + return await runStage(ctx, 'browser-media', seconds, async function () { + const result = await Widget.browser.fetch(url, { + visible: false, timeout: seconds, timeoutSeconds: seconds, + waitAfterLoad: 1, waitForMediaSource: true, + headers: requestHeaders(referer || url) + }); + const captured = firstPlayableInBrowserResult(result); + if (captured) return captured; + const link = extractPlayableURL(await playbackResponseText(result, 0)); + if (link) return { url: link, headers: playbackHeaders(url) }; + const keys = Object.keys(result || {}).slice(0, 12).join(','); + const blobOnly = /blob:https?:/.test(JSON.stringify((result && (result.mediaSources || result.capturedRequests || result.mediaRequests)) || [])); + throw stageError(blobOnly ? 'browser-blob-only' : 'browser-no-media', '浏览器未回传可用媒体;blobOnly=' + blobOnly + ';keys=' + keys); }); - return firstPlayableInBrowserResult(result) || - extractPlayableURL(responseText(result)) || - extractPlayableURL(result && result.html); } catch (error) { - return ''; + throw stageError(error.stage || 'browser-media', error.stage ? error.message.replace(/;stage=.*$/, '') : '浏览器媒体捕获失败'); } } function firstPlayableInBrowserResult(result) { - if (!result) return ''; - const keys = ['url', 'mediaURL', 'mediaUrl', 'videoURL', 'videoUrl', 'playURL', 'playUrl', 'src']; - for (let i = 0; i < keys.length; i += 1) { - if (isPlayableURL(result[keys[i]])) return result[keys[i]]; - } - const arrays = [result.mediaSources, result.mediaRequests, result.requests, result.responses, result.urls]; + if (!result) return null; + const arrays = [result.capturedRequests, result.mediaRequests, result.requests, result.responses, result.mediaSources, result.urls, [result]]; for (let a = 0; a < arrays.length; a += 1) { if (!Array.isArray(arrays[a])) continue; for (let i = 0; i < arrays[a].length; i += 1) { const item = arrays[a][i]; - const value = typeof item === 'string' ? item : firstNonEmpty(item && item.url, item && item.src, item && item.responseURL); - if (isPlayableURL(value) && !/^blob:/i.test(value)) return value; + const url = typeof item === 'string' ? item : firstNonEmpty(item && item.url, item && item.src, item && item.responseURL, item && item.mediaURL, item && item.mediaUrl, item && item.videoURL, item && item.videoUrl, item && item.playURL, item && item.playUrl); + if (isPlayableURL(url)) return { url: url, headers: capturedHeaders(item && (item.requestHeaders || item.headers)) }; } } - return ''; + return null; } function extractPlayableURL(value) { - let text = htmlDecode(responseText(value)).replace(/\\\//g, '/'); + const text = htmlDecode(responseText(value) + '\n' + unpackPlayerScripts(responseText(value)).join('\n')).replace(/\\\//g, '/'); const patterns = [ /(?:urlPlay|file|src)\s*[:=]\s*["'](https?:\/\/[^"']+\.(?:m3u8|mp4)(?:\?[^"']*)?)/i, /(https?:\/\/[^\s"'<>\\]+\.(?:m3u8|mp4)(?:\?[^\s"'<>\\]*)?)/i @@ -643,11 +558,13 @@ function imageHeaders(referer) { } function playbackHeaders(playerUrl) { - return { - 'User-Agent': JAVGG_UA, - Referer: playerUrl, - Origin: originOf(playerUrl) - }; + const headers = { 'User-Agent': JAVGG_UA }; + // Observed on luluvdoo master, variant and segment requests (not the detail-page URL). + if (/^https:\/\/luluvdoo\.com(?:\/|$)/i.test(String(playerUrl))) { + headers.Referer = 'https://luluvdoo.com/'; + headers.Origin = 'https://luluvdoo.com'; + } + return headers; } function baseURL(ctx) { @@ -874,6 +791,189 @@ function escapeRegExp(value) { return String(value || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } +async function discoverResourceVersions(rawCtx) { + const ctx = normalizeContext(rawCtx); + const directPayload = decodePayload(firstNonEmpty(ctx.versionId, ctx.itemId, ctx.id)); + const detailUrl = firstNonEmpty(directPayload.detailUrl, detailURLFromContext(ctx)); + if (!detailUrl) return []; + const html = await fetchPlaybackText(ctx, detailUrl, detailUrl, null, 4, 'detail-http'); + const title = cleanText(firstNonEmpty(ctx.title, metaContent(html, 'property', 'og:title'), pageTitle(html))); + const players = parsePlayers(html); + if (!players.length) throw stageError('detail-players', '源站详情页当前没有可用播放线路'); + const attempts = await inspectPlayerQualities(ctx, detailUrl, players); + const groups = attempts.filter(function (attempt) { return attempt.qualities.length; }) + .map(function (attempt) { return qualityGroup(detailUrl, title, attempt.player, attempt.qualities); }); + if (groups.length) return groups; + // Browser execution is one fallback only when static discovery produced no usable line. + const candidate = attempts.filter(function (attempt) { return !attempt.media; })[0]; + if (candidate && remainingPlaybackSeconds(ctx, 8) > 0.5) { + const captured = await extractFromBrowser(candidate.player.url, detailUrl, ctx, 8); + if (captured) { + const retry = await discoverQualities(ctx, detailUrl, candidate.player, false, captured); + if (retry.qualities.length) return [qualityGroup(detailUrl, title, retry.player, retry.qualities)]; + candidate.stage = retry.stage; + } + } + const diagnostic = attempts.map(function (attempt) { return attempt.player.line + ':' + attempt.stage; }).join(', '); + throw stageError('quality-discovery', '未发现可用画质;' + diagnostic + ';请稍后重试'); +} + +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); + let detailUrl = firstNonEmpty(payload.detailUrl, detailURLFromContext(ctx)); + let line = firstNonEmpty(payload.line, ctx.line); + const requestedHeight = positiveInt(firstNonEmpty(payload.height, ctx.height), 0); + let media; + if (!playerUrl && detailUrl) { + const players = parsePlayers(await fetchPlaybackText(ctx, detailUrl, detailUrl, null, 4, 'detail-http')); + const selected = players.filter(function (item) { return payload.lineId ? item.lineId === payload.lineId : (line && item.line === line); }); + const attempts = await inspectPlayerQualities(ctx, detailUrl, selected.length ? selected : players); + const usable = attempts.filter(function (attempt) { return attempt.qualities.length; }); + usable.sort(function (a, b) { + const aExact = a.qualities.some(function (q) { return q.height === requestedHeight && requestedHeight > 0; }); + const bExact = b.qualities.some(function (q) { return q.height === requestedHeight && requestedHeight > 0; }); + return Number(bExact) - Number(aExact) || (b.qualities[0].height || 0) - (a.qualities[0].height || 0); + }); + const chosen = usable[0]; + const player = chosen ? chosen.player : (selected[0] || players[0]); + playerUrl = player && player.url; + line = player && player.line; + media = chosen && chosen.media; + } + if (!playerUrl) throw stageError('detail-players', '未找到 JAVGG 播放器地址'); + if (!media) media = await resolvePlayerMedia(ctx, playerUrl, detailUrl, true); + if (!media.url) throw stageError(media.stage || 'player-media', '线路 ' + (line || '') + ' 暂时无法解析,请切换其他线路'); + let playable = media.url; + if (/\.m3u8(?:$|[?#])/i.test(playable)) { + try { + const playlist = await fetchPlaybackText(ctx, playable, playerUrl, media.headers, 4, 'manifest-http'); + if (!/#EXTM3U/i.test(playlist)) throw stageError('manifest-not-hls', '来源未返回 HLS 清单'); + const variants = parseMasterPlaylist(playable, playlist); + const selected = variants.filter(function (item) { return item.height === requestedHeight; })[0] || variants[0]; + if (selected) playable = selected.url; + } catch (error) { + if (/-4\d\d$|verification$|not-hls$/.test(error.stage || '')) throw error; + // Preserve the fresh real master only when probing fails transiently. + } + } + return { + url: playable, + container: /\.m3u8(?:$|[?#])/i.test(playable) ? 'm3u8' : 'mp4', + headers: media.headers, + startPositionSeconds: 0, + isLive: false, + streamKind: /\.m3u8(?:$|[?#])/i.test(playable) ? 'hls' : 'file' + }; +} + +function attributeValue(tag, name) { + return htmlDecode(firstMatch(tag, new RegExp('\\b' + escapeRegExp(name) + '\\s*=\\s*["\']([^"\']*)', 'i'))); +} + +function iframeURL(tag) { + const url = firstNonEmpty(attributeValue(tag, 'data-lazy-src'), attributeValue(tag, 'data-src'), attributeValue(tag, 'src')); + return /^https?:\/\//i.test(url) && /earnvidjav|javggvideo|streamhgjav|\/embed\/|\/e\/|\/t\//i.test(url) ? url : ''; +} + +async function inspectPlayerQualities(ctx, detailUrl, players) { + const attempts = new Array(players.length); + let next = 0; + async function worker() { + while (next < players.length) { + const index = next++; + attempts[index] = await discoverQualities(ctx, detailUrl, players[index], false); + } + } + await Promise.all([worker(), worker(), worker()]); + return attempts; +} + +function capturedHeaders(input) { + const headers = { 'User-Agent': JAVGG_UA }; + if (input && typeof input === 'object') Object.keys(input).forEach(function (key) { + const canonical = { 'user-agent': 'User-Agent', referer: 'Referer', origin: 'Origin', cookie: 'Cookie' }[key.toLowerCase()]; + if (canonical) headers[canonical] = String(input[key]); + }); + return headers; +} + +function unpackPlayerScripts(value) { + const text = String(value || '').slice(0, 500000); + const pattern = /eval\(function\(p,a,c,k,e,[dr]\)[\s\S]*?\(\s*'((?:\\.|[^'\\])*)'\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*'((?:\\.|[^'\\])*)'\.split\('\|'\)/g; + const output = []; + let match; + while (output.length < 4 && (match = pattern.exec(text))) { + const radix = Number(match[2]), count = Number(match[3]); + if (radix < 2 || radix > 62 || count < 1 || count > 2000 || match[1].length > 100000) continue; + const words = decodePackedString(match[4]).split('|'); + const dictionary = Object.create(null); + for (let index = 0; index < count; index += 1) if (words[index]) dictionary[baseEncode(index, radix)] = words[index]; + output.push(decodePackedString(match[1]).replace(/\b[0-9a-zA-Z]+\b/g, function (token) { return dictionary[token] || token; })); + } + return output; +} + +function decodePackedString(text) { + return String(text || '').replace(/\\(['"\\])/g, '$1').replace(/\\x([0-9a-f]{2})|\\u([0-9a-f]{4})/gi, function (_, x, u) { return String.fromCharCode(parseInt(x || u, 16)); }); +} + +function baseEncode(number, radix) { + const alphabet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; + if (!number) return '0'; + let token = ''; + while (number > 0) { token = alphabet[number % radix] + token; number = Math.floor(number / radix); } + return token; +} + +function stageError(stage, message) { + const error = new Error(message + ';stage=' + stage); + error.stage = stage; + return error; +} + +async function runStage(ctx, stage, seconds, operation) { + const budget = ctx && ctx.__playbackBudget; + if (budget && budget.expired) throw stageError('total-deadline', '解析超时'); + let timer; + try { + const timeout = new Promise(function (_, reject) { + timer = setTimeout(function () { reject(stageError(stage + '-timeout', '来源请求超时')); }, seconds * 1000); + }); + return await Promise.race([Promise.resolve().then(operation), timeout]); + } finally { clearTimeout(timer); } +} + +async function withPlaybackBudget(input, operation, label) { + const ctx = Object.assign({}, normalizeContext(input)); + const budget = { endAt: Date.now() + 28000, expired: false, browserCalls: 0 }; + ctx.__playbackBudget = budget; + let timer; + try { + const timeout = new Promise(function (_, reject) { + timer = setTimeout(function () { budget.expired = true; reject(stageError('total-deadline', (label || '播放解析') + '超时;请稍后重试')); }, 28000); + }); + return await Promise.race([Promise.resolve().then(function () { return operation(ctx); }), timeout]); + } finally { clearTimeout(timer); budget.expired = true; } +} + +function remainingPlaybackSeconds(ctx, limit) { + const budget = ctx && ctx.__playbackBudget; + if (!budget) return limit; + const remaining = (budget.endAt - Date.now()) / 1000; + if (budget.expired || remaining <= 0) throw new Error('播放解析超时;stage=total-deadline'); + return Math.max(0.1, Math.min(limit, remaining)); +} + +function playbackBrowserSeconds(ctx, limit, maxCalls) { + const timeout = remainingPlaybackSeconds(ctx, limit); + const budget = ctx && ctx.__playbackBudget; + if (budget && budget.browserCalls >= maxCalls) throw new Error('浏览器回退次数已用完;stage=browser-fallback-limit'); + if (budget) budget.browserCalls++; + return timeout; +} + const exported = { WidgetMetadata: WidgetMetadata, getManifest: getManifest, diff --git a/tests/adapters.test.cjs b/tests/adapters.test.cjs index bccaca9..e505b24 100644 --- a/tests/adapters.test.cjs +++ b/tests/adapters.test.cjs @@ -314,117 +314,199 @@ function javggListHTML(slugs, next) { '' ).join('') + '' + (next ? '' : '') + ''; } -function javggListContext() { - return load('javgg-mini-library.js', { setTimeout: (fn, ms) => setTimeout(fn, ms / 1000) }); +function javggDetail(lines = ['VH', 'playmate', 'luluvdoo', 'SW']) { + return '
    ' + lines.map((line, i) => + `
  • Server${line}
  • `).join('') + '
' + lines.map((line, i) => + `
`).join('') + '
'; } -test('JAVGG list repair retains its imported ID, parameter and superseded snapshot record', () => { - const c = javggListContext(); +function packedJavgg(host = 'cdn', file = 'master', signature = 'fresh') { + return `eval(function(p,a,c,k,e,d){return p;}('0({1:[{2:"3://4.5/6.7?8=9"}]});',36,10,'setup|sources|file|https|${host}|test|${file}|m3u8|sign|${signature}'.split('|'),0,{}))`; +} + +test('JAVGG maps real server labels to numbered frames, despite missing/reordered frames', () => { + const c = load('javgg-mini-library.js'); + const source = javggDetail().replace(/
[\s\S]*?<\/div><\/div>/, ''); + const players = c.parsePlayers(source); + assert.deepEqual(plain(players.map(x => [x.lineId, x.line])), [['2','playmate'],['3','luluvdoo'],['4','SW']]); + assert.equal(players[0].url, 'https://player2.test/e/film'); + const groups = players.map(x => c.qualityGroup('https://javgg.net/jav/a/', 'A', x, [{name:'720p',height:720}])); + assert.equal(new Set(groups.map(x => x.id)).size, 3); +}); + +test('JAVGG discovers packed HLS qualities while hung and empty players fail independently', async () => { + const c = load('javgg-mini-library.js', { setTimeout: (fn, ms) => setTimeout(fn, ms / 100) }); + let active = 0, max = 0, browsers = 0; + c.Widget.browser = { async fetch() { browsers++; throw new Error('should not be needed'); } }; + c.Widget.http.get = async url => { + if (url.includes('/jav/')) return {statusCode:200,data:javggDetail()}; + if (url.includes('player1')) return new Promise(() => {}); + active++; max = Math.max(active, max); await Promise.resolve(); active--; + if (url.includes('player2')) return {statusCode:200,data:'dynamic player'}; + if (url.includes('player3')) return {statusCode:200,data:packedJavgg('lulu')}; + if (url.includes('player4')) return {statusCode:200,data:packedJavgg('sw')}; + return {statusCode:200,data:url.includes('lulu') ? master.split('#EXT-X-STREAM-INF:BANDWIDTH=900000')[0] : master}; + }; + const groups = await c.getResourceVersions(JSON.stringify({detailUrl:'https://javgg.net/jav/a/'})); + assert.deepEqual(plain(groups.map(x => x.title)), ['luluvdoo 线路','SW 线路']); + assert.deepEqual(plain(groups[1].versions.map(x => x.name)), ['1080p','720p']); + assert.equal(groups[1].versions.filter(x => x.default).length, 1); + assert.equal(browsers, 0); assert.ok(max <= 2); + const payload = c.decodePayload(groups[1].versions[1].id); + assert.equal(payload.height,720); assert.equal(payload.lineId,'4'); assert.equal(payload.playerUrl,'https://player4.test/e/film'); + assert.ok(!groups[1].versions[0].id.includes('sign')); +}); + +test('JAVGG refreshes signed packed URLs and selects highest / requested / missing quality', async () => { + const c = load('javgg-mini-library.js'); let refreshes = 0; + c.Widget.http.get = async url => url.includes('player.test') ? {status:200,data:packedJavgg('cdn','master','fresh'+(++refreshes))} : {status:200,data:master}; + for (const height of [0,720,2160,1080]) { + const result = await c.resolvePlayback({versionId:c.encodePayload({kind:'play',detailUrl:'https://javgg.net/jav/a/',playerUrl:'https://player.test/e/a',line:'Server',height})}); + assert.equal(result.url, 'https://cdn.test/' + (height === 720 ? '720' : '1080') + '.m3u8'); + } + assert.equal(refreshes,4); +}); + +test('JAVGG does not execute packed scripts or accept unbounded packing dictionaries', () => { + const c = load('javgg-mini-library.js'); + assert.equal(c.extractPlayableURL(packedJavgg()),'https://cdn.test/master.m3u8?sign=fresh'); + assert.equal(c.extractPlayableURL(packedJavgg().replace('return p;', 'globalThis.compromised=true;return p;')),'https://cdn.test/master.m3u8?sign=fresh'); + assert.equal(c.compromised,undefined); + assert.equal(c.extractPlayableURL(packedJavgg().replace(',36,10,', ',36,200000,')), ''); +}); + +test('JAVGG rejects expired 404 manifests, retries fresh discovery, and leaves detail resources empty', async () => { + const c = load('javgg-mini-library.js'); let available = false; + c.Widget.http.get = async url => url.includes('/jav/') ? {status:200,data:javggDetail(['SW'])} : url.includes('player1') ? {status:200,data:packedJavgg()} : {statusCode:available ? 200 : 404,data:available ? media : 'Not Found'}; + const ctx={detailUrl:'https://javgg.net/jav/a/'}; + assert.equal((await c.getDetail(ctx)).resourceGroups.length,0); + await assert.rejects(c.getResourceVersions(ctx), /manifest-http-404/); + available=true; + const groups=await c.getResourceVersions(ctx);assert.equal(groups[0].versions[0].name,'HLS 原始画质'); +}); + +test('JAVGG captures real browser request headers once, then resolves a lower quality', async () => { + const c=load('javgg-mini-library.js'); let browsers=0; const manifestHeaders=[]; + c.Widget.http.get=async (url, options)=>{ + if(url.includes('/jav/')) return {status:200,data:javggDetail(['playmate'])}; + if(url.includes('player1')) return {status:200,data:'dynamic'}; + manifestHeaders.push(options.headers);return {status:200,data:master}; + }; + c.Widget.browser={async fetch(url,options){browsers++;assert.equal(options.visible,false);assert.equal(options.waitForAny,undefined);assert.ok(options.timeout<=10);return {capturedRequests:[{url:'https://cdn.test/master.m3u8?fresh=1',requestHeaders:{referer:'https://actual-player.test/',origin:'https://actual-player.test','user-agent':'Actual Agent',cookie:'fixture-only','Authorization':'must-not-forward'}}]};}}; + const groups=await c.getResourceVersions({detailUrl:'https://javgg.net/jav/a/'}); + const result=await c.resolvePlayback(groups[0].versions[1].action); + assert.equal(result.url,'https://cdn.test/720.m3u8');assert.equal(browsers,2); + assert.deepEqual(plain(result.headers),{'User-Agent':'Actual Agent',Referer:'https://actual-player.test/',Origin:'https://actual-player.test',Cookie:'fixture-only'}); + assert.ok(manifestHeaders.every(h=>h.Referer==='https://actual-player.test/')); +}); + +test('JAVGG bounds a hung native browser and identifies capture failure without exposing tokens', async () => { + const c=load('javgg-mini-library.js',{setTimeout:(fn,ms)=>setTimeout(fn,ms/100)});let browsers=0; + c.Widget.http.get=async url=>({status:200,data:url.includes('/jav/')?javggDetail(): 'dynamic'}); + c.Widget.browser={async fetch(){browsers++;return new Promise(()=>{});}}; + const start=Date.now();await assert.rejects(c.getResourceVersions({detailUrl:'https://javgg.net/jav/a/'}),/browser-media-timeout/); + assert.equal(browsers,1);assert.ok(Date.now()-start<500); + c.Widget.browser.fetch=async()=>({mediaSources:['blob:https://player.test/id'],secret:'must-not-leak'}); + await assert.rejects(c.resolvePlayback({playerUrl:'https://player.test/e/a'}),e=>/browser-.*media|browser-blob/.test(e.message)&&!e.message.includes('must-not-leak')); +}); + +test('JAVGG quality discovery itself has an overall deadline', async () => { + const c=load('javgg-mini-library.js',{setTimeout:fn=>setTimeout(fn,15)}); + c.discoverResourceVersions=async()=>new Promise(()=>{}); + await assert.rejects(c.getResourceVersions({}),/画质发现超时.*total-deadline/); +}); + +test('JAVGG direct detail Play explores later lines and selects the highest real quality', async () => { + const c=load('javgg-mini-library.js'); + c.Widget.http.get=async url=>{ + if(url.includes('/jav/')) return {status:200,data:javggDetail(['luluvdoo','SW'])}; + if(url.includes('player1')) return {status:200,data:packedJavgg('low')}; + if(url.includes('player2')) return {status:200,data:packedJavgg('high')}; + return {status:200,data:url.includes('low')?master.replace(/1920x1080/g,'1280x720').replace(/1080.m3u8/g,'720.m3u8'):master}; + }; + const result=await c.resolvePlayback({itemId:c.encodePayload({kind:'detail',detailUrl:'https://javgg.net/jav/a/'})}); + assert.equal(result.url,'https://high.test/1080.m3u8'); +}); + +test('JAVGG playback fails explicitly when a fresh manifest is permanently unavailable', async () => { + const c=load('javgg-mini-library.js'); + c.Widget.http.get=async url=>url.includes('player.test')?{status:200,data:packedJavgg()}:{statusCode:404,data:'Not Found'}; + await assert.rejects(c.resolvePlayback({playerUrl:'https://player.test/e/a'}),/manifest-http-404/); +}); + +test('JAVGG uses observed luluvdoo headers and does not invent headers for other players', () => { + const c=load('javgg-mini-library.js'); + const known=c.playbackHeaders('https://luluvdoo.com/e/film'); + assert.equal(known.Referer,'https://luluvdoo.com/');assert.equal(known.Origin,'https://luluvdoo.com'); + const other=c.playbackHeaders('https://javstreamhq.xyz/e/film'); + assert.equal(other.Origin,undefined);assert.equal(other.Referer,undefined); +}); + +test('JAVGG 1.0.4 keeps the imported identity and records the user requested replacement', () => { + const c = load('javgg-mini-library.js'); assert.equal(c.getManifest().id, 'javgg-mini-library'); - assert.equal(c.getManifest().version, '1.0.3'); + assert.equal(c.getManifest().version, '1.0.4'); assert.deepEqual(plain(c.getManifest().parameters.map(x => x.name)), ['baseUrl']); - assert.equal(restoration.supersededFiles['javgg-mini-library.js'].version, '1.0.0'); - assert.equal(restoration.supersededFiles['javgg-mini-library.js'].replacementVersion, '1.0.3'); -}); -test('JAVGG home uses one ordinary HTTP request and retains real poster and detail actions', async () => { - const c = javggListContext(); let http = 0, browser = 0; - c.Widget.http.get = async (url, options) => { - http++; assert.equal(url, 'https://javgg.net/new-post/'); - assert.equal(options.timeoutSeconds, 6); assert.equal(options.useBrowserCookie, true); - assert.equal(options.browserFallback, false); - return { status: 200, data: javggListHTML(['a', 'b', 'c']) }; + assert.equal(restoration.supersededFiles['javgg-mini-library.js'].replacementVersion, '1.0.4'); +}); +test('JAVGG working home keeps its original HTTP options, real cards and lazy sections', async () => { + const c=load('javgg-mini-library.js');let http=0,browser=0; + c.Widget.http.get=async(url,options)=>{ + http++;assert.equal(url,'https://javgg.net/new-post/'); + assert.equal(options.useBrowserCookie,false);assert.equal(options.browserFallback,false); + assert.equal(options.timeout,undefined);assert.equal(options.timeoutSeconds,undefined); + return {data:javggListHTML(['a','b','c'])}; }; - c.Widget.browser = { async fetch() { browser++; throw new Error('not needed'); } }; - const home = await c.getHome('{}'); - assert.equal(http, 1); assert.equal(browser, 0); assert.equal(home.hero.length, 3); - assert.equal(home.sections[1].items[0].poster, 'https://img.test/a.jpg'); - assert.equal(c.detailURLFromId(home.hero[0].action.itemId), 'https://javgg.net/jav/a/'); - assert.equal(home.sections.filter(x => x.lazy).length, 9); assert.equal(home.error, undefined); -}); -test('JAVGG hung HTTP gets exactly one bounded HTML browser fallback without media capture', async () => { - const c = javggListContext(); let browser = 0; - c.Widget.http.get = () => new Promise(() => {}); - c.Widget.browser = { async fetch(url, options) { - browser++; assert.equal(url, 'https://javgg.net/new-post/'); - assert.equal(options.timeoutSeconds, 12); assert.equal(options.visible, false); - assert.equal(options.waitForMediaSource, undefined); assert.equal(options.captureMedia, undefined); - return { html: javggListHTML(['recovered']) }; - } }; - const home = await c.getHome({}); - assert.equal(browser, 1); assert.equal(home.hero.length, 1); assert.equal(home.error, undefined); -}); -test('JAVGG host http-timeout error and async browser body recover the requested category', async () => { - const c = javggListContext(); const requests = []; - c.Widget.http.get = async () => { throw new Error('来源请求超时;stage=http-timeout'); }; - c.Widget.browser = { async fetch(url) { requests.push(url); return { statusCode: 200, async text() { return javggListHTML(['weekly']); } }; } }; - const page = await c.getCategory({ pageId: 'popular-weekly', page: 2 }); - assert.deepEqual(requests, ['https://javgg.net/trending/page/2/?sort=weekly']); - assert.equal(page.items.length, 1); assert.equal(page.page, 2); assert.equal(page.error, undefined); -}); -test('JAVGG Checking Browser pivots to HTML; ordinary Cloudflare jsd does not', async () => { - const c = javggListContext(); let browser = 0; - c.Widget.http.get = async () => ({ status: 200, data: 'Checking Browser' }); - c.Widget.browser = { async fetch() { browser++; return { body: { html: javggListHTML(['verified']) } }; } }; - assert.equal((await c.getHomeSection({ sectionId: 'featured' })).items.length, 1); - c.Widget.http.get = async () => ({ status: 200, data: javggListHTML(['plain']) + '' }); - assert.equal((await c.getHomeSection({ sectionId: 'featured' })).items.length, 1); - assert.equal(browser, 1); -}); -test('JAVGG total listing wait includes a hanging HTTP response body', async () => { - const c = javggListContext(); let browser = 0; - c.Widget.http.get = async () => ({ status: 200, text() { return new Promise(() => {}); } }); - c.Widget.browser = { async fetch() { browser++; return javggListHTML(['body-recovery']); } }; - assert.equal((await c.getHomeSection({ sectionId: 'new-post' })).items.length, 1); - assert.equal(browser, 1); -}); -test('JAVGG nested JSON and promise body responses retain real pagination and ranked items', async () => { - const c = javggListContext(); const requested = []; - c.Widget.http.get = async url => { - requested.push(url); - const second = url.includes('/page/2/'); - return { statusCode: 200, body: Promise.resolve(JSON.stringify({ data: { html: javggListHTML([second ? 'two' : 'one'], second ? '' : 'https://javgg.net/trending/page/2/?sort=today') } })) }; + c.Widget.browser={async fetch(){browser++;throw Error('list must stay on working path');}}; + const home=await c.getHome('{}'); + assert.equal(http,1);assert.equal(browser,0);assert.equal(home.hero.length,3); + assert.equal(home.sections[1].items[0].poster,'https://img.test/a.jpg'); + assert.equal(c.detailURLFromId(home.hero[0].action.itemId),'https://javgg.net/jav/a/'); + assert.equal(home.sections.filter(s=>s.lazy).length,9); +}); +test('JAVGG working category and search keep page two URLs and distinct detail IDs', async () => { + const c=load('javgg-mini-library.js');const urls=[]; + c.Widget.http.get=async url=>{ + urls.push(url);return {data:javggListHTML([url.includes('/page/2/')?'two':'one'],url.includes('/page/2/')?'':'/page/2/')}; }; - const first = await c.getCategory({ pageId: 'popular-today', page: 1 }); - const second = await c.getCategory(JSON.stringify({ pageId: 'popular-today', page: 2 })); - assert.equal(first.hasMore, true); assert.equal(second.hasMore, false); - assert.notEqual(first.items[0].id, second.items[0].id); - assert.deepEqual(requested, ['https://javgg.net/trending/?sort=today', 'https://javgg.net/trending/page/2/?sort=today']); - const ranked = await c.getHomeSection({ sectionId: 'popular-today' }); - assert.equal(ranked.items[0].rank, 1); -}); -test('JAVGG listing failure returns explicit error and no fake failure artwork', async () => { - const c = javggListContext(); let browser = 0; - c.Widget.http.get = async () => { throw new Error('timeout'); }; - c.Widget.browser = { fetch() { browser++; return new Promise(() => {}); } }; - const home = await c.getHome({}); - assert.match(home.error, /stage=browser-timeout/); assert.equal(home.hero.length, 0); - assert.equal(home.sections[1].items.length, 0); assert.match(home.sections[1].error, /http-timeout/); - const section = await c.getHomeSection({ sectionId: 'featured' }); - assert.equal(section.items.length, 0); assert.ok(section.error); - assert.equal(browser, 2); -}); -test('JAVGG genuine not-found page fails without a browser retry or advancing pagination', async () => { - const c = javggListContext(); let browser = 0; - c.Widget.http.get = async () => ({ status: 404, data: 'Not found' }); - c.Widget.browser = { async fetch() { browser++; return javggListHTML(['wrong']); } }; - const page = await c.getCategory({ pageId: 'new-post', page: 2 }); - assert.equal(page.items.length, 0); assert.equal(page.nextPage, 2); - assert.match(page.error, /HTTP 404/); assert.equal(browser, 0); -}); -test('JAVGG browser verification stays a visible error and cannot become an empty success', async () => { - const c = javggListContext(); - c.Widget.http.get = async () => ({ status: 403, data: 'Checking Browser' }); - c.Widget.browser = { async fetch() { return { status: 403, html: 'Just a moment' }; } }; - const page = await c.getCategory({ pageId: 'featured' }); - assert.equal(page.items.length, 0); assert.match(page.error, /browser-verification/); - assert.match(page.error, /完成验证/); -}); -test('JAVGG empty real search results are valid while missing browser support reports the HTTP stage', async () => { - const c = javggListContext(); const urls = []; - c.Widget.http.get = async url => { urls.push(url); return { status: 200, data: '
Nothing found
' }; }; - const result = await c.search({ query: 'A&B #tag', page: 2 }); - assert.equal(result.items.length, 0); assert.equal(result.error, undefined); - assert.deepEqual(urls, ['https://javgg.net/page/2/?s=A%26B%20%23tag']); - c.Widget.http.get = async () => { throw new Error('stage=http-timeout'); }; - const failed = await c.getCategory({ pageId: 'new-post' }); - assert.equal(failed.items.length, 0); assert.match(failed.error, /stage=http-timeout/); + const first=await c.getCategory({pageId:'popular-weekly',page:1}); + const second=await c.getCategory(JSON.stringify({pageId:'popular-weekly',page:2})); + assert.notEqual(first.items[0].id,second.items[0].id); + assert.equal(first.hasMore,true);assert.equal(second.hasMore,false); + await c.search({query:'A&B #tag',page:2}); + assert.deepEqual(urls,['https://javgg.net/trending/?sort=weekly','https://javgg.net/trending/page/2/?sort=weekly','https://javgg.net/page/2/?s=A%26B%20%23tag']); +}); +test('JAVGG five current server labels stay associated when a container has no iframe', () => { + const c=load('javgg-mini-library.js'); + const html=javggDetail(['vidhide','f4scom','streamwish','playmate','luluvdoo']) + .replace('',''); + const players=c.parsePlayers(html); + assert.deepEqual(plain(players.map(p=>[p.lineId,p.line])),[['1','vidhide'],['3','streamwish'],['4','playmate'],['5','luluvdoo']]); +}); +test('JAVGG playback awaits asynchronous response bodies while list HTTP stays separate', async () => { + const c=load('javgg-mini-library.js'); + c.Widget.http.get=async url=>url.includes('player.test') ? {statusCode:200,body:Promise.resolve(JSON.stringify({data:{html:packedJavgg()}}))} : {status:200,async text(){return master;}}; + const result=await c.resolvePlayback({versionId:c.encodePayload({playerUrl:'https://player.test/e/abc',height:720})}); + assert.match(result.url,/720\.m3u8$/);assert.equal(result.container,'m3u8'); +}); +test('JAVGG a hanging HTTP body times out and permits only one captured-media fallback', async () => { + const c=load('javgg-mini-library.js',{setTimeout:(fn,ms)=>setTimeout(fn,ms/100)});let browser=0; + c.Widget.http.get=async url=>url.includes('player.test') ? {text(){return new Promise(()=>{});}} : {data:media}; + c.Widget.browser={async fetch(url,options){browser++;assert.ok(options.timeoutSeconds<=10);assert.equal(options.waitForAny,undefined); + return {capturedRequests:[{url:'https://cdn.test/fresh.m3u8',requestHeaders:{Referer:'https://player.test/'}}]};}}; + const result=await c.resolvePlayback({versionId:c.encodePayload({playerUrl:'https://player.test/e/abc'})}); + assert.equal(browser,1);assert.equal(result.url,'https://cdn.test/fresh.m3u8'); + assert.equal(result.headers.Referer,'https://player.test/'); +}); +test('JAVGG a hanging browser body fails within its capture stage', async () => { + const c=load('javgg-mini-library.js',{setTimeout:(fn,ms)=>setTimeout(fn,ms/100)});let browser=0; + c.Widget.http.get=async()=>({data:'dynamic player'}); + c.Widget.browser={async fetch(){browser++;return {text(){return new Promise(()=>{});}};}}; + await assert.rejects(c.resolvePlayback({versionId:c.encodePayload({playerUrl:'https://player.test/e/abc'})}),/stage=browser-media-timeout/); + assert.equal(browser,1); +}); +test('JAVGG browser blob-only output reports a capability failure without fake media', async () => { + const c=load('javgg-mini-library.js'); + c.Widget.http.get=async()=>({data:'dynamic player'}); + c.Widget.browser={async fetch(){return {mediaSources:['blob:https://player.test/example'],html:'