JAV hover tools

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

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey, Greasemonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Userscripts.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een gebruikersscriptbeheerder nodig.

(Ik heb al een user script manager, laat me het downloaden!)

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

(Ik heb al een beheerder - laat me doorgaan met de installatie!)

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