V2EX Tweaks

V2EX 日常增强:用户标签(本地存储 / 导入导出 / 智能合并);回复自动带楼层号;回复嵌套树 + 合并分页;未读新回复标记 + j/k 跳转;高赞阅览室(图片 Lightbox);Base64 解码(熵过滤);折叠状态持久化;悬停引用预览;多页加载失败重试;每日签到;Imgur 代理。

K instalaci tototo skriptu si budete muset nainstalovat rozšíření jako Tampermonkey, Greasemonkey nebo Violentmonkey.

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

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Violentmonkey.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Userscripts.

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

K instalaci tohoto skriptu si budete muset nainstalovat manažer uživatelských skriptů.

(Už mám manažer uživatelských skriptů, nechte mě ho nainstalovat!)

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.

(Už mám manažer uživatelských stylů, nechte mě ho nainstalovat!)

// ==UserScript==
// @name         V2EX Tweaks
// @namespace    https://tampermonkey.net/
// @version      2.5.6
// @description  V2EX 日常增强:用户标签(本地存储 / 导入导出 / 智能合并);回复自动带楼层号;回复嵌套树 + 合并分页;未读新回复标记 + j/k 跳转;高赞阅览室(图片 Lightbox);Base64 解码(熵过滤);折叠状态持久化;悬停引用预览;多页加载失败重试;每日签到;Imgur 代理。
// @author       you
// @match        https://v2ex.com/*
// @match        https://www.v2ex.com/*
// @match        https://edge.v2ex.com/*
// @grant        GM_addStyle
// @grant        GM_setClipboard
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// @grant        GM_addValueChangeListener
// @grant        GM_registerMenuCommand
// @grant        GM_notification
// @grant        GM_xmlhttpRequest
// @grant        GM.xmlHttpRequest
// @connect      v2ex.com
// @connect      www.v2ex.com
// @connect      edge.v2ex.com
// @run-at       document-end
// @noframes
// @license      MIT
// ==/UserScript==

