☰

JAV hover tools

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

2026-11-01 기준 버전입니다. 최신 버전을 확인하세요.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램을 먼저 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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