番号悬停:复制+JavBus跳转+JavDB搜索+封面预览大图(中文翻译标题)
// ==UserScript==
// @name JAV hover tools
// @namespace https://www.javbus.com/
// @version 3.0.0
// @description 番号悬停:复制+JavBus跳转+JavDB搜索+封面预览大图(中文翻译标题)
// @author Cod
// @match *://*/*
// @grant GM_setClipboard
// @grant GM_xmlhttpRequest
// @connect www.javbus.com
// @connect www.cdnbus.bond
// @connect www.busjav.cyou
// @connect pics.javbus.info
// @connect pics.dmm.co.jp
// @connect api.siliconflow.cn
// @connect translate.google.com.hk
// @connect *
// @run-at document-idle
// @license MIT
// ==/UserScript==
(function () {
'use strict';
/* ═══════════════════ 用户配置区 ═══════════════════ */
// JavBus 数据源地址(封面图请求来源)— 代理不稳时改成防屏蔽地址
const JAVBUS_ORIGIN = 'https://www.busjav.cyou';
// 点击「JavBus」按钮时打开的站点(可与数据源不同)
const JAVBUS_LINK = 'https://www.javbus.com';
/* ═══════════════════ 常量配置 ═══════════════════ */
const JAVDB_SEARCH = 'https://javdb.com/search';
// 翻译配置 — 填入硅基流动 API Key 启用,留空则用 Google 翻译
const SILICONFLOW_KEY = '';
const SILICONFLOW_MODEL = 'tencent/Hunyuan-MT-7B'; // 免费翻译模型
const SILICONFLOW_URL = 'https://api.siliconflow.cn/v1/chat/completions';
const MARK_CLASS = 'javbus-number-hover-mark';
const SKIP_SELECTOR = [
'script', 'style', 'textarea', 'input', 'select', 'option', 'code', 'pre',
'[contenteditable="true"]',
`.${MARK_CLASS}`,
].join(',');
const MAX_CACHE = 1000; // 封面缓存上限
const FAIL_TTL = 60000; // 失败记录保留60秒后可重试
const SAFE_BOTTOM = 60; // 底部任务栏预留
/* ═══════════════════ 番号识别 ═══════════════════ */
// 独立正则实例,避免 lastIndex 竞态
const markRe = /\b(?:FC2(?:[-_\s]*PPV)?[-_\s]*\d{5,8}|[A-Z]{2,8}(?:[-_]\d{2,6}|\d{3,5}))\b/gi;
const noSepPrefixes = new Set([
'ABP','ABS','ADN','ADZ','AUKG','AVOP','BDA','BF','CAWD','CJOD',
'DANDY','DASD','DVAJ','EBOD','EKDV','FSDSS','FSDSSS','GENM','GVG',
'HND','IPX','IPZ','JUL','JUQ','JUX','KAWD','MIAA','MIDE','MIGD',
'MIMK','MIRD','MKMP','MMND','MOGI','NACR','NHDTA','NSPS','PRED',
'RBD','SDDE','SDJS','SDMU','SDNM','SDAB','SSIS','SSNI','START',
'STARS','SW','TEK','VEC','WANZ','WAAA','XVSR',
]);
function normalizeNumber(raw) {
let t = raw.toUpperCase().replace(/[_\s]+/g, '-').replace(/-+/g, '-');
if (/^FC2-?PPV-?\d+$/.test(t)) return t.replace(/^FC2-?PPV-?/, 'FC2-PPV-');
if (/^FC2-?\d+$/.test(t)) return t.replace(/^FC2-?/, 'FC2-');
if (/^[A-Z]{2,8}\d{2,6}$/.test(t)) t = t.replace(/^([A-Z]{2,8})(\d{2,6})$/, '$1-$2');
return t;
}
function isLikelyJav(raw) {
const t = raw.toUpperCase().trim();
if (/^FC2(?:[-_\s]*PPV)?[-_\s]*\d{5,8}$/.test(t)) return true;
if (/^[A-Z]{2,8}[-_]\d{2,6}$/.test(t)) return true;
const m = t.match(/^([A-Z]{2,8})(\d{3,5})$/);
return !!m && noSepPrefixes.has(m[1]);
}
/* ═══════════════════ DOM 扫描 ═══════════════════ */
const processed = new WeakSet();
function shouldSkip(node) {
const p = node.parentElement;
if (!p || p.closest(SKIP_SELECTOR)) return true;
if (!node.nodeValue) return true;
markRe.lastIndex = 0;
return !markRe.test(node.nodeValue);
}
function markTextNode(textNode) {
if (processed.has(textNode) || shouldSkip(textNode)) return;
processed.add(textNode);
const text = textNode.nodeValue;
const frag = document.createDocumentFragment();
let last = 0, m;
markRe.lastIndex = 0;
while ((m = markRe.exec(text)) !== null) {
const raw = m[0];
if (!isLikelyJav(raw)) continue;
const s = m.index, e = s + raw.length;
if (s > last) frag.appendChild(document.createTextNode(text.slice(last, s)));
const span = document.createElement('span');
span.className = MARK_CLASS;
span.dataset.javNumber = normalizeNumber(raw);
span.textContent = raw;
frag.appendChild(span);
last = e;
}
if (last < text.length) frag.appendChild(document.createTextNode(text.slice(last)));
if (frag.childNodes.length > 1 || last > 0) {
textNode.parentNode.replaceChild(frag, textNode);
}
}
function scan(root) {
if (!root) return;
if (root.nodeType === Node.TEXT_NODE) { markTextNode(root); return; }
if (root.nodeType !== Node.ELEMENT_NODE || root.closest?.(SKIP_SELECTOR)) return;
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(n) { return shouldSkip(n) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT; }
});
const nodes = [];
let n; while ((n = walker.nextNode())) nodes.push(n);
nodes.forEach(markTextNode);
}
/* ═══════════════════ 缓存 ═══════════════════ */
const coverCache = new Map(); // number → { blobURL?, title?, titleTrans?, failAt? }
const pending = new Map(); // number → Promise
function trimCache() {
if (coverCache.size <= MAX_CACHE) return;
const excess = coverCache.size - MAX_CACHE;
let i = 0;
for (const [key, val] of coverCache) {
if (i >= excess) break;
if (val.blobURL) URL.revokeObjectURL(val.blobURL);
coverCache.delete(key);
i++;
}
}
function setCache(number, data) {
coverCache.set(number, data);
trimCache();
}
// 失败也缓存,避免反复请求同一番号
function setFailCache(number) {
setCache(number, { failAt: Date.now() });
}
function getCached(number) {
const c = coverCache.get(number);
if (!c) return null;
// 失败记录过期后允许重试
if (c.failAt && Date.now() - c.failAt > FAIL_TTL) {
coverCache.delete(number);
return null;
}
return c.failAt ? null : c; // 还在失败TTL内视为无缓存
}
/* ═══════════════════ UI 样式 ═══════════════════ */
if (!document.getElementById('javbus-hover-styles')) {
const s = document.createElement('style');
s.id = 'javbus-hover-styles';
s.textContent = `
.${MARK_CLASS}{
position:relative; border-bottom:1px dotted rgba(32,92,255,.85); cursor:pointer;
}
#javbus-cover-preview{
position:fixed; z-index:2147483646; display:none;
flex-direction:column;
pointer-events:auto; border-radius:10px;
overflow:hidden; background:#000;
box-shadow:0 16px 48px rgba(0,0,0,.55),0 0 0 1px rgba(255,255,255,.06);
animation:jcp-in .12s ease;
}
@keyframes jcp-in{from{opacity:0;transform:scale(.96)}to{opacity:1;transform:scale(1)}}
#javbus-cover-preview .jcp-img-wrap{
width:100%; overflow:hidden; display:flex; align-items:flex-start; justify-content:center;
}
#javbus-cover-preview img{display:block;width:100%;height:auto}
#javbus-cover-preview .jcp-title{
color:#eee; font:13px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
padding:3px 4px; white-space:normal; word-break:break-all;
background:rgba(0,0,0,.78); align-self:flex-start; max-width:100%;
text-shadow:0 1px 3px rgba(0,0,0,.5);
}
#javbus-cover-preview .jcp-bar{
display:flex; align-items:center; gap:5px; padding:3px 4px;
background:rgba(0,0,0,.78); align-self:flex-start;
border-radius:0 0 6px 0;
}
#javbus-cover-preview .jcp-bar button{
min-width:44px; height:28px; padding:0 12px;
border:1px solid rgba(255,255,255,.12); border-radius:6px;
background:#1f6feb!important; color:#fff!important;
font:12px/1 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
font-weight:600; cursor:pointer;
}
#javbus-cover-preview .jcp-bar button:hover{background:#1759c7!important}
#javbus-cover-preview .jcp-bar button[data-action="open"]{background:#1f883d!important}
#javbus-cover-preview .jcp-bar button[data-action="open"]:hover{background:#187033!important}
#javbus-cover-preview .jcp-bar button[data-action="javdb"]{background:#d63638!important}
#javbus-cover-preview .jcp-bar button[data-action="javdb"]:hover{background:#b32d2e!important}
/* 正常: [按钮][标题][图片] */
/* flip: [图片][标题][按钮] */
#javbus-cover-preview.flip .jcp-img-wrap{order:-3}
#javbus-cover-preview.flip .jcp-title{order:-2}
#javbus-cover-preview.flip .jcp-bar{order:-1}
`;
document.documentElement.appendChild(s);
}
const preview = document.createElement('div');
preview.id = 'javbus-cover-preview';
preview.innerHTML = '<div class="jcp-bar"></div><div class="jcp-title"></div><div class="jcp-img-wrap"></div>';
document.documentElement.appendChild(preview);
/* ═══════════════════ 翻译 ═══════════════════ */
function translateTitle(text) {
if (!text?.trim()) return Promise.resolve('');
return SILICONFLOW_KEY ? translateSF(text) : translateGoogle(text);
}
// 硅基流动(非流式 — 兼容性最好)
function translateSF(text) {
const t0 = Date.now();
return new Promise((resolve) => {
GM_xmlhttpRequest({
method: 'POST',
url: SILICONFLOW_URL,
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + SILICONFLOW_KEY },
data: JSON.stringify({
model: SILICONFLOW_MODEL,
messages: [{ role: 'user', content: 'Translate the following Japanese text to Chinese: ' + text }],
max_tokens: 60, temperature: 0.1,
}),
timeout: 8000,
onload(resp) {
try {
const t = JSON.parse(resp.responseText).choices?.[0]?.message?.content?.trim();
if (t) {
console.log(`[JAV Hover] 硅基翻译 ✓ ${Date.now()-t0}ms | 「${text}」→「${t}」`);
resolve(t); return;
}
console.warn(`[JAV Hover] 硅基翻译返回空,fallback Google | resp:`, resp.responseText?.slice(0,200));
} catch (e) {
console.warn(`[JAV Hover] 硅基翻译解析异常,fallback Google |`, e.message);
}
translateGoogle(text).then(resolve);
},
onerror() { console.warn(`[JAV Hover] 硅基翻译网络错误,fallback Google`); translateGoogle(text).then(resolve); },
ontimeout() { console.warn(`[JAV Hover] 硅基翻译超时,fallback Google`); translateGoogle(text).then(resolve); },
});
});
}
// 仅获取标题(轻量:只请求搜索页,不等图片下载)
function fetchCoverTitle(number) {
return new Promise((resolve) => {
gmFetch(`${JAVBUS_ORIGIN}/search/${encodeURIComponent(number)}`, 4000, (html) => {
const info = parseSearch(html, number);
resolve(info?.title || '');
}, () => resolve(''));
});
}
function translateGoogle(text) {
const t0 = Date.now();
return new Promise((resolve) => {
GM_xmlhttpRequest({
method: 'GET',
url: 'https://translate.google.com.hk/translate_a/single?client=gtx&dt=t&dj=1&sl=auto&tl=zh-CN&hl=zh-CN&q=' + encodeURIComponent(text),
timeout: 3000,
onload(resp) {
try {
const d = JSON.parse(resp.responseText);
if (d.sentences) {
const r = d.sentences.reduce((s, x) => s + (x.trans || ''), '');
console.log(`[JAV Hover] Google翻译 ✓ ${Date.now()-t0}ms | 「${text}」→「${r}」`);
resolve(r); return;
}
} catch {}
console.warn(`[JAV Hover] Google翻译失败`);
resolve('');
},
onerror() { console.warn(`[JAV Hover] Google翻译网络错误`); resolve(''); },
ontimeout() { console.warn(`[JAV Hover] Google翻译超时`); resolve(''); },
});
});
}
/* ═══════════════════ 封面获取 ═══════════════════ */
function fetchCover(number) {
const cached = getCached(number);
if (cached) return Promise.resolve(cached);
if (pending.has(number)) return pending.get(number);
if (typeof GM_xmlhttpRequest !== 'function') return Promise.resolve(null);
const t0 = Date.now();
const p = new Promise((resolve) => {
const done = (result) => {
console.log(`[JAV Hover] ${number} → ${result ? 'OK' : 'FAIL'} (${Date.now()-t0}ms)`);
resolve(result);
};
// 先搜搜索页(轻量),失败再详情页
gmFetch(`${JAVBUS_ORIGIN}/search/${encodeURIComponent(number)}`, 6000, (html) => {
const info = parseSearch(html, number);
if (info) { dlBlob(info.src, info.title).then(done); return; }
// 搜索页没命中 → 详情页
gmFetch(`${JAVBUS_ORIGIN}/${encodeURIComponent(number)}`, 8000, (html2) => {
const info2 = parseDetail(html2);
if (info2) { dlBlob(info2.src, info2.title).then(done); return; }
setFailCache(number);
done(null);
}, () => { setFailCache(number); done(null); });
}, () => {
// 搜索页网络错误 → 详情页
gmFetch(`${JAVBUS_ORIGIN}/${encodeURIComponent(number)}`, 8000, (html2) => {
const info2 = parseDetail(html2);
if (info2) { dlBlob(info2.src, info2.title).then(done); return; }
setFailCache(number);
done(null);
}, () => { setFailCache(number); done(null); });
});
});
pending.set(number, p);
p.finally(() => pending.delete(number));
return p;
}
// 封装 GM_xmlhttpRequest GET HTML
function gmFetch(url, timeout, onload, onfail) {
GM_xmlhttpRequest({
method: 'GET', url,
headers: { 'Accept': 'text/html', 'Referer': JAVBUS_ORIGIN + '/' },
cookie: 'existmag=all',
timeout,
onload(resp) {
if (resp.status >= 200 && resp.status < 400 && resp.responseText) {
onload(resp.responseText);
} else { onfail(); }
},
onerror: onfail,
ontimeout: onfail,
});
}
// 解析搜索页
function parseSearch(html, number) {
try {
const doc = new DOMParser().parseFromString(html, 'text/html');
for (const box of doc.querySelectorAll('a.movie-box')) {
const numEl = box.querySelector('date');
if (numEl?.textContent.trim().toUpperCase() !== number.toUpperCase()) continue;
const img = box.querySelector('img');
let src = img?.getAttribute('src') || img?.getAttribute('data-src');
if (!src) continue;
src = resolveURL(src, JAVBUS_ORIGIN + '/');
src = thumbToCover(src);
const title = img.getAttribute('title') || img.getAttribute('alt') || '';
return { src, title };
}
return null;
} catch { return null; }
}
// 解析详情页
function parseDetail(html) {
try {
const doc = new DOMParser().parseFromString(html, 'text/html');
const bigImg = doc.querySelector('a.bigImage img');
if (bigImg) {
let src = bigImg.getAttribute('src') || bigImg.getAttribute('data-src') || bigImg.getAttribute('data-original');
if (src) return { src: resolveURL(src, JAVBUS_ORIGIN + '/'), title: doc.querySelector('h3')?.textContent?.trim() || '' };
}
const og = doc.querySelector('meta[property="og:image"]');
if (og) {
let src = og.getAttribute('content');
if (src) return { src: resolveURL(src, JAVBUS_ORIGIN + '/'), title: doc.querySelector('h3')?.textContent?.trim() || '' };
}
return null;
} catch { return null; }
}
// 缩略图 URL → 封面大图 URL
function thumbToCover(src) {
if (/\/cover\/[a-z\d]+_b\.(jpg|png)/i.test(src)) return src;
return src.replace(/\/thumbs?\//i, '/cover/').replace(/(\.[^.]+)$/i, '_b$1');
}
function resolveURL(src, base) {
if (!src) return '';
if (/^https?:\/\//i.test(src)) return src;
try { return new URL(src, base).href; } catch { return src; }
}
// 下载图片为 blob
function dlBlob(imgUrl, title) {
return new Promise((resolve) => {
GM_xmlhttpRequest({
method: 'GET', url: imgUrl,
headers: { 'Referer': JAVBUS_ORIGIN + '/', 'Accept': 'image/*,*/*;q=0.8' },
responseType: 'blob', timeout: 10000,
onload(resp) {
if (resp.status >= 200 && resp.status < 400 && resp.response) {
try { resolve({ blobURL: URL.createObjectURL(resp.response), title: title || '' }); }
catch { resolve(null); }
} else resolve(null);
},
onerror() { resolve(null); },
ontimeout() { resolve(null); },
});
});
}
/* ═══════════════════ 预览 ═══════════════════ */
let previewTimer = 0;
let isPreviewHovered = false;
let currentNumber = null;
let activeMark = null;
let lastMarkRect = null;
async function copyText(text) {
if (typeof GM_setClipboard === 'function') { GM_setClipboard(text, 'text'); return; }
await navigator.clipboard.writeText(text);
}
// 统一定位逻辑:根据空间自动选择展开方向
function positionPreview(markRect) {
const pr = preview.getBoundingClientRect();
const spaceAbove = markRect.top - 8;
const spaceBelow = window.innerHeight - SAFE_BOTTOM - markRect.bottom;
let flip;
if (spaceAbove >= pr.height) {
// 上面够 → 向上展开,底边对齐番号顶边
preview.style.top = `${markRect.top - pr.height}px`;
flip = true;
} else if (spaceBelow >= pr.height || spaceBelow >= spaceAbove) {
// 下面够,或下面比上面大 → 向下展开,顶边对齐番号底边
preview.style.top = `${markRect.bottom}px`;
flip = false;
} else {
// 两边都不够,选空间大的方向
if (spaceAbove >= spaceBelow) {
preview.style.top = `${Math.max(8, markRect.top - pr.height)}px`;
flip = true;
} else {
preview.style.top = `${markRect.bottom}px`;
flip = false;
}
}
preview.classList.toggle('flip', flip);
// 防溢出
const fr = preview.getBoundingClientRect();
if (fr.bottom > window.innerHeight - SAFE_BOTTOM) {
preview.style.top = `${Math.max(8, window.innerHeight - SAFE_BOTTOM - fr.height)}px`;
}
if (fr.top < 8) preview.style.top = '8px';
if (fr.right > window.innerWidth - 8) {
preview.style.left = `${Math.max(8, window.innerWidth - fr.width - 8)}px`;
}
}
function showPreview(mark) {
activeMark = mark;
clearTimeout(previewTimer);
const num = mark.dataset.javNumber;
if (!num) return;
currentNumber = num;
lastMarkRect = mark.getBoundingClientRect();
const cached = getCached(num);
// 水平定位
const rect = lastMarkRect;
const maxW = Math.min(600, window.innerWidth - 40);
const gap = 8;
let left = rect.right + gap + maxW <= window.innerWidth
? rect.right + gap
: Math.max(8, rect.left - gap - maxW);
preview.style.left = `${left}px`;
preview.style.width = 'fit-content';
// 清空上一张残留图片
preview.querySelector('.jcp-img-wrap').innerHTML = '';
// 按钮(只需填充一次)
const barEl = preview.querySelector('.jcp-bar');
if (!barEl.children.length) {
barEl.innerHTML = `
<button data-action="copy" title="复制番号">copy</button>
<button data-action="open" title="打开 JavBus 页面">JavBus</button>
<button data-action="javdb" title="在 JavDB 中搜索">JavDB</button>`;
}
const titleEl = preview.querySelector('.jcp-title');
titleEl.textContent = cached?.titleTrans || cached?.title || num;
titleEl.style.display = 'block';
preview.style.display = 'flex';
// 等布局完成后定位
requestAnimationFrame(() => positionPreview(rect));
// 有缓存 → 直接渲染图片
if (cached) { renderPreview(num, cached); return; }
// 无缓存 → 并行获取封面 & 翻译
let earlyTitle = '';
let earlyTrans = '';
fetchCoverTitle(num).then(title => {
if (title && currentNumber === num) {
earlyTitle = title;
updateTitleEl(title);
translateTitle(title).then(trans => {
if (!trans || currentNumber !== num) return;
earlyTrans = trans;
const c = coverCache.get(num);
if (c) c.titleTrans = trans;
updateTitleEl(trans);
});
}
});
fetchCover(num).then(data => {
if (currentNumber !== num || !data?.blobURL) return;
if (!data.title && earlyTitle) data.title = earlyTitle;
if (earlyTrans) data.titleTrans = earlyTrans;
setCache(num, data);
renderPreview(num, data);
});
}
function renderPreview(num, data) {
const rect = lastMarkRect || activeMark?.getBoundingClientRect();
if (!rect) return;
// 更新标题(优先已缓存的翻译)
const titleEl = preview.querySelector('.jcp-title');
const cached = coverCache.get(num);
titleEl.textContent = (cached?.titleTrans) || data.titleTrans || data.title || num;
// 图片
const wrap = preview.querySelector('.jcp-img-wrap');
wrap.innerHTML = '';
const img = document.createElement('img');
img.src = data.blobURL;
img.alt = num;
img.onload = () => {
if (img.naturalWidth && img.naturalHeight) {
const maxW = Math.min(600, window.innerWidth - 40);
preview.style.width = Math.min(maxW, img.naturalWidth) + 'px';
}
positionPreview(rect);
};
wrap.appendChild(img);
}
function updateTitleEl(trans) {
const el = preview.querySelector('.jcp-title');
if (el) el.textContent = trans;
}
function hidePreviewSoon(delay) {
clearTimeout(previewTimer);
previewTimer = setTimeout(() => {
if (isPreviewHovered) return;
preview.style.display = 'none';
preview.style.width = '';
currentNumber = null;
}, delay || 300);
}
/* ═══════════════════ 事件 ═══════════════════ */
let mouseoverTimer = 0;
document.addEventListener('mouseover', (ev) => {
clearTimeout(mouseoverTimer);
mouseoverTimer = setTimeout(() => {
const mark = ev.target.closest?.(`.${MARK_CLASS}`);
if (mark) showPreview(mark);
}, 80);
});
document.addEventListener('mouseout', (ev) => {
const mark = ev.target.closest?.(`.${MARK_CLASS}`);
if (!mark) return;
hidePreviewSoon(400);
});
preview.addEventListener('mouseenter', () => { isPreviewHovered = true; clearTimeout(previewTimer); });
preview.addEventListener('mouseleave', () => { isPreviewHovered = false; hidePreviewSoon(200); });
// 按钮点击(事件委托在 preview 上)
preview.addEventListener('click', async (ev) => {
const btn = ev.target.closest('button');
if (!btn) return;
const num = currentNumber;
if (!num) return;
if (btn.dataset.action === 'copy') {
await copyText(num);
btn.textContent = '✅'; setTimeout(() => { btn.textContent = 'copy'; }, 900);
} else if (btn.dataset.action === 'open') {
window.open(`${JAVBUS_LINK}/${encodeURIComponent(num)}`, '_blank', 'noopener');
} else if (btn.dataset.action === 'javdb') {
window.open(`${JAVDB_SEARCH}?q=${encodeURIComponent(num)}&f=all`, '_blank', 'noopener');
}
});
/* ═══════════════════ 初始化 ═══════════════════ */
const observer = new MutationObserver((mutations) => {
for (const m of mutations) for (const n of m.addedNodes) scan(n);
});
scan();
observer.observe(document.documentElement, { childList: true, subtree: true });
window.addEventListener('beforeunload', () => {
clearTimeout(mouseoverTimer); clearTimeout(previewTimer);
observer.disconnect();
// 清理所有 blobURL 释放内存
for (const [, val] of coverCache) {
if (val.blobURL) URL.revokeObjectURL(val.blobURL);
}
coverCache.clear();
});
})();