☰

JAV hover tools

番号悬停:复制+JavBus跳转+JavDB搜索+封面预览大图(中文翻译标题)|固定官方站源、骨架占位、翻译独立缓存

Tính đến 01-11-2026. Xem phiên bản mới nhất.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(Tôi đã có Trình quản lý tập lệnh người dùng, hãy cài đặt nó!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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();   // 元数据/翻译立刻落盘,不依赖定时器
  });
})();