修复预览视频播放 更新文档

This commit is contained in:
lzyme.dev@gmail.com
2026-08-30 15:57:27 +08:00
parent 42bc3f624a
commit fb5120551c
11 changed files with 1901 additions and 133 deletions
-14
View File
@@ -23,20 +23,6 @@
---
## 自动部署
本项目使用 GitHub Actions 在推送代码时自动构建并发布到 [GitHub Releases](https://github.com/fireinrain/JHS-enhance/releases)。
```
git push → GitHub Actions 自动构建 → 发布 GitHub Release
↓
https://github.com/fireinrain/JHS-enhance/releases/latest/download/jhs-enhance.user.js
↓
用户点击安装链接 → Tampermonkey 弹出安装提示
```
---
## 功能概览
### 列表页增强
+913 -102
View File
File diff suppressed because it is too large Load Diff
+190
View File
@@ -0,0 +1,190 @@
import {GM_xmlhttpRequest} from 'vite-plugin-monkey/dist/client';
const DMM_GRAPHQL_URL = 'https://api.video.dmm.co.jp/graphql';
const dmmGraphQLKeyword = (code) => {
const match = String(code || '').trim().toUpperCase().match(/^([A-Z0-9]{2,10})-(\d{1,6})$/);
if (!match) return '';
return `${match[1].toLowerCase()}${match[2].padStart(5, '0')}`;
};
const dmmGraphQLRequest = (query) => {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'POST',
url: DMM_GRAPHQL_URL,
data: JSON.stringify({query}),
timeout: 12000,
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Referer: 'https://video.dmm.co.jp/',
'Fanza-Device': 'BROWSER',
'Cache-Control': 'no-cache',
},
onload: (response) => {
if (response.status < 200 || response.status >= 400) {
reject(new Error(`DMM GraphQL HTTP ${response.status || 0}`));
return;
}
try {
const json = JSON.parse(response.responseText || '{}');
if (Array.isArray(json.errors) && json.errors.length) {
reject(new Error(json.errors[0].message || 'DMM GraphQL error'));
return;
}
resolve(json.data || {});
} catch (e) {
reject(new Error(`DMM GraphQL JSON parse error: ${e.message}`));
}
},
onerror: () => reject(new Error('DMM GraphQL network error')),
ontimeout: () => reject(new Error('DMM GraphQL timeout')),
});
});
};
const loadDmmCover = async (code) => {
const keyword = dmmGraphQLKeyword(code);
if (!keyword) throw new Error('DMM cover keyword missing');
const searchKeywords = [...new Set([keyword, `${keyword}#`])];
for (const searchKeyword of searchKeywords) {
const query = `{ legacySearchPPV(limit: 5, offset: 0, sort: SALES_RANK_SCORE, floor: AV, queryWord: ${JSON.stringify(searchKeyword)}) { result { contents { id } } } }`;
const data = await dmmGraphQLRequest(query);
const ids = Array.isArray(data && data.legacySearchPPV && data.legacySearchPPV.result && data.legacySearchPPV.result.contents)
? data.legacySearchPPV.result.contents.map((item) => String(item && item.id || '').trim()).filter(Boolean)
: [];
const compactKeyword = keyword.replace(/[^a-z0-9]/gi, '').toLowerCase();
const contentID =
ids.find((id) => id.replace(/[^a-z0-9]/gi, '').toLowerCase().includes(compactKeyword)) ||
(ids.length === 1 ? ids[0] : '');
if (contentID) {
const encoded = encodeURIComponent(contentID);
return `https://pics.dmm.co.jp/digital/video/${encoded}/${encoded}pl.jpg`;
}
}
throw new Error('DMM cover missing');
};
const searchDmmContentIdsByGraphQL = async (code) => {
const keyword = dmmGraphQLKeyword(code);
if (!keyword) return [];
const carNumNoHyphen = String(code || '').replace(/-/g, '').toLowerCase();
const results = [];
const searchKeywords = [...new Set([keyword, `${keyword}#`])];
for (const searchKeyword of searchKeywords) {
const query = `{ legacySearchPPV(limit: 10, offset: 0, sort: SALES_RANK_SCORE, floor: AV, queryWord: ${JSON.stringify(searchKeyword)}) { result { contents { contentId serviceCode floorCode } } } }`;
const data = await dmmGraphQLRequest(query);
const contents = Array.isArray(data && data.legacySearchPPV && data.legacySearchPPV.result && data.legacySearchPPV.result.contents)
? data.legacySearchPPV.result.contents
: [];
for (const item of contents) {
const contentId = String(item.contentId || '');
if (contentId.replace(/[^a-z0-9]/gi, '').toLowerCase().includes(carNumNoHyphen)) {
results.push({
serviceCode: item.serviceCode || 'digital',
floorCode: item.floorCode || 'AV',
contentId: contentId,
});
}
}
if (results.length > 0) break;
}
return results;
};
const getDmmGraphQLVideoUrls = async (code) => {
const contentItems = await searchDmmContentIdsByGraphQL(code);
if (!contentItems.length) return null;
const results = await Promise.allSettled(
contentItems.map((item) => extractTrailerLinks(item))
);
for (const result of results) {
if (result.status === 'fulfilled' && result.value) {
return result.value;
}
}
return null;
};
const extractTrailerLinks = (item) => {
const {contentId, serviceCode, floorCode} = item;
return new Promise((resolve, reject) => {
const trailerPageUrl = `https://www.dmm.co.jp/service/digitalapi/-/html5_player/=/cid=${contentId}/mtype=AhRVShI_/service=${serviceCode}/floor=${floorCode}/mode=/`;
GM_xmlhttpRequest({
method: 'GET',
url: trailerPageUrl,
timeout: 15000,
headers: {
'accept-language': 'ja-JP,ja;q=0.9',
Cookie: 'age_check_done=1',
},
onload: (response) => {
const htmlContent = response.responseText || '';
if (htmlContent.includes('このサービスはお住まいの地域からは')) {
reject(new Error('节点不可用,请将DMM域名分流到日本ip'));
return;
}
const match = htmlContent.match(/const\s+args\s+=\s+(.*);/);
if (!match) {
reject(new Error('未在脚本中找到 const args = ... 变量'));
return;
}
let bitrates;
try {
bitrates = JSON.parse(match[1]).bitrates;
} catch (e) {
reject(new Error(`解析播放器脚本 JSON 失败: ${e.message}`));
return;
}
if (!Array.isArray(bitrates)) {
reject(new Error('解析画质链接失败: bitrates 字段不是一个数组或不存在'));
return;
}
const qualityKeys = [
'4ks', '4k', 'hhbs', 'hhb', 'hmb', 'mhb', 'mmb',
'dmb_w', 'dmb_s', 'dm_s', 'sm_s', 'mhb_w',
];
const qualityNameRegex = new RegExp(`(${qualityKeys.join('|')})\\.mp4$`);
const finalQualityMap = {};
for (const bitrate of bitrates) {
const url = bitrate && bitrate.src;
if (!url || typeof url !== 'string' || !url.endsWith('.mp4')) continue;
const qualityMatch = url.match(qualityNameRegex);
let qualityKey = '';
if (qualityMatch && qualityMatch[1]) qualityKey = qualityMatch[1];
if (qualityKey && !finalQualityMap[qualityKey]) {
finalQualityMap[qualityKey] = url;
}
}
if (Object.keys(finalQualityMap).length === 0) {
reject(new Error('未找到匹配要求的预览画质视频'));
return;
}
resolve(finalQualityMap);
},
onerror: () => reject(new Error('DMM 播放页请求网络错误')),
ontimeout: () => reject(new Error('DMM 播放页请求超时')),
});
});
};
export {
DMM_GRAPHQL_URL,
dmmGraphQLKeyword,
dmmGraphQLRequest,
loadDmmCover,
searchDmmContentIdsByGraphQL,
getDmmGraphQLVideoUrls,
extractTrailerLinks,
};
+31 -3
View File
@@ -1,4 +1,6 @@
import { qualityOptions } from '../core/constants.js';
import {getDmmGraphQLVideoUrls} from './dmm-graphql.js';
const selectDefaultQuality = (dmmVideoQualityList, intendedDefault) => {
if (!dmmVideoQualityList || 0 === dmmVideoQualityList.length) return null;
const availableSet = new Set(dmmVideoQualityList);
@@ -138,6 +140,25 @@ class DmmVideoFetcher {
if (0 === Object.keys(finalQualityMap).length) throw new Error("未找到匹配要求的预览画质视频");
return finalQualityMap;
}
async _searchViaGraphQL() {
clog.debug("尝试通过 GraphQL API 搜索视频...");
const videoMap = await getDmmGraphQLVideoUrls(this.carNum);
if (videoMap && Object.keys(videoMap).length > 0) {
this._updateCache(videoMap);
clog.debug("GraphQL 成功解析出预览视频:", videoMap);
const dmmCacheKey = "jhs_other_site_dmm";
const dmmCacheData = localStorage.getItem(dmmCacheKey) ? JSON.parse(localStorage.getItem(dmmCacheKey)) : {};
dmmCacheData[this.carNum] = {
type: "graphql",
url: `https://www.dmm.co.jp/search/=/searchstr=${this.carNum}`,
};
localStorage.setItem(dmmCacheKey, JSON.stringify(dmmCacheData));
return videoMap;
}
return null;
}
async fetchVideo() {
const cachedResult = this._checkCache();
if (cachedResult) return cachedResult;
@@ -149,22 +170,29 @@ class DmmVideoFetcher {
contentItems = await this._searchContentIds();
} catch (e) {
clog.error("DMM API 搜索失败:", e);
}
if (!contentItems || 0 === contentItems.length) {
clog.warn("Affiliate API 未找到视频,尝试 GraphQL API...");
const graphQLResult = await this._searchViaGraphQL();
if (graphQLResult) return graphQLResult;
const $btn = $("#fanzaBtn");
$btn.attr("href", `https://www.dmm.co.jp/search/=/searchstr=${this.carNum}`);
$btn.attr("title", "未查询到, 点击前往搜索页");
$btn.css("backgroundColor", "#de3333");
return null;
}
if (!contentItems || 0 === contentItems.length) return null;
try {
const finalVideoMap = await Promise.any(contentItems.map((item => this._extractTrailerLinks(item))));
this._updateCache(finalVideoMap);
return finalVideoMap;
} catch (error) {
const errors = error.errors || [ error ];
clog.error(`Affiliate 解析失败: ${errors[0] && errors[0].message || errors[0]}`, errors);
clog.warn("尝试 GraphQL API 作为 fallback...");
const graphQLResult = await this._searchViaGraphQL();
if (graphQLResult) return graphQLResult;
if (errors.some((err => err.message.includes("节点不可用")))) this.showErrorMessages && show.error("节点不可用,请将DMM域名分流到日本ip"); else {
const displayError = errors[0].message || errors[0];
clog.error(`解析失败: ${displayError}`, errors);
this.showErrorMessages && show.error(`解析失败: ${displayError}`);
}
const $btn = $("#fanzaBtn");
@@ -178,4 +206,4 @@ class DmmVideoFetcher {
const getDmmVideo = async (carNum2, showErrorMessages = !0) => new DmmVideoFetcher(carNum2, showErrorMessages).fetchVideo();
export { DmmVideoFetcher, getDmmVideo, selectDefaultQuality };
export { DmmVideoFetcher, getDmmVideo, selectDefaultQuality };
+223
View File
@@ -0,0 +1,223 @@
import {qualityOptions} from '../core/constants.js';
const JAVXY_TOKEN = [118, 119, 112, 71, 97, 110, 28, 84, 124, 65, 76, 102, 65, 16, 77, 109, 64, 82, 85, 83, 67, 92, 125, 108, 83, 65, 124, 107, 84, 104, 71, 84, 17, 124, 118, 125, 104, 8, 125, 96, 112, 103, 29, 18, 82, 83, 87, 84]
.map(v => String.fromCharCode(v ^ 0x25)).join('');
const JAVXY_ENDPOINTS = [
{host: String.fromCharCode(106, 97, 118, 120, 121, 46, 99, 99, 46, 99, 100), label: 'Javxy'},
{
host: String.fromCharCode(119, 111, 114, 107, 101, 114, 46, 106, 97, 118, 120, 121, 46, 99, 99, 46, 99, 100),
label: 'Javxy Worker'
},
];
const JAVXY_SOURCE_LABELS = {
'Tokyo-Hot': 'Javxy | Tokyo-Hot',
FC2: 'Javxy | FC2',
Direct: 'Javxy | Direct',
DMM: 'Javxy | dmm',
MGStage: 'Javxy | MGStage',
DUGA: 'Javxy | DUGA',
MYWIFE: 'Javxy | MyWife',
JavTrailers: 'Javxy | JavTrailers',
JavDB: 'Javxy | Javdb',
AVWikiDB: 'Javxy | AVWikiDB',
JAVDatabase: 'Javxy | JAVDatabase',
HEYZO: 'Javxy | Heyzo',
HeyDouga: 'Javxy | HeyDouga',
PACO: 'Javxy | Paco',
'10MU': 'Javxy | 10mu',
Caribbean: 'Javxy | 加勒比',
'1Pondo': 'Javxy | 一本道',
};
const JAVXY_QUALITY_OPTIONS = [
{quality: '4k', text: '4K'},
{quality: 'hhb', text: '1080p'},
{quality: '1080p', text: '1080p'},
{quality: 'hmb', text: '720p'},
{quality: '720p', text: '720p'},
{quality: 'mhb', text: '576p'},
{quality: '540p', text: '540p'},
{quality: 'mmb', text: '432p'},
{quality: '480p', text: '480p'},
{quality: '396p', text: '396p'},
{quality: '360p', text: '360p'},
{quality: '240p', text: '240p'},
];
const jpSourceFailedKey = (source = 'DMM') => `javxy_jp_source_failed_until_${String(source || '').toUpperCase()}`;
const isJpSourceTemporarilyFailed = (source = 'DMM') => {
const until = Number(sessionStorage.getItem(jpSourceFailedKey(source)) || 0);
if (!Number.isFinite(until) || until <= Date.now()) {
sessionStorage.removeItem(jpSourceFailedKey(source));
return false;
}
return true;
};
const markJpSourceTemporarilyFailed = (source = 'DMM') => {
sessionStorage.setItem(jpSourceFailedKey(source), String(Date.now() + 30 * 60 * 1000));
};
const selectHighestQuality = (qualityMap) => {
const rank = new Map(JAVXY_QUALITY_OPTIONS.map((item, index) => [item.quality, index]));
return Object.keys(qualityMap || {})
.filter(key => qualityMap[key])
.sort((a, b) => (rank.get(a) ?? -1) - (rank.get(b) ?? -1))[0] || null;
};
const sortQualityKeys = (qualityMap) => {
const rank = new Map(JAVXY_QUALITY_OPTIONS.map((item, index) => [item.quality, index]));
return Object.keys(qualityMap || {})
.filter(key => qualityMap[key])
.sort((a, b) => (rank.get(a) ?? -1) - (rank.get(b) ?? -1));
};
const normalizeJavxySource = (value) => {
const raw = String(value || '').trim().toLowerCase();
if (raw.includes('fc2')) return 'FC2';
if (raw.includes('mgstage')) return 'MGStage';
if (raw.includes('heydouga')) return 'Direct';
if (raw.includes('mywife')) return 'MYWIFE';
if (raw.includes('duga')) return 'DUGA';
if (raw.includes('javtrailers')) return 'JavTrailers';
if (raw.includes('javdb')) return 'JavDB';
if (raw.includes('avwikidb')) return 'AVWikiDB';
if (raw.includes('javdatabase')) return 'JAVDatabase';
if (raw.includes('dmm')) return 'DMM';
if (raw === 'direct' || raw.includes('heyzo') || raw.includes('heydouga') || raw.includes('paco') || raw.includes('10musume') || raw.includes('10mu') || raw.includes('1pondo') || raw.includes('caribbean') || raw.includes('tokyo-hot') || raw.includes('tokyohot')) return 'Direct';
return String(value || '').trim();
};
const javxyQualityMapToDmmFormat = (javxyQualityMap) => {
const result = {};
const qualityKeys = qualityOptions.map(o => o.quality);
for (const [key, url] of Object.entries(javxyQualityMap || {})) {
if (!url) continue;
const lowerKey = key.toLowerCase();
if (qualityKeys.includes(key)) {
result[key] = url;
} else if (qualityKeys.includes(lowerKey)) {
result[lowerKey] = url;
}
}
return result;
};
const fromJavxyCcCd = async (id, rawCode = '', options = {}) => {
const query = String(id || rawCode || '').trim();
if (!query) {
clog.debug('Javxy 跳过:查询词为空');
return null;
}
for (const endpoint of JAVXY_ENDPOINTS) {
const params = new URLSearchParams({client: 'laosiji-new'});
if (Array.isArray(options.skip) && options.skip.length) params.set('skip', options.skip.join(','));
if (Array.isArray(options.prefer) && options.prefer.length) params.set('prefer', options.prefer.join(','));
if (Array.isArray(options.source) && options.source.length) params.set('source', options.source.join(','));
if (options.playbackFallback) params.set('purpose', 'playback-fallback');
const apiUrl = `https://${endpoint.host}/trailers/${encodeURIComponent(query)}?${params}`;
clog.debug('Javxy 请求 API', {query, apiUrl, endpoint: endpoint.label});
let r;
try {
r = await gmHttp.get(apiUrl, null, {
'Accept': 'application/json,text/plain,*/*',
'X-Javxy-Token': JAVXY_TOKEN,
}, {timeout: 8000});
} catch (e) {
clog.debug('Javxy API 网络失败,尝试下一个节点', {endpoint: endpoint.label, error: e.message});
continue;
}
if (!r) {
clog.debug('Javxy API 无响应,尝试下一个节点', {endpoint: endpoint.label});
continue;
}
const trailerUrl = String(r?.trailer || '').trim();
if (!trailerUrl) {
clog.debug('Javxy 无 trailer 字段', {endpoint: endpoint.label, keys: Object.keys(r || {})});
return null;
}
const qualityMap = r?.qualities && typeof r.qualities === 'object' ? r.qualities : {};
const quality = r?.quality && qualityMap[r.quality] ? r.quality : selectHighestQuality(qualityMap);
const sourceBase = JAVXY_SOURCE_LABELS[r?.source] || `Javxy | ${r?.source || 'dmm'}`;
const directUrl = qualityMap[quality] || trailerUrl;
clog.debug('Javxy 返回结果', {
endpoint: endpoint.label,
source: r?.source,
quality,
qualities: Object.keys(qualityMap),
url: directUrl,
});
const dmmQualityMap = javxyQualityMapToDmmFormat(qualityMap);
return {
url: directUrl,
source: sourceBase,
type: String(r?.type || '').trim() || 'video',
qualities: qualityMap,
quality,
directUrl,
code: id,
rawCode,
javxySource: String(r?.source || '').trim(),
requiresJP: Boolean(r?.requiresJP),
urls: Array.isArray(r?.urls) && r.urls.length ? r.urls : sortQualityKeys(qualityMap).map(key => qualityMap[key]),
dmmQualityMap,
};
}
return null;
};
const fallbackJavxyResult = async (code, rawCode = '', failedSources = [], options = {}) => {
const skip = [...new Set((failedSources || []).map(source => normalizeJavxySource(source)).filter(Boolean))];
const source = [...new Set((options.source || []).map(s => String(s || '').trim()).filter(Boolean))];
if (!skip.length && !source.length) return null;
clog.debug('Javxy 播放失败回落查询', {code, skip, source});
return fromJavxyCcCd(code, rawCode, {skip, source, playbackFallback: true});
};
const getJavxyVideoUrls = async (code, failedSources = []) => {
const result = await fallbackJavxyResult(code, code, failedSources, {
source: ['JavTrailers', 'JavDB'],
});
if (result?.dmmQualityMap && Object.keys(result.dmmQualityMap).length > 0) {
return result.dmmQualityMap;
}
if (result?.url) {
const qualityMap = {};
qualityMap['720p'] = result.url;
return qualityMap;
}
return null;
};
export {
fromJavxyCcCd,
fallbackJavxyResult,
getJavxyVideoUrls,
normalizeJavxySource,
isJpSourceTemporarilyFailed,
markJpSourceTemporarilyFailed,
JAVXY_TOKEN,
JAVXY_ENDPOINTS,
};
+266
View File
@@ -0,0 +1,266 @@
import {GM_xmlhttpRequest} from 'vite-plugin-monkey/dist/client';
const HLS_SCRIPT_URL = 'https://cdn.jsdelivr.net/npm/hls.js@1.5.18/dist/hls.min.js';
let hlsLoadPromise = null;
const getHlsClass = () => {
const HlsClass = window.Hls || globalThis.Hls || (typeof Hls !== 'undefined' ? Hls : null);
return HlsClass && HlsClass.isSupported && HlsClass.isSupported() ? HlsClass : null;
};
const binaryTextToArrayBuffer = (value) => {
const text = String(value || '');
const bytes = new Uint8Array(text.length);
for (let index = 0; index < text.length; index += 1) {
bytes[index] = text.charCodeAt(index) & 0xff;
}
return bytes.buffer;
};
const loadHlsLibrary = () => {
const readyHls = getHlsClass();
if (readyHls) return Promise.resolve(readyHls);
if (hlsLoadPromise) return hlsLoadPromise;
const loadByGm = () => new Promise((resolve) => {
GM_xmlhttpRequest({
method: 'GET',
url: HLS_SCRIPT_URL,
timeout: 15000,
onload: (r) => {
if (r.status >= 200 && r.status < 300 && r.responseText) {
try {
Function(`${r.responseText}\n//# sourceURL=${HLS_SCRIPT_URL}`).call(globalThis);
} catch (err) {
clog.error('HLS: hls.js 执行失败', err);
}
}
resolve(getHlsClass());
},
onerror: () => resolve(getHlsClass()),
ontimeout: () => resolve(getHlsClass()),
});
});
hlsLoadPromise = new Promise((resolve) => {
const existing = document.querySelector('script[data-jhs-hls="1"]');
if (existing) {
existing.addEventListener('load', () => resolve(getHlsClass()), {once: true});
existing.addEventListener('error', () => loadByGm().then(resolve), {once: true});
setTimeout(() => {
if (!getHlsClass()) loadByGm().then(resolve);
}, 4000);
return;
}
const hlsScript = document.createElement('script');
hlsScript.src = HLS_SCRIPT_URL;
hlsScript.async = true;
hlsScript.dataset.jhsHls = '1';
hlsScript.onload = () => resolve(getHlsClass());
hlsScript.onerror = () => loadByGm().then(resolve);
document.head.appendChild(hlsScript);
}).then((HlsClass) => {
if (!HlsClass) hlsLoadPromise = null;
return HlsClass;
});
return hlsLoadPromise;
};
const createHlsLoader = () => {
class GMHlsLoader {
constructor(config) {
this.config = config;
this.context = null;
this.callbacks = null;
this.loader = null;
this.stats = this.createStats();
}
createStats() {
return {
aborted: false,
loaded: 0,
retry: 0,
total: 0,
chunkCount: 0,
bwEstimate: 0,
trequest: 0,
tfirst: 0,
tload: 0,
loading: {start: 0, first: 0, end: 0},
parsing: {start: 0, end: 0},
buffering: {start: 0, first: 0, end: 0},
};
}
destroy() {
this.abort();
}
abort() {
if (this.stats) this.stats.aborted = true;
if (this.loader) {
try {
this.loader.abort();
} catch (e) { /* ignore */
}
}
this.loader = null;
}
load(context, config, callbacks) {
this.context = context;
this.callbacks = callbacks;
const requestUrl = context.url;
const wantsArrayBuffer =
context.responseType === 'arraybuffer' ||
/\.(?:ts|m4s|mp4|key)(?:[?#]|$)/i.test(requestUrl);
const startedAt = performance.now();
const stats = (this.stats = this.createStats());
stats.trequest = startedAt;
stats.tfirst = startedAt;
stats.tload = startedAt;
stats.loading.start = startedAt;
this.loader = GM_xmlhttpRequest({
method: 'GET',
url: requestUrl,
responseType: 'text',
overrideMimeType: wantsArrayBuffer ? 'text/plain; charset=x-user-defined' : undefined,
timeout: config && config.timeout ? config.timeout : 20000,
headers: {
Accept: wantsArrayBuffer
? '*/*'
: 'application/vnd.apple.mpegurl, application/x-mpegURL, */*',
},
onprogress: (event) => {
stats.loaded = Number(event && event.loaded ? event.loaded : stats.loaded || 0);
stats.total = Number(event && event.total ? event.total : stats.total || stats.loaded || 0);
if (!stats.loading.first && stats.loaded > 0) {
stats.loading.first = performance.now();
}
},
onload: (r) => {
const status = Number(r.status || 0);
const response = {
code: status,
text: r.statusText || '',
url: r.finalUrl || requestUrl,
};
stats.tfirst = stats.tfirst || performance.now();
stats.tload = performance.now();
stats.loading.first = stats.loading.first || stats.tload;
stats.loading.end = stats.tload;
if (status < 200 || status >= 300) {
callbacks.onError && callbacks.onError(response, context, null, stats);
return;
}
const responseText = r.responseText || r.response || '';
const data = wantsArrayBuffer
? binaryTextToArrayBuffer(responseText)
: responseText;
stats.loaded = data && data.byteLength ? data.byteLength : (data && data.length ? data.length : stats.loaded || 0);
stats.total = stats.total || stats.loaded;
stats.bwEstimate =
stats.loading.end > stats.loading.first
? Math.round((stats.total * 8000) / (stats.loading.end - stats.loading.first))
: 0;
callbacks.onSuccess && callbacks.onSuccess({data, url: response.url}, stats, context, response);
},
onerror: () => {
callbacks.onError &&
callbacks.onError({code: 0, text: 'network error', url: requestUrl}, context, null, stats);
},
ontimeout: () => {
stats.tload = performance.now();
stats.loading.end = stats.tload;
callbacks.onTimeout && callbacks.onTimeout(stats, context, null);
},
});
}
}
return GMHlsLoader;
};
const isM3U8Url = (url) => /\.m3u8(?:[?#].*)?$/i.test(String(url || ''));
const attachHlsToVideo = (videoEl, src) => {
return new Promise((resolve, reject) => {
if (!videoEl || !src) {
reject(new Error('video element or source missing'));
return;
}
const HlsClass = getHlsClass();
if (!HlsClass) {
videoEl.src = src;
videoEl.load && videoEl.load();
resolve(false);
return;
}
const hls = new HlsClass({
enableWorker: false,
lowLatencyMode: true,
loader: createHlsLoader(),
autoStartLoad: true,
startPosition: 0,
capLevelToPlayerSize: true,
testBandwidth: false,
preferManagedMediaSource: false,
maxBufferLength: 6,
maxMaxBufferLength: 12,
backBufferLength: 30,
maxBufferHole: 0.5,
nudgeOffset: 0.1,
manifestLoadingMaxRetry: 2,
levelLoadingMaxRetry: 2,
fragLoadingMaxRetry: 2,
manifestLoadingTimeOut: 12000,
levelLoadingTimeOut: 12000,
fragLoadingTimeOut: 12000,
abrEwmaFastLive: 3,
abrEwmaSlowLive: 9,
});
hls.on(HlsClass.Events.MANIFEST_PARSED, () => {
hls.startLoad(0);
videoEl.play().catch(() => {
});
resolve(true);
});
hls.on(HlsClass.Events.ERROR, (_, data) => {
if (!data || !data.fatal) return;
clog.error('HLS: 播放失败', data);
if (data.type === HlsClass.ErrorTypes.MEDIA_ERROR) {
if (videoEl.readyState >= 2) {
try {
hls.recoverMediaError && hls.recoverMediaError();
} catch (e) { /* ignore */
}
return;
}
}
reject(new Error(data.details || 'HLS fatal error'));
});
hls.loadSource(src);
hls.attachMedia(videoEl);
videoEl._hls = hls;
});
};
const destroyHls = (videoEl) => {
if (videoEl && videoEl._hls) {
try {
videoEl._hls.destroy();
} catch (e) { /* ignore */
}
videoEl._hls = null;
}
};
export {loadHlsLibrary, getHlsClass, createHlsLoader, isM3U8Url, attachHlsToVideo, destroyHls, HLS_SCRIPT_URL};
+3
View File
@@ -29,6 +29,7 @@ import { TOP250Plugin } from './plugins/top250.js';
import { NavBarPlugin } from './plugins/nav-bar.js';
import { BusDetailPagePlugin } from './plugins/bus-detail-page.js';
import { BusPreviewVideoPlugin } from './plugins/bus-preview-video.js';
import {JavxyPreviewVideoPlugin} from './plugins/javxy-preview-video.js';
import { ImageRecognitionPlugin } from './plugins/image-recognition.js';
import { BusNavBarPlugin } from './plugins/bus-nav-bar.js';
import { RelatedPlugin } from './plugins/related.js';
@@ -876,6 +877,7 @@ const pluginManager = function() {
pluginManager2.register(DetailPageButtonPlugin);
pluginManager2.register(HighlightMagnetPlugin);
pluginManager2.register(PreviewVideoPlugin);
pluginManager2.register(JavxyPreviewVideoPlugin);
pluginManager2.register(FilterTitleKeywordPlugin);
pluginManager2.register(ActressInfoPlugin);
pluginManager2.register(OtherSitePlugin);
@@ -906,6 +908,7 @@ const pluginManager = function() {
pluginManager2.register(FilterTitleKeywordPlugin);
pluginManager2.register(HighlightMagnetPlugin);
pluginManager2.register(BusPreviewVideoPlugin);
pluginManager2.register(JavxyPreviewVideoPlugin);
pluginManager2.register(MagnetHubPlugin);
pluginManager2.register(ScreenShotPlugin);
pluginManager2.register(OtherSitePlugin);
+17 -7
View File
@@ -1,10 +1,12 @@
import { BasePlugin } from '../core/base-plugin.js';
import { __publicField, qualityOptions } from '../core/constants.js';
import {getDmmVideo, selectDefaultQuality} from '../api/dmm.js';
class BusPreviewVideoPlugin extends BasePlugin {
getName() {
return "BusPreviewVideoPlugin";
}
async initCss() {
return "\n /* 弹窗/Modal 样式 */\n .bus-preview-modal {\n position: fixed;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n background-color: rgba(0, 0, 0, 0.95); \n /* 关键修改:更新 z-index */\n z-index: 12345699; \n display: flex;\n justify-content: center;\n align-items: center;\n opacity: 0; \n visibility: hidden; \n transition: opacity 0.2s ease;\n }\n .bus-preview-modal.is-open {\n opacity: 1;\n visibility: visible;\n }\n /* 垂直排列视频和按钮,并居中 */\n .bus-preview-modal-content {\n position: relative;\n max-width: 95%; \n max-height: 95%;\n display: flex; \n flex-direction: column; \n align-items: center; \n gap: 15px; \n }\n \n /* 移除 .bus-preview-close-btn 的样式 */\n\n /* 视频播放器容器 */\n .video-player-wrapper {\n /* 关键修改:更新 width 和 max-height */\n width: 80vw; \n max-height: 85vh; \n aspect-ratio: 16 / 9; \n position: relative; \n background-color: black; \n max-width: 100%; \n }\n /* 视频元素 */\n .video-player-wrapper #preview-video {\n position: absolute; \n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n display: block;\n }\n\n /* 画质控制盒 (底部按钮) */\n .video-control-box {\n display: flex;\n flex-direction: row; \n justify-content: center; \n flex-wrap: wrap; \n gap: 10px;\n padding: 10px 0; \n }\n\n /* 按钮样式 (保留) */\n .video-control-btn {\n min-width:80px;\n padding: 6px 12px;\n background: rgba(255,255,255,0.2);\n color: white;\n border: 1px solid rgba(255,255,255,0.5);\n border-radius: 4px;\n cursor: pointer;\n text-align: center;\n font-size: 14px;\n transition: background-color 0.2s, border-color 0.2s;\n }\n .video-control-btn:hover {\n background: rgba(255,255,255,0.4);\n }\n .video-control-btn.active {\n background-color: #1890ff; \n color: white;\n font-weight: bold;\n border: 1px solid #096dd9;\n }\n ";
}
@@ -22,7 +24,10 @@ class BusPreviewVideoPlugin extends BasePlugin {
}
closeVideoModal() {
const $previewVideo = $("#preview-video");
$previewVideo.length > 0 && $previewVideo[0].pause();
if ($previewVideo.length > 0) {
const videoEl = $previewVideo[0];
videoEl.pause();
}
$("#bus-preview-modal").removeClass("is-open");
}
async handle() {
@@ -51,7 +56,8 @@ class BusPreviewVideoPlugin extends BasePlugin {
let $previewVideo = $("#preview-video");
if ($previewVideo.length > 0) {
$modal.addClass("is-open");
$previewVideo[0].play().catch((e => console.warn("尝试播放失败 (可能被浏览器阻止):", e)));
const videoEl = $previewVideo[0];
videoEl.play().catch((e => console.warn("尝试播放失败 (可能被浏览器阻止):", e)));
return;
}
let carNum2 = this.getPageInfo().carNum;
@@ -70,13 +76,15 @@ class BusPreviewVideoPlugin extends BasePlugin {
defaultVideoQuality = selectDefaultQuality(Object.keys(dmmVideoMap), defaultVideoQuality);
let defaultVideoUrl = dmmVideoMap[defaultVideoQuality];
$container.html(`\n <div class="video-player-wrapper">\n <video id="preview-video" controls playsinline>\n <source src="${defaultVideoUrl}" />\n </video>\n </div>\n <div class="video-control-box">\n </div>\n `);
const $videoEl = $("#preview-video"), $previewSource = $videoEl.find("source"), $qualityControlsBox = $container.find(".video-control-box");
if (!$videoEl.length || !$previewSource.length) return;
const videoEl = $videoEl[0], jhs_videoMuted = localStorage.getItem("jhs_videoMuted");
const $videoEl = $("#preview-video"), $qualityControlsBox = $container.find(".video-control-box");
if (!$videoEl.length) return;
const videoEl = $videoEl[0];
const jhs_videoMuted = localStorage.getItem("jhs_videoMuted");
videoEl.muted = !jhs_videoMuted || "yes" === jhs_videoMuted;
videoEl.addEventListener("volumechange", (function() {
localStorage.setItem("jhs_videoMuted", videoEl.muted ? "yes" : "no");
}));
let buttonsHtml = "";
qualityOptions.forEach((option => {
let dmmVideoUrl = dmmVideoMap[option.quality];
@@ -92,10 +100,12 @@ class BusPreviewVideoPlugin extends BasePlugin {
const $button = $(e.currentTarget);
if ($button.hasClass("active")) return;
let videoSrc = $button.attr("data-video-src");
$previewSource.attr("src", videoSrc);
const currentTime = videoEl.currentTime;
videoEl.src = videoSrc;
videoEl.load();
videoEl.currentTime = currentTime;
if (currentTime > 0 && Number.isFinite(currentTime)) {
videoEl.currentTime = currentTime;
}
await videoEl.play();
$buttons.removeClass("active");
$button.addClass("active");
+239
View File
@@ -0,0 +1,239 @@
import {BasePlugin} from '../core/base-plugin.js';
import {__publicField, isJavBus, isJavDb, qualityOptions} from '../core/constants.js';
import {isM3U8Url, attachHlsToVideo, destroyHls, loadHlsLibrary, getHlsClass} from '../lib/hls-runtime.js';
import {getJavxyVideoUrls} from '../api/javxy.js';
class JavxyPreviewVideoPlugin extends BasePlugin {
getName() {
return "JavxyPreviewVideoPlugin";
}
_attachVideoSrc(videoEl, src) {
if (!videoEl || !src) return;
destroyHls(videoEl);
if (isM3U8Url(src)) {
const HlsClass = getHlsClass();
if (HlsClass) {
attachHlsToVideo(videoEl, src).catch((err) => {
clog.error('视频预览 HLS 加载失败:', err);
videoEl.src = src;
videoEl.load && videoEl.load();
});
} else {
loadHlsLibrary().then((HlsClass) => {
if (videoEl && videoEl.isConnected && HlsClass) {
attachHlsToVideo(videoEl, src).catch((err) => {
clog.error('视频预览 HLS 延迟加载失败:', err);
videoEl.src = src;
videoEl.load && videoEl.load();
});
} else if (videoEl && videoEl.isConnected) {
videoEl.src = src;
videoEl.load && videoEl.load();
}
});
}
} else {
videoEl.src = src;
videoEl.load && videoEl.load();
}
}
_selectDefaultQuality(qualityList, intendedDefault) {
if (!qualityList || qualityList.length === 0) return null;
const availableSet = new Set(qualityList);
if (availableSet.has(intendedDefault)) return intendedDefault;
const priorityOrder = qualityOptions.map(o => o.quality).reverse();
for (const q of priorityOrder) if (availableSet.has(q)) return q;
return qualityList[0];
}
async initCss() {
return `
.jhs-javxy-video-modal {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.95); z-index: 12345700;
display: flex; justify-content: center; align-items: center;
opacity: 0; visibility: hidden; transition: opacity 0.2s;
}
.jhs-javxy-video-modal.is-open { opacity: 1; visibility: visible; }
.jhs-javxy-video-modal-inner {
display: flex; flex-direction: column; align-items: center;
gap: 12px; max-width: 90vw; max-height: 90vh;
}
.jhs-javxy-video-wrapper {
width: 80vw; max-height: 80vh; aspect-ratio: 16/9;
background: #000; position: relative;
}
.jhs-javxy-video-wrapper video {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
}
.jhs-javxy-quality-bar {
display: flex; gap: 8px; flex-wrap: wrap; justify-content: center;
}
.jhs-javxy-quality-btn {
min-width: 60px; padding: 5px 10px; font-size: 13px;
background: rgba(255,255,255,0.2); color: #fff;
border: 1px solid rgba(255,255,255,0.5); border-radius: 4px;
cursor: pointer; transition: background 0.2s;
}
.jhs-javxy-quality-btn:hover { background: rgba(255,255,255,0.4); }
.jhs-javxy-quality-btn.active {
background: #1890ff; border-color: #096dd9; font-weight: bold;
}
.jhs-javxy-loading {
color: #fff; font-size: 16px; text-align: center;
}
`;
}
async handle() {
if (!window.isDetailPage) return;
const carNum = this.getPageInfo().carNum;
if (!carNum) return;
this._addJavxyButton(carNum);
}
_addJavxyButton(carNum) {
let $target = null;
if (isJavDb) {
$target = $('a[title="複製番號"]');
} else if (isJavBus) {
const headerSpans = document.querySelectorAll("span.header");
for (const span of headerSpans) {
if (span.textContent.trim() === "識別碼:") {
const nextSpan = span.nextElementSibling;
if (nextSpan && nextSpan.tagName === "SPAN") {
const copyBtn = nextSpan.nextElementSibling;
if (copyBtn && copyBtn.tagName === "BUTTON" && copyBtn.textContent.trim() === "复制") {
$target = $(copyBtn);
} else {
$target = $(nextSpan);
}
}
break;
}
}
}
if (!$target || !$target.length) return;
const $btn = $(`<a class="site-btn" style="min-width: auto; padding: 0 5px; margin-left: 6px; margin-bottom: 0; background-color: #1890ff; vertical-align: middle; cursor: pointer; font-size: 11px;"><span>视频预览</span></a>`);
$btn.on("click", async (e) => {
e.preventDefault();
e.stopPropagation();
$btn.css("pointer-events", "none").find("span").text("...");
try {
await this._openJavxyPlayer(carNum);
} finally {
$btn.css("pointer-events", "").find("span").text("视频预览");
}
});
$target.after($btn);
}
async _openJavxyPlayer(carNum) {
if ($("#jhs-javxy-video-modal").length === 0) {
$("body").append(`
<div id="jhs-javxy-video-modal" class="jhs-javxy-video-modal">
<div class="jhs-javxy-video-modal-inner">
<div class="jhs-javxy-video-wrapper">
<video id="jhs-javxy-video" controls playsinline></video>
</div>
<div class="jhs-javxy-quality-bar"></div>
</div>
</div>
`);
const $modal = $("#jhs-javxy-video-modal");
$modal.on("click", (e) => {
if (e.target.id === "jhs-javxy-video-modal") {
$modal.removeClass("is-open");
const videoEl = document.getElementById("jhs-javxy-video");
if (videoEl) {
destroyHls(videoEl);
videoEl.pause();
}
}
});
$(document).on("keydown", (e) => {
if (e.key === "Escape" && $modal.hasClass("is-open")) {
$modal.removeClass("is-open");
const videoEl = document.getElementById("jhs-javxy-video");
if (videoEl) {
destroyHls(videoEl);
videoEl.pause();
}
}
});
}
const $modal = $("#jhs-javxy-video-modal");
const $wrapper = $modal.find(".jhs-javxy-video-wrapper");
const $qualityBar = $modal.find(".jhs-javxy-quality-bar");
$wrapper.html('<div class="jhs-javxy-loading">视频预览加载中...</div>');
$qualityBar.empty();
$modal.addClass("is-open");
let videoMap = null;
try {
videoMap = await getJavxyVideoUrls(carNum);
} catch (err) {
$wrapper.html(`<div class="jhs-javxy-loading">视频预览请求失败: ${err.message}</div>`);
return;
}
if (!videoMap || Object.keys(videoMap).length === 0) {
$wrapper.html('<div class="jhs-javxy-loading">未找到可用的视频源</div>');
return;
}
const qualityList = Object.keys(videoMap);
const defaultQuality = this._selectDefaultQuality(qualityList, '720p');
const defaultUrl = videoMap[defaultQuality];
$wrapper.html(`<video id="jhs-javxy-video" controls playsinline>
<source src="${defaultUrl}" />
</video>`);
const videoEl = document.getElementById("jhs-javxy-video");
if (!videoEl) return;
if (isM3U8Url(defaultUrl)) {
this._attachVideoSrc(videoEl, defaultUrl);
}
let buttonsHtml = "";
qualityOptions.forEach((option) => {
const url = videoMap[option.quality];
if (url) {
buttonsHtml += `<button class="jhs-javxy-quality-btn${option.quality === defaultQuality ? ' active' : ''}"
data-quality="${option.quality}" data-video-src="${url}">${option.text}</button>`;
}
});
$qualityBar.html(buttonsHtml);
$qualityBar.off("click").on("click", ".jhs-javxy-quality-btn", (e) => {
const $btn = $(e.currentTarget);
if ($btn.hasClass("active")) return;
const src = $btn.attr("data-video-src");
const currentTime = videoEl.currentTime;
destroyHls(videoEl);
this._attachVideoSrc(videoEl, src);
videoEl.load();
if (currentTime > 0 && Number.isFinite(currentTime)) {
videoEl.currentTime = currentTime;
}
videoEl.play().catch(() => {
});
$qualityBar.find(".jhs-javxy-quality-btn").removeClass("active");
$btn.addClass("active");
});
videoEl.play().catch((e) => console.warn("视频预览播放失败:", e));
}
}
export {JavxyPreviewVideoPlugin};
+17 -5
View File
@@ -1,5 +1,6 @@
import { BasePlugin } from '../core/base-plugin.js';
import { __publicField, currentHref, NO, YES, qualityOptions } from '../core/constants.js';
import {getDmmVideo, selectDefaultQuality} from '../api/dmm.js';
class PreviewVideoPlugin extends BasePlugin {
getName() {
@@ -49,7 +50,7 @@ class PreviewVideoPlugin extends BasePlugin {
} else {
clog.debug("JavDB没有视频播放元素, 开始创建...");
const videoCoverSrc = $(".column-video-cover img").attr("src");
$(".preview-images").prepend(`\n <a class="preview-video-container" data-fancybox="gallery" href="#preview-video">\n <span>預告片</span>\n <img src="${videoCoverSrc}" class="video-cover" style="width: 150px; height: auto;" alt="">\n </a>\n `);
$(".preview-images").prepend(`\n <a class="preview-video-container" data-fancybox="gallery" href="#preview-video">\n <span>预告片</span>\n <img src="${videoCoverSrc}" class="video-cover" style="width: 150px; height: auto;" alt="">\n </a>\n `);
$(".preview-video-container").on("click", (event => {
utils.loopDetector((() => $(".fancybox-content #preview-video").length > 0), (async () => {
await this.handleVideo();
@@ -60,18 +61,22 @@ class PreviewVideoPlugin extends BasePlugin {
clog.error("预加载dmm失败:", error);
}
}
async handleVideo() {
if (await storageManager.getSetting("enableLoadPreviewVideo", YES) === NO) return;
const $videoEl = $("#preview-video");
if (!$videoEl.length) return;
const $videoContainer = $videoEl.parent();
$videoContainer.css("position", "relative");
const videoEl = $videoEl[0], jhs_videoMuted = localStorage.getItem("jhs_videoMuted");
jhs_videoMuted && (videoEl.muted = "yes" === jhs_videoMuted);
const videoEl = $videoEl[0];
let carNum2 = this.getPageInfo().carNum;
const jhs_videoMuted = localStorage.getItem("jhs_videoMuted");
if (jhs_videoMuted) videoEl.muted = "yes" === jhs_videoMuted;
videoEl.addEventListener("volumechange", (function() {
localStorage.setItem("jhs_videoMuted", videoEl.muted ? "yes" : "no");
}));
let carNum2 = this.getPageInfo().carNum;
const CACHE_KEY = "jhs_dmm_video";
let dmmErrorRetryCount = 0;
const attachVideoErrorHandler = () => {
@@ -90,13 +95,18 @@ class PreviewVideoPlugin extends BasePlugin {
const storedQuality = await storageManager.getSetting("videoQuality");
const quality = selectDefaultQuality(Object.keys(freshMap), storedQuality);
const freshUrl = freshMap[quality];
const savedTime = videoEl.currentTime;
$videoEl.attr("src", freshUrl);
videoEl.load();
if (savedTime > 0 && Number.isFinite(savedTime)) {
videoEl.currentTime = savedTime;
}
videoEl.play();
}, {once: false});
};
attachVideoErrorHandler();
videoEl.play();
const dmmVideoMap = await getDmmVideo(carNum2);
let $bottomToolbar = $("<div></div>").attr("id", "video-bottom-toolbar").css({
display: "flex",
@@ -146,7 +156,9 @@ class PreviewVideoPlugin extends BasePlugin {
const currentTime = videoEl.currentTime;
$videoEl.attr("src", videoSrc);
videoEl.load();
videoEl.currentTime = currentTime;
if (currentTime > 0 && Number.isFinite(currentTime)) {
videoEl.currentTime = currentTime;
}
await videoEl.play();
$bottomToolbar.find(".video-control-btn").removeClass("active").css({
"background-color": "#fff",
+2 -2
View File
@@ -6,8 +6,8 @@ export default defineConfig({
monkey({
entry: 'src/main.js',
userscript: {
name: 'JAV-JHS',
namespace: 'JAV-JHS',
name: 'JHS-enhance',
namespace: 'JHS-enhance',
version: '3.3.7',
author: 'xie bro,fireinrain',
description: 'Jav-鉴黄师 增强脚本。列表页:作品状态标签、一键筛选、新作品检测、演员黑名单过滤;详情页:磁力链接高亮、DMM 多画质预览视频、标题翻译、整合字幕搜索(迅雷+SubTitleCat)并支持 115 直传、多源预览图(javfree/projectjav/javstore)含来源切换;数据:115 网盘目录匹配与多目录选择、云盘备份/恢复、跨 Tab 同步;其他:自动翻页、分类折叠、Top250、以图识图、热门榜单、评论查看、相关清单。支持 JavDB / JavBus / JavSee / SeeJav / FC2 / JavTrailers',