番号悬停:复制+JavBus跳转+JavDB搜索+封面预览大图(中文翻译标题)|固定官方站源、骨架占位、翻译独立缓存
Mint
// ==UserScript==
// @license MIT
// @name JAV hover tools
// @namespace https://www.javbus.com/
// @version 3.3.2
// @description 番号悬停:复制+JavBus跳转+JavDB搜索+封面预览大图(中文翻译标题)|固定官方站源、骨架占位、翻译独立缓存
// @author Cod
// @match *://*/*
// @grant GM_setClipboard
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @connect www.javbus.com
// @connect pics.javbus.info
// @connect pics.dmm.co.jp
// @connect api.siliconflow.cn
// @connect translate.google.com.hk
// @connect *
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
/* ═══════════════════ 用户配置区 ═══════════════════ */
// JavBus 数据源地址(封面图 / 标题请求来源)— 固定用官方站,浏览器侧已有代理
const JAVBUS_ORIGIN = 'https://www.javbus.com';
// 点击「JavBus」按钮时打开的站点(可与数据源不同)
const JAVBUS_LINK = 'https://www.javbus.com';
/* ═══════════════════ 常量配置 ═══════════════════ */
const JAVDB_SEARCH = 'https://javdb.com/search';
// 翻译配置 — 填入硅基流动 API Key 启用,留空则用 Google 翻译
const SILICONFLOW_KEY = 'sk-wxxuzxbqgfmsirvncvxujvkzttnfkdnojzjayccbcwnfhcxl';
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 = 400; // 内存封面缓存上限(张)
const FAIL_TTL = 60000; // 失败记录保留60秒后可重试
const SAFE_BOTTOM = 60; // 底部任务栏预留
const HOVER_DELAY = 250; // 悬停多久才真正发请求(毫秒)——防止鼠标扫过时雪崩请求
// 缩略图占位模式(官方站的缩略图是 147x200 竖版,大图是 800x538 横版,
// 比例不一致,先出缩略图会让预览框从竖变横地跳一下,所以默认关闭):
// 'off' = 只下大图,等图期间显示骨架块,全程不出现模糊画面(默认)
// 'blur' = 先出模糊占位、大图到达后淡入(大图慢时能早点看到画面,但会有一次形变)
const THUMB_MODE = 'off';
const MAX_PREVIEW_W = 480; // 预览最大宽度
/* ═══════════════════ 番号识别 ═══════════════════ */
// 用 (?:^|[^a-zA-Z0-9]) 替代 \b,使番号紧跟中文/日文时也能匹配
// 捕获组1 = 实际番号文本
// 分支顺序很重要:越"特殊"的格式越靠前,避免被通用格式抢先匹配
// 1) FC2 / FC2-PPV
// 2) 数字前缀厂牌 300MIUM-910 / 259LUXU-1735
// 3) 日期式 010120_001 / 123119-001
// 4) 字母+数字前缀 T28-123
// 5) 通用 SSIS-001 / SSIS001
const markRe = /(?:^|[^a-zA-Z0-9])(FC2(?:[-_\s]*PPV)?[-_\s]*\d{5,8}|\d{2,3}[A-Z]{2,6}[-_]\d{3,6}|\d{6}[-_]\d{3}|[A-Z]\d{2}[-_]\d{2,5}|[A-Z]{2,8}(?:[-_]\d{2,6}|\d{3,5}))(?=$|[^a-zA-Z0-9])/gi;
// 大幅扩展的厂牌前缀白名单(无分隔符番号如 SSIS001 需要前缀匹配)
const noSepPrefixes = new Set([
// 主流厂牌
'ABP','ABF','ABS','ABW','ADN','ADZ','AEM','AKA','AKID','ALDN','AQSH','ARAN','ARSO','ATID','AUKG','AVOP','BAHP','BADA','BAKO','BANK','BBAN','BBD','BDA','BDH','BF','BIN','BKD','BLK','BLOR','BOMN','BOYA','BUNK','CAFR','CAWD','CAWD','CEAD','CEMD','CJOD','CAND','CHA','CLUB','CMAV','CMN','COSU','CPNU','CWT','DANDY','DAPN','DASD','DASS','DBER','DCV','DDHH','DDHK','DDHP','DEEP','DFDA','DGCE','DKSW','DLDSS','DMOW','DOKS','DPGJ','DRPT','DSFZ','DVAJ','EBOD','EBWH','ECHI','EIKI','EKDV','EMTH','ENFD','EVIS','FSDSS','FSDSSS','GENM','GMEM','GNS','GOLD','GVG','GVTM','HAWA','HBAD','HDD','HDKA','HDMI','HER','HODV','HND','HONZ','HNDS','HODV','HUNTA','HZGD','IENE','IKEA','IPX','IPZ','ISCD','JACT','JAP','JDJA','JFE','JHDV','JKIR','JKME','JKRF','JKTU','JUL','JUFE','JUQ','JURE','JUX','JUY','KAAD','KABE','KAKU','KATU','KAWD','KAWK','KBBI','KCDA','KEED','KIDE','KIRE','KNMD','KOJA','KRAV','KRHK','KSBJ','KTKL','KTKR','KTFT','KUDE','LULU','LULU','LWDN','LZBS','MAAN','MAIM','MAJU','MASS','MEYD','MIAA','MIDE','MIGD','MIMK','MIRD','MIST','MITC','MJUK','MKCK','MKMP','MKON','MMPA','MMND','MNAC','MOGI','MOPN','MORE','MOT','MXGS','NACR','NAHU','NASK','NATR','NAZI','NDVR','NEWD','NHDTA','NKKK','NKKD','NKVR','NNPJ','NOSAV','NSPS','NTRD','NKKD','OBFO','OHSH','OKAX','OKB','OKSN','ONCE','OONS','OPBD','OPPA','OAE','OR','OREC','ORGA','OUT','OVR','OX','PAPU','PATT','PBAB','PED','PES','PKPD','PKSK','PLA','PRED','PRIN','PRTD','PSTS','PURE','PYYM','RADA','RBB','RBD','REBD','REX','RKI','RM','ROE','ROYD','RSE','RUKO','RUNA','RYUU','SABA','SABA','SAME','SAN','SBDD','SBGA','SDAB','SDAM','SDDE','SDJS','SDMM','SDMU','SDNM','SDTH','SESJ','SEXY','SGA','SGK','SGNL','SHH','SILK','SILV','SIS','SIST','SKU','SLI','SMAV','SMWW','SNIS','SONE','SOUM','SPCD','SPZ','SQTE','SRGJ','SRX','SSIS','SSNI','STARS','START','STCV','STON','SUWK','SW','SW','SWDM','SABA','SZ','TADI','TAKA','TAMA','TCD','TDPT','TEK','TEYM','TFAW','THZ','TIES','TKTJ','TKWA','TMAM','TNDL','TPPN','TRA','TSP','UMSO','URE','VABM','VAND','VEC','VENX','VINX','VIVA','VOSS','VRKM','VROR','VRTM','WAAA','WANZ','WAPO','WBD','WKBR','WNZP','XBDB','XVSR','ZMEN','ZUKO',
]);
// 全角 → 半角(1:1 字符替换,长度不变,保证匹配偏移可直接回映射到原文)
const FW_RE = /[\uFF01-\uFF5E\u3000]/;
function toHalfWidth(str) {
if (!str || !FW_RE.test(str)) return str;
let out = '';
for (const ch of str) {
const c = ch.charCodeAt(0);
if (c === 0x3000) out += ' ';
else if (c >= 0xFF01 && c <= 0xFF5E) out += String.fromCharCode(c - 0xFEE0);
else out += ch;
}
return out;
}
function normalizeNumber(raw) {
let t = toHalfWidth(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 = toHalfWidth(raw).toUpperCase().trim();
// FC2
if (/^FC2(?:[-_\s]*PPV)?[-_\s]*\d{5,8}$/.test(t)) return true;
// 数字前缀厂牌 300MIUM-910 / 259LUXU-1735 / 390JAC-123
if (/^\d{2,3}[A-Z]{2,6}[-_]\d{3,6}$/.test(t)) return true;
// 日期式 010120_001 / 123119-001(一本道/加勒比格式)
// 首4位必须是合法 MMDD 或 DDMM,否则 "123456-789" 这种订单号会被误判
if (/^\d{6}[-_]\d{3}$/.test(t)) {
const d4 = t.slice(0, 4);
const ok = (mo, da) => mo >= 1 && mo <= 12 && da >= 1 && da <= 31;
return ok(+d4.slice(0, 2), +d4.slice(2, 4)) || ok(+d4.slice(2, 4), +d4.slice(0, 2));
}
// 字母+数字前缀 T28-123
if (/^[A-Z]\d{2}[-_]\d{2,5}$/.test(t)) return true;
// 通用带分隔符
if (/^[A-Z]{2,8}[-_]\d{2,6}$/.test(t)) return true;
// 无分隔符番号:3-5位数字需要前缀在白名单中
const m = t.match(/^([A-Z]{2,8})(\d{3,5})$/);
if (m) return noSepPrefixes.has(m[1]);
// 4-6位数字的无分隔符番号直接放行
if (/^[A-Z]{2,8}\d{4,6}$/.test(t)) return true;
return false;
}
/* ═══════════════════ 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(toHalfWidth(node.nodeValue));
}
function markTextNode(textNode) {
if (processed.has(textNode) || shouldSkip(textNode)) return;
processed.add(textNode);
const text = textNode.nodeValue;
const norm = toHalfWidth(text); // 长度与 text 一致,偏移可直接回映射
const frag = document.createDocumentFragment();
let last = 0, m;
markRe.lastIndex = 0;
while ((m = markRe.exec(norm)) !== null) {
const raw = m[1]; // 捕获组1 = 纯番号文本(不含前导非字母数字字符)
if (!isLikelyJav(raw)) continue;
const fullMatch = m[0];
const prefix = fullMatch.slice(0, fullMatch.length - raw.length); // 前导字符
const s = m.index + prefix.length; // 番号在 text 中的起始位置
const 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 = text.slice(s, e); // 显示原文(保留全角写法)
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);
}
}
// 扫描元素的 title/aria-label 等属性中的番号
const attrList = ['title', 'aria-label', 'data-number', 'data-code', 'data-id'];
// 全页批量扫描时只用这两个属性(data-* 太常见,乱插标记会破坏布局)
const BULK_ATTRS = ['title', 'aria-label'];
const processedAttrs = new WeakSet();
function scanAttrs(el, attrs) {
if (processedAttrs.has(el)) return;
processedAttrs.add(el);
if (el.querySelector?.(`.${MARK_CLASS}`)) return; // 已标记过
for (const attr of (attrs || attrList)) {
const val = el.getAttribute(attr);
if (!val) continue;
markRe.lastIndex = 0;
const m = markRe.exec(toHalfWidth(val));
if (m && isLikelyJav(m[1])) {
// 元素可见文本里已经含这个番号 → 交给文本扫描处理,避免出现两个标记
const visible = toHalfWidth(el.textContent || '').toUpperCase();
if (visible.includes(m[1].toUpperCase())) {
for (const child of el.childNodes) {
if (child.nodeType === Node.TEXT_NODE) {
processed.delete(child);
markTextNode(child);
}
}
return;
}
// 属性中找到番号但可见文本没有(如标题被截断)→ 插入可见标记
const span = document.createElement('span');
span.className = MARK_CLASS;
span.dataset.javNumber = normalizeNumber(m[1]);
span.textContent = m[1];
span.style.cssText = 'font-size:inherit;line-height:inherit;';
el.prepend(span);
return;
}
}
}
// 扫描被标签拆开的番号:如 <b>SSIS</b>-001、SSIS<b>-001</b>
// 做法:把子树内所有文本节点拼成一条虚拟字符串 → 在其中匹配
// → 用 Range 把跨节点的匹配范围整体包成标记 span
const processedEls = new WeakSet();
const SPLIT_TAGS = new Set([
'A','DIV','SPAN','LI','TD','TH','TR','STRONG','B','H1','H2','H3','H4','H5',
'P','FONT','EM','I','U','S','LABEL','DD','DT','SMALL','BIG','CENTER','BLOCKQUOTE',
]);
function findSpanItem(map, pos) {
for (const it of map) {
if (pos >= it.start && pos <= it.end) return it;
}
return null;
}
function scanSplitText(el) {
if (!el || el.nodeType !== Node.ELEMENT_NODE) return;
if (processedEls.has(el)) return;
processedEls.add(el);
// 番号被拆开 = 至少两个子节点;只有单个文本子节点的元素不可能拆分
if (el.childNodes.length < 2) return;
if (!SPLIT_TAGS.has(el.tagName)) return;
if (el.closest(SKIP_SELECTOR)) return;
if (el.querySelector(`.${MARK_CLASS}`)) return;
const quick = el.textContent || '';
if (!quick || quick.length > 200) return;
markRe.lastIndex = 0;
if (!markRe.test(toHalfWidth(quick))) return; // 快速排除,避免大页面逐元素开销
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, {
acceptNode(n) {
return (n.parentElement && n.parentElement.closest(SKIP_SELECTOR))
? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT;
}
});
const map = [];
let full = '', n;
while ((n = walker.nextNode())) {
map.push({ node: n, start: full.length, end: full.length + n.nodeValue.length });
full += n.nodeValue;
}
if (!full) return;
const norm = toHalfWidth(full);
const hits = [];
let m;
markRe.lastIndex = 0;
while ((m = markRe.exec(norm)) !== null) {
const raw = m[1];
if (!isLikelyJav(raw)) continue;
const s = m.index + (m[0].length - raw.length);
const e = s + raw.length;
const a = findSpanItem(map, s), b = findSpanItem(map, e);
if (!a || !b || a === b) continue; // 落在单个文本节点内 → 已由 markTextNode 处理
hits.push({ s, e, a, b, sOff: s - a.start, eOff: e - b.start });
}
if (!hits.length) return;
// 逆序包裹,前面的命中项偏移就不会失效
for (let i = hits.length - 1; i >= 0; i--) {
const h = hits[i];
try {
const range = document.createRange();
range.setStart(h.a.node, h.sOff);
range.setEnd(h.b.node, h.eOff);
const span = document.createElement('span');
span.className = MARK_CLASS;
span.dataset.javNumber = normalizeNumber(norm.slice(h.s, h.e));
span.appendChild(range.extractContents());
range.insertNode(span);
} catch {}
}
}
function scan(root) {
// 不传参数 = 全页扫描(首次进入页面时必须调用,否则静态页面永远识别不到番号)
if (!root) root = document.body;
if (!root) return;
if (root.nodeType === Node.TEXT_NODE) { markTextNode(root); return; }
if (root.nodeType === Node.DOCUMENT_NODE) root = root.body;
if (!root || root.nodeType !== Node.ELEMENT_NODE) return;
if (root.closest?.(SKIP_SELECTOR)) return;
// 1) 单文本节点内的番号(主路径,最快命中)
// 注意:这里不过滤"不含番号"的文本节点 —— 被拆开的番号每段本身都不含完整番号,
// 它们的宿主元素必须一并收集,否则跨节点匹配永远触发不到
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(n) {
return (n.parentElement && n.parentElement.closest(SKIP_SELECTOR))
? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT;
}
});
const nodes = [];
let n; while ((n = walker.nextNode())) nodes.push(n);
// 拆分番号的宿主元素(必须在标记前取,标记会替换文本节点)
const hosts = new Set();
for (const t of nodes) if (t.parentElement) hosts.add(t.parentElement);
nodes.forEach(markTextNode);
// 2) 被标签拆开的番号(<b>SSIS</b>-001)
for (const el of hosts) scanSplitText(el);
// 3) 元素属性里的番号(title / aria-label,标题被截断的帖子列表靠这条)
for (const el of [root, ...root.querySelectorAll('[title],[aria-label]')]) {
scanAttrs(el, BULK_ATTRS);
}
}
/* ═══════════════════ 存储层 ═══════════════════ */
// 键值存储:优先 GM 存储(跨站点共享),退回 localStorage,再退回内存
const memKV = new Map();
function kvGet(k) {
try {
if (typeof GM_getValue === 'function') return GM_getValue(k, '') || '';
} catch {}
try {
if (typeof localStorage !== 'undefined') return localStorage.getItem(k) || '';
} catch {}
return memKV.get(k) || '';
}
function kvSet(k, v) {
try {
if (typeof GM_setValue === 'function') { GM_setValue(k, v); return; }
} catch {}
try {
if (typeof localStorage !== 'undefined') { localStorage.setItem(k, v); return; }
} catch {}
memKV.set(k, v);
}
// ── 元数据(标题 / 翻译 / 图片地址):小、长期、跨站点共享 ──
const META_KEY = 'jht:meta-v3';
const META_MAX = 600;
let metaMap = null;
let metaSaveTimer = 0;
function metaStore() {
if (metaMap) return metaMap;
metaMap = new Map();
try {
const raw = kvGet(META_KEY);
if (raw) for (const [k, v] of Object.entries(JSON.parse(raw))) metaMap.set(k, v);
} catch {}
return metaMap;
}
function saveMetaSoon() {
clearTimeout(metaSaveTimer);
metaSaveTimer = setTimeout(() => {
try {
const m = metaStore();
if (m.size > META_MAX) {
const olds = [...m.entries()]
.filter(([, v]) => !v.failAt)
.sort((a, b) => (a[1].ts || 0) - (b[1].ts || 0));
let excess = m.size - META_MAX;
for (const [k] of olds) {
if (excess-- <= 0) break;
m.delete(k);
}
}
kvSet(META_KEY, JSON.stringify(Object.fromEntries(m)));
} catch {}
}, 400);
}
// 读元数据:失败记录在 TTL 内视为"没有",但要让调用方知道别再请求
function metaOf(number) {
const m = metaStore().get(number);
if (!m) return null;
if (m.failAt) {
if (Date.now() - m.failAt <= FAIL_TTL) return { failAt: m.failAt };
metaStore().delete(number);
return null;
}
return m;
}
// ── 图片(blob):内存 blobURL → Cache API 磁盘 → 网络 ──
const coverMem = new Map(); // number → blobURL(大图)
const thumbMem = new Map(); // number → blobURL(缩略图)
const pendingCover = new Map(); // number → { subs, last }
const pendingMeta = new Map(); // number → Promise
const pendingTrans = new Map(); // number → 回调数组
function trimMem() {
for (const [store, cap] of [[coverMem, MAX_CACHE], [thumbMem, MAX_CACHE]]) {
if (store.size <= cap) continue;
let excess = store.size - cap;
for (const [k, v] of store) {
if (excess-- <= 0) break;
try { URL.revokeObjectURL(v); } catch {}
store.delete(k);
}
}
}
const DISK_CACHE = 'jav-hover-covers-v1';
let diskCache = null;
function getDiskCache() {
if (diskCache) return Promise.resolve(diskCache);
if (typeof caches === 'undefined') return Promise.resolve(null);
return caches.open(DISK_CACHE).then(c => { diskCache = c; return c; }).catch(() => null);
}
async function loadFromDisk(number) {
const dc = await getDiskCache();
if (!dc) return null;
try {
const resp = await dc.match(`https://jav-hover-cache/${encodeURIComponent(number)}`);
if (!resp || !resp.ok) return null;
const blob = await resp.blob();
return { blobURL: URL.createObjectURL(blob) };
} catch { return null; }
}
async function saveToDisk(number, blob, title, titleTrans) {
const dc = await getDiskCache();
if (!dc || !blob) return;
try {
const headers = { 'Content-Type': blob.type || 'image/jpeg' };
if (title) headers['X-Title'] = encodeURIComponent(title);
if (titleTrans) headers['X-Title-Trans'] = encodeURIComponent(titleTrans);
await dc.put(`https://jav-hover-cache/${encodeURIComponent(number)}`, new Response(blob, { headers }));
} catch {}
}
// ── 并发限流:鼠标扫过多个番号时不要一次打出十几个请求 ──
function makeLimiter(limit) {
let active = 0;
const q = [];
const next = () => {
if (active >= limit || !q.length) return;
active++;
q.shift()();
};
return (job) => new Promise((resolve, reject) => {
q.push(() => {
Promise.resolve().then(job).then(
(r) => { active--; next(); resolve(r); },
(e) => { active--; next(); reject(e); }
);
});
next();
});
}
// 元数据/图片各自最多 3 个并发,扫过多个番号也不会打爆代理
const searchLimit = makeLimiter(3);
const imageLimit = makeLimiter(3);
// 卸载/隐藏时立刻落盘(定时器可能来不及触发)
function flushMeta() {
clearTimeout(metaSaveTimer);
try { kvSet(META_KEY, JSON.stringify(Object.fromEntries(metaStore()))); } catch {}
}
/* ═══════════════════ 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{
position:relative; 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 img.jcp-thumb{
filter:blur(16px) saturate(1.15); transform:scale(1.06);
will-change:opacity;
}
/* 清晰大图叠在占位之上淡入,替换时没有空白闪烁 */
#javbus-cover-preview img.jcp-big{opacity:0; transition:opacity .26s ease}
#javbus-cover-preview img.jcp-big.jcp-fade{opacity:1}
#javbus-cover-preview .jcp-img-wrap.has-thumb img.jcp-big{
position:absolute; inset:0; width:100%; height:100%; object-fit:contain;
}
/* 纯等图状态(THUMB_MODE='off'):骨架块,避免白屏和宽高跳动 */
#javbus-cover-preview .jcp-skeleton{
width:100%; background:linear-gradient(100deg,#151515 30%,#222 50%,#151515 70%);
background-size:220% 100%; animation:jcp-shimmer 1.1s linear infinite;
}
@keyframes jcp-shimmer{from{background-position:180% 0}to{background-position:-80% 0}}
#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);
/* ═══════════════════ 数据源 ═══════════════════ */
// 数据源固定官方站(浏览器侧已走代理,不再维护防屏蔽镜像)
const gatedUntil = new Map(); // origin → 时间戳(冷却期内不再重试)
const isGated = (o) => (gatedUntil.get(o) || 0) > Date.now();
function originOf(url) {
const m = /^(https?:\/\/[^/]+)/i.exec(url || '');
return m ? m[1] + '/' : JAVBUS_ORIGIN + '/';
}
// 门控页判定:302 到 /doc/driver-verify,或正文是 Age Verification
function isGatePage(r) {
if (!r) return false;
if (/driver-verify|age-verif/i.test(r.finalUrl || '')) return true;
const head = (r.html || '').slice(0, 5000);
return /Age Verification/i.test(head) && !/movie-box|bigImage/i.test(r.html || '');
}
function gmRaw(url, timeout) {
return new Promise((resolve) => {
if (typeof GM_xmlhttpRequest !== 'function') { resolve(null); return; }
let done = false;
const finish = (v) => { if (!done) { done = true; resolve(v); } };
try {
GM_xmlhttpRequest({
method: 'GET', url,
headers: { 'Accept': 'text/html,application/xhtml+xml,*/*;q=0.8', 'Referer': originOf(url) },
// dv=1 是官方站的年龄验证标记,带上就跳过 /doc/driver-verify 门控
cookie: 'existmag=all; dv=1',
timeout,
onload(resp) {
if (resp.status >= 200 && resp.status < 400 && resp.responseText) {
finish({ html: resp.responseText, finalUrl: resp.finalUrl || url, origin: originOf(resp.finalUrl || url) });
} else {
finish(null);
}
},
onerror: () => finish(null),
ontimeout: () => finish(null),
});
} catch { finish(null); }
});
}
// 抓取 + 门控判定:官方站返回年龄验证页时短暂冷却,避免每个番号都白烧一次请求
async function fetchWithGate(buildPath) {
const origin = JAVBUS_ORIGIN;
if (isGated(origin)) {
console.warn('[JAV Hover] 官方站刚返回过年龄验证页,冷却中;请在浏览器打开 www.javbus.com 过一次验证');
return null;
}
const r = await searchLimit(() => gmRaw(buildPath(origin), 9000));
if (!r) return null;
if (isGatePage(r)) {
gatedUntil.set(origin, Date.now() + 60 * 1000);
console.warn('[JAV Hover] 官方站返回年龄验证页(GM 请求没带上验证 cookie):浏览器打开 www.javbus.com 点一次「同意」即可,60 秒后自动恢复');
return null;
}
return r;
}
/* ═══════════════════ 解析 ═══════════════════ */
function parseSearch(html, number) {
try {
const doc = new DOMParser().parseFromString(html, 'text/html');
const want = number.toUpperCase().replace(/\s+/g, '');
for (const box of doc.querySelectorAll('a.movie-box')) {
const got = (box.querySelector('date')?.textContent || '').trim().toUpperCase().replace(/\s+/g, '');
if (got !== want) continue;
const img = box.querySelector('img');
const raw = img?.getAttribute('src') || img?.getAttribute('data-src') || img?.getAttribute('data-original');
if (!raw) continue;
const abs = resolveURL(raw, JAVBUS_ORIGIN + '/');
return {
thumb: abs, // 搜索页缩略图(小,先出)
src: thumbToCover(abs), // 大封面(后补)
title: img.getAttribute('title') || img.getAttribute('alt') || '',
};
}
return null;
} catch { return null; }
}
function parseDetail(html) {
try {
const doc = new DOMParser().parseFromString(html, 'text/html');
const title = doc.querySelector('h3')?.textContent?.trim() || '';
const bigImg = doc.querySelector('a.bigImage img');
const og = doc.querySelector('meta[property="og:image"]');
const raw = bigImg
? (bigImg.getAttribute('src') || bigImg.getAttribute('data-src') || bigImg.getAttribute('data-original'))
: (og?.getAttribute('content') || '');
if (!raw) return null;
const abs = resolveURL(raw, JAVBUS_ORIGIN + '/');
return { src: abs, thumb: '', title };
} catch { return null; }
}
// 缩略图 URL → 封面大图 URL
function thumbToCover(src) {
if (/\/cover\/[a-z\d]+_b\.(jpg|png)/i.test(src)) return src;
const converted = src.replace(/\/thumbs?\//i, '/cover/').replace(/(\.[^.]+)$/i, '_b$1');
return converted === src ? src : converted;
}
function resolveURL(src, base) {
if (!src) return '';
if (/^https?:\/\//i.test(src)) return src;
try { return new URL(src, base).href; } catch { return src; }
}
/* ═══════════════════ 元数据(一次请求拿全) ═══════════════════ */
function getMeta(number) {
const cache = metaOf(number);
if (cache) {
if (cache.failAt) return Promise.resolve(null); // 失败 TTL 内不再发请求
return Promise.resolve(cache);
}
if (pendingMeta.has(number)) return pendingMeta.get(number);
const p = fetchMeta(number).finally(() => pendingMeta.delete(number));
pendingMeta.set(number, p);
return p;
}
async function fetchMeta(number) {
const t0 = Date.now();
const book = encodeURIComponent(number);
// 只打一次搜索页:标题和封面地址一起拿到
let r = await fetchWithGate((o) => `${o}/search/${book}`);
let info = r?.html ? parseSearch(r.html, number) : null;
let from = '搜索页';
if (!info) {
r = await fetchWithGate((o) => `${o}/${book}`);
info = r?.html ? parseDetail(r.html) : null;
from = '详情页';
}
const store = metaStore();
if (!info) {
store.set(number, { failAt: Date.now(), ts: Date.now() });
saveMetaSoon();
console.warn(`[JAV Hover] ${number} 元数据获取失败 (${Date.now() - t0}ms)`);
return null;
}
const meta = {
title: info.title || '',
src: info.src || '',
thumb: info.thumb || '',
referer: r?.origin || JAVBUS_ORIGIN + '/',
ts: Date.now(),
};
const old = store.get(number);
if (old?.titleTrans) meta.titleTrans = old.titleTrans;
store.set(number, meta);
saveMetaSoon();
console.log(`[JAV Hover] ${number} 元数据 ✓ ${from} (${Date.now() - t0}ms)「${meta.title}」`);
return meta;
}
/* ═══════════════════ 翻译 ═══════════════════ */
function translateTitle(text) {
if (!text?.trim()) return Promise.resolve('');
return SILICONFLOW_KEY ? translateSF(text) : translateGoogle(text);
}
// 翻译结果独立于图片单独持久化 —— 不再因为"图片还没下完"而丢掉
function ensureTrans(number, title, cb) {
if (typeof cb !== 'function') cb = () => {};
const m = metaOf(number);
if (m?.titleTrans) { try { cb(m.titleTrans); } catch {} return; }
if (!title) return;
if (pendingTrans.has(number)) { pendingTrans.get(number).push(cb); return; }
const subs = [cb];
pendingTrans.set(number, subs);
translateTitle(title).then((tr) => {
pendingTrans.delete(number);
if (!tr) return;
const store = metaStore();
const cur = store.get(number) || {};
cur.titleTrans = tr;
cur.ts = Date.now();
store.set(number, cur);
saveMetaSoon();
for (const f of subs) { try { f(tr); } catch {} }
});
}
// 硅基流动(非流式 — 兼容性最好)
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`);
} 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 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`);
resolve(r); return;
}
} catch {}
console.warn(`[JAV Hover] Google翻译失败`);
resolve('');
},
onerror() { resolve(''); },
ontimeout() { resolve(''); },
});
});
}
/* ═══════════════════ 封面 ═══════════════════ */
// 下载图片为 blob
function dlBlob(imgUrl, timeout, referer) {
return new Promise((resolve) => {
if (!imgUrl || typeof GM_xmlhttpRequest !== 'function') { resolve(null); return; }
GM_xmlhttpRequest({
method: 'GET', url: imgUrl,
headers: { 'Referer': referer || JAVBUS_ORIGIN + '/', 'Accept': 'image/*,*/*;q=0.8' },
responseType: 'blob',
timeout: timeout || 15000,
onload(resp) {
if (resp.status >= 200 && resp.status < 400 && resp.response) {
try { resolve({ blobURL: URL.createObjectURL(resp.response), _blob: resp.response }); }
catch { resolve(null); }
} else resolve(null);
},
onerror: () => resolve(null),
ontimeout: () => resolve(null),
});
});
}
// 渐进式出图:缩略图先占位 → 大图到了替换;同一番号只下载一次
function startCover(number, meta, onUpdate) {
if (coverMem.has(number)) {
const url = coverMem.get(number);
setTimeout(() => onUpdate({ blobURL: url, final: true }), 0);
return;
}
const queued = pendingCover.get(number);
if (queued) {
queued.subs.push(onUpdate);
if (queued.last) onUpdate(queued.last);
return;
}
const state = { subs: [onUpdate], last: null };
pendingCover.set(number, state);
const emit = (d) => {
state.last = d;
for (const f of state.subs) { try { f(d); } catch {} }
};
const done = () => pendingCover.delete(number);
(async () => {
// 1) 磁盘缓存
const disk = await loadFromDisk(number);
if (disk?.blobURL) {
coverMem.set(number, disk.blobURL);
trimMem();
emit({ blobURL: disk.blobURL, final: true });
done();
return;
}
if (!meta?.src) { emit({ fail: true }); done(); return; }
// 2) 缩略图先出(几十 KB,通常 1~2s 内到位)
if (THUMB_MODE !== 'off' && meta.thumb && meta.thumb !== meta.src) {
if (thumbMem.has(number)) emit({ thumbURL: thumbMem.get(number) });
else imageLimit(() => dlBlob(meta.thumb, 6000, meta.referer)).then((r) => {
if (!r) return;
thumbMem.set(number, r.blobURL);
trimMem();
emit({ thumbURL: r.blobURL });
});
}
// 3) 大图
const big = await imageLimit(() => dlBlob(meta.src, 15000, meta.referer));
if (big?.blobURL) {
coverMem.set(number, big.blobURL);
trimMem();
saveToDisk(number, big._blob, meta.title, meta.titleTrans || '');
emit({ blobURL: big.blobURL, final: true });
} else {
emit({ fail: true });
}
done();
})();
}
/* ═══════════════════ 预览 ═══════════════════ */
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) {
if (!markRect) return;
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 meta = metaOf(num);
// 水平定位
const rect = lastMarkRect;
const maxW = Math.min(MAX_PREVIEW_W, window.innerWidth - 40);
const gap = 8;
const 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';
resetImgWrap(maxW);
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 = meta?.titleTrans || meta?.title || num;
titleEl.style.display = 'block';
preview.style.display = 'flex';
requestAnimationFrame(() => positionPreview(rect));
// 已缓存的大图:同步秒出,不做淡入
if (coverMem.has(num)) renderImage(coverMem.get(num), true);
const onUpdate = (d) => {
if (currentNumber !== num) return; // 只是预热,不上屏
if (d.thumbURL) renderThumb(d.thumbURL);
if (d.blobURL) renderImage(d.blobURL);
if (d.fail) updateTitleEl(`${titleEl.textContent}(未找到封面)`);
};
// 命中缓存 → 直接开图 + 补标题翻译,全程不再发搜索请求
if (meta && !meta.failAt) {
if (meta.title && !meta.titleTrans) {
updateTitleEl(meta.title);
ensureTrans(num, meta.title, (tr) => { if (currentNumber === num) updateTitleEl(tr); });
}
if (meta.src || meta.thumb) startCover(num, meta, onUpdate);
return;
}
if (meta?.failAt) {
updateTitleEl(`${num} · 暂无资料`);
return;
}
// 完全冷启动:一次请求拿标题 + 封面地址
getMeta(num).then((m) => {
if (!m) {
if (currentNumber === num) updateTitleEl(`${num} · 暂无资料`);
return;
}
if (currentNumber !== num) return;
updateTitleEl(m.titleTrans || m.title || num);
if (m.title && !m.titleTrans) {
ensureTrans(num, m.title, (tr) => { if (currentNumber === num) updateTitleEl(tr); });
}
startCover(num, m, onUpdate);
});
}
// 重置图片区:清掉上一张图,按模式决定先占位还是留空
function resetImgWrap(maxW) {
const wrap = preview.querySelector('.jcp-img-wrap');
wrap.innerHTML = '';
wrap.classList.remove('has-thumb');
if (THUMB_MODE === 'off') {
// 只下大图:先用骨架块占住尺寸,避免图到时把预览框撑开跳动
preview.style.width = `${maxW}px`;
const sk = document.createElement('div');
sk.className = 'jcp-skeleton';
sk.style.height = `${Math.round(maxW * 0.6725)}px`; // 800x538 封面的高宽比,图到时几乎不跳动
wrap.appendChild(sk);
} else {
preview.style.width = 'fit-content';
}
}
// 模糊占位:低分辨率缩略图只当毛玻璃底,不做"清晰展示",
// 避免出现"一张糊图 → 突然变清晰"的突兀观感
function renderThumb(url) {
const wrap = preview.querySelector('.jcp-img-wrap');
if (wrap.querySelector('img.jcp-big') || wrap.querySelector('img.jcp-thumb')) return;
const maxW = Math.min(MAX_PREVIEW_W, window.innerWidth - 40);
preview.style.width = `${maxW}px`;
wrap.querySelector('.jcp-skeleton')?.remove();
const img = document.createElement('img');
img.className = 'jcp-thumb';
img.src = url;
img.alt = currentNumber || '';
wrap.classList.add('has-thumb');
wrap.appendChild(img);
requestAnimationFrame(() => positionPreview(lastMarkRect));
}
// 大图:叠在占位之上淡入,替换过程中不出现空白帧
function renderImage(url, instant) {
const wrap = preview.querySelector('.jcp-img-wrap');
const cur = wrap.querySelector('img.jcp-big');
if (cur && cur.dataset.src === url) return;
if (cur) cur.remove();
wrap.querySelector('.jcp-skeleton')?.remove();
const img = document.createElement('img');
img.className = 'jcp-big' + (instant ? ' jcp-fade' : '');
img.dataset.src = url;
img.alt = currentNumber || '';
const settle = () => {
if (img !== wrap.querySelector('img.jcp-big')) return;
if (img.naturalWidth) {
const maxW = Math.min(MAX_PREVIEW_W, window.innerWidth - 40);
preview.style.width = Math.min(maxW, img.naturalWidth) + 'px';
}
positionPreview(lastMarkRect || activeMark?.getBoundingClientRect());
};
let fired = false;
img.onload = () => {
if (fired) return;
fired = true;
settle();
const dropThumb = () => {
wrap.querySelector('img.jcp-thumb')?.remove();
wrap.classList.remove('has-thumb');
positionPreview(lastMarkRect || activeMark?.getBoundingClientRect());
};
if (instant) { dropThumb(); return; }
// 先绘制一帧透明态,再切不透明,保证淡入真的发生
requestAnimationFrame(() => {
img.classList.add('jcp-fade');
setTimeout(dropThumb, 300); // 清晰图铺满后再撤模糊底,撤早了会露黑底
});
};
img.onerror = () => { img.remove(); };
wrap.appendChild(img);
img.src = url;
if (img.complete && img.naturalWidth) img.onload();
}
function updateTitleEl(txt) {
const el = preview.querySelector('.jcp-title');
if (el && txt) el.textContent = txt;
}
function hidePreviewSoon(delay) {
clearTimeout(previewTimer);
previewTimer = setTimeout(() => {
if (isPreviewHovered) return;
preview.style.display = 'none';
preview.style.width = '';
const wrap = preview.querySelector('.jcp-img-wrap');
if (wrap) { wrap.innerHTML = ''; wrap.classList.remove('has-thumb'); }
currentNumber = null;
}, delay || 300);
}
/* ═══════════════════ 事件 ═══════════════════ */
let mouseoverTimer = 0;
// 悬停意图:停留 HOVER_DELAY 才真正发请求,扫过的番号一律不请求
document.addEventListener('mouseover', (ev) => {
const mark = ev.target.closest?.(`.${MARK_CLASS}`);
clearTimeout(mouseoverTimer);
if (!mark) return;
mouseoverTimer = setTimeout(() => {
if (mark.isConnected) showPreview(mark);
}, HOVER_DELAY);
});
document.addEventListener('mouseout', (ev) => {
const mark = ev.target.closest?.(`.${MARK_CLASS}`);
if (!mark) return;
clearTimeout(mouseoverTimer);
hidePreviewSoon(400);
});
preview.addEventListener('mouseenter', () => { isPreviewHovered = true; clearTimeout(previewTimer); });
preview.addEventListener('mouseleave', () => { isPreviewHovered = false; hidePreviewSoon(200); });
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);
// 属性变化也可能引入番号(如 title 被动态设置)
if (m.type === 'attributes' && m.target.nodeType === Node.ELEMENT_NODE) {
scanAttrs(m.target);
scanSplitText(m.target);
}
}
});
scan(document.body);
const marked = document.querySelectorAll(`.${MARK_CLASS}`).length;
console.log(`[JAV Hover] v3.3 初始扫描完成,识别到 ${marked} 个番号;元数据缓存 ${metaStore().size} 条`);
observer.observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: attrList });
window.addEventListener('beforeunload', () => {
clearTimeout(mouseoverTimer); clearTimeout(previewTimer);
observer.disconnect();
for (const store of [coverMem, thumbMem]) {
for (const [, url] of store) { try { URL.revokeObjectURL(url); } catch {} }
store.clear();
}
flushMeta(); // 元数据/翻译立刻落盘,不依赖定时器
});
})();