V2EX Tweaks

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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