JAV hover tools

番号悬停:复制+JavBus跳转+JavDB搜索+封面预览大图(中文翻译标题)

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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();
  });
})();