From 2c3359a374cffaae5c361aec4b2b7bec1f7180dd Mon Sep 17 00:00:00 2001 From: hzl <1803573449@qq.com> Date: Sat, 3 Oct 2026 21:32:23 +0800 Subject: [PATCH] Recover JAVGG packed media and bounded quality discovery --- CHANGELOG.md | 8 + README.md | 8 + javgg-mini-library.js | 338 +++++++++++++++++++++++++++------------- tests/adapters.test.cjs | 127 +++++++++++++++ 4 files changed, 373 insertions(+), 108 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 552a6bf..c94acc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +# JAVGG 1.0.2 — 2026-10-03 + +- 修复四条线路都被读为 `Server` 的问题;播放器容器编号与选项编号对应,避免遗漏 iframe 后错配线路及重复分组 ID。 +- 作为数据解包公开的 Dean Edwards 播放配置,不使用 `eval` 或执行网页代码。发现真实 HLS master 和画质,最高可用画质默认,旧载荷与原下载路径保留。 +- 资源发现限两路并发;失败线路独立降级。画质发现和播放均限 28 秒,播放器/清单 HTTP 各最多 4 秒;无静态可用线路时才进行一次浏览器捕获,不缓存失败或签名 URL。 +- 读取 `capturedRequests` 及其请求头;luluvdoo 使用实测的来源头,其他静态播放器不猜测 Origin/Referer。永久失效清单、验证页、blob-only 和宿主挂起分别给出阶段诊断。 +- 57 项媒体库离线回归通过,包含 11 项新检查。桌面匿名 HTTP 已验证两部影片的清单和范围分片;ATID-666 SW 实测 404。浏览器和匿名 HTTP 属诊断证据,iPhone/DreamBy 原生播放仍未验收。 + # 2026-10-03 基于 `main` 的 `8e43017532ec81e3338046ba6e32dff7655bd3dd` 修复审查中 O01–O25;保留所有已发布文件路径。 diff --git a/README.md b/README.md index 7754c84..e005a0d 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,14 @@ GitHub Actions 会先生成兼容副本并验证;`main` 的检查通过后, 具体行为和设备验证限制见 [CHANGELOG.md](CHANGELOG.md)。 +## JAVGG 1.0.2 播放修复稿 + +`javgg-mini-library.js` 保持原下载路径、媒体库 ID、`baseUrl` 参数和旧 `javgg://` 载荷。按实际 `data-nume` 对应 VH、playmate、luluvdoo、SW 线路,静态读取公开播放器的打包代码,不执行远程脚本。资源列表仅包含本次成功读取到的真实画质,最高画质默认;地址在每次播放时刷新。 + +画质发现与播放各有 28 秒总时限,播放器及清单 HTTP 阶段最多 4 秒;两路并发检查,某条线路失效不会抹掉其他可用线路。静态发现已有可用线路时直接返回;全部失败时最多一次 8 秒浏览器媒体捕获。已选线路播放的捕获最多 10 秒。404、验证页、非 HLS 响应会明确失败,临时发现失败可重新请求。脚本截止时间不能强制取消宿主已经启动的原生请求。 + +匿名桌面 HTTP 已验证 CAWB-046 的 luluvdoo 1080p、SW 1080p/720p/480p 和 MIRD-287 的 luluvdoo 720p,master、变体与分片可请求;ATID-666 的 SW 清单当时返回 404。首页/详情 HTTP 还可能遇到站点浏览器检查,浏览器正常打开不等于设备脚本能读取。DreamBy 浏览器回传、iPhone 原生起播与切画质仍待实测。 + ## 本地验收 ```bash diff --git a/javgg-mini-library.js b/javgg-mini-library.js index 98a770f..d78faf0 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.1', + version: '1.0.2', requiredVersion: '0.0.1', author: 'Alan huang', site: JAVGG_DEFAULT_BASE, @@ -218,21 +218,34 @@ async function getDetail(rawCtx) { } async function getResourceVersions(rawCtx) { + return withPlaybackBudget(rawCtx, discoverResourceVersions, '画质发现'); +} + +async function discoverResourceVersions(rawCtx) { const ctx = normalizeContext(rawCtx); const directPayload = decodePayload(firstNonEmpty(ctx.versionId, ctx.itemId, ctx.id)); const detailUrl = firstNonEmpty(directPayload.detailUrl, detailURLFromContext(ctx)); if (!detailUrl) return []; - const html = await fetchText(ctx, detailUrl); + const html = await fetchText(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 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 (!players.length) throw stageError('detail-players', '源站详情页当前没有可用播放线路'); + const attempts = await inspectPlayerQualities(ctx, detailUrl, players); + const groups = attempts.filter(function (attempt) { return attempt.qualities.length; }) + .map(function (attempt) { return qualityGroup(detailUrl, title, attempt.player, attempt.qualities); }); + if (groups.length) return groups; + // Browser execution is one fallback only when static discovery produced no usable line. + const candidate = attempts.filter(function (attempt) { return !attempt.media; })[0]; + if (candidate && remainingPlaybackSeconds(ctx, 8) > 0.5) { + const captured = await extractFromBrowser(candidate.player.url, detailUrl, ctx, 8); + if (captured) { + const retry = await discoverQualities(ctx, detailUrl, candidate.player, false, captured); + if (retry.qualities.length) return [qualityGroup(detailUrl, title, retry.player, retry.qualities)]; + candidate.stage = retry.stage; + } } - if (!groups.length) throw new Error('暂时无法发现可用画质,请稍后重试'); - return groups; + const diagnostic = attempts.map(function (attempt) { return attempt.player.line + ':' + attempt.stage; }).join(', '); + throw stageError('quality-discovery', '未发现可用画质;' + diagnostic + ';请稍后重试'); } async function resolvePlaybackWithinBudget(rawCtx) { @@ -241,31 +254,44 @@ async function resolvePlaybackWithinBudget(rawCtx) { let playerUrl = firstNonEmpty(ctx.playerUrl, payload.playerUrl); let detailUrl = firstNonEmpty(payload.detailUrl, detailURLFromContext(ctx)); let line = firstNonEmpty(payload.line, ctx.line); + const requestedHeight = positiveInt(firstNonEmpty(payload.height, ctx.height), 0); + let media; if (!playerUrl && detailUrl) { - const players = parsePlayers(await fetchText(ctx, detailUrl)); - const selected = players.filter(function (item) { return !line || item.line === line; })[0] || players[0]; - playerUrl = selected && selected.url; - line = selected && selected.line; + const players = parsePlayers(await fetchText(ctx, detailUrl, detailUrl, null, 4, 'detail-http')); + const selected = players.filter(function (item) { return payload.lineId ? item.lineId === payload.lineId : (line && item.line === line); }); + const attempts = await inspectPlayerQualities(ctx, detailUrl, selected.length ? selected : players); + const usable = attempts.filter(function (attempt) { return attempt.qualities.length; }); + usable.sort(function (a, b) { + const aExact = a.qualities.some(function (q) { return q.height === requestedHeight && requestedHeight > 0; }); + const bExact = b.qualities.some(function (q) { return q.height === requestedHeight && requestedHeight > 0; }); + return Number(bExact) - Number(aExact) || (b.qualities[0].height || 0) - (a.qualities[0].height || 0); + }); + const chosen = usable[0]; + const player = chosen ? chosen.player : (selected[0] || players[0]); + playerUrl = player && player.url; + line = player && player.line; + media = chosen && chosen.media; } - 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)) { + 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 fetchText(ctx, playable, playerUrl); + const playlist = await fetchText(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]; + const selected = variants.filter(function (item) { return item.height === requestedHeight; })[0] || variants[0]; if (selected) playable = selected.url; } catch (error) { - // The master itself remains a valid highest/automatic fallback. + 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: playbackHeaders(playerUrl), + headers: media.headers, startPositionSeconds: 0, isLive: false, streamKind: /\.m3u8(?:$|[?#])/i.test(playable) ? 'hls' : 'file' @@ -350,32 +376,56 @@ 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]*?]*>)/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 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 : ''; } 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, @@ -392,34 +442,50 @@ 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 }; - }); +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); } - if (/#EXTINF/i.test(playlist)) return [{ name: 'HLS 原始画质', height: 0 }]; - } catch (error) { - return []; } - return []; + await Promise.all([worker(), worker()]); + return attempts; } -async function resolvePlayerMedia(ctx, playerUrl, detailUrl) { - let playable = ''; +async function discoverQualities(ctx, detailUrl, player, allowBrowser, captured) { + const media = captured || await resolvePlayerMedia(ctx, player.url, detailUrl, allowBrowser); + const result = { player: player, qualities: [], media: media.url ? media : null, stage: media.stage || 'player-media' }; + if (!media.url) return result; + if (!/\.m3u8(?:$|[?#])/i.test(media.url)) { + result.qualities = [{ name: 'MP4', height: 0 }]; + return result; + } try { - playable = extractPlayableURL(await fetchText(ctx, playerUrl, detailUrl || playerUrl)); - } catch (error) { - playable = ''; + const playlist = await fetchText(ctx, media.url, player.url, media.headers, 4, 'manifest-http'); + const variants = parseMasterPlaylist(media.url, playlist); + if (variants.length) result.qualities = variants.map(function (variant) { + return { name: variant.height ? variant.height + 'p' : 'HLS', height: variant.height || 0 }; + }); + else if (/#EXTINF/i.test(playlist)) result.qualities = [{ name: 'HLS 原始画质', height: 0 }]; + else result.stage = 'manifest-not-hls'; + } catch (error) { result.stage = error.stage || 'manifest-http'; } + return result; +} + +async function resolvePlayerMedia(ctx, playerUrl, detailUrl, allowBrowser) { + let stage = 'player-media'; + try { + const playable = extractPlayableURL(await fetchText(ctx, playerUrl, detailUrl || playerUrl, null, 4, 'player-http')); + if (playable) return { url: playable, headers: playbackHeaders(playerUrl) }; + } catch (error) { stage = error.stage || 'player-http'; } + if (allowBrowser) { + const captured = await extractFromBrowser(playerUrl, detailUrl, ctx, 10); + if (captured) return captured; } - if (!playable) playable = await extractFromBrowser(playerUrl, detailUrl, ctx); - return playable; + return { url: '', stage: stage }; } function parseMasterPlaylist(masterURL, text) { @@ -450,20 +516,21 @@ function resolveRelativeURL(base, value) { return String(base || '').replace(/[?#].*$/, '').replace(/\/[^/]*$/, '/') + 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, - browserFallback: false +async function fetchText(ctx, url, referer, headers, limit, stage) { + const seconds = remainingPlaybackSeconds(ctx, limit || 6); + const response = await runStage(ctx, stage || 'http', seconds, function () { + return httpGet(url, { + headers: headers || requestHeaders(referer || url), + timeout: seconds, timeoutSeconds: seconds, + useBrowserCookie: false, attachBrowserCookie: false, + useBrowserFallback: false, browserFallback: false + }); }); const text = responseText(response); - if (!text) throw new Error('请求失败: ' + url); - if (isVerificationPage(text, response && (response.status || response.statusCode))) { - throw new Error('源站返回了浏览器验证页'); - } + const status = Number(response && (response.status || response.statusCode)); + if (status >= 400) throw stageError((stage || 'http') + '-' + status, '来源请求失败(HTTP ' + status + ')'); + if (isVerificationPage(text, status)) throw stageError((stage || 'http') + '-verification', '源站要求浏览器验证'); + if (!text) throw stageError((stage || 'http') + '-empty', '来源返回空内容'); return text; } @@ -483,46 +550,54 @@ async function httpGet(url, options) { throw new Error('当前环境没有可用的 HTTP 客户端'); } -async function extractFromBrowser(url, referer, ctx) { - if (typeof Widget === 'undefined' || !Widget.browser || typeof Widget.browser.fetch !== 'function') 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: playbackBrowserSeconds(ctx, 12, 1), - timeoutSeconds: remainingPlaybackSeconds(ctx, 12), - waitAfterLoad: 4, - waitForAny: true, - waitForMediaSource: true, - headers: requestHeaders(referer || url) + const result = await runStage(ctx, 'browser-media', seconds, function () { + return Widget.browser.fetch(url, { + visible: false, timeout: seconds, timeoutSeconds: seconds, + waitAfterLoad: 1, waitForMediaSource: true, + headers: requestHeaders(referer || url) + }); }); - return firstPlayableInBrowserResult(result) || - extractPlayableURL(responseText(result)) || - extractPlayableURL(result && result.html); + const captured = firstPlayableInBrowserResult(result); + if (captured) return captured; + const link = extractPlayableURL(responseText(result)) || extractPlayableURL(result && result.html); + if (link) return { url: link, headers: { 'User-Agent': JAVGG_UA } }; + const keys = Object.keys(result || {}).slice(0, 12).join(','); + const blobOnly = /blob:https?:/.test(JSON.stringify((result && (result.mediaSources || result.capturedRequests || result.mediaRequests)) || [])); + throw stageError(blobOnly ? 'browser-blob-only' : 'browser-no-media', '浏览器未回传可用媒体;blobOnly=' + blobOnly + ';keys=' + keys); } 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 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 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 @@ -534,6 +609,35 @@ function extractPlayableURL(value) { return ''; } +// Decode the public Dean Edwards packing format as data; never execute source scripts. +function unpackPlayerScripts(value) { + const text = String(value || '').slice(0, 500000); + const pattern = /eval\(function\(p,a,c,k,e,[dr]\)[\s\S]*?\(\s*'((?:\\.|[^'\\])*)'\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*'((?:\\.|[^'\\])*)'\.split\('\|'\)/g; + const output = []; + let match; + while (output.length < 4 && (match = pattern.exec(text))) { + const radix = Number(match[2]), count = Number(match[3]); + if (radix < 2 || radix > 62 || count < 1 || count > 2000 || match[1].length > 100000) continue; + const words = decodePackedString(match[4]).split('|'); + const dictionary = Object.create(null); + for (let index = 0; index < count; index += 1) if (words[index]) dictionary[baseEncode(index, radix)] = words[index]; + output.push(decodePackedString(match[1]).replace(/\b[0-9a-zA-Z]+\b/g, function (token) { return dictionary[token] || token; })); + } + return output; +} + +function decodePackedString(text) { + return String(text || '').replace(/\\(['"\\])/g, '$1').replace(/\\x([0-9a-f]{2})|\\u([0-9a-f]{4})/gi, function (_, x, u) { return String.fromCharCode(parseInt(x || u, 16)); }); +} + +function baseEncode(number, radix) { + const alphabet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; + if (!number) return '0'; + let token = ''; + while (number > 0) { token = alphabet[number % radix] + token; number = Math.floor(number / radix); } + return token; +} + function requestHeaders(referer) { return { 'User-Agent': JAVGG_UA, @@ -547,11 +651,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) { @@ -648,7 +754,7 @@ function responseText(response) { function isVerificationPage(html, status) { const text = String(html || '').slice(0, 30000); - return Number(status) === 403 || /Just a moment|Checking your browser|cf-browser-verification|cf-chl-/i.test(text); + return Number(status) === 403 || /Just a moment|Checking (?:your )?browser|cf-browser-verification|cf-chl-/i.test(text); } function absoluteURL(ctx, value) { @@ -808,16 +914,32 @@ if (typeof globalThis !== 'undefined') { } 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 : {}); +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(new Error('播放解析超时;stage=total-deadline;请稍后重试')); }, 28000); + 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; } diff --git a/tests/adapters.test.cjs b/tests/adapters.test.cjs index eba6a24..a4f58f5 100644 --- a/tests/adapters.test.cjs +++ b/tests/adapters.test.cjs @@ -257,3 +257,130 @@ test('XVideos attaches the configured session token without printing it', async const options=await vm.runInContext('widgetAPI.getDefaultOptions()',c); assert.equal(options.headers.Cookie,'session_token='+token); assert.ok(logs.every(line=>!line.includes(token))); }); + +function javggDetail(lines = ['VH', 'playmate', 'luluvdoo', 'SW']) { + return '
    ' + lines.map((line, i) => + `
  • Server${line}
  • `).join('') + '
' + lines.map((line, i) => + `
`).join('') + '
'; +} +function packedJavgg(host = 'cdn', file = 'master', signature = 'fresh') { + return `eval(function(p,a,c,k,e,d){return p;}('0({1:[{2:"3://4.5/6.7?8=9"}]});',36,10,'setup|sources|file|https|${host}|test|${file}|m3u8|sign|${signature}'.split('|'),0,{}))`; +} + +test('JAVGG maps real server labels to numbered frames, despite missing/reordered frames', () => { + const c = load('javgg-mini-library.js'); + const source = javggDetail().replace(/
[\s\S]*?<\/div><\/div>/, ''); + const players = c.parsePlayers(source); + assert.deepEqual(plain(players.map(x => [x.lineId, x.line])), [['2','playmate'],['3','luluvdoo'],['4','SW']]); + assert.equal(players[0].url, 'https://player2.test/e/film'); + const groups = players.map(x => c.qualityGroup('https://javgg.net/jav/a/', 'A', x, [{name:'720p',height:720}])); + assert.equal(new Set(groups.map(x => x.id)).size, 3); +}); + +test('JAVGG discovers packed HLS qualities while hung and empty players fail independently', async () => { + const c = load('javgg-mini-library.js', { setTimeout: (fn, ms) => setTimeout(fn, ms / 100) }); + let active = 0, max = 0, browsers = 0; + c.Widget.browser = { async fetch() { browsers++; throw new Error('should not be needed'); } }; + c.Widget.http.get = async url => { + if (url.includes('/jav/')) return {statusCode:200,data:javggDetail()}; + if (url.includes('player1')) return new Promise(() => {}); + active++; max = Math.max(active, max); await Promise.resolve(); active--; + if (url.includes('player2')) return {statusCode:200,data:'dynamic player'}; + if (url.includes('player3')) return {statusCode:200,data:packedJavgg('lulu')}; + if (url.includes('player4')) return {statusCode:200,data:packedJavgg('sw')}; + return {statusCode:200,data:url.includes('lulu') ? master.split('#EXT-X-STREAM-INF:BANDWIDTH=900000')[0] : master}; + }; + const groups = await c.getResourceVersions(JSON.stringify({detailUrl:'https://javgg.net/jav/a/'})); + assert.deepEqual(plain(groups.map(x => x.title)), ['luluvdoo 线路','SW 线路']); + assert.deepEqual(plain(groups[1].versions.map(x => x.name)), ['1080p','720p']); + assert.equal(groups[1].versions.filter(x => x.default).length, 1); + assert.equal(browsers, 0); assert.ok(max <= 2); + const payload = c.decodePayload(groups[1].versions[1].id); + assert.equal(payload.height,720); assert.equal(payload.lineId,'4'); assert.equal(payload.playerUrl,'https://player4.test/e/film'); + assert.ok(!groups[1].versions[0].id.includes('sign')); +}); + +test('JAVGG refreshes signed packed URLs and selects highest / requested / missing quality', async () => { + const c = load('javgg-mini-library.js'); let refreshes = 0; + c.Widget.http.get = async url => url.includes('player.test') ? {status:200,data:packedJavgg('cdn','master','fresh'+(++refreshes))} : {status:200,data:master}; + for (const height of [0,720,2160,1080]) { + const result = await c.resolvePlayback({versionId:c.encodePayload({kind:'play',detailUrl:'https://javgg.net/jav/a/',playerUrl:'https://player.test/e/a',line:'Server',height})}); + assert.equal(result.url, 'https://cdn.test/' + (height === 720 ? '720' : '1080') + '.m3u8'); + } + assert.equal(refreshes,4); +}); + +test('JAVGG does not execute packed scripts or accept unbounded packing dictionaries', () => { + const c = load('javgg-mini-library.js'); + assert.equal(c.extractPlayableURL(packedJavgg()),'https://cdn.test/master.m3u8?sign=fresh'); + assert.equal(c.extractPlayableURL(packedJavgg().replace('return p;', 'globalThis.compromised=true;return p;')),'https://cdn.test/master.m3u8?sign=fresh'); + assert.equal(c.compromised,undefined); + assert.equal(c.extractPlayableURL(packedJavgg().replace(',36,10,', ',36,200000,')), ''); +}); + +test('JAVGG rejects expired 404 manifests, retries fresh discovery, and leaves detail resources empty', async () => { + const c = load('javgg-mini-library.js'); let available = false; + c.Widget.http.get = async url => url.includes('/jav/') ? {status:200,data:javggDetail(['SW'])} : url.includes('player1') ? {status:200,data:packedJavgg()} : {statusCode:available ? 200 : 404,data:available ? media : 'Not Found'}; + const ctx={detailUrl:'https://javgg.net/jav/a/'}; + assert.equal((await c.getDetail(ctx)).resourceGroups.length,0); + await assert.rejects(c.getResourceVersions(ctx), /manifest-http-404/); + available=true; + const groups=await c.getResourceVersions(ctx);assert.equal(groups[0].versions[0].name,'HLS 原始画质'); +}); + +test('JAVGG captures real browser request headers once, then resolves a lower quality', async () => { + const c=load('javgg-mini-library.js'); let browsers=0; const manifestHeaders=[]; + c.Widget.http.get=async (url, options)=>{ + if(url.includes('/jav/')) return {status:200,data:javggDetail(['playmate'])}; + if(url.includes('player1')) return {status:200,data:'dynamic'}; + manifestHeaders.push(options.headers);return {status:200,data:master}; + }; + c.Widget.browser={async fetch(url,options){browsers++;assert.equal(options.visible,false);assert.equal(options.waitForAny,undefined);assert.ok(options.timeout<=10);return {capturedRequests:[{url:'https://cdn.test/master.m3u8?fresh=1',requestHeaders:{referer:'https://actual-player.test/',origin:'https://actual-player.test','user-agent':'Actual Agent',cookie:'fixture-only','Authorization':'must-not-forward'}}]};}}; + const groups=await c.getResourceVersions({detailUrl:'https://javgg.net/jav/a/'}); + const result=await c.resolvePlayback(groups[0].versions[1].action); + assert.equal(result.url,'https://cdn.test/720.m3u8');assert.equal(browsers,2); + assert.deepEqual(plain(result.headers),{'User-Agent':'Actual Agent',Referer:'https://actual-player.test/',Origin:'https://actual-player.test',Cookie:'fixture-only'}); + assert.ok(manifestHeaders.every(h=>h.Referer==='https://actual-player.test/')); +}); + +test('JAVGG bounds a hung native browser and identifies capture failure without exposing tokens', async () => { + const c=load('javgg-mini-library.js',{setTimeout:(fn,ms)=>setTimeout(fn,ms/100)});let browsers=0; + c.Widget.http.get=async url=>({status:200,data:url.includes('/jav/')?javggDetail(): 'dynamic'}); + c.Widget.browser={async fetch(){browsers++;return new Promise(()=>{});}}; + const start=Date.now();await assert.rejects(c.getResourceVersions({detailUrl:'https://javgg.net/jav/a/'}),/browser-media-timeout/); + assert.equal(browsers,1);assert.ok(Date.now()-start<500); + c.Widget.browser.fetch=async()=>({mediaSources:['blob:https://player.test/id'],secret:'must-not-leak'}); + await assert.rejects(c.resolvePlayback({playerUrl:'https://player.test/e/a'}),e=>/browser-.*media|browser-blob/.test(e.message)&&!e.message.includes('must-not-leak')); +}); + +test('JAVGG quality discovery itself has an overall deadline', async () => { + const c=load('javgg-mini-library.js',{setTimeout:fn=>setTimeout(fn,15)}); + c.discoverResourceVersions=async()=>new Promise(()=>{}); + await assert.rejects(c.getResourceVersions({}),/画质发现超时.*total-deadline/); +}); + +test('JAVGG direct detail Play explores later lines and selects the highest real quality', async () => { + const c=load('javgg-mini-library.js'); + c.Widget.http.get=async url=>{ + if(url.includes('/jav/')) return {status:200,data:javggDetail(['luluvdoo','SW'])}; + if(url.includes('player1')) return {status:200,data:packedJavgg('low')}; + if(url.includes('player2')) return {status:200,data:packedJavgg('high')}; + return {status:200,data:url.includes('low')?master.replace(/1920x1080/g,'1280x720').replace(/1080.m3u8/g,'720.m3u8'):master}; + }; + const result=await c.resolvePlayback({itemId:c.encodePayload({kind:'detail',detailUrl:'https://javgg.net/jav/a/'})}); + assert.equal(result.url,'https://high.test/1080.m3u8'); +}); + +test('JAVGG playback fails explicitly when a fresh manifest is permanently unavailable', async () => { + const c=load('javgg-mini-library.js'); + c.Widget.http.get=async url=>url.includes('player.test')?{status:200,data:packedJavgg()}:{statusCode:404,data:'Not Found'}; + await assert.rejects(c.resolvePlayback({playerUrl:'https://player.test/e/a'}),/manifest-http-404/); +}); + +test('JAVGG uses observed luluvdoo headers and does not invent headers for other players', () => { + const c=load('javgg-mini-library.js'); + const known=c.playbackHeaders('https://luluvdoo.com/e/film'); + assert.equal(known.Referer,'https://luluvdoo.com/');assert.equal(known.Origin,'https://luluvdoo.com'); + const other=c.playbackHeaders('https://javstreamhq.xyz/e/film'); + assert.equal(other.Origin,undefined);assert.equal(other.Referer,undefined); +});