ChatGPT Local Backup

实时抓取 ChatGPT 聊天记录 + 本地持久化 + 极简历史管理 UI,支持导出 zip + 媒体,FAB 可拖动,带动画、DEBUG 模式、批量导出/删除、搜索与分页,导出 chatlog.md,附带百宝箱工具菜单(新增 Canvas 画布模式内容抓取,并优化超长会话的存储安全,避免历史被清空),并显示本地备份占用大小,优化百宝箱在移动端的展开与过渡动画体验。当前版本增强:抓取原始 HTML 并在导出时恢复 Markdown/LaTeX,兼容 KaTeX / data-latex,支持项目(/g/...)模式。

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         ChatGPT Local Backup
// @namespace    https://ccbkkb.github.io/chatgpt-backup
// @version      0.8.2-latex-project
// @description  实时抓取 ChatGPT 聊天记录 + 本地持久化 + 极简历史管理 UI,支持导出 zip + 媒体,FAB 可拖动,带动画、DEBUG 模式、批量导出/删除、搜索与分页,导出 chatlog.md,附带百宝箱工具菜单(新增 Canvas 画布模式内容抓取,并优化超长会话的存储安全,避免历史被清空),并显示本地备份占用大小,优化百宝箱在移动端的展开与过渡动画体验。当前版本增强:抓取原始 HTML 并在导出时恢复 Markdown/LaTeX,兼容 KaTeX / data-latex,支持项目(/g/...)模式。
// @author       Mashiro Shiina (modified by ChatGPT)
// @license      MIT
// @match        https://chatgpt.com/*
// @match        https://chat.openai.com/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_xmlhttpRequest
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/jszip.min.js
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/turndown.js
// @connect      *
// @run-at       document-end
// ==/UserScript==