(() => {
  'use strict';

  // 某些 Safari 脚本管理器不识别 @noframes;运行时再兜底一次,避免
  // 签到用的隐藏 iframe 重复启动整套增强脚本或抢占签到锁。
  if (window.top !== window.self) return;

  // =========================
  // 0) 统一配置
  // =========================
  const CONFIG = {
    daily: {
      page: '/mission/daily',
      delayMinMs: 1500,
      delayMaxMs: 3800,
      requestTimeoutMs: 12000,
      verifyRetries: 3,
      verifyIntervalMs: 900,
      // 网络类错误的自动补签:失败后延迟重试,避免一次抖动就漏签
      networkRetries: 3,
      networkRetryDelayMs: 5 * 60 * 1000,
      // 页面长期打开时的兜底轮询:仅在"今日未签到"时才真正发请求
      pollIntervalMs: 15 * 60 * 1000,
      // V2EX 的每日奖励按服务器所在时区(UTC+8)跨天,不能用 UTC 日期判断
      timeZone: 'Asia/Shanghai',
      storeKey: 'v2ex_daily_check_ymd_v4',
      notify: true,
    },
    tags: {
      storeKey: 'v2ex_user_tags_v1',
      maxTagLength: 24,
      // 墓碑保留时长:超过该时长的删除记录在合并时被清理
      tombstoneTtlMs: 180 * 24 * 60 * 60 * 1000,
      exportVersion: 1,
    },
    b64: {
      minLen: 8,
      targetSelectors: ['.topic_content', '.reply_content'],
      excludeList: [
        'boss', 'bilibili', 'Bilibili', 'Encrypto', 'encrypto',
        'Window10', 'airpords', 'Windows7',
      ],
      // Shannon 熵阈值:低于此值的解码结果视为乱码/误判,直接跳过
      entropyThreshold: 3.0,
    },
    nav: {
      scrollOffsetRatio: 0.22,
    },
    threadTree: {
      collapseKeyPrefix: 'v2_collapse_',
      readKeyPrefix: 'v2_last_read_',
      pageFetchConcurrency: 4,
      pageFetchTimeoutMs: 15000,
    },
  };

  // =========================
  // 1) 通用小工具
  // =========================
  const log = (...args) => console.log('[V2EX-Enhance]', ...args);
  const sleep = ms => new Promise(r => setTimeout(r, ms));
  const randInt = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min;

  function notify(title, text, timeout = 3500) {
    try { GM_notification({ title, text, timeout }); } catch (_) {}
  }

  // 不是所有管理器都提供 GM_addStyle(Greasemonkey 只有 GM.addStyle),
  // 缺了它就直接抛异常、整个脚本停在这里——楼层树连跑都没跑。退回自己插 <style>。
  function addStyle(css) {
    try {
      if (typeof GM_addStyle === 'function') return GM_addStyle(css);
    } catch (err) {
      log('GM_addStyle 失败,改用 <style> 注入:', err);
    }
    try {
      const style = document.createElement('style');
      style.textContent = css;
      (document.head || document.documentElement).appendChild(style);
      return style;
    } catch (err) {
      log('样式注入失败:', err);
      return null;
    }
  }

  // V2EX 服务端按 UTC+8 跨天,签到判重必须用同一时区,否则 16:00Z–24:00Z 这段
  // 时间里(北京时间次日 0–8 点)会误判为"今天已签到"而漏签。
  const YMD_FORMATTER = (() => {
    try {
      return new Intl.DateTimeFormat('en-CA', {
        timeZone: CONFIG.daily.timeZone,
        year: 'numeric', month: '2-digit', day: '2-digit',
      });
    } catch (_) { return null; }
  })();

  function ymd(time = Date.now()) {
    if (YMD_FORMATTER) {
      // en-CA 固定输出 YYYY-MM-DD
      return YMD_FORMATTER.format(time);
    }
    return new Date(time + 8 * 60 * 60 * 1000).toISOString().slice(0, 10);
  }

  function isTopicPage() {
    return /^\/t\/\d+(?:\/|$)/.test(location.pathname);
  }

  function debounce(fn, wait) {
    let timer = 0;
    return (...args) => {
      clearTimeout(timer);
      timer = setTimeout(() => fn(...args), wait);
    };
  }

  // GM_* 在部分管理器(或 @grant 缺失时)不存在,统一降级到 localStorage
  const GM = {
    get(key, fallback) {
      try { return typeof GM_getValue === 'function' ? GM_getValue(key, fallback) : JSON.parse(localStorage.getItem(key) ?? 'null') ?? fallback; }
      catch (_) { return fallback; }
    },
    set(key, value) {
      try {
        if (typeof GM_setValue === 'function') GM_setValue(key, value);
        else localStorage.setItem(key, JSON.stringify(value));
        return true;
      } catch (err) { log('Storage write failed:', err); return false; }
    },
    onChange(key, handler) {
      try {
        if (typeof GM_addValueChangeListener === 'function') {
          GM_addValueChangeListener(key, (_k, _old, next, remote) => { if (remote) handler(next); });
          return;
        }
      } catch (_) {}
      window.addEventListener('storage', e => { if (e.key === key) handler(undefined); });
    },
    menu(label, handler) {
      try { if (typeof GM_registerMenuCommand === 'function') GM_registerMenuCommand(label, handler); } catch (_) {}
    },
  };

  // #rrggbb → [r, g, b],解析不出来时返回 null 交给调用方决定退路
  function parseHex(hex) {
    const m = /^#?([0-9a-f]{6})$/i.exec(hex || '');
    if (!m) return null;
    const n = parseInt(m[1], 16);
    return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
  }

  // 把 hex 颜色转成 rgba,用于标签胶囊的底色/边框(避免依赖 color-mix 的浏览器支持)
  function hexToRgba(hex, alpha) {
    const rgb = parseHex(hex);
    if (!rgb) return `rgba(138, 148, 166, ${alpha})`;
    return `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${alpha})`;
  }

  // 往白色方向混,用于夜间模式下把偏暗的标签色提亮到可读
  function lightenHex(hex, amount) {
    const rgb = parseHex(hex);
    if (!rgb) return hex;
    const mix = c => Math.round(c + (255 - c) * amount);
    return `rgb(${mix(rgb[0])}, ${mix(rgb[1])}, ${mix(rgb[2])})`;
  }

  // Shannon 熵:衡量字符串的信息多样性
  // 低熵 = 字符分布单一,高概率是乱码或短英文单词误判
  function shannonEntropy(str) {
    if (!str.length) return 0;
    const freq = {};
    for (const c of str) freq[c] = (freq[c] || 0) + 1;
    const len = str.length;
    let h = 0;
    for (const count of Object.values(freq)) {
      const p = count / len;
      h -= p * Math.log2(p);
    }
    return h;
  }

  // =========================
  // 2) 样式(合并注入)
  // =========================

  // ── 全站样式:用户标签胶囊 / 编辑气泡 / 管理面板 / Toast ──
  addStyle(`
    :root {
      --v2t-font: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
      --v2t-surface: #fff;
      --v2t-surface-2: #f7f8fa;
      --v2t-text: #2b2f38;
      --v2t-text-dim: #8a94a6;
      --v2t-border: #e6e9f0;
      --v2t-shadow: 0 12px 40px rgba(18, 24, 40, 0.16);
      /* 标签体系专用主色(紫)。刻意避开脚本里代表"系统状态"的蓝
         (NEW / 引用 / b64 / 新回复计数条),两者不该被看成同一类东西。 */
      --v2t-accent: #8b45c9;
      --v2t-accent-soft: rgba(139, 69, 201, 0.15);
    }
    #Wrapper.Night {
      --v2t-surface: #23252b;
      --v2t-surface-2: #2b2e35;
      --v2t-text: #dfe2e8;
      --v2t-text-dim: #8b93a3;
      --v2t-border: #3a3d45;
      --v2t-shadow: 0 12px 40px rgba(0, 0, 0, 0.55);
      --v2t-accent: #c084e8;
      --v2t-accent-soft: rgba(192, 132, 232, 0.2);
    }

    /* ── 胶囊 ──
       圆角药丸 + 前导圆点。同为药丸形的还有右端的楼层号,但一紫一蓝、
       一个贴用户名一个贴行右缘,位置和颜色都分得开 */
    .v2t-slot { display: inline-flex; align-items: center; gap: 4px; vertical-align: middle; margin-left: 6px; }
    .v2t-chip {
      display: inline-flex; align-items: center; max-width: 160px;
      padding: 0 8px 0 6px; height: 17px; line-height: 17px;
      font-size: 11px; font-weight: 600; font-family: var(--v2t-font);
      letter-spacing: 0.2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
      border-radius: 9px; cursor: pointer; user-select: none;
      background: var(--v2t-chip-bg); color: var(--v2t-chip-fg);
      border: 1px solid var(--v2t-chip-bd);
      transition: filter 0.12s, transform 0.12s;
    }
    .v2t-chip::before {
      content: ''; flex: none;
      width: 4px; height: 4px; margin-right: 5px;
      border-radius: 50%; background: currentColor; opacity: 0.8;
    }
    .v2t-chip:hover { filter: brightness(0.94); }
    .v2t-chip:active { transform: scale(0.96); }
    #Wrapper.Night .v2t-chip {
      background: var(--v2t-chip-bg-n); color: var(--v2t-chip-fg-n); border-color: var(--v2t-chip-bd-n);
    }
    /* 未打标签时的 + 按钮平时完全隐藏(且不可点击),仅在该行悬停/聚焦时浮现 */
    .v2t-add {
      display: inline-flex; align-items: center; justify-content: center;
      width: 15px; height: 15px; border-radius: 4px;
      font-size: 12px; line-height: 1; font-family: var(--v2t-font);
      color: var(--v2t-text-dim); border: 1px dashed var(--v2t-border);
      cursor: pointer; user-select: none; opacity: 0; pointer-events: none;
      transition: opacity 0.15s, color 0.15s, border-color 0.15s;
    }
    .v2t-add:focus-visible,
    .cell:hover .v2t-add, .hot-card:hover .v2t-add, .header:hover .v2t-add { opacity: 1; pointer-events: auto; }
    .v2t-add:hover { color: var(--v2t-accent); border-color: var(--v2t-accent); }
    /* 列表页的 .topic_info 只是一行 12px 小字,楼层里那颗 17px 的胶囊会把
       整行撑高、几十条主题叠起来很明显。这里整体缩一档,不改变形状语言。 */
    .topic_info .v2t-slot { margin-left: 5px; gap: 3px; }
    .topic_info .v2t-chip {
      height: 15px; line-height: 15px; font-size: 10px;
      padding: 0 7px 0 5px; border-radius: 8px; max-width: 120px;
    }
    .topic_info .v2t-chip::before { width: 3px; height: 3px; margin-right: 4px; }
    .topic_info .v2t-add { width: 13px; height: 13px; font-size: 11px; border-radius: 3px; }

    /* ── 编辑气泡 ── */
    #v2t-editor {
      position: fixed; z-index: 100001; width: 268px;
      background: var(--v2t-surface); color: var(--v2t-text);
      border: 1px solid var(--v2t-border); border-radius: 10px;
      box-shadow: var(--v2t-shadow); padding: 12px;
      font-family: var(--v2t-font); font-size: 13px;
      opacity: 0; pointer-events: none; transform: translateY(6px) scale(0.98);
      transition: opacity 0.14s ease, transform 0.14s ease;
    }
    #v2t-editor.visible { opacity: 1; pointer-events: auto; transform: none; }
    #v2t-editor .v2t-ed-head {
      display: flex; align-items: center; gap: 6px;
      margin-bottom: 10px; font-size: 12px; color: var(--v2t-text-dim);
    }
    #v2t-editor .v2t-ed-head img { width: 18px; height: 18px; border-radius: 4px; object-fit: cover; flex: none; }
    #v2t-editor .v2t-ed-head b { color: var(--v2t-text); font-size: 13px; font-weight: 600; }
    #v2t-editor input[type="text"] {
      width: 100%; box-sizing: border-box; height: 30px; padding: 0 9px;
      border: 1px solid var(--v2t-border); border-radius: 6px;
      background: var(--v2t-surface-2); color: var(--v2t-text);
      font-size: 13px; font-family: inherit; outline: none;
      transition: border-color 0.15s, box-shadow 0.15s;
    }
    #v2t-editor input[type="text"]:focus {
      border-color: var(--v2t-accent);
      box-shadow: 0 0 0 3px var(--v2t-accent-soft);
    }
    /* 配色默认收起:常规流程是"输入 → 回车",不必先挑颜色 */
    #v2t-editor .v2t-color-toggle {
      display: inline-flex; align-items: center; gap: 5px; margin-top: 10px;
      font-size: 11px; color: var(--v2t-text-dim); cursor: pointer; user-select: none;
    }
    #v2t-editor .v2t-color-toggle:hover { color: var(--v2t-accent); }
    #v2t-editor .v2t-color-toggle .v2t-color-preview {
      width: 10px; height: 10px; border-radius: 50%; flex: none;
    }
    #v2t-editor .v2t-swatches { display: none; gap: 6px; margin: 8px 0 2px; }
    #v2t-editor .v2t-swatches.open { display: flex; }
    #v2t-editor .v2t-swatch {
      width: 20px; height: 20px; border-radius: 50%; cursor: pointer;
      border: 2px solid transparent; background-clip: padding-box;
      transition: transform 0.12s;
    }
    #v2t-editor .v2t-swatch:hover { transform: scale(1.12); }
    #v2t-editor .v2t-swatch.selected { box-shadow: 0 0 0 2px var(--v2t-surface), 0 0 0 4px currentColor; }
    #v2t-editor .v2t-recent { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 10px; }
    #v2t-editor .v2t-recent-item {
      font-size: 11px; padding: 1px 7px; border-radius: 4px; cursor: pointer;
      background: var(--v2t-surface-2); color: var(--v2t-text-dim);
      border: 1px solid var(--v2t-border); max-width: 110px;
      overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
    }
    #v2t-editor .v2t-recent-item:hover { color: var(--v2t-accent); border-color: var(--v2t-accent); }
    #v2t-editor .v2t-ed-actions { display: flex; align-items: center; gap: 8px; margin-top: 12px; }
    #v2t-editor .v2t-ed-actions .v2t-spacer { margin-left: auto; }

    .v2t-btn {
      height: 27px; padding: 0 12px; border-radius: 6px;
      border: 1px solid var(--v2t-border); background: var(--v2t-surface);
      color: var(--v2t-text); font-size: 12px; font-family: var(--v2t-font);
      cursor: pointer; transition: background 0.15s, border-color 0.15s, color 0.15s;
    }
    .v2t-btn:hover { background: var(--v2t-surface-2); }
    .v2t-btn:disabled { opacity: 0.5; cursor: default; }
    .v2t-btn.primary { background: var(--v2t-accent); border-color: var(--v2t-accent); color: #fff; }
    .v2t-btn.primary:hover { filter: brightness(1.06); background: var(--v2t-accent); }
    .v2t-btn.danger { color: #e0483a; }
    .v2t-btn.danger:hover { background: rgba(224, 72, 58, 0.08); border-color: rgba(224, 72, 58, 0.4); }

    /* ── 管理面板 ── */
    #v2t-manager {
      position: fixed; inset: 0; z-index: 100000;
      background: rgba(20, 24, 34, 0.42); backdrop-filter: blur(2px);
      display: flex; align-items: center; justify-content: center;
      opacity: 0; visibility: hidden; transition: opacity 0.18s, visibility 0.18s;
      font-family: var(--v2t-font);
    }
    #v2t-manager.active { opacity: 1; visibility: visible; }
    #v2t-manager .v2t-panel {
      width: min(680px, 92vw); max-height: min(76vh, 720px);
      display: flex; flex-direction: column;
      background: var(--v2t-surface); color: var(--v2t-text);
      border-radius: 14px; box-shadow: var(--v2t-shadow);
      transform: translateY(10px) scale(0.99); transition: transform 0.18s ease;
      overflow: hidden;
    }
    #v2t-manager.active .v2t-panel { transform: none; }
    #v2t-manager .v2t-panel-head {
      display: flex; align-items: center; gap: 10px;
      padding: 14px 16px; border-bottom: 1px solid var(--v2t-border);
    }
    #v2t-manager .v2t-title { font-size: 15px; font-weight: 600; }
    #v2t-manager .v2t-count { font-size: 12px; color: var(--v2t-text-dim); }
    #v2t-manager .v2t-search {
      margin-left: auto; width: 170px; height: 28px; padding: 0 10px;
      border: 1px solid var(--v2t-border); border-radius: 6px;
      background: var(--v2t-surface-2); color: var(--v2t-text);
      font-size: 12px; font-family: inherit; outline: none;
    }
    #v2t-manager .v2t-search:focus { border-color: var(--v2t-accent); }
    #v2t-manager .v2t-list { overflow-y: auto; flex: 1; padding: 4px 0; }
    #v2t-manager .v2t-row {
      display: flex; align-items: center; gap: 10px;
      padding: 8px 16px; border-bottom: 1px solid var(--v2t-border);
    }
    #v2t-manager .v2t-row:last-child { border-bottom: none; }
    #v2t-manager .v2t-row:hover { background: var(--v2t-surface-2); }
    #v2t-manager .v2t-row a.v2t-user { color: var(--v2t-text); font-size: 13px; text-decoration: none; font-weight: 500; }
    #v2t-manager .v2t-row a.v2t-user:hover { color: var(--v2t-accent); }
    #v2t-manager .v2t-row .v2t-time { margin-left: auto; font-size: 11px; color: var(--v2t-text-dim); font-variant-numeric: tabular-nums; }
    #v2t-manager .v2t-row .v2t-row-act {
      font-size: 11px; color: var(--v2t-text-dim); cursor: pointer; padding: 2px 4px; border-radius: 4px;
    }
    #v2t-manager .v2t-row .v2t-row-act:hover { color: var(--v2t-accent); background: var(--v2t-surface); }
    #v2t-manager .v2t-row .v2t-row-act.danger:hover { color: #e0483a; }
    #v2t-manager .v2t-empty { padding: 48px 16px; text-align: center; color: var(--v2t-text-dim); font-size: 13px; }
    #v2t-manager .v2t-panel-foot {
      display: flex; align-items: center; gap: 8px;
      padding: 12px 16px; border-top: 1px solid var(--v2t-border);
      background: var(--v2t-surface-2);
    }
    #v2t-manager .v2t-panel-foot .v2t-spacer { margin-left: auto; }
    #v2t-manager .v2t-merge {
      padding: 12px 16px; border-top: 1px solid var(--v2t-border);
      background: var(--v2t-surface-2); font-size: 12px; line-height: 1.7;
    }
    #v2t-manager .v2t-merge b { color: var(--v2t-accent); font-variant-numeric: tabular-nums; }
    #v2t-manager .v2t-merge .v2t-merge-opts { display: flex; flex-wrap: wrap; gap: 12px; margin: 8px 0 10px; }
    #v2t-manager .v2t-merge label { display: inline-flex; align-items: center; gap: 5px; cursor: pointer; color: var(--v2t-text); }

    /* ── Toast ── */
    #v2t-toast {
      position: fixed; left: 50%; bottom: 32px; z-index: 100002;
      transform: translate(-50%, 10px); opacity: 0; pointer-events: none;
      padding: 8px 16px; border-radius: 20px;
      background: rgba(22, 27, 46, 0.92); color: #eef2ff;
      font-size: 12.5px; font-family: var(--v2t-font);
      box-shadow: 0 6px 24px rgba(0, 0, 0, 0.25);
      transition: opacity 0.18s ease, transform 0.18s ease;
    }
    #v2t-toast.visible { opacity: 1; transform: translate(-50%, 0); }

  `);

  if (isTopicPage()) addStyle(`
    /* ===== 楼层树 ===== */
    :root {
      --indent-width: 18px;
      --rail-gap: 13px;      /* 竖导轨到子回复左边缘的横向距离 = 肘接横线的长度 */
      --rail-width: 1px;     /* 1px 的细线比 2px 干净得多,靠透明度补可见性 */
      --elbow-top: 19px;     /* 肘接横线的高度,对准子回复头像的上半部 */
      --line-color: rgba(128, 128, 128, 0.38);
      --line-hover: #7fa0f5;
      --bg-hover: #fafbff;
      --new-accent: #4a7af0;
      /* 未读药丸走"淡底 + 同色字",不再是整块实心蓝:
         一屏几十个实心色块会盖过正文,淡底同样能一眼扫出来。 */
      --new-pill-bg: rgba(74, 122, 240, 0.15);
      --new-pill-fg: #3f6cd8;
      --new-pill-bd: rgba(74, 122, 240, 0.30);
    }

    .box { padding-bottom: 0 !important; }

    /* 竖线(层级导轨)+ 每个直接子回复一小段横线(肘接),
       让"谁挂在谁下面"一眼可见,而不是只靠缩进去猜。

       导轨不再画成容器的 border-left:那样它会一路拖到整棵子树的最底下,
       在最后一个子回复的肘接之后还垂着一大截没有任何含义的尾巴。
       改成每个子回复自带一段竖线、首尾相接拼出整条导轨,
       最后一个就能在肘接处收住,画成一个圆角 └。 */
    .reply-children {
      margin-left: var(--indent-width);
      padding-left: calc(var(--rail-gap) + var(--rail-width));
      transition: opacity 0.2s;
      position: relative;
    }
    /* 线的颜色统一走这个变量,悬停时只要换它一处,
       竖线(background)和圆角 └(border-color)就一起变 */
    .reply-children > .reply-wrapper { position: relative; --rail-ink: var(--line-color); }

    /* 竖直段:从上一个兄弟接过来,贯穿整个 wrapper(含它自己的子树) */
    .reply-children > .reply-wrapper::after {
      content: '';
      position: absolute;
      left: calc((var(--rail-gap) + var(--rail-width)) * -1);
      top: 0; bottom: 0;
      width: var(--rail-width);
      background: var(--rail-ink);
      transition: background 0.2s;
      pointer-events: none;
    }
    /* 肘接:从导轨横向接到子回复左边缘(├ 的那一横) */
    .reply-children > .reply-wrapper::before {
      content: '';
      position: absolute;
      left: calc(var(--rail-gap) * -1);
      top: var(--elbow-top);
      width: var(--rail-gap);
      height: var(--rail-width);
      background: var(--rail-ink);
      transition: background 0.2s;
      pointer-events: none;
    }
    /* 最后一个子回复:竖直段和肘接合成一个带圆角的 └,竖线到此为止 */
    .reply-children > .reply-wrapper:last-child::before { display: none; }
    .reply-children > .reply-wrapper:last-child::after {
      box-sizing: border-box;
      bottom: auto;
      width: calc(var(--rail-gap) + var(--rail-width));
      height: calc(var(--elbow-top) + var(--rail-width));
      background: none;
      border-left: var(--rail-width) solid var(--rail-ink);
      border-bottom: var(--rail-width) solid var(--rail-ink);
      border-bottom-left-radius: 6px;
      transition: border-color 0.2s;
    }
    /* 悬停点亮导轨,但只点亮鼠标当前所在的那一层::hover 会一路冒泡到所有
       祖先容器,不加这个 :not(:has(…)) 的话,悬停一条深层回复会把左边每一层
       的导轨全部点亮,反而看不出层级。 */
    .reply-children:hover:not(:has(.reply-children:hover)) > .reply-wrapper {
      --rail-ink: var(--line-hover);
    }
    .reply-children.is-collapsed { display: none; }

    /* cursor:auto 保留浏览器默认行为(文字上 I 型,空白处箭头)
       只在左侧 20px 伪元素上设 pointer,与 JS 的 rect.left > 20 判断对齐 */
    .reply-children.collapsible { cursor: auto; }
    /* 导轨的 border-left 没了,容器 padding box 的左边缘就是导轨所在处,
       点击热区从 left: 0 起算即可(原来要 -2px 去补那条边框的宽度) */
    .reply-children.collapsible::before {
      content: '';
      position: absolute;
      left: 0;
      top: 0; bottom: 0;
      width: 20px;
      cursor: pointer;
    }

    .reply-collapsed-hint {
      display: none;
      font-size: 11px; color: #999;
      padding: 3px 8px 3px calc(var(--indent-width) + var(--rail-gap) + var(--rail-width));
      cursor: pointer; user-select: none;
      transition: color 0.15s;
    }
    .reply-collapsed-hint:hover { color: var(--new-accent); }
    .reply-children.is-collapsed + .reply-collapsed-hint { display: block; }

    /* 分隔线与内边距沿用 V2EX 原装(--box-border-color 由 V2EX 定义,夜间模式会自动切换),
       之前自己写死的 #f5f5f5 在白底上几乎看不见 */
    .reply-wrapper .cell {
      padding: 10px !important;
      border-bottom: 1px solid var(--box-border-color, rgba(128, 128, 128, 0.228)) !important;
      background: transparent;
      transition: background 0.12s;
    }
    .reply-wrapper > .cell:hover { background-color: var(--bg-hover); }
    .reply-wrapper .avatar {
      display: block;
      width: 100% !important; min-width: 0 !important; max-width: 100% !important;
      height: auto !important; min-height: 0 !important; max-height: none !important;
      aspect-ratio: 1 / 1; object-fit: cover; flex: none;
      max-inline-size: 100% !important;
      border-radius: 4px; margin: 0 auto;
    }
    .reply_content { font-size: 14px; line-height: 1.5; margin-top: 2px; }
    .ago, .fade { font-size: 11px !important; }

    /* ===== 回复头部 =====
       时间保持 V2EX 原来的位置——紧跟用户名,宽度随内容,不占定宽列。
       [用户名 ●标签] [时间 ♥95] ——弹性留白—— [♥ ↩] [19]
       弹性留白吃掉所有宽度差,右端那组按钮 + 楼层号整体贴住行右边缘。 */
    .v2-reply-head { display: flex; align-items: center; gap: 8px; min-width: 0; }
    .v2-reply-head .rh-id {
      display: flex; align-items: center; gap: 4px;
      flex: 0 1 auto; min-width: 0;
    }
    .v2-reply-head .rh-id > strong {
      display: inline-flex; align-items: center;
      flex: 0 1 auto; min-width: 0;
    }
    /* 只让用户名本身省略号截断,标签胶囊永远完整显示 */
    .v2-reply-head .rh-id > strong > a {
      min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
    }
    .v2-reply-head .rh-id .v2t-slot { flex: 0 0 auto; }
    /* 时间 + ♥ 计数:跟在用户名后面,宽度随内容 */
    .v2-reply-head .rh-meta {
      display: flex; align-items: center; gap: 8px;
      flex: 0 0 auto; white-space: nowrap;
    }
    .v2-reply-head .rh-gap { flex: 1 1 auto; min-width: 8px; }
    /* 感谢/回复按钮 + 楼层号:整体右边缘贴住行的右边缘(同 V2EX 原样) */
    .v2-reply-head > .fr {
      float: none !important;
      display: inline-flex; align-items: center;
      flex: 0 0 auto; margin: 0 !important;
    }
    /* ── 楼层号 = 未读指示器 ──
       原来 NEW 是独立徽标,排在 .fr 里那组操作按钮的左边,而"感谢"按钮
       悬停才出现——鼠标一进一出,NEW 左右横跳,不悬停时又像悬在空处。
       现在直接把状态画进楼层号本身:它是 .fr 的最后一个元素、右边缘就是
       行的右边缘,按钮显不显示都影响不到它,天然对齐成一列。
       已读 = 安静的灰数字;未读 = 同样尺寸的淡蓝药丸。
       两种状态只换颜色、不换几何:宽高/内边距/字号全一致,
       否则同一列里未读那几个会明显胖出来。 */
    .reply-wrapper .fr .no {
      display: inline-flex !important; align-items: center; justify-content: center;
      box-sizing: border-box;
      min-width: 24px; height: 17px; padding: 0 6px !important;
      margin-left: 2px;
      font-size: 10px !important; font-weight: 600; line-height: 1 !important;
      font-variant-numeric: tabular-nums; letter-spacing: 0.3px;
      color: #b9bdc4 !important; background: transparent !important;
      border: 1px solid transparent; border-radius: 9px !important;
      transition: color 0.15s ease, background 0.15s ease, border-color 0.15s ease;
    }
    /* 悬停整行时轻轻浮出来,方便定位/复制楼层号 */
    .reply-wrapper > .cell:hover .fr .no {
      color: #8b9098 !important; border-color: rgba(0, 0, 0, 0.09);
    }

    /* 未读行只留左边一条蓝竖条,不铺任何底色——扫读靠这条竖条,
       楼层号那颗淡蓝药丸只作确认,两者一重一轻不会互相打架。
       3px 边框 + 7px 内边距 = 普通行的 10px,正文左边缘不会跟着错开。
       只有进场那一下闪一次(背景不带 !important,动画才压得住)。 */
    .cell.reply-new {
      border-left: 3px solid var(--new-accent) !important;
      padding-left: 7px !important;
      animation: new-reply-flash 0.6s ease-out;
    }
    @keyframes new-reply-flash {
      0%   { background-color: rgba(74,122,240,0.18); }
      100% { background-color: transparent; }
    }
    /* 淡底 + 同色描边:在白底上比灰数字重、比实心蓝轻,正好落在"能扫到但不抢眼" */
    .cell.reply-new .fr .no,
    .cell.reply-new:hover .fr .no {
      color: var(--new-pill-fg) !important;
      background: var(--new-pill-bg) !important;
      border-color: var(--new-pill-bd);
    }

    #v2ex-new-count-bar {
      padding: 6px 12px;
      background: linear-gradient(90deg, #eef2ff 0%, #f8f9ff 100%);
      border-bottom: 1px solid #dde5ff;
      border-radius: 4px 4px 0 0;
      font-size: 12px; color: #6680cc;
      display: flex; align-items: center; gap: 6px;
      user-select: none;
    }
    #v2ex-new-count-bar .ncb-dot { width:6px; height:6px; background:var(--new-accent); border-radius:50%; flex-shrink:0; }
    #v2ex-new-count-bar strong { color: var(--new-accent); font-weight: 700; }
    #v2ex-new-count-bar .ncb-hint { margin-left: auto; opacity: 0.5; font-size: 11px; }

    #v2ex-loading-bar {
      padding: 8px; background: #fff;
      text-align: center; border-bottom: 1px solid #eee;
      font-size: 12px; color: #999;
    }
    .cell[style*="text-align: center"], #bottom-pagination, a[name="last_page"] { display: none; }

    /* ── 多页加载失败重试横幅 ── */
    #v2ex-retry-banner {
      padding: 8px 14px;
      background: #fff8e6; border-bottom: 1px solid #ffe0a0;
      border-radius: 4px 4px 0 0;
      font-size: 12px; color: #a06000;
      display: flex; align-items: center; gap: 10px;
    }
    #v2ex-retry-banner button {
      padding: 2px 10px;
      border: 1px solid #f0b030; border-radius: 4px;
      background: #fff; color: #a06000;
      cursor: pointer; font-size: 12px;
      transition: background 0.15s;
    }
    #v2ex-retry-banner button:hover { background: #fff8e6; }
    #v2ex-retry-banner button:disabled { opacity: 0.5; cursor: default; }

    /* ===== j/k 导航 HUD ===== */
    #v2ex-nav-hud {
      position: fixed; bottom: 28px; right: 28px; z-index: 99998;
      display: flex; align-items: center; gap: 6px;
      padding: 6px 14px 6px 10px;
      background: rgba(22,27,46,0.90); color: #dde4ff;
      border-radius: 20px; font-size: 12px;
      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
      letter-spacing: 0.3px; backdrop-filter: blur(8px);
      box-shadow: 0 4px 20px rgba(0,0,0,0.25), 0 0 0 1px rgba(255,255,255,0.06);
      pointer-events: none; opacity: 0;
      transform: translateY(8px) scale(0.97);
      transition: opacity 0.18s ease, transform 0.18s ease;
    }
    #v2ex-nav-hud.visible { opacity: 1; transform: translateY(0) scale(1); }
    #v2ex-nav-hud .hud-arrow { font-size: 13px; opacity: 0.7; }
    #v2ex-nav-hud .hud-label { font-size: 10px; font-weight: 700; letter-spacing: 1px; color: #7fa8ff; opacity: 0.8; }
    #v2ex-nav-hud .hud-count { font-weight: 600; color: #c5d5ff; font-variant-numeric: tabular-nums; }
    #v2ex-nav-hud .hud-sep { opacity: 0.2; }
    #v2ex-nav-hud .hud-hint { opacity: 0.35; font-size: 11px; font-family: monospace; }
    .reply-nav-active > .cell { outline: 2px solid rgba(74,122,240,0.50) !important; outline-offset: -2px; transition: outline 0.15s ease; }

    /* ===== Base64 解码 ===== */
    .v2-b64-wrap {
      word-break: break-all;
      position: relative;
      display: inline;
    }
    .v2-b64-link { color: #4a7af0 !important; text-decoration: none; }
    .v2-b64-link:hover { color: #3060d8 !important; text-decoration: underline; }
    .v2-b64-plain {
      background: rgba(74, 122, 240, 0.08);
      border-radius: 3px;
      padding: 0 3px;
      color: #3a5ab8;
      cursor: default;
    }
    .v2-b64-plain:hover { background: rgba(74, 122, 240, 0.14); }
    .v2-b64-mark {
      display: inline-block;
      font-size: 9px; font-weight: 700; font-style: normal;
      color: #8aa8f8; border: 1px solid #d0defe; border-radius: 2px;
      padding: 0 3px; line-height: 13px;
      vertical-align: middle; position: relative; top: -1px;
      margin-right: 4px; cursor: default; user-select: none;
      text-decoration: none !important; letter-spacing: 0.2px;
    }
    .v2-b64-actions {
      display: none;
      position: absolute;
      top: -22px;
      right: 0;
      align-items: center;
      background: #fff;
      border: 1px solid #c8d8ff;
      border-radius: 4px;
      overflow: hidden;
      box-shadow: 0 1px 6px rgba(74,122,240,0.13);
      z-index: 10;
      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
    }
    .v2-b64-action {
      font-size: 10px;
      padding: 1px 7px;
      color: #4a7af0;
      cursor: pointer;
      white-space: nowrap;
      line-height: 17px;
      user-select: none;
    }
    .v2-b64-action + .v2-b64-action { border-left: 1px solid #dbe5ff; }
    .v2-b64-wrap:hover .v2-b64-actions { display: inline-flex; }
    .v2-b64-action:hover { background: #eef2ff; }
    .v2-b64-action.copied { color: #52c41a; }

    /* ===== 悬停引用预览 ===== */
    #v2ex-ref-preview {
      position: fixed; z-index: 99997;
      background: #fff;
      border: 1px solid #e4e8f0; border-radius: 8px;
      box-shadow: 0 4px 20px rgba(0,0,0,0.12);
      padding: 10px 14px; max-width: 360px;
      font-size: 13px; line-height: 1.5;
      pointer-events: none;
      opacity: 0; transform: translateY(4px);
      transition: opacity 0.15s, transform 0.15s;
    }
    #v2ex-ref-preview.visible { opacity: 1; transform: translateY(0); }
    .rp-header { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; font-size: 12px; }
    .rp-avatar { width: 16px; height: 16px; border-radius: 3px; object-fit: cover; flex: none; }
    .rp-name { font-weight: 600; color: #444; }
    .rp-floor { color: #aaa; font-size: 11px; }
    .rp-content {
      color: #333; max-height: 120px; overflow: hidden;
      -webkit-mask-image: linear-gradient(180deg, #000 70%, transparent 100%);
      mask-image: linear-gradient(180deg, #000 70%, transparent 100%);
    }
    .v2-ref-link {
      color: var(--new-accent);
      text-decoration: underline; text-decoration-style: dotted;
      text-underline-offset: 2px; cursor: pointer;
    }

    /* ===== Lightbox ===== */
    #v2ex-lightbox {
      position: fixed; inset: 0;
      background: rgba(0,0,0,0.85); z-index: 999999;
      display: flex; align-items: center; justify-content: center;
      cursor: zoom-out; opacity: 0; visibility: hidden;
      transition: opacity 0.2s, visibility 0.2s;
    }
    #v2ex-lightbox.active { opacity: 1; visibility: visible; }
    #v2ex-lightbox img {
      max-width: 92vw; max-height: 92vh;
      object-fit: contain; border-radius: 4px;
      box-shadow: 0 8px 40px rgba(0,0,0,0.5);
      pointer-events: none;
    }

    /* ===== 高赞阅览室 ===== */
    #v2ex-hot-btn {
      display: inline-block; margin-left: 10px;
      padding: 2px 10px; background-color: #f0f2f5; color: #ccc;
      border-radius: 12px; font-size: 12px; cursor: pointer;
      transition: all 0.2s ease; line-height: 1.5;
      border: 1px solid transparent;
    }
    #v2ex-hot-btn:hover { background-color: #e3e8f0; color: #555; border-color: #ccc; }

    #hot-overlay {
      position: fixed; top: 0; left: 0; width: 100%; height: 100%;
      background: rgba(240,242,245,0.95); z-index: 99999;
      display: flex; justify-content: center; align-items: flex-start;
      overflow-y: scroll; opacity: 0; visibility: hidden;
      transition: opacity 0.15s ease;
    }
    #hot-overlay.active { opacity: 1; visibility: visible; }
    .hot-container {
      width: 92%; max-width: 1000px;
      margin: 30px auto 80px auto;
      background: #fff; border-radius: 8px;
      box-shadow: 0 10px 30px rgba(0,0,0,0.08);
      overflow: hidden; padding: 0;
    }
    .hot-card {
      background: #fff; padding: 14px 24px;
      border-bottom: 1px solid #f0f0f0;
      display: flex; flex-direction: column;
      transition: background 0.1s;
    }
    .hot-card:last-child { border-bottom: none; }
    .hot-card:hover { background: #fafafa; }
    .rank-1 { border-left: 3px solid #faad14; background: linear-gradient(90deg, #fffdf5 0%, #fff 100%); }
    .rank-2 { border-left: 3px solid #ccc; }
    .rank-3 { border-left: 3px solid #d48806; }
    .card-header-row { display: flex; align-items: center; margin-bottom: 6px; font-size: 12px; }
    .user-avatar {
      display: block;
      width: 18px; min-width: 18px; max-width: 18px;
      height: 18px; min-height: 18px; max-height: 18px;
      aspect-ratio: 1 / 1; object-fit: cover; flex: none;
      max-inline-size: none; border-radius: 3px; margin-right: 8px;
    }
    .user-name { font-weight: 600; color: #444; text-decoration: none; margin-right: 8px; }
    .floor-tag {
      background: #f5f5f5; color: #aaa; padding: 0 5px; border-radius: 3px;
      margin-right: 10px; cursor: pointer; font-size: 11px; height: 18px; line-height: 18px;
    }
    .floor-tag:hover { background: #e6f7ff; color: #1890ff; }
    .time-tag { color: #ddd; margin-right: auto; transform: scale(0.9); transform-origin: left; }
    .likes-pill { font-size: 12px; font-weight: 600; padding: 0 6px; }
    .rank-1 .likes-pill { color: #faad14; }
    .rank-normal .likes-pill { color: #ff6b6b; opacity: 0.8; }
    .card-content { font-size: 14px; line-height: 1.6; color: #222; word-wrap: break-word; padding-left: 26px; }
    .card-content p { margin: 0 0 5px 0; }
    .card-content img { max-width: 100%; max-height: 350px; border-radius: 4px; margin: 5px 0; display: block; cursor: zoom-in; }
    .card-content pre { padding: 10px; background: #f8f8f8; border: 1px solid #eee; border-radius: 3px; font-size: 12px; margin: 8px 0; }
    #hot-overlay::-webkit-scrollbar { width: 4px; }
    #hot-overlay::-webkit-scrollbar-thumb { background: #ddd; border-radius: 2px; }

    /* ===== 夜间模式适配 ===== */
    #Wrapper.Night {
      --line-color: rgba(150, 150, 150, 0.5);
      --line-hover: #6c8fe8;
      --bg-hover: #2a2d34;
      --new-accent: #6f97ff;
      --new-pill-bg: rgba(111, 151, 255, 0.18);
      --new-pill-fg: #9fb8ff;
      --new-pill-bd: rgba(111, 151, 255, 0.32);
    }
    #Wrapper.Night .reply-wrapper .cell { border-bottom-color: #303239 !important; }
    #Wrapper.Night .reply-wrapper .fr .no { color: #676c75 !important; }
    #Wrapper.Night .reply-wrapper > .cell:hover .fr .no {
      color: #9aa1ab !important; border-color: rgba(255, 255, 255, 0.13);
    }
    /* 夜间的已读/悬停规则带 #Wrapper 前缀,特异性高过通用的未读规则,
       这里必须同样带前缀重写一次,否则未读药丸会被刷回灰数字 */
    #Wrapper.Night .cell.reply-new .fr .no,
    #Wrapper.Night .cell.reply-new:hover .fr .no {
      color: var(--new-pill-fg) !important;
      background: var(--new-pill-bg) !important;
      border-color: var(--new-pill-bd);
    }
    #Wrapper.Night .reply-collapsed-hint { color: #7b818c; }
    #Wrapper.Night #v2ex-new-count-bar {
      background: linear-gradient(90deg, #262b3a 0%, #23252b 100%);
      border-bottom-color: #343a4d; color: #93a6d8;
    }
    #Wrapper.Night #v2ex-loading-bar { background: #23252b; border-bottom-color: #303239; color: #7b818c; }
    #Wrapper.Night #v2ex-retry-banner { background: #33291a; border-bottom-color: #5a4520; color: #e0b25e; }
    #Wrapper.Night #v2ex-retry-banner button { background: #2a2d34; border-color: #5a4520; color: #e0b25e; }
    #Wrapper.Night #v2ex-ref-preview { background: #23252b; border-color: #3a3d45; box-shadow: 0 4px 20px rgba(0,0,0,0.5); }
    #Wrapper.Night .rp-name { color: #dfe2e8; }
    #Wrapper.Night .rp-content { color: #c2c7d0; }
    #Wrapper.Night .v2-b64-plain { background: rgba(111,151,255,0.14); color: #9fb8ff; }
    #Wrapper.Night .v2-b64-actions { background: #23252b; border-color: #3a4a6d; }
    #Wrapper.Night .v2-b64-action { color: #8fabff; }
    #Wrapper.Night .v2-b64-action:hover { background: #2c3346; }
    #Wrapper.Night #v2ex-hot-btn { background-color: #2b2e35; color: #6a707c; }
    #Wrapper.Night #v2ex-hot-btn:hover { background-color: #343841; color: #b9bfca; border-color: #4a4e58; }
    #Wrapper.Night #hot-overlay { background: rgba(16,18,22,0.94); }
    #Wrapper.Night .hot-container { background: #23252b; box-shadow: 0 10px 30px rgba(0,0,0,0.5); }
    #Wrapper.Night .hot-card { background: #23252b; border-bottom-color: #303239; }
    #Wrapper.Night .hot-card:hover { background: #2a2d34; }
    #Wrapper.Night .rank-1 { background: linear-gradient(90deg, #2e2a1d 0%, #23252b 100%); }
    #Wrapper.Night .user-name { color: #dfe2e8; }
    #Wrapper.Night .card-content { color: #d3d7de; }
    #Wrapper.Night .card-content pre { background: #1c1e23; border-color: #303239; }
    #Wrapper.Night .floor-tag { background: #2b2e35; color: #7b818c; }
  `);

  // =========================
  // 3) 功能A:每日自动签到
  // =========================
  const Daily = (() => {
    const CLAIMED_RE = /每日登录奖励已(?:领取|发放)|今天的登录奖励已经领取过了(?:哦)?|今天已经领取|已成功领取每日登录奖励|成功领取每日登录奖励|奖励已发放/i;
    // 已领取的任务页会展示连续登录天数,且不再渲染领取按钮
    const CONSECUTIVE_RE = /已连续登录\s*\d+\s*天/;
    const REJECTED_RE = /浏览器有一些奇奇怪怪的设置|请用一个干净安装的浏览器重试/i;

    // 网络抖动/限流属于可重试错误,与"页面结构变了""登录失效"区分开,
    // 避免一次超时就弹一个失败通知并放弃当天签到。
    class RetryableError extends Error {}

    let inFlight = null;
    let retryTimer = 0;
    let networkFailures = 0;

    function isSafari() {
      // Safari 不允许扩展后台请求改写 Referer;V2EX 领取接口又会
      // 校验该字段,需要走真实的同源页面跳转。
      return /AppleWebKit/i.test(navigator.userAgent) && /Apple Computer/i.test(navigator.vendor || '');
    }

    async function requestTextWithFetch(target, referer) {
      const controller = new AbortController();
      const timer = setTimeout(() => controller.abort(), CONFIG.daily.requestTimeoutMs);
      try {
        const options = {
          method: 'GET',
          credentials: 'include',
          cache: 'no-store',
          redirect: 'follow',
          headers: { Accept: 'text/html,application/xhtml+xml' },
          signal: controller.signal,
        };
        if (referer) options.referrer = new URL(referer, location.origin).href;

        const response = await fetch(target.href, options);
        if (!response.ok) throw new RetryableError(`HTTP ${response.status} for ${target.pathname}`);
        return { text: await response.text(), finalUrl: response.url || target.href };
      } catch (err) {
        if (err instanceof RetryableError) throw err;
        const reason = err?.name === 'AbortError' ? '请求超时' : '请求失败';
        throw new RetryableError(`${reason}:${target.pathname}`);
      } finally {
        clearTimeout(timer);
      }
    }

    function requestTextWithGM(target, referer) {
      const legacyRequest = typeof GM_xmlhttpRequest === 'function' ? GM_xmlhttpRequest : null;
      const modernRequest = typeof globalThis.GM?.xmlHttpRequest === 'function'
        ? globalThis.GM.xmlHttpRequest.bind(globalThis.GM)
        : null;
      const send = legacyRequest || modernRequest;
      if (!send) return requestTextWithFetch(target, referer);

      const headers = { Accept: 'text/html,application/xhtml+xml' };
      if (referer) headers.Referer = new URL(referer, location.origin).href;

      return new Promise((resolve, reject) => {
        const fail = message => reject(new RetryableError(`${message}:${target.pathname}`));
        try {
          const request = send({
            method: 'GET',
            url: target.href,
            responseType: 'text',
            timeout: CONFIG.daily.requestTimeoutMs,
            headers,
            onload: response => {
              if (response.status < 200 || response.status >= 400) {
                reject(new RetryableError(`HTTP ${response.status} for ${target.pathname}`));
                return;
              }
              resolve({
                text: typeof response.responseText === 'string'
                  ? response.responseText
                  : (typeof response.response === 'string' ? response.response : ''),
                finalUrl: response.finalUrl || response.responseURL || target.href,
              });
            },
            ontimeout: () => fail('请求超时'),
            onerror: () => fail('请求失败'),
            onabort: () => fail('请求已取消'),
          });
          // Safari Userscripts 的现代 API 同时返回 Promise;防止它只 reject
          // 而没有触发 onerror 时留下未处理的 Promise rejection。
          request?.catch?.(() => fail('请求失败'));
        } catch (_) {
          fail('请求失败');
        }
      });
    }

    function requestText(url, referer) {
      const target = new URL(url, location.origin);
      if (target.origin !== location.origin) return Promise.reject(new Error('拒绝跨站签到请求'));
      return isSafari()
        ? requestTextWithFetch(target, referer)
        : requestTextWithGM(target, referer);
    }
    function parseHtml(html) { return new DOMParser().parseFromString(html, 'text/html'); }

    function pageText(doc) {
      return (doc.body?.textContent || '').replace(/\s+/g, ' ').trim();
    }

    function isSignedOut(doc, finalUrl = location.href) {
      let finalPath = '';
      try { finalPath = new URL(finalUrl, location.origin).pathname; } catch (_) {}
      if (finalPath.startsWith('/signin')) return true;
      const signin = doc.querySelector('#Top a[href^="/signin"], form[action^="/signin"]');
      const member = doc.querySelector('#Top a[href^="/member/"], #Top a[onclick*="/signout?once="]');
      return !!signin && !member;
    }

    function alreadyRedeemed(doc) {
      if (doc.querySelector('#Main .fa-ok-sign')) return true;
      const text = pageText(doc);
      if (CLAIMED_RE.test(text)) return true;
      // 兜底:任务页已渲染连续登录天数却没有领取入口 → 今日已领取
      return CONSECUTIVE_RE.test(text) && !findRedeemUrl(doc);
    }

    function findRedeemUrl(doc) {
      const selectors = [
        'a[href*="/mission/daily/redeem"]',
        'form[action*="/mission/daily/redeem"]',
        '[onclick*="/mission/daily/redeem"]',
      ];
      for (const el of doc.querySelectorAll(selectors.join(','))) {
        const source = [el.getAttribute('href'), el.getAttribute('action'), el.getAttribute('onclick')]
          .filter(Boolean).join(' ');
        const match = source.match(/\/mission\/daily\/redeem\?[^'"<>\s)]*\bonce=([A-Za-z0-9_-]+)/i);
        if (match?.[1]) return `/mission/daily/redeem?once=${encodeURIComponent(match[1])}`;
      }

      // 兼容领取地址被放在内联脚本或转义 HTML 中的旧版页面。
      const match = (doc.documentElement?.innerHTML || '')
        .replaceAll('&amp;', '&')
        .match(/\/mission\/daily\/redeem\?[^'"<>\s)]*\bonce=([A-Za-z0-9_-]+)/i);
      if (match?.[1]) return `/mission/daily/redeem?once=${encodeURIComponent(match[1])}`;
      return null;
    }

    async function loadPage(url, referer) {
      const response = await requestText(url, referer);
      const doc = parseHtml(response.text);
      return { ...response, doc };
    }

    function validateRedeemUrl(redeemUrl) {
      const target = new URL(redeemUrl, location.origin);
      if (target.origin !== location.origin || target.pathname !== '/mission/daily/redeem' || !target.searchParams.get('once')) {
        throw new Error('领取地址无效');
      }
      return target;
    }

    function claimWithDailyFrame(page) {
      const dailyUrl = new URL(page, location.origin);
      if (dailyUrl.origin !== location.origin) return Promise.reject(new Error('拒绝跨站签到请求'));

      return new Promise((resolve, reject) => {
        const frame = document.createElement('iframe');
        frame.dataset.v2exDailyFrame = '1';
        frame.hidden = true;
        frame.setAttribute('aria-hidden', 'true');
        frame.title = 'V2EX 每日签到';

        let stage = 'daily';
        let settled = false;
        let verifyTimer = 0;
        let verifyAttempts = 0;

        const cleanup = () => {
          clearTimeout(timeoutTimer);
          clearTimeout(verifyTimer);
          frame.remove();
        };
        const finish = (status, error = null) => {
          if (settled) return;
          settled = true;
          cleanup();
          if (error) reject(error);
          else resolve(status);
        };
        const timeoutTimer = setTimeout(() => {
          finish(null, new RetryableError('Safari 页面跳转签到超时'));
        }, CONFIG.daily.requestTimeoutMs * Math.max(3, CONFIG.daily.verifyRetries + 2));
        const scheduleVerification = () => {
          stage = 'verify-wait';
          verifyTimer = setTimeout(() => {
            if (settled) return;
            stage = 'verify';
            frame.src = dailyUrl.href;
          }, CONFIG.daily.verifyIntervalMs);
        };

        frame.addEventListener('load', () => {
          if (settled) return;

          let doc;
          let finalUrl;
          try {
            doc = frame.contentDocument;
            finalUrl = frame.contentWindow?.location?.href || frame.src;
          } catch (_) {
            finish(null, new RetryableError('Safari 无法访问签到页面'));
            return;
          }
          if (!doc || finalUrl === 'about:blank') return;

          if (isSignedOut(doc, finalUrl)) {
            finish('signed-out');
            return;
          }
          if (REJECTED_RE.test(pageText(doc))) {
            finish(null, new Error('V2EX 拒绝了当前浏览器环境'));
            return;
          }
          if (alreadyRedeemed(doc)) {
            finish(stage === 'daily' ? 'already-claimed' : 'claimed');
            return;
          }

          if (stage === 'daily') {
            const redeemUrl = findRedeemUrl(doc);
            if (!redeemUrl) {
              finish(null, new Error('未找到领取按钮(页面结构可能已变更)'));
              return;
            }

            let target;
            try { target = validateRedeemUrl(redeemUrl); }
            catch (error) { finish(null, error); return; }

            // 在真实的 /mission/daily 文档里点击链接,让 WebKit 自然生成
            // Referer 和第一方 Cookie;RequestInit.referrer 在 Safari 扩展里会被忽略。
            stage = 'redeem';
            const link = doc.createElement('a');
            link.href = target.href;
            link.hidden = true;
            (doc.body || doc.documentElement).appendChild(link);
            link.click();
            return;
          }

          if (stage === 'redeem') {
            // 领取接口通常跳到 /balance;等该导航完成后回任务页做服务端确认。
            scheduleVerification();
            return;
          }

          if (stage === 'verify') {
            verifyAttempts++;
            if (verifyAttempts < Math.max(1, CONFIG.daily.verifyRetries)) scheduleVerification();
            else finish(null, new RetryableError('领取请求已发送,但服务端未确认成功'));
          }
        });

        frame.src = dailyUrl.href;
        (document.body || document.documentElement).appendChild(frame);
      });
    }

    async function verifyClaimed(page, retries, intervalMs) {
      let lastStatus = 'unknown';
      for (let attempt = 0; attempt < retries; attempt++) {
        if (attempt > 0) await sleep(intervalMs);
        const result = await loadPage(page, location.href);
        if (isSignedOut(result.doc, result.finalUrl)) return { status: 'signed-out', result };
        if (REJECTED_RE.test(pageText(result.doc))) return { status: 'rejected', result };
        if (alreadyRedeemed(result.doc)) return { status: 'claimed', result };
        lastStatus = findRedeemUrl(result.doc) ? 'claimable' : 'unknown';
      }
      return { status: lastStatus };
    }

    async function execute() {
      const {
        page, delayMinMs, delayMaxMs, storeKey,
        verifyRetries, verifyIntervalMs,
      } = CONFIG.daily;
      const today = ymd();
      if (GM.get(storeKey, '') === today) return 'already-done';
      if (isSignedOut(document, location.href)) return 'signed-out';

      const lockKey = `${storeKey}_lock`;
      const lock = GM.get(lockKey, null);
      // 跨标签页互斥:5 分钟内已有实例在跑就让路(也兼容进程被杀导致的锁残留)
      if (lock?.date === today && Date.now() - lock.startedAt < 5 * 60 * 1000) return 'locked';

      const token = `${Date.now()}_${Math.random().toString(36).slice(2)}`;
      GM.set(lockKey, { date: today, startedAt: Date.now(), token });

      try {
        await sleep(randInt(delayMinMs, delayMaxMs));
        // 等待期间可能已被其它标签页完成
        if (GM.get(storeKey, '') === today) return 'already-done';

        // Safari / wBlock Scripts 的后台请求与普通 fetch 都不能可靠地产生
        // V2EX 要求的任务页 Referer,因此改用隐藏的同源页面完成真实导航。
        if (isSafari()) {
          const status = await claimWithDailyFrame(page);
          if (status === 'claimed' || status === 'already-claimed') GM.set(storeKey, today);
          return status;
        }

        const daily = await loadPage(page, location.href);
        if (isSignedOut(daily.doc, daily.finalUrl)) return 'signed-out';
        if (REJECTED_RE.test(pageText(daily.doc))) throw new Error('V2EX 拒绝了当前浏览器环境');

        if (alreadyRedeemed(daily.doc)) {
          GM.set(storeKey, today);
          return 'already-claimed';
        }

        const redeemUrl = findRedeemUrl(daily.doc);
        if (!redeemUrl) throw new Error('未找到领取按钮(页面结构可能已变更)');

        const target = validateRedeemUrl(redeemUrl);
        // 领取请求必须带上任务页作为 Referer
        const redeem = await loadPage(target.href, page);
        if (isSignedOut(redeem.doc, redeem.finalUrl)) throw new Error('登录状态已失效');
        if (REJECTED_RE.test(pageText(redeem.doc))) throw new Error('V2EX 拒绝了当前浏览器环境');

        // 领取后 V2EX 通常跳转到 /balance,页面本身不含"已领取"字样,
        // 因此需要回到任务页确认,而不是把跳转当作失败。
        let confirmed = alreadyRedeemed(redeem.doc);
        if (!confirmed) {
          const verification = await verifyClaimed(page, verifyRetries, verifyIntervalMs);
          if (verification.status === 'signed-out') throw new Error('登录状态已失效');
          if (verification.status === 'rejected') throw new Error('V2EX 拒绝了当前浏览器环境');
          confirmed = verification.status === 'claimed';
        }

        if (!confirmed) throw new RetryableError('领取请求已发送,但服务端未确认成功');

        GM.set(storeKey, today);
        return 'claimed';
      } finally {
        if (GM.get(lockKey, null)?.token === token) GM.set(lockKey, null);
      }
    }

    function run() {
      if (inFlight) return inFlight;
      inFlight = execute().then(status => {
        networkFailures = 0;
        if (status === 'claimed') {
          log('签到:领取成功');
          if (CONFIG.daily.notify) notify('V2EX 签到', '领取成功 ✅');
        } else if (status === 'already-claimed') {
          // 已在别处领取过:只记日志,不打扰用户
          log('签到:今日奖励已领取');
        } else if (status === 'signed-out') {
          log('签到:账号未登录,已跳过');
        }
        return status;
      }).catch(err => {
        const { networkRetries, networkRetryDelayMs, notify: doNotify } = CONFIG.daily;
        if (err instanceof RetryableError && networkFailures < networkRetries) {
          networkFailures++;
          log(`签到暂时失败(第 ${networkFailures}/${networkRetries} 次),稍后重试:`, err);
          clearTimeout(retryTimer);
          retryTimer = setTimeout(run, networkRetryDelayMs);
          return 'retrying';
        }
        networkFailures = 0;
        log('签到失败:', err);
        if (doNotify) notify('V2EX 签到', `失败:${err?.message || err}`);
        return 'failed';
      }).finally(() => { inFlight = null; });
      return inFlight;
    }

    function boot() {
      const tick = () => { if (GM.get(CONFIG.daily.storeKey, '') !== ymd()) run(); };
      const start = () => setTimeout(tick, 800);
      if (document.readyState === 'complete') start();
      else window.addEventListener('load', start, { once: true });

      // 长期打开的页面靠轮询跨天补签;后台标签页恢复可见时立刻补一次。
      // 轮询本身零成本:当天已签到时 tick 不会发出任何请求。
      setInterval(tick, CONFIG.daily.pollIntervalMs);
      document.addEventListener('visibilitychange', () => { if (!document.hidden) tick(); });
    }
    return { boot, run };
  })();

  // =========================
  // 4) 功能B:Base64 解码(含 Shannon 熵过滤)
  // =========================
  const B64 = (() => {
    const BASE64_RE = /[A-Za-z0-9+/]{8,}={0,2}/g;
    const VALID_BASE64_RE = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
    const TARGET_SELECTOR = CONFIG.b64.targetSelectors.join(',');
    const UTF8_DECODER = new TextDecoder('utf-8', { fatal: true });

    function tryDecode(text) {
      const { minLen, excludeList, entropyThreshold } = CONFIG.b64;
      if (text.length <= minLen) return null;
      if (excludeList.includes(text)) return null;
      if (!VALID_BASE64_RE.test(text)) return null;
      try {
        const binary = window.atob(text);
        const bytes = new Uint8Array(binary.length);
        for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
        const d = UTF8_DECODER.decode(bytes);
        if (!/[A-Za-z0-9\u4e00-\u9fff]/.test(d)) return null;
        // 熵过滤:排除低熵解码(乱码、单调重复字符等误判)
        if (shannonEntropy(d) < entropyThreshold) return null;
        return d;
      } catch (_) { return null; }
    }
    function makeReplacement(raw, decoded) {
      const wrap = document.createElement('span');
      wrap.className = 'v2-b64-wrap';
      if (/^https?:\/\//i.test(decoded)) {
        let href;
        try {
          const u = new URL(decoded);
          if (u.protocol !== 'https:' && u.protocol !== 'http:') return null;
          href = u.href;
        } catch (_) { return null; }
        const a = document.createElement('a');
        a.className = 'v2-b64-link';
        a.href = href; a.target = '_blank'; a.rel = 'noreferrer noopener';
        a.title = href; a.textContent = decoded;
        wrap.appendChild(a);
      } else {
        const span = document.createElement('span');
        span.className = 'v2-b64-plain';
        span.textContent = decoded;
        wrap.appendChild(span);
      }
      const mark = document.createElement('span');
      mark.className = 'v2-b64-mark'; mark.textContent = 'b64';
      mark.title = `decoded from base64\n${raw}`;
      wrap.prepend(mark);

      function createCopyAction(label, copiedLabel, clipboardText, title) {
        const btn = document.createElement('span');
        btn.className = 'v2-b64-action';
        btn.textContent = label;
        btn.title = title;
        btn.dataset.label = label;
        btn.dataset.copiedLabel = copiedLabel;
        btn.dataset.clipboardText = clipboardText;
        return btn;
      }

      // copy 是主动作:复制当前展示的解码结果;raw 才复制原始 base64。
      const actions = document.createElement('span');
      actions.className = 'v2-b64-actions';
      actions.appendChild(createCopyAction('copy', 'copied', decoded, '复制解码后的内容'));
      actions.appendChild(createCopyAction('raw', 'copied', raw, '复制原始 base64'));
      wrap.appendChild(actions);

      return wrap;
    }

    function processTextNode(node) {
      if (!node?.isConnected || !node.nodeValue || node.nodeValue.length <= CONFIG.b64.minLen) return;
      const parent = node.parentElement;
      if (!parent || parent.closest('.v2-b64-wrap, .v2-ref-link, a, script, style, textarea')) return;

      const text = node.nodeValue;
      let last = 0;
      const frag = document.createDocumentFragment();
      let changed = false;
      BASE64_RE.lastIndex = 0;
      let match;
      while ((match = BASE64_RE.exec(text)) !== null) {
        const candidate = match[0];
        const decoded = tryDecode(candidate);
        if (!decoded) continue;
        const replacement = makeReplacement(candidate, decoded);
        if (!replacement) continue;
        changed = true;
        frag.appendChild(document.createTextNode(text.slice(last, match.index)));
        frag.appendChild(replacement);
        last = match.index + candidate.length;
      }
      if (!changed || !node.parentNode) return;
      frag.appendChild(document.createTextNode(text.slice(last)));
      node.parentNode.replaceChild(frag, node);
    }

    function processSubtree(root) {
      if (!root) return;
      if (root.nodeType === Node.TEXT_NODE) {
        processTextNode(root);
        return;
      }
      if (root.nodeType !== Node.ELEMENT_NODE || root.closest('.v2-b64-wrap, .v2-ref-link')) return;
      const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
        acceptNode(node) {
          if (!node.nodeValue || node.nodeValue.length <= CONFIG.b64.minLen) return NodeFilter.FILTER_REJECT;
          const p = node.parentElement;
          if (p.closest('.v2-b64-wrap, .v2-ref-link, a, script, style, textarea')) return NodeFilter.FILTER_REJECT;
          return NodeFilter.FILTER_ACCEPT;
        },
      });
      const nodes = [];
      while (walker.nextNode()) nodes.push(walker.currentNode);
      nodes.forEach(processTextNode);
    }

    function processContent(contentEl) {
      if (!contentEl || contentEl.dataset.v2b64scanned === '1') return;
      processSubtree(contentEl);
      contentEl.dataset.v2b64scanned = '1';
    }

    function scanAll() {
      document.querySelectorAll(TARGET_SELECTOR).forEach(processContent);
    }

    function processAddedNode(node) {
      if (node.nodeType === Node.TEXT_NODE) {
        if (node.parentElement?.closest(TARGET_SELECTOR)?.dataset.v2b64scanned === '1') processTextNode(node);
        return;
      }
      if (node.nodeType !== Node.ELEMENT_NODE) return;

      if (node.matches(TARGET_SELECTOR)) processContent(node);
      node.querySelectorAll(TARGET_SELECTOR).forEach(processContent);

      const owner = node.closest(TARGET_SELECTOR);
      if (owner?.dataset.v2b64scanned === '1' && node !== owner) processSubtree(node);
    }

    function boot() {
      if (!isTopicPage()) return;
      scanAll();
      const root = document.querySelector('#Main') || document.body;
      document.addEventListener('click', e => {
        const btn = e.target.closest?.('.v2-b64-action');
        if (!btn) return;
        e.preventDefault();
        e.stopPropagation();
        try { GM_setClipboard(btn.dataset.clipboardText || ''); } catch (err) { log('Clipboard error:', err); return; }
        btn.textContent = btn.dataset.copiedLabel;
        btn.classList.add('copied');
        clearTimeout(btn._v2CopyTimer);
        btn._v2CopyTimer = setTimeout(() => {
          if (!btn.isConnected) return;
          btn.textContent = btn.dataset.label;
          btn.classList.remove('copied');
        }, 1200);
      });
      new MutationObserver(mutations => {
        for (const mut of mutations) {
          for (const node of mut.addedNodes) processAddedNode(node);
        }
      }).observe(root, { childList: true, subtree: true });
    }
    return { boot };
  })();

  // =========================
  // 5) 功能C:楼层树 + 多页 + 折叠持久化 + 悬停引用预览
  // =========================
  const ThreadTree = (() => {

    // 楼层引用只应来自普通正文。按独立文本节点扫描,避免 textContent
    // 把 <br> 两边拼在一起(例如链接以 # 结尾、下一行以 3. 开头时会伪造 #3)。
    // 同时忽略链接和代码块,防止 URL / 示例代码中的 #数字 被当成回复引用。
    const FLOOR_REF_EXCLUDED_TAGS = new Set(['A', 'CODE', 'PRE', 'SCRIPT', 'STYLE']);

    function extractFloorReferences(contentEl) {
      const floors = [];

      function scan(node) {
        if (node.nodeType === 3) {
          for (const match of (node.nodeValue || '').matchAll(/#(\d+)/g)) floors.push(match[1]);
          return;
        }
        if (node.nodeType !== 1 || FLOOR_REF_EXCLUDED_TAGS.has(node.tagName)) return;
        for (const child of node.childNodes) scan(child);
      }

      scan(contentEl);
      return floors;
    }

    // V2EX 原生 replyOne() 只会在输入框里写入“@用户名 ”。在捕获阶段记住
    // 点击前的内容,等原生 onclick 执行完后,再只改写它刚追加的那段,得到
    // “@用户名 #楼层号 ”;这样不会误改用户已经输入的正文。这里必须用下一轮
    // task,不能用微任务:浏览器可能在捕获监听器结束后、目标 onclick 执行前
    // 就清空微任务队列,导致过早检查输入框而漏掉本次回复。
    function initReplyFloorReference() {
      if (document._v2ReplyFloorBound) return;
      document._v2ReplyFloorBound = true;

      document.addEventListener('click', event => {
        const button = event.target.closest?.('[onclick*="replyOne("]');
        if (!button) return;

        const cell = button.closest('div.cell[id^="r_"]');
        const input = document.getElementById('reply_content');
        const memberName = (cell?.querySelector('strong a[href*="/member/"]')?.textContent || '').trim();
        const floorNum = Number((cell?.querySelector('.no')?.textContent || '').match(/\d+/)?.[0]);
        if (!cell || !input || !memberName || !Number.isSafeInteger(floorNum)) return;

        const before = input.value;
        const nativePrefix = `@${memberName} `;
        const floorPrefix = `@${memberName} #${floorNum} `;

        setTimeout(() => {
          if (!input.isConnected) return;
          const current = input.value;
          let next = null;

          if (!before && current === nativePrefix) {
            next = floorPrefix;
          } else if (before && current === `${before}\n${nativePrefix}`) {
            next = `${before}\n${floorPrefix}`;
          } else if (before === nativePrefix && !current) {
            // 原生逻辑在输入框恰好等于同一个 @ 前缀时会清空内容。
            next = floorPrefix;
          }

          if (next === null) return;
          input.value = next;
          input.focus();
          input.setSelectionRange?.(next.length, next.length);
        }, 0);
      }, true);
    }

    // ── 解析单条回复 ──
    function parseReplyCell(cell, idx) {
      if (!cell?.id?.startsWith('r_')) return null;
      const replyId   = cell.id.replace('r_', '');
      const contentEl = cell.querySelector('.reply_content');
      const authorEl  = cell.querySelector('strong a');
      const floorEl   = cell.querySelector('.no');
      const avatarEl  = cell.querySelector('img.avatar');
      if (!contentEl || !authorEl || !floorEl) return null;

      const memberName = (authorEl.textContent || '').trim();
      const content    = contentEl.textContent || '';
      const floor      = (floorEl.textContent || '').trim();
      const floorNum   = Number(floor.match(/\d+/)?.[0]);
      if (!Number.isSafeInteger(floorNum)) return null;
      const likesText  = cell.querySelector('span.small')?.textContent || '';
      const likes      = Number(likesText.match(/\d+/)?.[0] || 0);
      const refMemberNames = [...content.matchAll(/@([a-zA-Z0-9_-]+)/g)].map(([, n]) => n);
      const refFloors      = extractFloorReferences(contentEl);

      return {
        element: cell, floorEl, id: replyId, index: idx,
        memberName, memberLink: authorEl.href, memberAvatar: avatarEl?.src || '',
        content, floor, floorNum, likes,
        refMemberNames: refMemberNames.length ? refMemberNames : undefined,
        refFloors:      refFloors.length      ? refFloors      : undefined,
        children: [],
      };
    }

    // 其它分页的 HTML 要解析成文档再把楼层搬进当前页面。在沙箱环境里
    // (Firefox 的 Xray)DOMParser 建出的文档和页面文档主体不同,跨文档搬运
    // 可能失败,所以优先用页面自己的 document.implementation 建文档。
    function parseReplyDoc(html) {
      try {
        const doc = document.implementation.createHTMLDocument('');
        doc.documentElement.innerHTML = html;
        if (doc.querySelector('div.cell[id^="r_"]')) return doc;
      } catch (err) {
        log('createHTMLDocument 解析失败,回退 DOMParser:', err);
      }
      return new DOMParser().parseFromString(html, 'text/html');
    }

    function extractRepliesFromDoc(doc) {
      // 外来文档的节点先 importNode 成本页面拥有的节点,避免依赖 appendChild 的隐式 adopt
      const foreign = doc !== document;
      return Array.from(doc.querySelectorAll('div.cell[id^="r_"]'))
        .map((cell, idx) => {
          let node = cell;
          if (foreign) {
            try { node = document.importNode(cell, true); }
            catch (err) { log('importNode 失败,直接使用原节点:', err); }
          }
          return parseReplyCell(node, idx);
        })
        .filter(Boolean);
    }

    // ── Map 构建,O(n),供 inferParent 和 hoverPreview 共用 ──
    function buildLookupMaps(allReplies) {
      const floorMap = new Map();
      const nameMap  = new Map();
      for (const r of allReplies) {
        floorMap.set(r.floorNum, r);
        const key = r.memberName.toLowerCase();
        if (!nameMap.has(key)) nameMap.set(key, []);
        nameMap.get(key).push(r);
      }
      return { floorMap, nameMap };
    }

    function findPreviousCandidate(candidates, index) {
      if (!candidates?.length) return null;
      let low = 0;
      let high = candidates.length - 1;
      let best = null;
      while (low <= high) {
        const middle = (low + high) >> 1;
        const candidate = candidates[middle];
        if (candidate.index < index) {
          best = candidate;
          low = middle + 1;
        } else {
          high = middle - 1;
        }
      }
      return best;
    }

    // ── 父节点推断,Map 查找 + 同名候选二分搜索 ──
    function inferParent(reply, { floorMap, nameMap }) {
      const { refMemberNames, refFloors, index, floorNum } = reply;
      const firstRefFloor = refFloors?.[0] ? Number(refFloors[0]) : null;
      if (firstRefFloor !== null && firstRefFloor < floorNum) {
        const exact = floorMap.get(firstRefFloor);
        if (exact && (!refMemberNames?.length || exact.memberName.toLowerCase() === refMemberNames[0].toLowerCase())) {
          return exact;
        }
      }
      if (!refMemberNames?.length) return null;

      const targetName = refMemberNames[0].toLowerCase();
      const candidates = nameMap.get(targetName);
      const candidate = findPreviousCandidate(candidates, index);
      return candidate?.floorNum < floorNum ? candidate : null;
    }

    // ── 折叠持久化(sessionStorage)──
    function collapseKey(topicId)    { return `${CONFIG.threadTree.collapseKeyPrefix}${topicId}`; }
    function getCollapsedSet(topicId) {
      try { return new Set(JSON.parse(sessionStorage.getItem(collapseKey(topicId)) || '[]')); }
      catch { return new Set(); }
    }
    function saveCollapsedSet(topicId, set) {
      try { sessionStorage.setItem(collapseKey(topicId), JSON.stringify([...set])); }
      catch (err) { log('Collapse state error:', err); }
    }

    // j/k 或锚点跳转可能落在被折叠的子树里,滚动过去会看到"空白"。
    // 先展开沿途所有折叠祖先,并同步持久化状态。
    function revealAncestors(el) {
      if (!el) return;
      const collapsed = [];
      for (let node = el.parentElement; node; node = node.parentElement) {
        if (node.classList?.contains('reply-children') && node.classList.contains('is-collapsed')) {
          collapsed.push(node);
        }
      }
      if (!collapsed.length) return;
      const state = collapsed[collapsed.length - 1].closest('.box')?._v2CollapseState;
      for (const node of collapsed) {
        node.classList.remove('is-collapsed');
        state?.collapsedSet.delete(node.dataset.replyId);
      }
      if (state) saveCollapsedSet(state.topicId, state.collapsedSet);
    }

    // ── 头部重排 ──
    // V2EX 原始结构是一串行内节点,楼层块靠 float:right 贴右边。这里改成一个
    // flex 行,用一段弹性留白把楼层块推到右边缘:
    // [用户名 + 标签] [时间 ♥] ——弹性留白—— [♥ ↩ 楼层号]
    // 时间维持原位(紧跟用户名)。未读状态直接画在楼层号上,不额外插节点。
    // 结构不符合预期时直接返回,保持 V2EX 原样,不做半吊子改动。
    function layoutReplyHeader(cell) {
      const strong = cell.querySelector('strong');
      const container = strong?.parentElement;
      if (!container || container.querySelector(':scope > .v2-reply-head')) return;
      const content = container.querySelector(':scope > .reply_content');
      if (!content) return;

      // 收集 strong 到正文之间的头部节点(.sep5 之前)
      const nodes = [];
      for (let node = strong; node && node !== content; node = node.nextSibling) {
        if (node.nodeType === Node.ELEMENT_NODE && node.classList.contains('sep5')) break;
        nodes.push(node);
      }
      if (!nodes.length) return;

      const head = document.createElement('div');
      head.className = 'v2-reply-head';
      const identity = document.createElement('span');
      identity.className = 'rh-id';
      const meta = document.createElement('span');
      meta.className = 'rh-meta';
      const gap = document.createElement('span');
      gap.className = 'rh-gap';

      // 先把空的 head 挂进文档,再往里搬节点。反过来做的话,一旦中途抛异常,
      // 用户名/时间会跟着游离的 head 一起从页面上消失。
      container.insertBefore(head, container.querySelector(':scope > .sep5') || content);
      head.append(identity, meta, gap);

      const likes = [];
      const times = [];
      for (const node of nodes) {
        if (node.nodeType === Node.TEXT_NODE) {
          // 原来的 &nbsp; 间隔交给 flex gap,非空文本保留在左侧
          if (node.nodeValue.trim()) identity.appendChild(node);
          else node.remove();
          continue;
        }
        if (node.nodeType !== Node.ELEMENT_NODE) { node.remove(); continue; }
        const cls = node.classList;
        if (cls.contains('ago')) times.push(node);
        else if (cls.contains('small') && cls.contains('fade')) likes.push(node);
        else identity.appendChild(node);   // strong / badges / 未知元素一律留在左侧
      }

      // 时间在前、♥ 在后,保持 V2EX 原本的阅读顺序
      meta.append(...times, ...likes);

      // 楼层块(感谢/回复按钮 + 楼层号)整体挪到头部末尾,由 .rh-gap 顶到最右。
      const floor = container.querySelector(':scope > .fr');
      if (floor) head.appendChild(floor);
    }

    // ── 渲染树 ──
    function renderTree(flatReplies, maps, container, topicId) {
      const roots = [];
      flatReplies.forEach(r => { r.children = []; });
      flatReplies.forEach(r => {
        const parent = inferParent(r, maps);
        if (parent) parent.children.push(r);
        else roots.push(r);
      });

      const collapsedSet = getCollapsedSet(topicId);
      const fragment     = document.createDocumentFragment();

      container._v2CollapseState = { topicId, collapsedSet };
      if (!container._v2CollapseBound) {
        container._v2CollapseBound = true;
        container.addEventListener('click', e => {
          const state = container._v2CollapseState;
          if (!state) return;

          const clickedHint = e.target.closest?.('.reply-collapsed-hint');
          let childrenEl = null;
          let hint = null;

          if (clickedHint && container.contains(clickedHint)) {
            hint = clickedHint;
            childrenEl = hint.previousElementSibling;
          } else {
            const clickedRail = e.target.closest?.('.reply-children.collapsible');
            if (!clickedRail || !container.contains(clickedRail)) return;
            const rect = clickedRail.getBoundingClientRect();
            if (e.clientX - rect.left > 20) return;
            childrenEl = clickedRail;
            hint = childrenEl.nextElementSibling;
          }

          if (!childrenEl?.classList.contains('reply-children') || !hint?.classList.contains('reply-collapsed-hint')) return;
          e.preventDefault();
          e.stopPropagation();

          const replyId = childrenEl.dataset.replyId;
          const nowCollapsed = childrenEl.classList.toggle('is-collapsed');
          if (nowCollapsed) state.collapsedSet.add(replyId);
          else state.collapsedSet.delete(replyId);
          saveCollapsedSet(state.topicId, state.collapsedSet);
          hint.textContent = `▶ 展开 ${childrenEl.dataset.replyCount} 条回复`;
        });
      }

      function appendNode(reply, parentEl) {
        const wrapper = document.createElement('div');
        wrapper.className = 'reply-wrapper';
        wrapper.dataset.replyId = reply.id;
        reply.element.classList.remove('inner');
        // 头部重排纯属美化,任何意外都不该连累整棵树
        try { layoutReplyHeader(reply.element); }
        catch (err) { log('楼层头部重排失败:', err); }
        wrapper.appendChild(reply.element);

        if (reply.children.length > 0) {
          const count      = reply.children.length;
          const childrenEl = document.createElement('div');
          childrenEl.className = 'reply-children collapsible';
          childrenEl.dataset.replyId = reply.id;
          childrenEl.dataset.replyCount = String(count);
          reply.children.forEach(child => appendNode(child, childrenEl));

          const hint = document.createElement('div');
          hint.className = 'reply-collapsed-hint';
          hint.textContent = `▶ 展开 ${count} 条回复`;

          // 恢复折叠状态
          if (collapsedSet.has(reply.id)) childrenEl.classList.add('is-collapsed');

          wrapper.appendChild(childrenEl);
          wrapper.appendChild(hint);
        }
        parentEl.appendChild(wrapper);
      }

      try {
        roots.forEach(r => appendNode(r, fragment));
      } catch (err) {
        // 兜底:宁可平铺显示,也不能因为建树失败把回复弄丢
        // (楼层此时已被搬进 fragment,直接放弃会导致整页回复消失)
        log('构建楼层树失败,退回平铺显示:', err);
        fragment.replaceChildren();
        for (const reply of flatReplies) fragment.appendChild(reply.element);
      }
      container.innerHTML = '';
      container.appendChild(fragment);
    }

    // ── 未读标记 ──
    // 阅读进度走 GM 存储而不是 localStorage:脚本 @match 了 v2ex.com /
    // www.v2ex.com / edge.v2ex.com 三个 origin,localStorage 按 origin 隔离,
    // 换个域名进同一个帖子就会被当成没读过。
    function createReadState(topicId) {
      const key = `${CONFIG.threadTree.readKeyPrefix}${topicId}`;
      const stored = GM.get(key, null);
      return {
        key,
        // null = 真的没进过。首次访问不标记,否则满屏都是"新回复"。
        firstVisit: stored === null,
        lastReadFloor: Number.isSafeInteger(stored) ? stored : 0,
      };
    }

    function markUnread(state, replies) {
      if (state.firstVisit) return 0;
      let newCount = 0;
      for (const r of replies) {
        if (r.floorNum <= state.lastReadFloor) continue;
        newCount++;
        // 未读状态全靠这个 class 驱动:行左边的蓝条 + 楼层号变成淡蓝药丸,
        // 不再往头部塞任何额外节点(见 .cell.reply-new .fr .no)
        r.element.classList.add('reply-new');
        // 楼层号节点在解析阶段就拿到了,不必再 querySelector 一遍
        if (r.floorEl) r.floorEl.title = '未读新回复';
      }
      return newCount;
    }

    // 只推进到"从 1 楼起连续加载成功"的那个楼层,而不是见到的最大楼层:
    // 中间某页抓失败时,它后面的页照样会带来更大的楼层号,直接存 max 会把
    // 失败页里那几十楼永久标成已读。取连续前缀就能带着缺口安全地存进度,
    // 不必像以前那样"只要有一页失败就整个不写"——那会让这个帖子永远停在
    // firstVisit,之后的新回复一条都标不出来。
    function commitReadState(state, replies) {
      const floors = new Set();
      for (const r of replies) floors.add(r.floorNum);
      let contiguous = 0;
      while (floors.has(contiguous + 1)) contiguous++;
      // 进度只前进不后退;firstVisit 时哪怕是 0 也要落一笔,把"没进过"翻成"进过"
      if (!state.firstVisit && contiguous <= state.lastReadFloor) return;
      GM.set(state.key, contiguous);
    }

    function updateNewCountBar(replyBox, newCount) {
      let bar = document.getElementById('v2ex-new-count-bar');
      if (newCount <= 0) {
        bar?.remove();
        return;
      }
      if (!bar) {
        bar = document.createElement('div');
        bar.id = 'v2ex-new-count-bar';
        const dot = document.createElement('span');
        dot.className = 'ncb-dot';
        const strong = document.createElement('strong');
        const text = document.createElement('span');
        text.append('有 ', strong, ' 条新回复');
        const hint = document.createElement('span');
        hint.className = 'ncb-hint';
        hint.textContent = 'j / k 键跳转';
        bar.append(dot, text, hint);
        bar._countEl = strong;
        replyBox.parentNode.insertBefore(bar, replyBox);
      }
      const countEl = bar._countEl || bar.querySelector('strong');
      if (countEl) countEl.textContent = String(newCount);
    }

    // ── 悬停引用预览(在 B64 完成后运行,延迟 150ms)──
    function initHoverPreview(allReplies, { floorMap, nameMap }) {
      // 非全局:用于 acceptNode 内的测试(无 lastIndex 副作用)
      const REF_TEST_RE = /@[a-zA-Z0-9_-]+|#\d+/;
      // 全局:用于 exec 循环
      const REF_EXEC_RE = /(@[a-zA-Z0-9_-]+|#\d+)/g;

      let card      = null;
      let hideTimer = null;

      function getCard() {
        if (!card?.isConnected) {
          card = document.getElementById('v2ex-ref-preview');
        }
        if (!card) {
          card = document.createElement('div');
          card.id = 'v2ex-ref-preview';
          document.body.appendChild(card);
        }
        return card;
      }

      function showCard(refReply, anchorEl) {
        clearTimeout(hideTimer);
        const c = getCard();
        const header = document.createElement('div');
        header.className = 'rp-header';
        if (refReply.memberAvatar) {
          const avatar = document.createElement('img');
          avatar.className = 'rp-avatar';
          avatar.src = refReply.memberAvatar;
          avatar.alt = '';
          header.appendChild(avatar);
        }
        const name = document.createElement('span');
        name.className = 'rp-name';
        name.textContent = refReply.memberName;
        const floor = document.createElement('span');
        floor.className = 'rp-floor';
        floor.textContent = refReply.floor.startsWith('#') ? refReply.floor : `#${refReply.floor}`;
        header.append(name, floor);

        const content = document.createElement('div');
        content.className = 'rp-content';
        const source = refReply.element.querySelector('.reply_content');
        if (source) content.append(...source.cloneNode(true).childNodes);
        else content.textContent = refReply.content;
        c.replaceChildren(header, content);

        const rect  = anchorEl.getBoundingClientRect();
        const cardRect = c.getBoundingClientRect();
        const cardW = Math.min(360, cardRect.width || 360);
        const cardH = cardRect.height || 160;
        const left  = Math.max(4, Math.min(rect.left, window.innerWidth - cardW - 10));
        const top   = (window.innerHeight - rect.bottom > cardH + 10)
          ? rect.bottom + 6
          : Math.max(4, rect.top - cardH - 6);
        c.style.left = `${left}px`;
        c.style.top  = `${top}px`;
        c.classList.add('visible');
      }

      function hideCard() {
        hideTimer = setTimeout(() => card?.classList.remove('visible'), 120);
      }

      function findPreviousReplyByName(name, replyIndex) {
        const candidates = nameMap.get(name.toLowerCase());
        return findPreviousCandidate(candidates, replyIndex);
      }

      function bindPreview(anchor, refReply) {
        anchor.classList.add('v2-ref-link');
        anchor._v2RefReply = refReply;
        if (document._v2RefPreviewBound) return;
        document._v2RefPreviewBound = true;
        document.addEventListener('mouseover', e => {
          const target = e.target.closest?.('.v2-ref-link');
          if (!target?._v2RefReply || (e.relatedTarget instanceof Node && target.contains(e.relatedTarget))) return;
          showCard(target._v2RefReply, target);
        });
        document.addEventListener('mouseout', e => {
          const target = e.target.closest?.('.v2-ref-link');
          if (!target?._v2RefReply || (e.relatedTarget instanceof Node && target.contains(e.relatedTarget))) return;
          hideCard();
        });
      }

      for (const reply of allReplies) {
        const contentEl = reply.element.querySelector('.reply_content');
        if (!contentEl) continue;

        // V2EX 会把 @ 与用户名拆成相邻文本和链接,直接绑定现有会员链接。
        contentEl.querySelectorAll('a[href^="/member/"]:not(.v2-ref-link)').forEach(anchor => {
          const previous = anchor.previousSibling;
          if (previous?.nodeType !== Node.TEXT_NODE || !/@\s*$/.test(previous.nodeValue || '')) return;
          const refReply = findPreviousReplyByName(anchor.textContent?.trim() || '', reply.index);
          if (refReply) bindPreview(anchor, refReply);
        });

        const walker = document.createTreeWalker(contentEl, NodeFilter.SHOW_TEXT, {
          acceptNode(node) {
            if (!node.nodeValue) return NodeFilter.FILTER_REJECT;
            if (node.parentElement?.closest('.v2-b64-wrap, .v2-ref-link')) return NodeFilter.FILTER_REJECT;
            if (!REF_TEST_RE.test(node.nodeValue)) return NodeFilter.FILTER_REJECT;
            return NodeFilter.FILTER_ACCEPT;
          },
        });

        const nodes = [];
        while (walker.nextNode()) nodes.push(walker.currentNode);

        nodes.forEach(node => {
          const text = node.nodeValue;
          const frag = document.createDocumentFragment();
          let last = 0, changed = false;
          REF_EXEC_RE.lastIndex = 0;
          let m;

          while ((m = REF_EXEC_RE.exec(text)) !== null) {
            const token = m[0];
            let refReply = null;

            if (token.startsWith('@')) {
              refReply = findPreviousReplyByName(token.slice(1), reply.index);
            } else if (token.startsWith('#')) {
              const floor = parseInt(token.slice(1), 10);
              const found = floorMap.get(floor) ?? null;
              refReply = (found && found.floorNum < reply.floorNum) ? found : null;
            }

            frag.appendChild(document.createTextNode(text.slice(last, m.index)));
            if (refReply) {
              const span = document.createElement('span');
              span.className = 'v2-ref-link'; span.textContent = token;
              bindPreview(span, refReply);
              frag.appendChild(span);
              changed = true;
            } else {
              frag.appendChild(document.createTextNode(token));
            }
            last = m.index + token.length;
          }

          if (changed && node.parentNode) {
            frag.appendChild(document.createTextNode(text.slice(last)));
            node.parentNode.replaceChild(frag, node);
          }
        });
      }
    }

    function getTotalPages() {
      const candidates = [1];
      const max = Number.parseInt(document.querySelector('.page_input')?.getAttribute('max') || '', 10);
      if (Number.isSafeInteger(max)) candidates.push(max);
      document.querySelectorAll('a.page_normal, .page_current').forEach(el => {
        const fromText = Number.parseInt(el.textContent || '', 10);
        if (Number.isSafeInteger(fromText)) candidates.push(fromText);
        try {
          const fromUrl = Number.parseInt(new URL(el.href, location.href).searchParams.get('p') || '', 10);
          if (Number.isSafeInteger(fromUrl)) candidates.push(fromUrl);
        } catch (_) {}
      });
      return Math.max(...candidates);
    }

    function pageUrl(page) {
      const url = new URL(location.href);
      url.searchParams.set('p', String(page));
      url.hash = '';
      return url.href;
    }

    async function fetchReplyPage(page) {
      const controller = new AbortController();
      const timer = setTimeout(() => controller.abort(), CONFIG.threadTree.pageFetchTimeoutMs);
      try {
        const res = await fetch(pageUrl(page), {
          credentials: 'include',
          cache: 'no-store',
          signal: controller.signal,
        });
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        const doc = parseReplyDoc(await res.text());
        const replies = extractRepliesFromDoc(doc);
        if (!replies.length) throw new Error('页面中没有回复');
        return replies;
      } finally {
        clearTimeout(timer);
      }
    }

    async function fetchPages(pages, onProgress) {
      if (!pages.length) return [];
      const results = new Array(pages.length);
      let cursor = 0;
      let completed = 0;
      const worker = async () => {
        while (cursor < pages.length) {
          const index = cursor++;
          const page = pages[index];
          try {
            results[index] = { page, replies: await fetchReplyPage(page) };
          } catch (error) {
            results[index] = { page, replies: null, error };
          } finally {
            completed++;
            onProgress?.(completed, pages.length);
          }
        }
      };
      const workerCount = Math.min(CONFIG.threadTree.pageFetchConcurrency, pages.length);
      await Promise.all(Array.from({ length: workerCount }, () => worker()));
      return results;
    }

    // 个别页失败多半是并发触发的限流,静默重试一次再打扰用户。
    // 全军覆没则更可能是断网/掉登录,直接交给横幅让用户决定,避免再空等一轮超时。
    async function fetchPagesWithRetry(pages, onProgress) {
      const results = await fetchPages(pages, onProgress);
      const failedIndexes = results
        .map((result, index) => (result.replies ? -1 : index))
        .filter(index => index >= 0);
      if (!failedIndexes.length || failedIndexes.length === results.length) return results;

      await sleep(600);
      const retried = await fetchPages(failedIndexes.map(index => results[index].page));
      failedIndexes.forEach((resultIndex, i) => {
        if (retried[i]?.replies) results[resultIndex] = retried[i];
      });
      return results;
    }

    function normalizeReplies(replies) {
      const byId = new Map();
      for (const reply of replies) {
        if (!byId.has(reply.id)) byId.set(reply.id, reply);
      }
      const normalized = [...byId.values()].sort((a, b) => a.floorNum - b.floorNum);
      normalized.forEach((reply, index) => { reply.index = index; });
      return normalized;
    }

    // ── 主流程 ──
    async function init() {
      if (!isTopicPage()) return;
      initReplyFloorReference();
      const topicId = location.pathname.match(/\/t\/(\d+)/)?.[1];
      if (!topicId) return;

      const replyBox = Array.from(document.querySelectorAll('.box')).find(b => b.querySelector('div[id^="r_"]'));
      if (!replyBox) {
        // 帖子此刻还没有回复也要落一条进度:不然下次进来 stored 仍是 null、
        // 被当成首次访问,期间攒下的新回复一条都不会被标记。
        // (从首页点进刚发出来的新帖,就是这条路径。)
        const emptyState = createReadState(topicId);
        if (emptyState.firstVisit) commitReadState(emptyState, []);
        log('楼层树:没找到回复容器(.box 内无 div[id^="r_"]),已跳过');
        return;
      }

      const loadingBar = document.createElement('div');
      loadingBar.id = 'v2ex-loading-bar'; loadingBar.textContent = '加载中…';
      replyBox.parentNode.insertBefore(loadingBar, replyBox);

      const totalPages = getTotalPages();
      let allReplies = extractRepliesFromDoc(document);
      const requestedPage = Number.parseInt(new URLSearchParams(location.search).get('p') || '1', 10);
      const currentPage = Number.isSafeInteger(requestedPage) && requestedPage >= 1 && requestedPage <= totalPages
        ? requestedPage
        : 1;
      const pages = Array.from({ length: totalPages }, (_, i) => i + 1).filter(page => page !== currentPage);
      const pageResults = await fetchPagesWithRetry(pages, (completed, total) => {
        loadingBar.textContent = `加载回复页 ${completed} / ${total}…`;
      });

      const failedPages = pageResults.filter(result => !result.replies).map(result => result.page);
      for (const result of pageResults) {
        if (result.replies) allReplies.push(...result.replies);
        else log(`Reply page ${result.page} failed:`, result.error);
      }

      allReplies = normalizeReplies(allReplies);
      document.querySelectorAll('.page_input, .page_current, .page_normal').forEach(el => el.remove());

      // let 以便 doRetry 内可重新赋值
      let maps = buildLookupMaps(allReplies);
      const readState = createReadState(topicId);
      let newCount = markUnread(readState, allReplies);
      renderTree(allReplies, maps, replyBox, topicId);
      UserTags.decorate(replyBox);

      loadingBar.remove();
      log(`楼层树:${allReplies.length} 条回复 / 共 ${totalPages} 页(失败 ${failedPages.length} 页),`
        + `嵌套 ${replyBox.querySelectorAll('.reply-children').length} 组,未读 ${newCount} 条`);
      document.querySelectorAll('a[name="last_page"]').forEach(e => e.remove());
      updateNewCountBar(replyBox, newCount);
      commitReadState(readState, allReplies);

      let hoverTimer = null;
      const scheduleHoverPreview = () => {
        clearTimeout(hoverTimer);
        hoverTimer = setTimeout(() => initHoverPreview(allReplies, maps), 50);
      };

      // ── 失败页重试(支持递归多次重试)──
      if (failedPages.length > 0) {
        const banner = document.createElement('div');
        banner.id = 'v2ex-retry-banner';
        replyBox.parentNode.insertBefore(banner, replyBox);

        const attachRetry = pagesToRetry => {
          const text = document.createElement('span');
          text.textContent = `⚠️ 第 ${pagesToRetry.join('、')} 页加载失败`;
          const btn = document.createElement('button');
          btn.type = 'button';
          btn.textContent = '重试';
          banner.replaceChildren(text, btn);
          btn.addEventListener('click', async () => {
            btn.textContent = '重试中…'; btn.disabled = true;

            const results = await fetchPages(pagesToRetry, (completed, total) => {
              btn.textContent = `重试中 ${completed}/${total}…`;
            });
            const stillFailed = [];
            const newReplies = [];
            for (const result of results) {
              if (result.replies) newReplies.push(...result.replies);
              else {
                stillFailed.push(result.page);
                log(`Reply page ${result.page} retry failed:`, result.error);
              }
            }

            if (newReplies.length > 0) {
              allReplies = normalizeReplies([...allReplies, ...newReplies]);
              maps = buildLookupMaps(allReplies);
              newCount = markUnread(readState, allReplies);
              renderTree(allReplies, maps, replyBox, topicId);
              UserTags.decorate(replyBox);
              updateNewCountBar(replyBox, newCount);
              // 补回来的页可能填上了缺口,让连续前缀继续往前走;
              // 哪怕还有别的页没救回来,这一段进度也该存下
              commitReadState(readState, allReplies);
              scheduleHoverPreview();
            }

            if (stillFailed.length > 0) {
              attachRetry(stillFailed); // 仍有失败页 → 重新挂载按钮
            } else {
              banner.remove();
            }
          }, { once: true });
        };
        attachRetry([...failedPages]);
      }

      // 等待 B64 的 MutationObserver 完成本轮 DOM 处理。
      scheduleHoverPreview();
    }

    function boot() {
      init().catch(err => {
        document.getElementById('v2ex-loading-bar')?.remove();
        log('ThreadTree error:', err);
      });
    }
    return { boot, revealAncestors };
  })();

  // =========================
  // 6) 功能D:高赞回复阅览室(含图片 Lightbox)
  // =========================
  const HotRoom = (() => {

    // ── Lightbox ──
    function openLightbox(src) {
      if (!src) return;
      let lb = document.getElementById('v2ex-lightbox');
      if (!lb) {
        lb = document.createElement('div');
        lb.id = 'v2ex-lightbox';
        const img = document.createElement('img');
        img.alt = '';
        lb.appendChild(img);
        document.body.appendChild(lb);
        lb.addEventListener('click', () => lb.classList.remove('active'));
        document.addEventListener('keydown', e => {
          if (e.key === 'Escape' && lb.classList.contains('active')) lb.classList.remove('active');
        });
      }
      lb.querySelector('img').src = src;
      requestAnimationFrame(() => lb.classList.add('active'));
    }

    function extractComments() {
      const comments = [];
      document.querySelectorAll('.cell[id^="r_"]').forEach(cell => {
        try {
          let likes = 0;
          for (const span of cell.querySelectorAll('.small.fade')) {
            const text = span.textContent || '';
            const m1 = text.match(/(?:♥|❤️)\s*(\d+)/);
            if (m1) { likes = parseInt(m1[1], 10); break; }
            if (span.querySelector('img[alt="❤️"]') && text.trim().length > 0) {
              likes = parseInt(text.trim(), 10) || 0; break;
            }
          }
          if (likes > 0) {
            const content = cell.querySelector('.reply_content');
            const author = cell.querySelector('strong > a');
            comments.push({
              id: cell.id, likes,
              avatar:      cell.querySelector('img.avatar')?.src || '',
              username:    author?.textContent?.trim() || 'Unknown',
              userUrl:     author?.href || '#',
              time:        cell.querySelector('.ago')?.textContent?.trim() || '',
              contentNode: content?.cloneNode(true) || null,
              floor:       cell.querySelector('.no')?.textContent?.trim() || '#',
            });
          }
        } catch (_) {}
      });
      return comments.sort((a, b) => b.likes - a.likes);
    }

    function buildUI(comments) {
      const existing = document.getElementById('hot-overlay');
      existing?._cleanup?.();
      existing?.remove();
      const overlay   = document.createElement('div');
      overlay.id      = 'hot-overlay';
      const container = document.createElement('div');
      container.className = 'hot-container';

      if (!comments.length) {
        const empty = document.createElement('div');
        empty.style.cssText = 'text-align:center;padding:40px;color:#ccc;font-size:13px;';
        empty.textContent = '暂无高赞回复';
        container.appendChild(empty);
      } else {
        comments.forEach((c, index) => {
          const rankClass = index === 0 ? 'rank-1' : index === 1 ? 'rank-2' : index === 2 ? 'rank-3' : 'rank-normal';
          const card = document.createElement('div');
          card.className = `hot-card ${rankClass}`;

          const header = document.createElement('div');
          header.className = 'card-header-row';

          if (c.avatar) {
            const avatar = document.createElement('img');
            avatar.className = 'user-avatar'; avatar.src = c.avatar; avatar.alt = '';
            header.appendChild(avatar);
          }

          const user = document.createElement('a');
          user.className = 'user-name'; user.href = c.userUrl;
          user.target = '_blank'; user.rel = 'noreferrer noopener';
          user.textContent = c.username;
          header.appendChild(user);

          const floor = document.createElement('div');
          floor.className = 'floor-tag'; floor.title = '跳转'; floor.textContent = c.floor;
          floor.addEventListener('click', () => {
            closeOverlay(overlay);
            setTimeout(() => {
              const el = document.getElementById(c.id);
              if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
            }, 250);
          });
          header.appendChild(floor);

          const time = document.createElement('span');
          time.className = 'time-tag'; time.textContent = c.time;
          header.appendChild(time);

          const likes = document.createElement('div');
          likes.className = 'likes-pill'; likes.textContent = `♥ ${c.likes}`;
          header.appendChild(likes);

          const content = document.createElement('div');
          content.className = 'card-content';
          if (c.contentNode) content.append(...c.contentNode.childNodes);

          // ── Lightbox:为卡片内图片挂载点击处理 ──
          content.querySelectorAll('img').forEach(img => {
            img.addEventListener('click', e => {
              e.stopPropagation();
              openLightbox(img.src);
            });
          });

          card.appendChild(header);
          card.appendChild(content);
          container.appendChild(card);
        });
      }

      overlay.appendChild(container);
      document.body.appendChild(overlay);
      // 浮层挂在 body 上,不在 UserTags 的观察范围内,需要显式装饰一次
      UserTags.decorate(overlay);
      overlay.addEventListener('click', e => { if (e.target === overlay) closeOverlay(overlay); });
      const onKey = e => {
        if (e.key !== 'Escape' || document.getElementById('v2ex-lightbox')?.classList.contains('active')) return;
        closeOverlay(overlay);
      };
      document.addEventListener('keydown', onKey);
      overlay._cleanup = () => document.removeEventListener('keydown', onKey);
      return overlay;
    }

    function closeOverlay(overlay) {
      if (!overlay) return;
      overlay.classList.remove('active');
      setTimeout(() => {
        if (!overlay.classList.contains('active')) { overlay._cleanup?.(); overlay.remove(); }
      }, 200);
    }

    function boot() {
      if (!isTopicPage()) return;
      setTimeout(() => {
        const target = document.querySelector('#Main .header h1') || document.querySelector('#Main .box .header');
        if (target && !document.getElementById('v2ex-hot-btn')) {
          const btn = document.createElement('span');
          btn.id = 'v2ex-hot-btn'; btn.textContent = '高赞';
          btn.addEventListener('click', e => {
            e.preventDefault(); e.stopPropagation();
            const overlay = buildUI(extractComments());
            requestAnimationFrame(() => overlay.classList.add('active'));
          });
          target.appendChild(btn);
        }
      }, 500);
    }

    return { boot };
  })();

  // =========================
  // 7) 功能E:j/k 键盘导航(按需刷新,无常驻扫描)
  // =========================
  const NavKeys = (() => {
    const SCROLL_OFFSET_RATIO = CONFIG.nav.scrollOffsetRatio;
    let newReplies = [], curIndex = -1, hudTimer = null, activeReply = null;

    // HUD 每次按键都重建 innerHTML 的话,等于每次都重新解析一遍 HTML;
    // 结构建一次留着,之后只改箭头和计数两处文本。
    let hud = null;
    let hudArrow = null;
    let hudIndex = null;
    let hudTotal = null;
    function buildHud() {
      hud = document.createElement('div');
      hud.id = 'v2ex-nav-hud';
      hudArrow = document.createElement('span');
      hudArrow.className = 'hud-arrow';
      const label = document.createElement('span');
      label.className = 'hud-label';
      label.textContent = 'NEW';
      const sep = document.createElement('span');
      sep.className = 'hud-sep';
      sep.textContent = ' / ';
      hudIndex = document.createTextNode('');
      hudTotal = document.createTextNode('');
      const count = document.createElement('span');
      count.className = 'hud-count';
      count.append(hudIndex, sep, hudTotal);
      const hint = document.createElement('span');
      hint.className = 'hud-hint';
      hint.textContent = 'j↓ k↑';
      hud.append(hudArrow, label, count, hint);
      document.body.appendChild(hud);
    }
    function showHud(index, total, direction) {
      if (!hud?.isConnected) buildHud();
      hudArrow.textContent = direction === 'next' ? '↓' : '↑';
      hudIndex.nodeValue = String(index + 1);
      hudTotal.nodeValue = String(total);
      hud.classList.add('visible');
      clearTimeout(hudTimer);
      hudTimer = setTimeout(() => hud.classList.remove('visible'), 2200);
    }
    function scrollToReply(el) {
      const targetTop = el.getBoundingClientRect().top + window.scrollY - window.innerHeight * SCROLL_OFFSET_RATIO;
      window.scrollTo({ top: Math.max(0, targetTop), behavior: 'smooth' });
    }
    function setActive(el) {
      document.querySelector('.reply-nav-active')?.classList.remove('reply-nav-active');
      activeReply = el || null;
      if (el) (el.closest('.reply-wrapper') || el).classList.add('reply-nav-active');
    }
    function refreshList() {
      newReplies = Array.from(document.querySelectorAll('.reply-new'));
      const activeIndex = activeReply ? newReplies.indexOf(activeReply) : -1;
      curIndex = activeIndex >= 0 ? activeIndex : -1;
    }
    function navigate(direction) {
      refreshList();
      if (!newReplies.length) return;
      curIndex = direction === 'next'
        ? Math.min(curIndex + 1, newReplies.length - 1)
        : (curIndex < 0 ? newReplies.length - 1 : Math.max(curIndex - 1, 0));
      const target = newReplies[curIndex];
      setActive(target);
      // 目标可能藏在折叠的子树里,先展开再滚动,否则会滚到一片空白
      ThreadTree.revealAncestors(target);
      scrollToReply(target);
      showHud(curIndex, newReplies.length, direction);
    }
    function onKeyDown(e) {
      const tag = document.activeElement?.tagName?.toLowerCase();
      if (tag === 'input' || tag === 'textarea' || tag === 'select' || document.activeElement?.isContentEditable) return;
      if (e.metaKey || e.ctrlKey || e.altKey) return;
      // 有浮层时把按键让给浮层
      if (document.getElementById('hot-overlay')?.classList.contains('active')) return;
      if (document.getElementById('v2t-manager')?.classList.contains('active')) return;
      if (document.getElementById('v2ex-lightbox')?.classList.contains('active')) return;
      if (document.getElementById('v2t-editor')?.classList.contains('visible')) return;
      if (e.key === 'j') { e.preventDefault(); navigate('next'); }
      else if (e.key === 'k') { e.preventDefault(); navigate('prev'); }
    }
    function boot() {
      if (!isTopicPage()) return;
      document.addEventListener('keydown', onKeyDown);
    }
    return { boot };
  })();

  // =========================
  // 8) 功能F:Imgur 图片代理
  // =========================
  const ImgurProxy = (() => {
    function parseImgurUrl(value) {
      if (!value) return null;
      try {
        const url = new URL(value.startsWith('//') ? `https:${value}` : value, location.href);
        const host = url.hostname.toLowerCase();
        return host === 'imgur.com' || host.endsWith('.imgur.com') ? url : null;
      } catch (_) { return null; }
    }

    function proxyUrl(url) {
      return `https://external-content.duckduckgo.com/iu/?u=${encodeURIComponent(url.href)}&f=1&nofb=1`;
    }

    function isDirectImage(url) {
      return url.hostname.toLowerCase() === 'i.imgur.com' || /\.(?:avif|gif|jpe?g|png|webp)$/i.test(url.pathname);
    }

    function processImage(img) {
      if (img.dataset.proxied === '1') return;
      const src = img.getAttribute('src');
      const imgurUrl = parseImgurUrl(src);
      if (!imgurUrl) return;
      img.dataset.proxied = '1';
      img.setAttribute('src', proxyUrl(imgurUrl));
      const parent = img.parentElement;
      if (parent?.tagName?.toLowerCase() === 'a') {
        const href = parseImgurUrl(parent.getAttribute('href'));
        if (href && isDirectImage(href)) parent.setAttribute('href', proxyUrl(href));
      }
    }

    function processNode(node) {
      if (node.nodeType !== Node.ELEMENT_NODE) return;
      if (node.matches('img')) processImage(node);
      node.querySelectorAll('img').forEach(processImage);
    }

    function scanAll() { document.querySelectorAll('img[src*="imgur.com"]').forEach(processImage); }
    function boot() {
      scanAll();
      new MutationObserver(mutations => {
        for (const mutation of mutations) {
          if (mutation.type === 'attributes') processImage(mutation.target);
          else for (const node of mutation.addedNodes) processNode(node);
        }
      }).observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['src'] });
    }
    return { boot };
  })();

  // =========================
  // 9) 功能G:用户标签(本地存储 / 导入导出 / 合并)
  // =========================
  const UserTags = (() => {
    const { storeKey, maxTagLength, tombstoneTtlMs, exportVersion } = CONFIG.tags;

    // 主色(紫)排第一并作为默认值:不选颜色也能得到一致好看的标签。
    // 整套配色刻意不含 #4a7af0 —— 那是 NEW / 引用 / b64 的系统蓝,
    // 允许用户选到它就等于允许把"人的标签"伪装成"系统状态"。
    const COLORS = [
      { key: 'violet', hex: '#8b45c9', label: '默认紫' },
      { key: 'teal',   hex: '#0e8f8f', label: '青' },
      { key: 'green',  hex: '#2f8f4e', label: '绿' },
      { key: 'amber',  hex: '#b5730b', label: '琥珀' },
      { key: 'red',    hex: '#cf3b30', label: '红' },
      { key: 'pink',   hex: '#c72d78', label: '粉' },
      { key: 'slate',  hex: '#5f6b7a', label: '灰蓝' },
    ];
    const COLOR_MAP = new Map(COLORS.map(c => [c.key, c.hex]));
    const DEFAULT_COLOR = 'violet';

    // 会员链接出现的位置。.topic_info 那条覆盖所有主题列表页——首页、节点页、
    // /recent、/my/topics、个人主页——里面的作者和"最后回复来自"两个用户名;
    // 它只出现在列表项里,所以不会误伤主题页。限定 strong > a 是为了跳过
    // 同一行里那个只包着头像的 <a href="/member/x">,否则会往头像后面塞胶囊。
    const AUTHOR_SELECTOR = [
      'div.cell[id^="r_"] strong > a[href*="/member/"]',   // 回复楼层
      '#Main .header small.gray > a[href*="/member/"]',    // 主题头部作者
      '.topic_info strong > a[href*="/member/"]',          // 主题列表:作者 / 最后回复
      '.hot-card a.user-name',                             // 高赞阅览室卡片
    ].join(',');

    // ── 存储层 ──
    // 结构:{ v, tags: { <小写用户名>: {name, tag, color, updatedAt} }, deleted: { <小写用户名>: ts } }
    // deleted 是墓碑,保证"删除"在跨浏览器合并时也能正确传播(否则旧数据会把删掉的标签复活)。
    let store = null;

    const emptyStore = () => ({ v: exportVersion, tags: {}, deleted: {} });
    const keyOf = name => String(name ?? '').trim().toLowerCase();

    function sanitizeEntry(raw, fallbackName) {
      if (!raw || typeof raw !== 'object') return null;
      const tag = typeof raw.tag === 'string' ? raw.tag.replace(/\s+/g, ' ').trim().slice(0, maxTagLength) : '';
      if (!tag) return null;
      const name = typeof raw.name === 'string' && raw.name.trim() ? raw.name.trim() : String(fallbackName ?? '').trim();
      const updatedAt = Number.isFinite(raw.updatedAt) && raw.updatedAt > 0 ? raw.updatedAt : 0;
      return {
        name: name || tag,
        tag,
        color: COLOR_MAP.has(raw.color) ? raw.color : DEFAULT_COLOR,
        updatedAt,
      };
    }

    function normalizeStore(raw) {
      const next = emptyStore();
      if (!raw || typeof raw !== 'object') return next;
      for (const [k, v] of Object.entries(raw.tags || {})) {
        const key = keyOf(k);
        const entry = sanitizeEntry(v, k);
        if (key && entry) next.tags[key] = entry;
      }
      for (const [k, v] of Object.entries(raw.deleted || {})) {
        const key = keyOf(k);
        const ts = Number(v);
        if (key && Number.isFinite(ts) && ts > 0) next.deleted[key] = ts;
      }
      return next;
    }

    function pruneTombstones(target, now = Date.now()) {
      for (const [k, ts] of Object.entries(target.deleted)) {
        if (now - ts > tombstoneTtlMs) delete target.deleted[k];
      }
      return target;
    }

    function load() {
      if (!store) store = normalizeStore(GM.get(storeKey, null));
      return store;
    }

    function persist(next) {
      pruneTombstones(next);
      store = next;
      GM.set(storeKey, next);
      repaintAll();
      Manager.refresh();
    }

    const get = name => load().tags[keyOf(name)] || null;

    function setTag(name, tag, color) {
      const key = keyOf(name);
      if (!key) return false;
      const entry = sanitizeEntry({ name, tag, color, updatedAt: Date.now() }, name);
      if (!entry) return removeTag(name);
      const next = { v: exportVersion, tags: { ...load().tags }, deleted: { ...load().deleted } };
      next.tags[key] = entry;
      delete next.deleted[key];
      persist(next);
      return true;
    }

    function removeTag(name) {
      const key = keyOf(name);
      const current = load();
      if (!key || !current.tags[key]) return false;
      const next = { v: exportVersion, tags: { ...current.tags }, deleted: { ...current.deleted } };
      delete next.tags[key];
      next.deleted[key] = Date.now();
      persist(next);
      return true;
    }

    function entries() {
      return Object.entries(load().tags)
        .map(([key, entry]) => ({ key, ...entry }))
        .sort((a, b) => b.updatedAt - a.updatedAt || a.key.localeCompare(b.key));
    }

    // 最近使用过的标签文本,按使用频次 + 新旧排序,用作编辑气泡里的快捷输入
    function suggestions(limit = 8) {
      const counter = new Map();
      for (const entry of Object.values(load().tags)) {
        const item = counter.get(entry.tag) || { tag: entry.tag, count: 0, updatedAt: 0 };
        item.count++;
        item.updatedAt = Math.max(item.updatedAt, entry.updatedAt);
        counter.set(entry.tag, item);
      }
      return [...counter.values()]
        .sort((a, b) => b.count - a.count || b.updatedAt - a.updatedAt)
        .slice(0, limit);
    }

    // ── 导入 / 导出 / 合并 ──
    function exportPayload() {
      const current = load();
      return {
        app: 'v2ex-tweaks',
        kind: 'user-tags',
        version: exportVersion,
        exportedAt: Date.now(),
        count: Object.keys(current.tags).length,
        tags: current.tags,
        deleted: current.deleted,
      };
    }

    // 兼容三种输入:本脚本导出的完整包、只有 {tags:{…}} 的裁剪包、
    // 以及手写的 { 用户名: "标签文本" } 简易映射。
    function parseImport(text) {
      let data;
      try { data = JSON.parse(text); }
      catch (_) { throw new Error('不是合法的 JSON 文件'); }
      if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error('文件内容不是标签数据');

      // 带信封的导出包必须走 data.tags,否则 app/kind/version 这些元字段会被当成用户名
      const hasEnvelope = ['app', 'kind', 'version', 'exportedAt', 'count', 'tags', 'deleted']
        .some(field => field in data);
      if (hasEnvelope && (!data.tags || typeof data.tags !== 'object')) {
        throw new Error('文件缺少 tags 字段');
      }
      const rawTags = hasEnvelope ? data.tags : data;
      const rawDeleted = (data.deleted && typeof data.deleted === 'object') ? data.deleted : {};

      const tags = {};
      const deleted = {};
      let invalid = 0;
      for (const [k, v] of Object.entries(rawTags)) {
        const key = keyOf(k);
        const entry = typeof v === 'string' ? sanitizeEntry({ tag: v }, k) : sanitizeEntry(v, k);
        if (!key || !entry) { invalid++; continue; }
        if (!tags[key] || entry.updatedAt > tags[key].updatedAt) tags[key] = entry;
      }
      for (const [k, v] of Object.entries(rawDeleted)) {
        const key = keyOf(k);
        const ts = Number(v);
        if (key && Number.isFinite(ts) && ts > 0) deleted[key] = ts;
      }
      if (!Object.keys(tags).length && !Object.keys(deleted).length) throw new Error('文件里没有可导入的标签');
      return { tags, deleted, invalid };
    }

    // strategy: smart(按更新时间取新,尊重删除墓碑)/ add(只补充本地没有的)/ replace(用文件完全覆盖)
    function computeMerge(incoming, strategy) {
      const base = load();
      const next = strategy === 'replace'
        ? emptyStore()
        : { v: exportVersion, tags: { ...base.tags }, deleted: { ...base.deleted } };

      for (const [key, entry] of Object.entries(incoming.tags)) {
        const local = next.tags[key];
        if (strategy === 'add') {
          if (!local) { next.tags[key] = entry; delete next.deleted[key]; }
          continue;
        }
        if (strategy === 'smart' && (next.deleted[key] || 0) > entry.updatedAt) continue;
        if (!local || entry.updatedAt > local.updatedAt) {
          next.tags[key] = entry;
          delete next.deleted[key];
        }
      }

      if (strategy !== 'add') {
        for (const [key, ts] of Object.entries(incoming.deleted)) {
          const local = next.tags[key];
          if (local && ts > local.updatedAt) delete next.tags[key];
          if (!next.tags[key]) next.deleted[key] = Math.max(next.deleted[key] || 0, ts);
        }
      }

      pruneTombstones(next);

      const stats = { added: 0, updated: 0, removed: 0, unchanged: 0, invalid: incoming.invalid };
      for (const [key, entry] of Object.entries(next.tags)) {
        const local = base.tags[key];
        if (!local) stats.added++;
        else if (local.tag !== entry.tag || local.color !== entry.color) stats.updated++;
        else stats.unchanged++;
      }
      for (const key of Object.keys(base.tags)) if (!next.tags[key]) stats.removed++;
      return { stats, next };
    }

    function download(filename, text) {
      const url = URL.createObjectURL(new Blob([text], { type: 'application/json;charset=utf-8' }));
      const link = document.createElement('a');
      link.href = url;
      link.download = filename;
      document.body.appendChild(link);
      link.click();
      link.remove();
      setTimeout(() => URL.revokeObjectURL(url), 2000);
    }

    // ── Toast ──
    let toastTimer = 0;
    function toast(message) {
      let el = document.getElementById('v2t-toast');
      if (!el) {
        el = document.createElement('div');
        el.id = 'v2t-toast';
        document.body.appendChild(el);
      }
      el.textContent = message;
      requestAnimationFrame(() => el.classList.add('visible'));
      clearTimeout(toastTimer);
      toastTimer = setTimeout(() => el.classList.remove('visible'), 2200);
    }

    // ── 胶囊渲染 ──
    // 配色只有固定 7 种,六个 CSS 变量在启动时算一次拼成 cssText;
    // 否则每画一个胶囊都要跑 6 次正则解析 + 字符串拼接,一页几百个用户名很可观。
    const CHIP_CSS = new Map(COLORS.map(({ key, hex }) => [key, [
      `--v2t-chip-bg:${hexToRgba(hex, 0.13)}`,
      `--v2t-chip-fg:${hex}`,
      `--v2t-chip-bd:${hexToRgba(hex, 0.32)}`,
      `--v2t-chip-bg-n:${hexToRgba(hex, 0.22)}`,
      `--v2t-chip-fg-n:${lightenHex(hex, 0.38)}`,
      `--v2t-chip-bd-n:${hexToRgba(hex, 0.45)}`,
    ].join(';')]));

    function userFromLink(anchor) {
      try {
        const path = new URL(anchor.getAttribute('href') || '', location.origin).pathname;
        const m = /^\/member\/([^/?#]+)/.exec(path);
        return m ? decodeURIComponent(m[1]) : '';
      } catch (_) { return ''; }
    }

    function paintSlot(slot) {
      const user = slot.dataset.user;
      const entry = get(user);
      if (entry) {
        const chip = document.createElement('span');
        chip.className = 'v2t-chip';
        chip.textContent = entry.tag;
        // 标签在楼层头部可能被省略号截断,完整文案放进 title 兜底
        chip.title = `${entry.tag} · ${entry.name || user}\n点击编辑标签`;
        chip.setAttribute('role', 'button');
        chip.tabIndex = 0;
        chip.style.cssText = CHIP_CSS.get(entry.color) || CHIP_CSS.get(DEFAULT_COLOR);
        slot.replaceChildren(chip);
        return;
      }
      const add = document.createElement('span');
      add.className = 'v2t-add';
      add.textContent = '+';
      add.title = `给 ${user} 添加标签`;
      add.setAttribute('role', 'button');
      add.setAttribute('aria-label', `给 ${user} 添加标签`);
      add.tabIndex = 0;
      slot.replaceChildren(add);
    }

    function attachSlot(anchor) {
      if (anchor.dataset.v2tBound === '1') return;
      const user = userFromLink(anchor);
      if (!user) return;
      anchor.dataset.v2tBound = '1';
      const slot = document.createElement('span');
      slot.className = 'v2t-slot';
      slot.dataset.user = user;
      anchor.insertAdjacentElement('afterend', slot);
      paintSlot(slot);
    }

    function decorate(root = document) {
      if (root.nodeType === Node.ELEMENT_NODE && root.matches?.(AUTHOR_SELECTOR)) attachSlot(root);
      root.querySelectorAll?.(AUTHOR_SELECTOR).forEach(attachSlot);
    }

    function repaintAll() {
      document.querySelectorAll('.v2t-slot').forEach(paintSlot);
    }

    // ── 编辑气泡 ──
    const Editor = (() => {
      let el = null;
      let currentUser = '';
      let currentColor = DEFAULT_COLOR;
      let anchorEl = null;

      function place() {
        if (!el || !anchorEl?.isConnected) return;
        const rect = anchorEl.getBoundingClientRect();
        const width = el.offsetWidth || 268;
        const height = el.offsetHeight || 180;
        const left = Math.max(8, Math.min(rect.left, window.innerWidth - width - 8));
        const top = (window.innerHeight - rect.bottom > height + 12)
          ? rect.bottom + 8
          : Math.max(8, rect.top - height - 8);
        el.style.left = `${left}px`;
        el.style.top = `${top}px`;
      }

      function close() {
        if (!el) return;
        el.classList.remove('visible');
        el.style.pointerEvents = 'none';
        currentUser = '';
        anchorEl = null;
        window.removeEventListener('scroll', place, true);
        window.removeEventListener('resize', place);
      }

      function isOpen() { return !!currentUser; }

      function build() {
        el = document.createElement('div');
        el.id = 'v2t-editor';
        document.body.appendChild(el);
        // 气泡内部的点击不能冒泡到"点击外部关闭"的全局监听
        el.addEventListener('mousedown', e => e.stopPropagation());
        return el;
      }

      function open(user, anchor) {
        if (!el) build();
        if (currentUser && keyOf(currentUser) === keyOf(user)) { close(); return; }
        currentUser = user;
        anchorEl = anchor;
        const existing = get(user);
        currentColor = existing?.color || DEFAULT_COLOR;

        el.replaceChildren();

        const head = document.createElement('div');
        head.className = 'v2t-ed-head';
        const avatar = anchor.closest('.cell, .hot-card, .header')?.querySelector('img.avatar, img.user-avatar');
        if (avatar?.src) {
          const img = document.createElement('img');
          img.src = avatar.src;
          img.alt = '';
          head.appendChild(img);
        }
        const nameEl = document.createElement('b');
        nameEl.textContent = user;
        head.append(nameEl, document.createTextNode(existing ? '· 编辑标签' : '· 新建标签'));

        const input = document.createElement('input');
        input.type = 'text';
        input.maxLength = maxTagLength;
        input.placeholder = '例如:靠谱 / 杠精 / 同行…';
        input.value = existing?.tag || '';
        input.setAttribute('aria-label', `${user} 的标签`);

        // 配色是可选项:默认收起,只有在改过颜色的标签上才自动展开
        const toggle = document.createElement('div');
        toggle.className = 'v2t-color-toggle';
        toggle.setAttribute('role', 'button');
        const preview = document.createElement('span');
        preview.className = 'v2t-color-preview';
        const toggleText = document.createElement('span');
        toggle.append(preview, toggleText);

        const swatches = document.createElement('div');
        swatches.className = 'v2t-swatches';

        const syncColor = () => {
          const hex = COLOR_MAP.get(currentColor) || COLOR_MAP.get(DEFAULT_COLOR);
          preview.style.background = hex;
          const name = COLORS.find(c => c.key === currentColor)?.label || '';
          toggleText.textContent = swatches.classList.contains('open') ? '收起配色' : `配色:${name}`;
        };

        for (const { key, hex, label } of COLORS) {
          const dot = document.createElement('span');
          dot.className = 'v2t-swatch' + (key === currentColor ? ' selected' : '');
          dot.style.background = hex;
          dot.style.color = hex;
          dot.title = label;
          dot.setAttribute('role', 'button');
          dot.setAttribute('aria-label', `颜色 ${label}`);
          dot.addEventListener('click', () => {
            currentColor = key;
            swatches.querySelectorAll('.v2t-swatch').forEach(s => s.classList.remove('selected'));
            dot.classList.add('selected');
            syncColor();
            input.focus();
          });
          swatches.appendChild(dot);
        }

        toggle.addEventListener('click', () => {
          swatches.classList.toggle('open');
          syncColor();
          place();
        });
        if (existing && existing.color !== DEFAULT_COLOR) swatches.classList.add('open');
        syncColor();

        el.append(head, input, toggle, swatches);

        const recent = suggestions().filter(item => item.tag !== input.value);
        if (recent.length) {
          const list = document.createElement('div');
          list.className = 'v2t-recent';
          for (const item of recent) {
            const chip = document.createElement('span');
            chip.className = 'v2t-recent-item';
            chip.textContent = item.tag;
            chip.title = `已用于 ${item.count} 人`;
            chip.addEventListener('click', () => { input.value = item.tag; input.focus(); });
            list.appendChild(chip);
          }
          el.appendChild(list);
        }

        const actions = document.createElement('div');
        actions.className = 'v2t-ed-actions';
        if (existing) {
          const del = document.createElement('button');
          del.type = 'button';
          del.className = 'v2t-btn danger';
          del.textContent = '删除';
          del.addEventListener('click', () => {
            removeTag(user);
            toast(`已删除 ${user} 的标签`);
            close();
          });
          actions.appendChild(del);
        }
        const spacer = document.createElement('span');
        spacer.className = 'v2t-spacer';
        const cancel = document.createElement('button');
        cancel.type = 'button';
        cancel.className = 'v2t-btn';
        cancel.textContent = '取消';
        cancel.addEventListener('click', close);
        const save = document.createElement('button');
        save.type = 'button';
        save.className = 'v2t-btn primary';
        save.textContent = '保存';

        const commit = () => {
          const value = input.value.trim();
          if (!value) {
            if (existing) { removeTag(user); toast(`已删除 ${user} 的标签`); }
            close();
            return;
          }
          setTag(user, value, currentColor);
          toast(`已${existing ? '更新' : '添加'} ${user} 的标签`);
          close();
        };
        save.addEventListener('click', commit);
        input.addEventListener('keydown', e => {
          if (e.key === 'Enter') { e.preventDefault(); commit(); }
          else if (e.key === 'Escape') { e.preventDefault(); close(); }
        });

        actions.append(spacer, cancel, save);
        el.appendChild(actions);

        el.style.pointerEvents = 'auto';
        place();
        requestAnimationFrame(() => {
          el.classList.add('visible');
          place();
          input.focus();
          input.select();
        });
        window.addEventListener('scroll', place, true);
        window.addEventListener('resize', place);
      }

      return { open, close, isOpen };
    })();

    // ── 管理面板 ──
    const Manager = (() => {
      let overlay = null;
      let listEl = null;
      let countEl = null;
      let mergeEl = null;
      let searchEl = null;
      let pending = null; // 待确认的导入数据
      let strategy = 'smart';

      function isOpen() { return !!overlay?.classList.contains('active'); }

      function renderList() {
        if (!listEl) return;
        const keyword = (searchEl?.value || '').trim().toLowerCase();
        const all = entries();
        const rows = keyword
          ? all.filter(item => item.key.includes(keyword) || item.tag.toLowerCase().includes(keyword))
          : all;

        countEl.textContent = keyword ? `${rows.length} / ${all.length}` : `${all.length} 个用户`;
        listEl.replaceChildren();

        if (!rows.length) {
          const empty = document.createElement('div');
          empty.className = 'v2t-empty';
          empty.textContent = all.length ? '没有匹配的标签' : '还没有标签,在主题页把鼠标移到用户名旁点 + 即可添加';
          listEl.appendChild(empty);
          return;
        }

        const fragment = document.createDocumentFragment();
        for (const item of rows) {
          const row = document.createElement('div');
          row.className = 'v2t-row';

          const user = document.createElement('a');
          user.className = 'v2t-user';
          user.href = `/member/${encodeURIComponent(item.name || item.key)}`;
          user.target = '_blank';
          user.rel = 'noreferrer noopener';
          user.textContent = item.name || item.key;

          const slot = document.createElement('span');
          slot.className = 'v2t-slot';
          slot.dataset.user = item.name || item.key;
          paintSlot(slot);

          const time = document.createElement('span');
          time.className = 'v2t-time';
          time.textContent = item.updatedAt ? new Date(item.updatedAt).toLocaleDateString() : '—';

          const edit = document.createElement('span');
          edit.className = 'v2t-row-act';
          edit.textContent = '编辑';
          edit.setAttribute('role', 'button');
          edit.addEventListener('click', () => Editor.open(item.name || item.key, edit));

          const del = document.createElement('span');
          del.className = 'v2t-row-act danger';
          del.textContent = '删除';
          del.setAttribute('role', 'button');
          del.addEventListener('click', () => { removeTag(item.key); });

          row.append(user, slot, time, edit, del);
          fragment.appendChild(row);
        }
        listEl.appendChild(fragment);
      }

      function renderMerge() {
        if (!mergeEl) return;
        if (!pending) {
          mergeEl.hidden = true;
          mergeEl.replaceChildren();
          return;
        }
        const { stats } = computeMerge(pending, strategy);
        mergeEl.hidden = false;
        mergeEl.replaceChildren();

        const title = document.createElement('div');
        title.innerHTML = `准备导入 <b>${Object.keys(pending.tags).length}</b> 条标签`
          + (pending.invalid ? `(已忽略 <b>${pending.invalid}</b> 条无效记录)` : '');

        const opts = document.createElement('div');
        opts.className = 'v2t-merge-opts';
        const choices = [
          ['smart', '智能合并(保留较新,推荐)'],
          ['add', '仅新增(不动本地已有)'],
          ['replace', '覆盖本地(清空后导入)'],
        ];
        for (const [value, label] of choices) {
          const wrap = document.createElement('label');
          const radio = document.createElement('input');
          radio.type = 'radio';
          radio.name = 'v2t-merge-strategy';
          radio.value = value;
          radio.checked = strategy === value;
          radio.addEventListener('change', () => { strategy = value; renderMerge(); });
          wrap.append(radio, document.createTextNode(label));
          opts.appendChild(wrap);
        }

        const summary = document.createElement('div');
        summary.innerHTML = `结果:新增 <b>${stats.added}</b> · 更新 <b>${stats.updated}</b> · 删除 <b>${stats.removed}</b> · 不变 <b>${stats.unchanged}</b>`;

        const actions = document.createElement('div');
        actions.className = 'v2t-ed-actions';
        const spacer = document.createElement('span');
        spacer.className = 'v2t-spacer';
        const cancel = document.createElement('button');
        cancel.type = 'button';
        cancel.className = 'v2t-btn';
        cancel.textContent = '取消';
        cancel.addEventListener('click', () => { pending = null; renderMerge(); });
        const apply = document.createElement('button');
        apply.type = 'button';
        apply.className = 'v2t-btn primary';
        apply.textContent = '应用';
        apply.addEventListener('click', () => {
          const { stats: applied, next } = computeMerge(pending, strategy);
          pending = null;
          persist(next);
          toast(`导入完成:新增 ${applied.added} · 更新 ${applied.updated} · 删除 ${applied.removed}`);
        });
        actions.append(spacer, cancel, apply);

        mergeEl.append(title, opts, summary, actions);
      }

      function pickFile() {
        const input = document.createElement('input');
        input.type = 'file';
        input.accept = 'application/json,.json';
        input.style.display = 'none';
        document.body.appendChild(input);
        input.addEventListener('change', async () => {
          const file = input.files?.[0];
          input.remove();
          if (!file) return;
          try {
            pending = parseImport(await file.text());
            strategy = 'smart';
            renderMerge();
          } catch (err) {
            pending = null;
            renderMerge();
            toast(`导入失败:${err.message}`);
          }
        }, { once: true });
        input.click();
      }

      function build() {
        overlay = document.createElement('div');
        overlay.id = 'v2t-manager';

        const panel = document.createElement('div');
        panel.className = 'v2t-panel';

        const head = document.createElement('div');
        head.className = 'v2t-panel-head';
        const title = document.createElement('span');
        title.className = 'v2t-title';
        title.textContent = '用户标签';
        countEl = document.createElement('span');
        countEl.className = 'v2t-count';
        searchEl = document.createElement('input');
        searchEl.type = 'text';
        searchEl.className = 'v2t-search';
        searchEl.placeholder = '搜索用户或标签…';
        searchEl.addEventListener('input', debounce(renderList, 120));
        head.append(title, countEl, searchEl);

        listEl = document.createElement('div');
        listEl.className = 'v2t-list';

        mergeEl = document.createElement('div');
        mergeEl.className = 'v2t-merge';
        mergeEl.hidden = true;

        const foot = document.createElement('div');
        foot.className = 'v2t-panel-foot';
        const exportBtn = document.createElement('button');
        exportBtn.type = 'button';
        exportBtn.className = 'v2t-btn';
        exportBtn.textContent = '导出';
        exportBtn.addEventListener('click', () => {
          const payload = exportPayload();
          if (!payload.count) { toast('还没有标签可导出'); return; }
          download(`v2ex-user-tags-${ymd()}.json`, JSON.stringify(payload, null, 2));
          toast(`已导出 ${payload.count} 条标签`);
        });
        const importBtn = document.createElement('button');
        importBtn.type = 'button';
        importBtn.className = 'v2t-btn';
        importBtn.textContent = '导入';
        importBtn.addEventListener('click', pickFile);
        const clearBtn = document.createElement('button');
        clearBtn.type = 'button';
        clearBtn.className = 'v2t-btn danger';
        clearBtn.textContent = '清空';
        clearBtn.addEventListener('click', () => {
          const current = load();
          const total = Object.keys(current.tags).length;
          if (!total) { toast('没有可清空的标签'); return; }
          if (!window.confirm(`确定删除全部 ${total} 条标签?此操作不可撤销。`)) return;
          const next = { v: exportVersion, tags: {}, deleted: { ...current.deleted } };
          const now = Date.now();
          for (const key of Object.keys(current.tags)) next.deleted[key] = now;
          persist(next);
          toast('已清空全部标签');
        });
        const spacer = document.createElement('span');
        spacer.className = 'v2t-spacer';
        const closeBtn = document.createElement('button');
        closeBtn.type = 'button';
        closeBtn.className = 'v2t-btn';
        closeBtn.textContent = '关闭';
        closeBtn.addEventListener('click', close);
        foot.append(exportBtn, importBtn, clearBtn, spacer, closeBtn);

        panel.append(head, listEl, mergeEl, foot);
        overlay.appendChild(panel);
        document.body.appendChild(overlay);
        overlay.addEventListener('click', e => { if (e.target === overlay) close(); });
      }

      function onKey(e) {
        if (e.key === 'Escape' && isOpen() && !Editor.isOpen()) close();
      }

      function open() {
        if (!overlay) build();
        pending = null;
        searchEl.value = '';
        renderMerge();
        renderList();
        overlay.classList.add('active');
        document.addEventListener('keydown', onKey);
        searchEl.focus();
      }

      function close() {
        if (!overlay) return;
        Editor.close();
        overlay.classList.remove('active');
        document.removeEventListener('keydown', onKey);
      }

      function refresh() {
        if (isOpen()) { renderList(); renderMerge(); }
      }

      return { open, close, refresh, isOpen };
    })();

    // ── 装载 ──
    function boot() {
      decorate(document);

      // 统一委托:胶囊 / + 按钮 / 点击空白关闭气泡
      document.addEventListener('click', e => {
        const trigger = e.target.closest?.('.v2t-chip, .v2t-add');
        if (!trigger) return;
        const slot = trigger.closest('.v2t-slot');
        if (!slot?.dataset.user) return;
        e.preventDefault();
        e.stopPropagation();
        Editor.open(slot.dataset.user, trigger);
      });
      document.addEventListener('keydown', e => {
        if (e.key !== 'Enter' && e.key !== ' ') return;
        const trigger = e.target.closest?.('.v2t-chip, .v2t-add');
        if (!trigger) return;
        e.preventDefault();
        trigger.click();
      });
      document.addEventListener('mousedown', e => {
        if (Editor.isOpen() && !e.target.closest?.('.v2t-chip, .v2t-add, #v2t-editor')) Editor.close();
      });

      // 只扫新插入的子树,不做全文档 rescan:楼层树一次会搬进上千个节点,
      // 每批变更都重扫整页的话,光 querySelectorAll 就要走满整棵 DOM。
      // 用 Set 攒住待扫根节点,防抖后统一处理(同一批里祖先覆盖后代,重复由
      // attachSlot 的 data-v2t-bound 挡掉)。
      const pendingRoots = new Set();
      const flushDecorate = debounce(() => {
        const roots = [...pendingRoots];
        pendingRoots.clear();
        for (const node of roots) {
          if (node.isConnected) decorate(node);
        }
      }, 150);
      const root = document.getElementById('Main') || document.body;
      new MutationObserver(records => {
        for (const record of records) {
          for (const node of record.addedNodes) {
            // 自己插入的槽位和槽位里的胶囊都不必回扫,
            // 否则每装饰/重绘一个用户名就要多排一轮 decorate
            if (node.nodeType !== Node.ELEMENT_NODE) continue;
            if (node.classList.contains('v2t-slot')) continue;
            if (node.parentElement?.classList.contains('v2t-slot')) continue;
            pendingRoots.add(node);
          }
        }
        if (pendingRoots.size) flushDecorate();
      }).observe(root, { childList: true, subtree: true });

      // 其它标签页改动标签后同步刷新
      GM.onChange(storeKey, next => {
        store = next === undefined ? null : normalizeStore(next);
        repaintAll();
        Manager.refresh();
      });

      // 管理面板(导入 / 导出 / 批量编辑)是低频操作,入口放在油猴菜单里,
      // 不在每个主题页的标题旁常驻一个按钮。日常打标签走用户名旁的 + 即可。
      GM.menu('用户标签管理…', () => Manager.open());
    }

    return { boot, decorate, open: () => Manager.open() };
  })();

  // =========================
  // 10) 启动
  // =========================
  Daily.boot();
  UserTags.boot();
  if (isTopicPage()) {
    ThreadTree.boot();
    B64.boot();
    HotRoom.boot();
    NavKeys.boot();
    ImgurProxy.boot();
  }
})();