(function () {
  'use strict';

  // ---------------------------
  // 0. 样式
  // ---------------------------
  (function injectStyle() {
    try {
      const styleContent = `
        #chatgpt-backup-overlay {
          opacity: 0;
          visibility: hidden;
          transition: opacity 0.24s ease-out, visibility 0s linear 0.24s;
        }
        #chatgpt-backup-overlay.show {
          opacity: 1;
          visibility: visible;
          transition: opacity 0.24s ease-out;
        }
        #chatgpt-backup-overlay .panel {
          transform: translateY(100%);
          transition: transform 0.3s ease-out;
        }
        #chatgpt-backup-overlay.show .panel {
          transform: translateY(0);
        }
        #chatgpt-backup-toast {
          opacity: 0;
          transition: opacity 0.3s ease-out;
        }
        #chatgpt-backup-toast.show {
          opacity: 1;
        }
        #chatgpt-backup-fab:active {
          transform: scale(0.9);
          opacity: 0.8;
          transition: transform 0.1s ease-out, opacity 0.1s ease-out;
        }
        #chatgpt-backup-menu {
          transition: opacity 0.18s ease-out, transform 0.18s ease-out;
        }
        #chatgpt-backup-menu .treasure-links {
          max-height: 0;
          overflow-y: hidden;
          transition: max-height 0.22s ease-out;
        }
        #chatgpt-backup-menu .treasure-links.expanded {
          max-height: 420px;
        }
      `;
      const styleTag = document.createElement('style');
      styleTag.type = 'text/css';
      styleTag.appendChild(document.createTextNode(styleContent));
      (document.head || document.documentElement).appendChild(styleTag);
    } catch (e) {
      console.warn('[ChatGPT Backup] injectStyle error', e);
    }
  })();

  // ---------------------------
  // 0.1.1 百宝箱书签配置
  // 在这里添加 / 编辑你的常用工具链接
  // ---------------------------
  const TREASURE_LINKS = [
    {
      id: 'tempmail',
      label: '临时邮箱',
      url: 'https://chat-tempmail.com/zh'
    },
    {
      id: 'md_pdf_mdtool',
      label: 'Markdown 转 PDF(MD-TOOL)',
      url: 'https://md-tool.com/zh/markdown-to-pdf'
    },
    {
      id: 'md_pdf_janqi',
      label: 'Markdown 转 PDF(前端)',
      url: 'https://markdowntopdf.janqi.com/zh.html'
    },
    {
      id: 'md_img_cn',
      label: 'Markdown 转图片(MarkdownToImage)',
      url: 'https://markdowntoimage.cn/markdown-to-image'
    },
    {
      id: 'md_img_md2img',
      label: 'Markdown 转图片(MD2IMG)',
      url: 'https://md2img.online/zh-cn/'
    },
    {
      id: 'md_word_io',
      label: 'Markdown 转 Word DOCX',
      url: 'https://markdowntoword.io/'
    },
    {
      id: 'md_word_mdtoword',
      label: 'Markdown 转 Word(mdtoword.org)',
      url: 'https://mdtoword.org/markdown-to-docx'
    },
    {
      id: 'stackedit',
      label: '在线 Markdown 编辑器(StackEdit)',
      url: 'https://stackedit.io/app#'
    },
    {
      id: 'dillinger',
      label: '在线 Markdown 编辑器(Dillinger)',
      url: 'https://dillinger.io/'
    }
  ];

  // ---------------------------
  // 1. 存储封装
  // ---------------------------
  const hasGMValue =
    typeof GM_getValue === 'function' &&
    typeof GM_setValue === 'function';
  const hasGMRequest = typeof GM_xmlhttpRequest === 'function';

  const STORAGE_KEY_CONVERSATIONS = 'chatgpt_conversations';
  const STORAGE_KEY_LAST_CONV_ID = 'chatgpt_last_conversation_id';
  const STORAGE_KEY_DEBUG = 'chatgpt_backup_debug';
  const STORAGE_KEY_FAB_POS = 'chatgpt_backup_fab_pos';

  // 针对超长会话的安全限制,避免单个会话撑爆整个存储
  const MAX_MESSAGES_PER_CONVERSATION = 400;
  const MAX_CHARS_PER_MESSAGE = 32000;

  function storageGet(key, def) {
    if (hasGMValue) {
      try {
        return GM_getValue(key, def);
      } catch (e) {
        console.warn('[ChatGPT Backup] GM_getValue error, fallback to localStorage', e);
      }
    }
    try {
      const raw = localStorage.getItem(key);
      if (!raw) return def;
      return JSON.parse(raw);
    } catch (e) {
      console.warn('[ChatGPT Backup] localStorage get error', e);
      return def;
    }
  }

  /**
   * 安全写入:返回布尔值,表示写入是否成功。
   * 避免写入失败时误以为成功,从而导致后续逻辑用“空对象”覆盖历史。
   */
  function storageSet(key, value) {
    let ok = false;
    if (hasGMValue) {
      try {
        GM_setValue(key, value);
        ok = true;
      } catch (e) {
        console.warn('[ChatGPT Backup] GM_setValue error, fallback to localStorage', e);
      }
    }
    if (!ok) {
      try {
        localStorage.setItem(key, JSON.stringify(value));
        ok = true;
      } catch (e) {
        console.warn('[ChatGPT Backup] localStorage set error', e);
      }
    }
    return ok;
  }

  function loadAllConversations() {
    const data = storageGet(STORAGE_KEY_CONVERSATIONS, null);
    if (!data || typeof data !== 'object') return {};
    return data;
  }

  /**
   * 尝试在存储空间不足时自动删除最早的会话,避免“全仓清空”
   */
  function tryShrinkAndSave(map) {
    try {
      const copy = {};
      Object.keys(map || {}).forEach((id) => {
        copy[id] = map[id];
      });

      const convs = Object.values(copy)
        .filter((c) => c && typeof c === 'object' && c.id)
        .sort((a, b) => (a.updatedAt || 0) - (b.updatedAt || 0)); // 最早的在前

      if (!convs.length) return false;

      let removed = 0;
      for (let i = 0; i < convs.length; i++) {
        const c = convs[i];
        if (!c || !c.id) continue;
        if (Object.keys(copy).length <= 1) break; // 至少保留一个
        delete copy[c.id];
        removed++;
        if (storageSet(STORAGE_KEY_CONVERSATIONS, copy)) {
          if (removed > 0) {
            showToast(`本地备份空间不足,已自动删除最早的 ${removed} 个会话`);
          }
          return true;
        }
      }

      console.warn('[ChatGPT Backup] tryShrinkAndSave failed after removing oldest conversations');
      showToast('本地空间不足,新会话暂时无法保存,请导出或删除部分旧会话后重试');
    } catch (e) {
      console.warn('[ChatGPT Backup] tryShrinkAndSave error', e);
    }
    return false;
  }

  /**
   * 保存所有会话:写入失败时不会“写空”,而是尝试缩减再写;失败则保持原状。
   * options.force = true 时跳过 shrink 逻辑(用于删除操作)。
   */
  function saveAllConversations(map, options) {
    const force = options && options.force;
    const ok = storageSet(STORAGE_KEY_CONVERSATIONS, map);
    if (ok || force) return ok;
    // 写入失败时尝试缩减
    return tryShrinkAndSave(map);
  }

  function isDebug() {
    return !!storageGet(STORAGE_KEY_DEBUG, false);
  }

  function setDebug(enabled) {
    storageSet(STORAGE_KEY_DEBUG, !!enabled);
    showToast('DEBUG 模式已' + (enabled ? '开启' : '关闭'));
  }

  /**
   * 归一化会话,防止单个会话消息过多或单条消息过长
   */
  function normalizeConversationForStorage(conv) {
    if (!conv || typeof conv !== 'object') return conv;

    const out = Object.assign({}, conv);
    const msgs = Array.isArray(conv.messages) ? conv.messages.slice() : [];

    // 限制单条消息长度
    for (let i = 0; i < msgs.length; i++) {
      const m = Object.assign({}, msgs[i]);
      if (typeof m.content === 'string' && m.content.length > MAX_CHARS_PER_MESSAGE) {
        m.content =
          m.content.slice(0, MAX_CHARS_PER_MESSAGE) +
          '\n\n[内容过长,已截断,仅保留前 ' +
          MAX_CHARS_PER_MESSAGE +
          ' 字符]';
      }
      if (typeof m.html === 'string' && m.html.length > MAX_CHARS_PER_MESSAGE) {
        m.html =
          m.html.slice(0, MAX_CHARS_PER_MESSAGE) +
          '<!-- 内容过长,已截断,仅保留前 ' +
          MAX_CHARS_PER_MESSAGE +
          ' 字符 -->';
      }
      if (typeof m.markdown === 'string' && m.markdown.length > MAX_CHARS_PER_MESSAGE) {
        m.markdown =
          m.markdown.slice(0, MAX_CHARS_PER_MESSAGE) +
          '\n\n<!-- 内容过长,已截断,仅保留前 ' +
          MAX_CHARS_PER_MESSAGE +
          ' 字符 -->';
      }
      msgs[i] = m;
    }

    // 限制消息条数,只保留最近 N 条
    if (msgs.length > MAX_MESSAGES_PER_CONVERSATION) {
      const cut = msgs.length - MAX_MESSAGES_PER_CONVERSATION;
      out.messages = msgs.slice(cut);
    } else {
      out.messages = msgs;
    }

    return out;
  }

  function saveConversation(conv) {
    const normalized = normalizeConversationForStorage(conv);
    const prevMap = loadAllConversations();
    const map = Object.assign({}, prevMap);
    map[normalized.id] = normalized;
    const ok = saveAllConversations(map);
    if (!ok) {
      // 保存失败,保留旧数据,避免把历史清空
      if (isDebug()) {
        console.warn('[ChatGPT Backup] saveConversation failed, keep previous data only');
        showToast('本地空间不足,本次会话未保存,但已有历史依然安全');
      }
      return;
    }
    storageSet(STORAGE_KEY_LAST_CONV_ID, normalized.id);
    if (isDebug()) {
      showToast(`已保存 ${normalized.messages.length} 条消息`);
      console.log('[ChatGPT Backup] saveConversation', normalized.id, normalized);
    }
  }

  function getLastConversation() {
    const lastId = storageGet(STORAGE_KEY_LAST_CONV_ID, null);
    if (!lastId) return null;
    const map = loadAllConversations();
    return map[lastId] || null;
  }

  function deleteConversationById(id) {
    const map = loadAllConversations();
    if (map[id]) {
      delete map[id];
      const ok = saveAllConversations(map, { force: true });
      if (!ok) {
        console.warn('[ChatGPT Backup] deleteConversationById save failed');
        showToast('删除本地备份失败,请检查存储空间');
        return;
      }
      const lastId = storageGet(STORAGE_KEY_LAST_CONV_ID, null);
      if (lastId === id) {
        const ids = Object.keys(map)
          .map((cid) => map[cid])
          .sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
          .map((c) => c.id);
        storageSet(STORAGE_KEY_LAST_CONV_ID, ids[0] || null);
      }
    }
  }

  // ---------------------------
  // 2. 会话 ID
  // ---------------------------
  let currentConversationId = null;

  function getConversationIdFromUrl() {
    const path = location.pathname || '';

    // 正常对话 /c/uuid
    const m = path.match(/\/c\/([0-9a-fA-F\-]+)/);
    if (m) {
      return 'chatgpt_' + m[1];
    }

    // 项目 /g/... 模式
    const g = path.match(/\/g\/([0-9a-zA-Z\-_]+)/);
    if (g) {
      return 'chatgpt_g_' + g[1];
    }

    // 其他路径,使用 path 兜底
    if (path && path !== '/') {
      return 'chatgpt_path_' + path.replace(/\//g, '_');
    }
    return null;
  }

  function ensureConversationId() {
    const idFromUrl = getConversationIdFromUrl();
    if (idFromUrl) {
      currentConversationId = idFromUrl;
    }
    return currentConversationId;
  }

  // ---------------------------
  // 3. Toast
  // ---------------------------
  let toastEl = null;
  let toastTimer = null;

  function ensureToast() {
    if (toastEl) return toastEl;
    if (!document.body) return null;
    const el = document.createElement('div');
    el.id = 'chatgpt-backup-toast';
    el.style.position = 'fixed';
    el.style.left = '50%';
    el.style.bottom = '12px';
    el.style.transform = 'translateX(-50%)';
    el.style.zIndex = '999999';
    el.style.padding = '6px 12px';
    el.style.fontSize = '12px';
    el.style.borderRadius = '999px';
    el.style.background = 'rgba(0,0,0,0.76)';
    el.style.color = '#fff';
    el.style.maxWidth = '80vw';
    el.style.textAlign = 'center';
    el.style.pointerEvents = 'none';
    el.style.fontFamily =
      'system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Text", sans-serif';
    document.body.appendChild(el);
    toastEl = el;
    return el;
  }

  function showToast(text) {
    const el = ensureToast();
    if (!el) return;
    el.textContent = text;
    el.classList.add('show');
    if (toastTimer) clearTimeout(toastTimer);
    toastTimer = setTimeout(() => {
      el.classList.remove('show');
    }, 1800);
  }

  // ---------------------------
  // 4. Markdown / HTML 辅助(导出时用 Turndown + LaTeX 预处理)
  // ---------------------------
  let turndownService = null;

  function getTurndownService() {
    if (turndownService) return turndownService;
    try {
      if (typeof TurndownService === 'function') {
        const svc = new TurndownService({
          headingStyle: 'atx',
          codeBlockStyle: 'fenced',
          bulletListMarker: '-',
        });
        turndownService = svc;
        return svc;
      }
    } catch (e) {
      console.warn('[ChatGPT Backup] TurndownService init error', e);
    }
    return null;
  }

  /**
   * 在 Turndown 之前,把 KaTeX / data-latex 等结构转换回 TeX 源码并用 $ / $$ 包裹。
   * 这样导出的 Markdown 中公式就是真正可复制的 LaTeX。
   */
  function convertLatexInDom(root) {
    if (!root || !root.querySelectorAll) return;
    const doc = root.ownerDocument || document;

    try {
      // 1. KaTeX 结构:span.katex-display / span.katex
      const katexWrappers = Array.from(
        root.querySelectorAll('span.katex-display, span.katex')
      );

      katexWrappers.forEach((wrapper) => {
        if (!wrapper.parentNode) return;

        const annotation =
          wrapper.querySelector('annotation[encoding="application/x-tex"]') ||
          wrapper.querySelector('annotation[encoding="application/x-TeX"]');

        if (!annotation) return;

        let tex = annotation.textContent || '';
        if (!tex) return;

        // 把不间断空格换回普通空格,避免奇怪字符
        tex = tex.replace(/\u00A0/g, ' ');
        tex = tex.trim();

        const isDisplay = wrapper.classList.contains('katex-display');
        const delimiter = isDisplay ? '$$' : '$';

        let replacement = delimiter + tex + delimiter;
        if (isDisplay) {
          // 给块级公式前后加空行,方便 Markdown 渲染
          replacement = '\n\n' + replacement + '\n\n';
        }

        const textNode = doc.createTextNode(replacement);
        wrapper.parentNode.replaceChild(textNode, wrapper);
      });

      // 2. data-latex 属性(部分新 UI 可能采用)
      const latexNodes = Array.from(root.querySelectorAll('[data-latex]'));
      latexNodes.forEach((el) => {
        if (!el.parentNode) return;
        const tex = el.getAttribute('data-latex');
        if (!tex) return;

        const type = (el.getAttribute('data-latex-type') || '').toLowerCase();
        const displayAttr =
          (el.getAttribute('data-latex-display') || '').toLowerCase();

        const ds = el.dataset || {};
        const isBlock =
          type === 'block' ||
          type === 'display' ||
          displayAttr === 'block' ||
          displayAttr === 'display' ||
          ds.block === 'true' ||
          ds.display === 'block' ||
          /block|display/.test((el.className || '').toString()) ||
          el.tagName === 'DIV' ||
          el.tagName === 'P';

        const delimiter = isBlock ? '$$' : '$';
        let replacement = delimiter + tex.trim() + delimiter;

        if (isBlock) {
          replacement = '\n\n' + replacement + '\n\n';
        }

        const textNode = doc.createTextNode(replacement);
        el.parentNode.replaceChild(textNode, el);
      });
    } catch (e) {
      console.warn('[ChatGPT Backup] convertLatexInDom error', e);
    }
  }

  function htmlToMarkdown(html) {
    try {
      const svc = getTurndownService();
      if (!svc || !html) return '';
      const wrapper = document.createElement('div');
      wrapper.innerHTML = html;

      // 先把 KaTeX / data-latex 等转换为真正的 TeX 源码
      convertLatexInDom(wrapper);

      // 移除按钮等 UI 垃圾节点,避免污染导出内容
      ['button', 'svg', 'path', 'textarea'].forEach((sel) => {
        wrapper.querySelectorAll(sel).forEach((el) => el.remove());
      });

      const md = svc.turndown(wrapper);
      return (md || '').trim();
    } catch (e) {
      console.warn('[ChatGPT Backup] htmlToMarkdown error', e);
      return '';
    }
  }

  function getMessagePlainText(m) {
    if (m.content && m.content.trim()) return m.content;
    if (m.markdown && m.markdown.trim()) return m.markdown;
    if (m.html) {
      try {
        const wrapper = document.createElement('div');
        wrapper.innerHTML = m.html;
        return (wrapper.innerText || '').trim();
      } catch (e) {
        return '';
      }
    }
    return '';
  }

  function getMessageMarkdown(m) {
    if (m.markdown && m.markdown.trim()) return m.markdown.trim();
    if (m.html) {
      const md = htmlToMarkdown(m.html);
      if (md) return md;
    }
    if (m.content && m.content.trim()) return m.content.trim();
    return '';
  }

  // ---------------------------
  // 5. 抓取 ChatGPT 消息 + 媒体
  // ---------------------------
  function detectRole(node) {
    if (!node || !node.getAttribute) return 'assistant';

    const roleAttr = node.getAttribute('data-message-author-role');
    if (roleAttr === 'user' || roleAttr === 'assistant') return roleAttr;

    const testId =
      node.getAttribute('data-testid') ||
      node.getAttribute('data-test-id') ||
      '';
    const cls = (node.className || '').toString();
    const hint = (testId + ' ' + cls).toLowerCase();

    if (/\buser\b/.test(hint)) return 'user';
    if (/\bassistant\b/.test(hint) || /\bbot\b/.test(hint) || /\bgpt\b/.test(hint)) {
      return 'assistant';
    }

    return 'assistant';
  }

  function extractTextFromNode(node) {
    return node.innerText || '';
  }

  function extractMediaFromNode(node) {
    const media = [];
    const imgEls = node.querySelectorAll('img');
    imgEls.forEach((img) => {
      const src = img.currentSrc || img.src;
      if (!src) return;
      media.push({ type: 'image', url: src, alt: img.alt || '' });
    });
    const avEls = node.querySelectorAll('audio, video');
    avEls.forEach((el) => {
      const src = el.currentSrc || el.src;
      if (!src) return;
      media.push({ type: el.tagName.toLowerCase(), url: src });
    });
    return media;
  }

  function extractHtmlFromNode(node) {
    try {
      return node.innerHTML || '';
    } catch (e) {
      return '';
    }
  }

  /**
   * Canvas 画布模式内容抓取:
   * 尝试在 main 中识别带有 "canvas" 特征的编辑区域,并将其作为额外的一条消息备份。
   */
  function queryCanvasBlocks(container) {
    const blocks = [];
    if (!container) return blocks;

    const now = Date.now();

    const selectors =
      '[data-testid*="canvas"],[data-test-id*="canvas"],[id*="canvas"],[class*="canvas"]';
    const candidateNodes = Array.from(container.querySelectorAll(selectors));

    if (!candidateNodes.length) return blocks;

    // 只保留顶层 canvas 容器(避免重复包含)
    const topLevelCandidates = candidateNodes.filter((el) => {
      if (el.closest('[data-message-author-role]')) return false;
      return !candidateNodes.some((other) => other !== el && other.contains(el));
    });

    topLevelCandidates.forEach((el) => {
      const text = (el.innerText || '').trim();
      if (!text) return;
      const rect = el.getBoundingClientRect && el.getBoundingClientRect();
      // 排除很小的按钮/标签,粗略认为真正的画布至少有一定尺寸或文本
      if (
        rect &&
        rect.width < 200 &&
        rect.height < 80 &&
        text.length < 200
      ) {
        return;
      }
      blocks.push({
        role: 'assistant',
        content: text,
        timestamp: now,
        isCanvas: true,
      });
    });

    return blocks;
  }

  function queryMessages() {
    const messages = [];

    const container =
      document.querySelector('[data-testid="conversation-pane"]') ||
      document.querySelector('main') ||
      document.querySelector('div[role="main"]') ||
      document.body;

    if (!container) return messages;

    let nodes = container.querySelectorAll('[data-message-author-role]');
    if (!nodes || !nodes.length) {
      // 项目模式 / 新 UI 兜底选择
      nodes = container.querySelectorAll(
        '[data-testid*="chat-message"],[data-testid*="conversation-turn"],[data-testid*="assistant-message"],[data-testid*="user-message"]'
      );
    }
    if (!nodes || !nodes.length) return messages;

    const now = Date.now();

    // 普通聊天消息:这次不仅保存文本 + 媒体,也保存 HTML,
    // 便于导出时通过 Turndown + LaTeX 预处理恢复 Markdown / TeX
    nodes.forEach((node) => {
      const role = detectRole(node);
      const content = extractTextFromNode(node).trim();
      const media = extractMediaFromNode(node);
      const html = extractHtmlFromNode(node);

      if (!content && !media.length && !html) return;

      const msg = {
        role,
        timestamp: now,
      };
      if (content) msg.content = content;
      if (html) msg.html = html;
      if (media.length) msg.media = media;
      messages.push(msg);
    });

    // Canvas 画布内容(如果存在)
    const canvasBlocks = queryCanvasBlocks(container);
    if (canvasBlocks.length) {
      canvasBlocks.forEach((block) => {
        messages.push(block);
      });
    }

    return messages;
  }

  // ---------------------------
  // 6. 自动保存(防抖)
  // ---------------------------
  let saveTimer = null;

  function scheduleSave() {
    if (saveTimer) clearTimeout(saveTimer);
    saveTimer = setTimeout(doSaveConversation, 1500);
  }

  function doSaveConversation() {
    const msgs = queryMessages();
    if (!msgs.length) return;

    const path = location.pathname || '';
    const hasUuid = /\/c\/[0-9a-fA-F\-]+/.test(path);
    if (!hasUuid && path === '/') {
      if (isDebug()) {
        console.log('[ChatGPT Backup] skip save on root path /, wait for /c/uuid');
      }
      return;
    }

    const cid = ensureConversationId();
    if (!cid) {
      if (isDebug()) {
        console.log('[ChatGPT Backup] no conversation id yet, skip save');
      }
      return;
    }

    const rawConv = {
      id: cid,
      url: location.href,
      title: document.title,
      messages: msgs,
      updatedAt: Date.now(),
    };
    const conv = normalizeConversationForStorage(rawConv);
    saveConversation(conv);
  }

  // ---------------------------
  // 7. 观察 DOM 变化
  // ---------------------------
  function initObserver() {
    const target =
      document.querySelector('[data-testid="conversation-pane"]') ||
      document.querySelector('main') ||
      document.querySelector('div[role="main"]') ||
      document.body;

    if (!target) {
      setTimeout(initObserver, 2000);
      return;
    }

    const obs = new MutationObserver((ms) => {
      for (const m of ms) {
        if (m.type === 'childList' && (m.addedNodes.length || m.removedNodes.length)) {
          scheduleSave();
          break;
        }
      }
    });
    obs.observe(target, { childList: true, subtree: true });
    scheduleSave();
  }

  // ---------------------------
  // 8. FAB + 管理 UI 变量
  // ---------------------------
  let fabBtn = null;
  let overlayEl = null;
  let listViewEl = null;
  let detailViewEl = null;
  let detailContentEl = null;
  let debugBtnEl = null;

  let batchModeEnabled = false;
  let batchSelectedIds = new Set();

  let batchBarEl = null;
  let batchCountEl = null;
  let batchSelectAllBtnEl = null;
  let batchExportBtnEl = null;
  let batchDeleteBtnEl = null;
  let batchExitBtnEl = null;

  const PAGE_SIZE = 20;
  let currentPage = 1;
  let searchQuery = '';

  let searchInputEl = null;
  let searchWrapperEl = null;
  let listBodyEl = null;
  let storageInfoEl = null;

  function ensureManagerButton() {
    if (fabBtn || !document.body) return;
    const btn = document.createElement('button');
    btn.id = 'chatgpt-backup-fab';
    btn.type = 'button';
    btn.textContent = '≡';
    btn.style.position = 'fixed';
    btn.style.zIndex = '999998';
    btn.style.width = '48px';
    btn.style.height = '48px';
    btn.style.borderRadius = '999px';
    btn.style.border = 'none';
    btn.style.background = 'rgba(0,0,0,0.82)';
    btn.style.color = '#fff';
    btn.style.fontSize = '18px';
    btn.style.lineHeight = '48px';
    btn.style.textAlign = 'center';
    btn.style.boxShadow = '0 4px 12px rgba(0,0,0,0.35)';
    btn.style.cursor = 'pointer';
    btn.style.padding = '0';
    btn.style.margin = '0';
    btn.style.fontFamily =
      'system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Text", sans-serif';
    btn.style.userSelect = 'none';
    btn.style.touchAction = 'none';
    btn.style.opacity = '0.88';

    const saved = storageGet(STORAGE_KEY_FAB_POS, null);
    if (saved && typeof saved.x === 'number' && typeof saved.y === 'number') {
      btn.style.left = saved.x + 'px';
      btn.style.top = saved.y + 'px';
    } else {
      const vw = window.innerWidth || 360;
      const vh = window.innerHeight || 640;
      btn.style.left = Math.max(16, vw - 64) + 'px';
      btn.style.top = Math.max(16, vh - 88) + 'px';
    }

    let dragging = false;
    let dragMoved = false;
    let dragOffsetX = 0;
    let dragOffsetY = 0;
    let startX = 0;
    let startY = 0;
    const TH2 = 25;

    function getP(e) {
      if (e.touches && e.touches.length) return e.touches[0];
      return e;
    }
    function onDragStart(e) {
      const p = getP(e);
      dragging = true;
      dragMoved = false;
      startX = p.clientX;
      startY = p.clientY;
      const rect = btn.getBoundingClientRect();
      dragOffsetX = p.clientX - rect.left;
      dragOffsetY = p.clientY - rect.top;
    }
    function onDragMove(e) {
      if (!dragging) return;
      const p = getP(e);
      const dx = p.clientX - startX;
      const dy = p.clientY - startY;
      if (!dragMoved && dx * dx + dy * dy > TH2) {
        dragMoved = true;
      }
      if (!dragMoved) return;
      e.preventDefault();
      let x = p.clientX - dragOffsetX;
      let y = p.clientY - dragOffsetY;
      const margin = 8;
      const vw = window.innerWidth;
      const vh = window.innerHeight;
      const w = btn.offsetWidth;
      const h = btn.offsetHeight;
      x = Math.min(vw - w - margin, Math.max(margin, x));
      y = Math.min(vh - h - margin, Math.max(margin, y));
      btn.style.left = x + 'px';
      btn.style.top = y + 'px';
    }
    function onDragEnd() {
      if (!dragging) return;
      dragging = false;
      if (dragMoved) {
        const r = btn.getBoundingClientRect();
        storageSet(STORAGE_KEY_FAB_POS, { x: r.left, y: r.top });
      }
    }

    btn.addEventListener('mousedown', onDragStart);
    window.addEventListener('mousemove', onDragMove);
    window.addEventListener('mouseup', onDragEnd);
    btn.addEventListener('touchstart', (e) => onDragStart(e), { passive: true });
    window.addEventListener('touchmove', (e) => onDragMove(e), { passive: false });
    window.addEventListener('touchend', onDragEnd);

    btn.addEventListener('click', () => {
      if (dragMoved) return;
      openManagerOverlay();
    });

    document.body.appendChild(btn);
    fabBtn = btn;
  }

  // ---------------------------
  // 9. 管理面板 + 百宝箱 + 批量条
  // ---------------------------
  function createOverlay() {
    if (overlayEl || !document.body) return;
    const overlay = document.createElement('div');
    overlay.id = 'chatgpt-backup-overlay';

    overlay.style.position = 'fixed';
    overlay.style.inset = '0';
    overlay.style.zIndex = '999997';
    overlay.style.display = 'flex';
    overlay.style.justifyContent = 'center';
    overlay.style.alignItems = 'flex-end';
    overlay.style.fontFamily =
      'system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Text", sans-serif';

    const panel = document.createElement('div');
    panel.className = 'panel';
    panel.style.position = 'relative';
    panel.style.width = '100%';
    panel.style.maxWidth = '720px';
    panel.style.maxHeight = '80vh';
    panel.style.margin = '0 auto 8px';
    panel.style.background = '#ffffff';
    panel.style.borderRadius = '18px 18px 14px 14px';
    panel.style.boxShadow = '0 10px 30px rgba(0,0,0,0.25)';
    panel.style.display = 'flex';
    panel.style.flexDirection = 'column';
    panel.style.overflow = 'hidden';

    const header = document.createElement('div');
    header.style.display = 'flex';
    header.style.alignItems = 'center';
    header.style.justifyContent = 'space-between';
    header.style.padding = '10px 14px 8px';
    header.style.borderBottom = '1px solid rgba(0,0,0,0.06)';

    const title = document.createElement('div');
    title.textContent = '聊天备份';
    title.style.fontSize = '14px';
    title.style.fontWeight = '600';

    const headerRight = document.createElement('div');
    headerRight.style.display = 'flex';
    headerRight.style.alignItems = 'center';
    headerRight.style.gap = '6px';

    const moreBtn = document.createElement('button');
    moreBtn.type = 'button';
    moreBtn.textContent = '⋯';
    moreBtn.style.border = 'none';
    moreBtn.style.background = 'transparent';
    moreBtn.style.fontSize = '16px';
    moreBtn.style.color = '#666';
    moreBtn.style.padding = '2px 6px';
    moreBtn.style.cursor = 'pointer';
    moreBtn.style.lineHeight = '1';

    const debugBtn = document.createElement('button');
    debugBtn.type = 'button';
    debugBtn.textContent = isDebug() ? 'DEBUG 开' : 'DEBUG 关';
    debugBtn.style.border = 'none';
    debugBtn.style.background = 'transparent';
    debugBtn.style.fontSize = '11px';
    debugBtn.style.color = '#999';
    debugBtn.style.padding = '4px 6px';
    debugBtn.style.cursor = 'pointer';

    debugBtn.addEventListener('click', () => {
      const now = !isDebug();
      setDebug(now);
      debugBtn.textContent = now ? 'DEBUG 开' : 'DEBUG 关';
    });

    const closeBtn = document.createElement('button');
    closeBtn.type = 'button';
    closeBtn.textContent = '关闭';
    closeBtn.style.border = 'none';
    closeBtn.style.background = 'transparent';
    closeBtn.style.fontSize = '13px';
    closeBtn.style.color = '#666';
    closeBtn.style.padding = '4px 8px';
    closeBtn.style.cursor = 'pointer';

    closeBtn.addEventListener('click', () => {
      hideMenu();
      closeManagerOverlay();
    });

    headerRight.appendChild(moreBtn);
    headerRight.appendChild(debugBtn);
    headerRight.appendChild(closeBtn);

    header.appendChild(title);
    header.appendChild(headerRight);

    const listView = document.createElement('div');
    listView.style.flex = '1';
    listView.style.overflowY = 'auto';
    listView.style.padding = '8px 10px 10px';
    listView.style.fontSize = '13px';
    listView.style.background = '#fafafa';

    const detailView = document.createElement('div');
    detailView.style.flex = '1';
    detailView.style.overflowY = 'auto';
    detailView.style.padding = '10px 12px 12px';
    detailView.style.fontSize = '13px';
    detailView.style.display = 'none';
    detailView.style.background = '#fafafa';

    const detailTopBar = document.createElement('div');
    detailTopBar.style.display = 'flex';
    detailTopBar.style.justifyContent = 'space-between';
    detailTopBar.style.alignItems = 'center';
    detailTopBar.style.marginBottom = '8px';

    const detailTitle = document.createElement('div');
    detailTitle.textContent = '会话详情';
    detailTitle.style.fontSize = '13px';
    detailTitle.style.fontWeight = '500';

    const detailBackBtn = document.createElement('button');
    detailBackBtn.type = 'button';
    detailBackBtn.textContent = '返回';
    detailBackBtn.style.border = 'none';
    detailBackBtn.style.background = 'transparent';
    detailBackBtn.style.fontSize = '13px';
    detailBackBtn.style.color = '#666';
    detailBackBtn.style.padding = '4px 8px';
    detailBackBtn.style.cursor = 'pointer';

    detailBackBtn.addEventListener('click', () => {
      listView.style.display = 'block';
      detailView.style.display = 'none';
    });

    detailTopBar.appendChild(detailTitle);
    detailTopBar.appendChild(detailBackBtn);

    const detailContent = document.createElement('pre');
    detailContent.style.whiteSpace = 'pre-wrap';
    detailContent.style.wordBreak = 'break-word';
    detailContent.style.fontFamily =
      'system-ui, -apple-system, BlinkMacSystemFont, "SF Mono", monospace';
    detailContent.style.fontSize = '12px';
    detailContent.style.lineHeight = '1.5';
    detailContent.style.padding = '8px 10px';
    detailContent.style.borderRadius = '10px';
    detailContent.style.background = '#ffffff';
    detailContent.style.border = '1px solid rgba(0,0,0,0.05)';

    detailView.appendChild(detailTopBar);
    detailView.appendChild(detailContent);

    const batchBar = document.createElement('div');
    batchBar.style.display = 'none';
    batchBar.style.alignItems = 'center';
    batchBar.style.justifyContent = 'space-between';
    batchBar.style.padding = '6px 10px 8px';
    batchBar.style.borderTop = '1px solid rgba(0,0,0,0.06)';
    batchBar.style.background = '#fff';

    const batchLeft = document.createElement('div');
    batchLeft.style.fontSize = '12px';
    batchLeft.style.color = '#555';

    const batchCount = document.createElement('span');
    batchCount.textContent = '已选 0';

    batchLeft.appendChild(batchCount);

    const batchRight = document.createElement('div');
    batchRight.style.display = 'flex';
    batchRight.style.gap = '6px';

    function createBatchButton(label, color, handler) {
      const btn = document.createElement('button');
      btn.type = 'button';
      btn.textContent = label;
      btn.style.border = '1px solid rgba(0,0,0,0.08)';
      btn.style.borderRadius = '999px';
      btn.style.padding = '4px 10px';
      btn.style.fontSize = '12px';
      btn.style.background = '#fff';
      btn.style.color = color || '#333';
      btn.style.cursor = 'pointer';
      btn.style.minHeight = '32px';
      btn.style.touchAction = 'manipulation';
      btn.addEventListener('click', handler);
      return btn;
    }

    const btnSelectAll = createBatchButton('全选', '#007aff', () => {
      handleSelectAll();
    });
    const btnBatchExport = createBatchButton('导出', '#007aff', () => {
      exportSelectedConversationsAsZip().catch((err) => {
        console.error('[ChatGPT Backup] batch export error', err);
        showToast('批量导出失败');
      });
    });
    const btnBatchDelete = createBatchButton('删除', '#ff3b30', () => {
      handleBatchDelete();
    });
    const btnBatchExit = createBatchButton('退出', '#666', () => {
      exitBatchMode();
    });

    batchRight.appendChild(btnSelectAll);
    batchRight.appendChild(btnBatchExport);
    batchRight.appendChild(btnBatchDelete);
    batchRight.appendChild(btnBatchExit);

    batchBar.appendChild(batchLeft);
    batchBar.appendChild(batchRight);

    // 百宝箱菜单
    const menu = document.createElement('div');
    menu.id = 'chatgpt-backup-menu';
    menu.style.position = 'fixed';
    menu.style.right = '12px';
    menu.style.top = '44px';
    menu.style.minWidth = '210px';
    menu.style.maxWidth = '90vw';
    menu.style.background = '#fff';
    menu.style.borderRadius = '12px';
    menu.style.boxShadow = '0 10px 30px rgba(0,0,0,0.25)';
    menu.style.padding = '6px 0';
    menu.style.fontSize = '13px';
    menu.style.display = 'none';
    menu.style.zIndex = '1000';
    menu.style.maxHeight = '70vh';
    menu.style.overflowY = 'auto';
    menu.style.webkitOverflowScrolling = 'touch';

    const linksHeader = document.createElement('div');
    linksHeader.style.display = 'flex';
    linksHeader.style.alignItems = 'center';
    linksHeader.style.justifyContent = 'space-between';
    linksHeader.style.padding = '6px 12px';
    linksHeader.style.cursor = 'pointer';
    linksHeader.style.userSelect = 'none';

    const linksHeaderText = document.createElement('span');
    linksHeaderText.textContent = '百宝箱';

    const linksArrow = document.createElement('span');
    linksArrow.textContent = '▾';
    linksArrow.style.fontSize = '11px';
    linksArrow.style.opacity = '0.6';

    linksHeader.appendChild(linksHeaderText);
    linksHeader.appendChild(linksArrow);

    const linksContainer = document.createElement('div');
    linksContainer.style.paddingTop = '4px';
    linksContainer.style.borderTop = '1px solid rgba(0,0,0,0.06)';
    linksContainer.classList.add('treasure-links', 'expanded');

    const logoutBtn = document.createElement('button');
    logoutBtn.type = 'button';
    logoutBtn.textContent = 'ChatGPT 强制登出(幽灵账号修复)';
    logoutBtn.style.display = 'block';
    logoutBtn.style.width = '100%';
    logoutBtn.style.textAlign = 'left';
    logoutBtn.style.border = 'none';
    logoutBtn.style.background = 'transparent';
    logoutBtn.style.padding = '6px 20px';
    logoutBtn.style.fontSize = '13px';
    logoutBtn.style.color = '#ff3b30';
    logoutBtn.style.cursor = 'pointer';

    logoutBtn.addEventListener('click', () => {
      const msg =
        '该操作会在新标签页打开 ChatGPT 登出页面:\n' +
        'https://chatgpt.com/auth/logout\n\n' +
        '使用场景:\n' +
        '• bugTeam / workspace 异常导致出现 “幽灵账号” 或登录状态异常\n' +
        '• 正常登出按钮失效、账号卡在无法退出的状态\n\n' +
        '注意:\n' +
        '• 若当前有正在进行的工作,请确认已保存\n' +
        '• 登出后可能需要重新登录,且可能触发风控校验\n\n' +
        '是否继续打开强制登出页面?';
      const first = window.confirm(msg);
      if (!first) return;
      const second = window.confirm('再次确认:现在立即打开强制登出页面?');
      if (!second) return;
      const url = 'https://chatgpt.com/auth/logout';
      try {
        window.open(url, '_blank', 'noopener');
      } catch (e) {
        window.location.href = url;
      }
    });

    linksContainer.appendChild(logoutBtn);

    // 由 TREASURE_LINKS JSON 生成的普通链接(不含强制退出)
    TREASURE_LINKS.forEach((item) => {
      if (!item || !item.url) return;
      const link = document.createElement('a');
      link.href = item.url;
      link.target = '_blank';
      link.rel = 'noreferrer';
      link.textContent = item.label || item.url;
      link.style.display = 'block';
      link.style.padding = '6px 20px';
      link.style.textDecoration = 'none';
      link.style.color = '#007aff';
      link.style.fontSize = '13px';

      link.addEventListener('click', () => {
        hideMenu();
      });

      linksContainer.appendChild(link);
    });

    let linksExpanded = true;
    linksHeader.addEventListener('click', () => {
      linksExpanded = !linksExpanded;
      if (linksExpanded) {
        linksContainer.classList.add('expanded');
        linksArrow.textContent = '▾';
      } else {
        linksContainer.classList.remove('expanded');
        linksArrow.textContent = '▸';
      }
    });

    menu.appendChild(linksHeader);
    menu.appendChild(linksContainer);

    function positionTreasureMenu() {
      const vh =
        window.innerHeight ||
        (document.documentElement && document.documentElement.clientHeight) ||
        0;
      const vw =
        window.innerWidth ||
        (document.documentElement && document.documentElement.clientWidth) ||
        0;
      const btnRect = moreBtn.getBoundingClientRect();

      const maxHeight = Math.round(vh * 0.7);
      menu.style.maxHeight = maxHeight + 'px';

      const rawHeight = menu.scrollHeight || 0;
      const menuHeight = Math.min(rawHeight, maxHeight);

      const spaceBelow = vh - btnRect.bottom - 8;
      const spaceAbove = btnRect.top - 8;

      if (spaceBelow >= menuHeight || spaceBelow >= spaceAbove) {
        const top = Math.min(btnRect.bottom + 8, vh - menuHeight - 8);
        menu.style.top = top + 'px';
        menu.style.bottom = 'auto';
      } else {
        const bottom = Math.min(vh - btnRect.top + 8, vh - menuHeight - 8);
        menu.style.top = 'auto';
        menu.style.bottom = bottom + 'px';
      }

      const right = Math.max(8, vw - btnRect.right - 4);
      menu.style.right = right + 'px';
      menu.style.left = 'auto';
    }

    function showMenu() {
      menu.style.display = 'block';
      menu.style.opacity = '0';
      menu.style.transform = 'scale(0.96)';
      positionTreasureMenu();
      requestAnimationFrame(() => {
        menu.style.opacity = '1';
        menu.style.transform = 'scale(1)';
      });
    }

    function hideMenu() {
      if (menu.style.display !== 'block') return;
      menu.style.opacity = '0';
      menu.style.transform = 'scale(0.96)';
      setTimeout(() => {
        menu.style.display = 'none';
      }, 160);
    }

    moreBtn.addEventListener('click', (e) => {
      e.stopPropagation();
      const isShown = menu.style.display === 'block';
      if (isShown) {
        hideMenu();
      } else {
        showMenu();
      }
    });

    panel.appendChild(header);
    panel.appendChild(listView);
    panel.appendChild(detailView);
    panel.appendChild(batchBar);
    panel.appendChild(menu);

    overlay.appendChild(panel);
    document.body.appendChild(overlay);

    overlay.addEventListener('click', (e) => {
      if (menu && menu.style.display === 'block') {
        if (!menu.contains(e.target) && e.target !== moreBtn && !moreBtn.contains(e.target)) {
          hideMenu();
        }
      }
      if (e.target === overlay) {
        closeManagerOverlay();
      }
    });

    window.addEventListener('resize', () => {
      if (menu && menu.style.display === 'block') {
        positionTreasureMenu();
      }
    });

    overlayEl = overlay;
    listViewEl = listView;
    detailViewEl = detailView;
    detailContentEl = detailContent;
    debugBtnEl = debugBtn;

    batchBarEl = batchBar;
    batchCountEl = batchCount;
    batchSelectAllBtnEl = btnSelectAll;
    batchExportBtnEl = btnBatchExport;
    batchDeleteBtnEl = btnBatchDelete;
    batchExitBtnEl = btnBatchExit;

    ensureSearchAndBody();
    updateBatchUI();
  }

  function openManagerOverlay() {
    createOverlay();
    renderConversationList();
    if (overlayEl) overlayEl.classList.add('show');
  }

  function closeManagerOverlay() {
    if (overlayEl) overlayEl.classList.remove('show');
  }

  // ---------------------------
  // 10. 搜索 + 列表容器
  // ---------------------------
  function ensureSearchAndBody() {
    if (!listViewEl) return;

    if (!storageInfoEl) {
      const info = document.createElement('div');
      info.style.fontSize = '11px';
      info.style.color = '#888';
      info.style.marginBottom = '4px';
      info.style.lineHeight = '1.4';
      storageInfoEl = info;
      if (listViewEl.firstChild) {
        listViewEl.insertBefore(info, listViewEl.firstChild);
      } else {
        listViewEl.appendChild(info);
      }
    }

    if (!searchWrapperEl) {
      const wrapper = document.createElement('div');
      wrapper.style.display = 'flex';
      wrapper.style.alignItems = 'center';
      wrapper.style.marginBottom = '8px';

      const input = document.createElement('input');
      input.type = 'search';
      input.placeholder = '搜索标题 / URL';
      input.value = searchQuery;
      input.style.flex = '1';
      input.style.fontSize = '13px';
      input.style.padding = '6px 10px';
      input.style.borderRadius = '999px';
      input.style.border = '1px solid rgba(0,0,0,0.12)';
      input.style.outline = 'none';
      input.style.background = '#fff';

      input.addEventListener('input', (e) => {
        searchQuery = e.target.value || '';
        currentPage = 1;
        renderConversationItems();
      });

      wrapper.appendChild(input);
      listViewEl.appendChild(wrapper);

      searchWrapperEl = wrapper;
      searchInputEl = input;
    } else if (searchInputEl) {
      searchInputEl.value = searchQuery;
    }

    if (!listBodyEl) {
      const body = document.createElement('div');
      body.style.marginTop = '4px';
      listBodyEl = body;
      listViewEl.appendChild(body);
    }
  }

  // ---------------------------
  // 11. 批量模式 UI
  // ---------------------------
  function updateBatchUI() {
    if (
      !batchBarEl ||
      !batchCountEl ||
      !batchSelectAllBtnEl ||
      !batchExportBtnEl ||
      !batchDeleteBtnEl ||
      !batchExitBtnEl
    ) {
      return;
    }

    if (batchModeEnabled) {
      batchBarEl.style.display = 'flex';
      const count = batchSelectedIds.size;
      batchCountEl.textContent = `已选 ${count}`;
      const disabled = count === 0;
      [batchExportBtnEl, batchDeleteBtnEl].forEach((btn) => {
        btn.style.opacity = disabled ? '0.4' : '1';
        btn.style.pointerEvents = disabled ? 'none' : 'auto';
      });

      const allConvs = getFilteredSortedConversations();
      const allIds = allConvs.map((c) => c.id);
      const allSelected =
        allIds.length > 0 && allIds.every((id) => batchSelectedIds.has(id));
      batchSelectAllBtnEl.textContent = allSelected ? '全不选' : '全选';
    } else {
      batchBarEl.style.display = 'none';
    }
  }

  function enterBatchMode(initialId) {
    if (!batchModeEnabled) {
      batchModeEnabled = true;
      batchSelectedIds = new Set();
    }
    if (initialId) {
      batchSelectedIds.add(initialId);
    }
    renderConversationItems();
    updateBatchUI();
    showToast('已进入批量模式');
  }

  function exitBatchMode() {
    batchModeEnabled = false;
    batchSelectedIds.clear();
    renderConversationItems();
    updateBatchUI();
  }

  function toggleBatchSelection(id) {
    if (batchSelectedIds.has(id)) {
      batchSelectedIds.delete(id);
    } else {
      batchSelectedIds.add(id);
    }
    renderConversationItems();
    updateBatchUI();
  }

  function handleBatchDelete() {
    if (!batchModeEnabled || batchSelectedIds.size === 0) {
      showToast('请先选择要删除的会话');
      return;
    }
    const ok = window.confirm(
      `确认删除选中的 ${batchSelectedIds.size} 个会话的本地备份?此操作不可恢复。`
    );
    if (!ok) return;
    batchSelectedIds.forEach((id) => {
      deleteConversationById(id);
    });
    batchSelectedIds.clear();
    renderConversationItems();
    updateBatchUI();
    showToast('已删除所选会话');
  }

  function handleSelectAll() {
    if (!batchModeEnabled) return;
    const allConvs = getFilteredSortedConversations();
    const allIds = allConvs.map((c) => c.id);
    const allSelected =
      allIds.length > 0 && allIds.every((id) => batchSelectedIds.has(id));
    if (allSelected) {
      allIds.forEach((id) => batchSelectedIds.delete(id));
    } else {
      allIds.forEach((id) => batchSelectedIds.add(id));
    }
    renderConversationItems();
    updateBatchUI();
  }

  // ---------------------------
  // 12. 搜索 + 排序
  // ---------------------------
  function getFilteredSortedConversations() {
    const map = loadAllConversations();
    const ids = Object.keys(map);
    const sorted = ids
      .map((id) => map[id])
      .sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));

    if (!searchQuery) return sorted;
    const q = searchQuery.toLowerCase();
    return sorted.filter((conv) => {
      const t = (conv.title || '').toLowerCase();
      const u = (conv.url || '').toLowerCase();
      return t.includes(q) || u.includes(q);
    });
  }

  // ---------------------------
  // 13. 列表渲染 + 文本/Markdown 格式
  // ---------------------------
  function formatTimestamp(ts) {
    try {
      const d = new Date(ts);
      if (isNaN(d.getTime())) return '';
      const y = d.getFullYear();
      const m = (d.getMonth() + 1).toString().padStart(2, '0');
      const day = d.getDate().toString().padStart(2, '0');
      const hh = d.getHours().toString().padStart(2, '0');
      const mm = d.getMinutes().toString().padStart(2, '0');
      return `${y}-${m}-${day} ${hh}:${mm}`;
    } catch (e) {
      return '';
    }
  }

  function getConversationCreatedAt(conv) {
    if (!conv.messages || !conv.messages.length) return null;
    let ts = conv.messages[0].timestamp || conv.updatedAt || Date.now();
    conv.messages.forEach((m) => {
      if (m.timestamp && m.timestamp < ts) ts = m.timestamp;
    });
    return ts;
  }

  function buildMediaMap(conv) {
    const map = new Map();
    let counter = 1;
    (conv.messages || []).forEach((m) => {
      const arr = m.media || [];
      arr.forEach((mediaInfo) => {
        const url = mediaInfo && mediaInfo.url;
        if (!url || map.has(url)) return;
        const cleanUrl = url.split(/[?#]/)[0] || '';
        const extMatch = cleanUrl.match(/\.([a-zA-Z0-9]{1,6})$/);
        const ext = extMatch
          ? extMatch[1].toLowerCase()
          : mediaInfo.type === 'image'
          ? 'jpg'
          : 'bin';
        const filename = `media_${counter}.${ext}`;
        map.set(url, {
          url,
          type: mediaInfo.type || 'media',
          filename,
          alt: mediaInfo.alt || '',
        });
        counter++;
      });
    });
    return map;
  }

  function calcConversationSize(conv) {
    if (!conv) return 0;
    try {
      const str = JSON.stringify(conv);
      if (typeof Blob === 'function') {
        return new Blob([str]).size;
      }
      return str.length;
    } catch (e) {
      console.warn('[ChatGPT Backup] calcConversationSize error', e);
      return 0;
    }
  }

  function formatBytes(bytes) {
    if (!bytes || bytes <= 0) return '0 B';
    const units = ['B', 'KB', 'MB', 'GB'];
    let value = bytes;
    let idx = 0;
    while (value >= 1024 && idx < units.length - 1) {
      value = value / 1024;
      idx++;
    }
    const fixed = value >= 100 || idx === 0 ? value.toFixed(0) : value.toFixed(1);
    return fixed + ' ' + units[idx];
  }

  function updateStorageUsageUI(totalBytes, convCount) {
    if (!storageInfoEl) return;
    const count = typeof convCount === 'number' ? convCount : 0;
    storageInfoEl.textContent =
      '本地备份:' +
      count +
      ' 个会话,约 ' +
      formatBytes(totalBytes || 0) +
      '。';
  }

  function formatConversationPlainText(conv, mediaMap) {
    const lines = [];
    lines.push(`标题:${(conv.title || '未命名对话').trim()}`);
    lines.push(`URL:${conv.url || ''}`);
    lines.push(`更新时间:${formatTimestamp(conv.updatedAt)}`);
    const createdAt = getConversationCreatedAt(conv);
    if (createdAt) {
      lines.push(`创建时间:${formatTimestamp(createdAt)}`);
    }
    lines.push('');

    (conv.messages || []).forEach((m, idx) => {
      const roleLabel = m.role === 'user' ? '用户' : '助手';
      lines.push(`【${roleLabel} #${idx + 1}】`);
      const body = getMessagePlainText(m);
      if (body) {
        lines.push(body);
      }
      const mediaArr = m.media || [];
      if (mediaArr.length && mediaMap) {
        mediaArr.forEach((mediaInfo) => {
          const entry = mediaMap.get(mediaInfo.url);
          if (entry) {
            lines.push(
              `(媒体: ${entry.type || 'media'} -> media/${entry.filename})`
            );
          } else if (mediaInfo.url) {
            lines.push(
              `(媒体: ${mediaInfo.type || 'media'} -> ${mediaInfo.url})`
            );
          }
        });
      }
      lines.push('');
    });

    return lines.join('\n');
  }

  function formatConversationMarkdown(conv, mediaMap) {
    const lines = [];
    const title = (conv.title || '未命名对话').trim();
    lines.push('# ' + title);

    if (conv.url) {
      lines.push('', `> URL:${conv.url}`);
    }
    lines.push('', `> 更新时间:${formatTimestamp(conv.updatedAt)}`);
    const createdAt = getConversationCreatedAt(conv);
    if (createdAt) {
      lines.push(`> 创建时间:${formatTimestamp(createdAt)}`);
    }
    lines.push('', '---', '');

    (conv.messages || []).forEach((m, idx) => {
      const roleLabel = m.role === 'user' ? '用户' : '助手';
      lines.push(`## ${roleLabel} #${idx + 1}`, '');
      const body = getMessageMarkdown(m);
      if (body) {
        lines.push(body, '');
      }
      const mediaArr = m.media || [];
      if (mediaArr.length && mediaMap) {
        mediaArr.forEach((mediaInfo) => {
          const entry = mediaMap.get(mediaInfo.url);
          if (entry) {
            if (entry.type === 'image') {
              lines.push(
                `![${entry.alt || 'image'}](media/${entry.filename})`
              );
            } else {
              lines.push(
                `[${entry.type || 'media'}](media/${entry.filename})`
              );
            }
          } else if (mediaInfo.url) {
            if (mediaInfo.type === 'image') {
              lines.push(`![image](${mediaInfo.url})`);
            } else {
              lines.push(
                `[${mediaInfo.type || 'media'}](${mediaInfo.url})`
              );
            }
          }
        });
      }
      lines.push('');
    });

    return lines.join('\n');
  }

  function createTextButton(label, onClick) {
    const btn = document.createElement('button');
    btn.type = 'button';
    btn.textContent = label;
    btn.style.border = '1px solid rgba(0,0,0,0.12)';
    btn.style.borderRadius = '999px';
    btn.style.padding = '4px 10px';
    btn.style.fontSize = '12px';
    btn.style.background = '#fff';
    btn.style.color = '#333';
    btn.style.cursor = 'pointer';
    btn.style.minWidth = '48px';
    btn.style.minHeight = '32px';
    btn.style.touchAction = 'manipulation';
    btn.addEventListener('click', onClick);
    return btn;
  }

  function renderConversationList() {
    ensureSearchAndBody();
    renderConversationItems();
  }

  function renderConversationItems() {
    if (!listBodyEl) return;

    listBodyEl.innerHTML = '';

    const map = loadAllConversations();
    const allIds = Object.keys(map);

    let totalBytes = 0;
    const sizeCache = {};
    allIds.forEach((id) => {
      const conv = map[id];
      const size = calcConversationSize(conv);
      sizeCache[id] = size;
      totalBytes += size;
    });
    updateStorageUsageUI(totalBytes, allIds.length);

    const allConvsFiltered = getFilteredSortedConversations();
    const total = allConvsFiltered.length;

    if (!allIds.length) {
      const empty = document.createElement('div');
      empty.textContent = '当前还没有本地备份的会话。';
      empty.style.padding = '6px 8px 10px';
      empty.style.color = '#777';
      empty.style.textAlign = 'center';
      empty.style.fontSize = '13px';
      listBodyEl.appendChild(empty);
      updateBatchUI();
      return;
    }

    if (!total) {
      const empty = document.createElement('div');
      empty.textContent = '没有匹配的会话。';
      empty.style.padding = '6px 8px 10px';
      empty.style.color = '#777';
      empty.style.textAlign = 'center';
      empty.style.fontSize = '13px';
      listBodyEl.appendChild(empty);
      updateBatchUI();
      return;
    }

    const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
    if (currentPage > totalPages) currentPage = totalPages;
    if (currentPage < 1) currentPage = 1;

    const startIdx = (currentPage - 1) * PAGE_SIZE;
    const endIdx = startIdx + PAGE_SIZE;
    const pageConvs = allConvsFiltered.slice(startIdx, endIdx);

    const LONG_PRESS_MS = 550;

    pageConvs.forEach((conv) => {
      const isSelected = batchSelectedIds.has(conv.id);

      const item = document.createElement('div');
      item.style.borderRadius = '12px';
      item.style.border = '1px solid rgba(0,0,0,0.05)';
      item.style.background = '#fff';
      item.style.padding = '8px 10px 6px';
      item.style.marginBottom = '8px';
      item.style.display = 'flex';
      item.style.flexDirection = 'column';
      item.style.gap = '4px';
      item.style.position = 'relative';

      const topRow = document.createElement('div');
      topRow.style.display = 'flex';
      topRow.style.alignItems = 'center';
      topRow.style.gap = '8px';

      const selectCircle = document.createElement('div');
      selectCircle.style.width = '18px';
      selectCircle.style.height = '18px';
      selectCircle.style.borderRadius = '999px';
      selectCircle.style.border = '1px solid rgba(0,0,0,0.18)';
      selectCircle.style.display = batchModeEnabled ? 'flex' : 'none';
      selectCircle.style.alignItems = 'center';
      selectCircle.style.justifyContent = 'center';
      selectCircle.style.fontSize = '11px';
      selectCircle.style.flexShrink = '0';

      if (isSelected) {
        selectCircle.style.background = '#111';
        selectCircle.style.color = '#fff';
        selectCircle.textContent = '✓';
      } else {
        selectCircle.style.background = '#fff';
        selectCircle.style.color = 'transparent';
        selectCircle.textContent = '✓';
      }

      const titleRow = document.createElement('div');
      titleRow.style.display = 'flex';
      titleRow.style.justifyContent = 'space-between';
      titleRow.style.alignItems = 'center';
      titleRow.style.gap = '8px';
      titleRow.style.flex = '1';

      const title = document.createElement('div');
      title.textContent =
        conv.title && conv.title.trim()
          ? conv.title.trim()
          : '未命名对话';
      title.style.fontSize = '13px';
      title.style.fontWeight = '500';
      title.style.color = '#222';
      title.style.flex = '1';
      title.style.minWidth = '0';

      const meta = document.createElement('div');
      meta.textContent = formatTimestamp(conv.updatedAt);
      meta.style.fontSize = '11px';
      meta.style.color = '#999';
      meta.style.whiteSpace = 'nowrap';
      meta.style.marginLeft = '6px';

      titleRow.appendChild(title);
      titleRow.appendChild(meta);

      topRow.appendChild(selectCircle);
      topRow.appendChild(titleRow);

      const subRow = document.createElement('div');
      subRow.style.fontSize = '11px';
      subRow.style.color = '#888';
      const mediaCount = buildMediaMap(conv).size;
      const convSizeBytes = sizeCache[conv.id] || calcConversationSize(conv);
      const sizeLabel = formatBytes(convSizeBytes);
      subRow.textContent = `${(conv.messages || []).length} 条消息${
        mediaCount ? ` · ${mediaCount} 个媒体` : ''
      } · 约 ${sizeLabel}`;

      const actions = document.createElement('div');
      actions.style.display = batchModeEnabled ? 'none' : 'flex';
      actions.style.gap = '6px';
      actions.style.marginTop = '4px';
      actions.style.flexWrap = 'wrap';

      if (!batchModeEnabled) {
        const btnView = createTextButton('查看', () => {
          const fullText = formatConversationPlainText(conv, null);
          if (detailContentEl) {
            detailContentEl.textContent = fullText;
          }
          if (listViewEl && detailViewEl) {
            listViewEl.style.display = 'none';
            detailViewEl.style.display = 'block';
          }
        });

        const btnCopy = createTextButton('复制', async () => {
          const fullText = formatConversationPlainText(conv, null);
          try {
            if (navigator.clipboard && navigator.clipboard.writeText) {
              await navigator.clipboard.writeText(fullText);
              showToast('已复制到剪贴板');
            } else {
              window.prompt('复制下面的内容:', fullText);
            }
          } catch (e) {
            window.prompt('复制失败,请手动复制:', fullText);
          }
        });

        const btnExport = createTextButton('导出', () => {
          exportConversationAsZip(conv).catch((err) => {
            console.error('[ChatGPT Backup] export error', err);
            showToast('导出失败,请查看控制台');
          });
        });

        const btnDelete = createTextButton('删除', () => {
          const ok = window.confirm('确认删除此会话的本地备份?此操作不可恢复。');
          if (!ok) return;
          deleteConversationById(conv.id);
          renderConversationItems();
          showToast('已删除本地备份');
        });

        actions.appendChild(btnView);
        actions.appendChild(btnCopy);
        actions.appendChild(btnExport);
        actions.appendChild(btnDelete);
      }

      item.appendChild(topRow);
      item.appendChild(subRow);
      item.appendChild(actions);

      item.addEventListener('click', (e) => {
        if (!batchModeEnabled) return;
        if (e.target.tagName === 'BUTTON') return;
        toggleBatchSelection(conv.id);
      });

      let pressTimer = null;
      function startPress() {
        if (batchModeEnabled) return;
        if (pressTimer) clearTimeout(pressTimer);
        pressTimer = setTimeout(() => {
          pressTimer = null;
          enterBatchMode(conv.id);
        }, LONG_PRESS_MS);
      }
      function cancelPress() {
        if (pressTimer) {
          clearTimeout(pressTimer);
          pressTimer = null;
        }
      }

      item.addEventListener('mousedown', (e) => {
        if (e.button !== 0) return;
        startPress();
      });
      item.addEventListener('mouseup', cancelPress);
      item.addEventListener('mouseleave', cancelPress);
      item.addEventListener(
        'touchstart',
        () => {
          startPress();
        },
        { passive: true }
      );
      item.addEventListener('touchend', cancelPress);
      item.addEventListener('touchmove', cancelPress);

      if (batchModeEnabled && isSelected) {
        item.style.borderColor = 'rgba(0,0,0,0.18)';
        item.style.background = '#f4f4f4';
      }

      listBodyEl.appendChild(item);
    });

    const totalPages2 = Math.max(1, Math.ceil(total / PAGE_SIZE));
    const pager = document.createElement('div');
    pager.style.display = totalPages2 > 1 ? 'flex' : 'none';
    pager.style.alignItems = 'center';
    pager.style.justifyContent = 'center';
    pager.style.gap = '10px';
    pager.style.marginTop = '4px';
    pager.style.paddingTop = '4px';
    pager.style.borderTop = '1px solid rgba(0,0,0,0.04)';
    pager.style.fontSize = '12px';
    pager.style.color = '#666';

    function createPagerBtn(label, disabled, handler) {
      const btn = document.createElement('button');
      btn.type = 'button';
      btn.textContent = label;
      btn.style.border = '1px solid rgba(0,0,0,0.12)';
      btn.style.borderRadius = '999px';
      btn.style.padding = '3px 10px';
      btn.style.fontSize = '12px';
      btn.style.background = '#fff';
      btn.style.color = '#333';
      btn.style.minHeight = '28px';
      btn.style.cursor = disabled ? 'default' : 'pointer';
      btn.style.opacity = disabled ? '0.4' : '1';
      if (!disabled) {
        btn.addEventListener('click', handler);
      }
      return btn;
    }

    const prevBtn = createPagerBtn('上一页', currentPage <= 1, () => {
      if (currentPage > 1) {
        currentPage--;
        renderConversationItems();
      }
    });
    const nextBtn = createPagerBtn('下一页', currentPage >= totalPages2, () => {
      if (currentPage < totalPages2) {
        currentPage++;
        renderConversationItems();
      }
    });

    const pageInfo = document.createElement('span');
    pageInfo.textContent = `第 ${currentPage} / ${totalPages2} 页`;

    pager.appendChild(prevBtn);
    pager.appendChild(pageInfo);
    pager.appendChild(nextBtn);

    listBodyEl.appendChild(pager);

    if (listViewEl && detailViewEl) {
      listViewEl.style.display = 'block';
      detailViewEl.style.display = 'none';
    }

    updateBatchUI();
  }

  // ---------------------------
  // 14. 媒体下载 + zip 打包
  // ---------------------------
  function fetchBinary(url) {
    if (hasGMRequest) {
      return new Promise((resolve) => {
        GM_xmlhttpRequest({
          method: 'GET',
          url,
          responseType: 'arraybuffer',
          onload: (res) => {
            if (res.status >= 200 && res.status < 300) {
              resolve(res.response);
            } else {
              console.warn(
                '[ChatGPT Backup] GM request failed',
                url,
                res.status
              );
              resolve(null);
            }
          },
          onerror: (err) => {
            console.warn('[ChatGPT Backup] GM request error', url, err);
            resolve(null);
          },
        });
      });
    } else {
      return fetch(url)
        .then((resp) => {
          if (!resp.ok) {
            console.warn(
              '[ChatGPT Backup] fetch failed',
              url,
              resp.status
            );
            return null;
          }
          return resp.arrayBuffer();
        })
        .catch((err) => {
          console.warn('[ChatGPT Backup] fetch error', url, err);
          return null;
        });
    }
  }

  function downloadBlob(blob, fileName) {
    const link = document.createElement('a');
    link.href = URL.createObjectURL(blob);
    link.download = fileName;
    document.body.appendChild(link);
    link.click();
    setTimeout(() => {
      URL.revokeObjectURL(link.href);
      link.remove();
    }, 2000);
  }

  async function buildConversationZipBlob(conv) {
    if (typeof JSZip === 'undefined') {
      throw new Error('JSZip 未加载');
    }
    const zip = new JSZip();
    const mediaMap = buildMediaMap(conv);

    const chatText = formatConversationPlainText(conv, mediaMap);
    zip.file('chatlog.txt', chatText);

    const chatMarkdown = formatConversationMarkdown(conv, mediaMap);
    zip.file('chatlog.md', chatMarkdown);

    const info = {
      id: conv.id,
      url: conv.url,
      title: conv.title,
      updatedAt: conv.updatedAt,
      createdAt: getConversationCreatedAt(conv),
      messageCount: (conv.messages || []).length,
      media: Array.from(mediaMap.values()),
      hasMarkdown: !!(conv.messages || []).some((m) => !!(m.markdown || m.html)),
    };
    zip.file('info.json', JSON.stringify(info, null, 2));

    for (const entry of mediaMap.values()) {
      const bin = await fetchBinary(entry.url);
      if (!bin) continue;
      zip.file('media/' + entry.filename, bin);
    }

    const blob = await zip.generateAsync({ type: 'blob' });
    return blob;
  }

  async function exportConversationAsZip(conv) {
    try {
      const blob = await buildConversationZipBlob(conv);
      const safeTitle = (conv.title || 'chatgpt_conversation')
        .replace(/[\\\/:*?"<>|]+/g, '_')
        .slice(0, 50);
      const suffix = (conv.id || '').slice(-6);
      const fileName = `${safeTitle || 'conversation'}_${suffix || 'log'}.zip`;
      downloadBlob(blob, fileName);
      showToast('导出完成');
    } catch (e) {
      console.error('[ChatGPT Backup] exportConversationAsZip error', e);
      showToast('导出失败');
    }
  }

  async function exportSelectedConversationsAsZip() {
    if (!batchModeEnabled || batchSelectedIds.size === 0) {
      showToast('请先选择要导出的会话');
      return;
    }
    if (typeof JSZip === 'undefined') {
      showToast('JSZip 未加载,无法导出');
      return;
    }
    const map = loadAllConversations();
    const outerZip = new JSZip();
    let count = 0;

    showToast('正在打包选中的会话…');

    for (const id of batchSelectedIds) {
      const conv = map[id];
      if (!conv) continue;
      try {
        const innerBlob = await buildConversationZipBlob(conv);
        const safeTitle = (conv.title || 'chatgpt_conversation')
          .replace(/[\\\/:*?"<>|]+/g, '_')
          .slice(0, 50);
        const suffix = (conv.id || '').slice(-6);
        const innerName = `${safeTitle || 'conversation'}_${suffix || 'log'}.zip`;
        outerZip.file(innerName, innerBlob);
        count++;
      } catch (e) {
        console.error('[ChatGPT Backup] build inner zip error', e);
      }
    }

    if (count === 0) {
      showToast('没有可导出的会话');
      return;
    }

    const outerBlob = await outerZip.generateAsync({ type: 'blob' });
    const fileName = `chatgpt_conversations_${Date.now()}.zip`;
    downloadBlob(outerBlob, fileName);
    showToast('批量导出完成');
  }

  // ---------------------------
  // 15. 启动
  // ---------------------------
  function start() {
    try {
      initObserver();
    } catch (e) {
      console.warn('[ChatGPT Backup] initObserver error', e);
    }
    try {
      ensureManagerButton();
    } catch (e) {
      console.warn('[ChatGPT Backup] ensureManagerButton error', e);
    }
  }

  if (
    document.readyState === 'complete' ||
    document.readyState === 'interactive'
  ) {
    start();
  } else {
    window.addEventListener('DOMContentLoaded', start);
  }
})();