ChatGPT-Exporter

导出当前/全部与ChatGPT的聊天记录(JSON 或 Markdown)

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         ChatGPT-Exporter
// @namespace    qiusheng
// @version      2.2.0
// @description  导出当前/全部与ChatGPT的聊天记录(JSON 或 Markdown)
// @match        https://chatgpt.com/*
// @match        https://chat.openai.com/*
// @run-at       document-start
// @grant        none
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/jszip.min.js
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/FileSaver.min.js
// @icon         https://chatgpt.com/favicon.ico
// @author       qiusheng
// @license MIT
// ==/UserScript==

(() => {
  const U = {
    qs: (s, r=document) => r.querySelector(s),
    ce: (t, props={}, attrs={}) => { const el=document.createElement(t); Object.assign(el, props); for (const k in attrs) el.setAttribute(k, attrs[k]); return el; },
    sleep: (ms)=>new Promise(r=>setTimeout(r,ms)),
    nowStr: ()=>{const d=new Date(); const p=n=>String(n).padStart(2,'0'); return `${d.getFullYear()}${p(d.getMonth()+1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;},
    sanitize: s => (s||'untitled').replace(/[\\/:*?"<>|]+/g,'_').slice(0,80),
    isConvPage: ()=> /^\/c\/[0-9a-f-]+$/i.test(location.pathname) || /^\/g\/[^/]+\/c\/[0-9a-f-]+$/i.test(location.pathname),
    convId: ()=> {
      const m1 = location.pathname.match(/^\/c\/([0-9a-f-]+)$/i);
      if (m1) return m1[1] || '';
      const m2 = location.pathname.match(/^\/g\/[^/]+\/c\/([0-9a-f-]+)$/i);
      return (m2 && m2[1]) || '';
    },
    projectId: ()=> {
      const m = location.pathname.match(/^\/g\/([^/]+)\/c\/[0-9a-f-]+$/i);
      return (m && m[1]) || '';
    },
    isHostOK: ()=> location.host.endsWith('chatgpt.com') || location.host.endsWith('chat.openai.com'),
    on: (t,fn)=>window.addEventListener(t,fn),
    emit:(t,d)=>window.dispatchEvent(new CustomEvent(t,{detail:d})),
    ts: s => { if(!s && s!==0) return ''; const n = typeof s==='number'? s : Number(s); const ms = n>1e12?n:(n*1000); const d=new Date(ms); return isFinite(d)? d.toISOString().replace('T',' ').replace('Z',' UTC') : ''; },
    isoToStamp: (s)=>{ if(!s) return ''; const d=new Date(s); if(!isFinite(d)) return ''; const p=n=>String(n).padStart(2,'0'); return `${d.getFullYear()}${p(d.getMonth()+1)}${p(d.getDate())}${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`; }
  };

  const MAX_CONCURRENCY = 5;

  const Cred = (() => {
    let token = null, accountId = null, lastTs = 0, lastErr = '';

    const mask = (s, keepL=8, keepR=6) => {
      if (!s) return '';
      if (s.length <= keepL + keepR) return s;
      return `${s.slice(0, keepL)}…${s.slice(-keepR)}`;
    };

    const ensureViaSession = async (tries=4) => {
      for (let i=0;i<tries;i++){
        try{
          const r = await fetch('/api/auth/session', {credentials:'include'});
          if (r.ok){
            const j = await r.json().catch(()=>({}));
            if (j && j.accessToken){ token = j.accessToken; lastErr=''; lastTs = Date.now(); }
            if (!accountId){
              const m = document.cookie.match(/(?:^|;\s*)_account=([^;]+)/);
              if (m) accountId = decodeURIComponent(m[1]);
            }
            U.emit('cgptx-cred-update', { token, accountId });
            if (token) return true;
          }else{
            lastErr = `session ${r.status}`;
            U.emit('cgptx-cred-update', { token, accountId });
          }
        }catch(e){ lastErr = e && e.message ? e.message : 'session_error'; U.emit('cgptx-cred-update', { token, accountId }); }
        await U.sleep(300 * (i+1));
      }
      return !!token;
    };

    const getAuthHeaders = () => {
      const h = new Headers();
      if (token) h.set('authorization', `Bearer ${token}`);
      if (accountId) h.set('chatgpt-account-id', accountId);
      return h;
    };

    const debugText = () => {
      const ts = lastTs ? new Date(lastTs).toLocaleString() : '—';
      const tok = token ? mask(token) : '未获取';
      const acc = accountId || '未获取';
      const err = lastErr ? `\n错误:${lastErr}` : '';
      return `Token:${tok}\nAccount:${acc}\n更新时间:${ts}${err}`;
    };

    return {
      get token(){return token;},
      get accountId(){return accountId;},
      get last(){return lastTs;},
      get debug(){return debugText();},
      ensureViaSession, getAuthHeaders,
    };
  })();

  const Net = (() => {
    const base = () => location.origin;
    const mergeHeaders = (a,b)=>{const h=new Headers(a||{}); (b||new Headers()).forEach((v,k)=>h.set(k,v)); return h;};
    const backoff = (i)=>U.sleep(Math.min(15000,500*Math.pow(1.8,i)));

    const req = async (url, opt={}, expectJson=true, attempt=0) => {
      if (!Cred.token) await Cred.ensureViaSession(6);
      const h = mergeHeaders(opt.headers, Cred.getAuthHeaders());
      const init = Object.assign({credentials:'include'}, opt, { headers:h });
      const resp = await fetch(url, init).catch(()=>null);
      if (!resp) throw new Error('network_failed');
      if (resp.status === 401 && attempt < 2){ await Cred.ensureViaSession(6); return req(url,opt,expectJson,attempt+1); }
      if ((resp.status===429 || resp.status>=500) && attempt<5){ await backoff(attempt); return req(url,opt,expectJson,attempt+1); }
      if (!resp.ok){ const t = await resp.text().catch(()=> ''); throw new Error(`http_${resp.status}:${t.slice(0,160)}`); }
      return expectJson ? resp.json() : resp.blob();
    };

    const list = (p={})=>{
      const {is_archived, is_starred, offset=0, limit=50, order='updated'} = p;
      const q = new URLSearchParams({
        offset:String(offset), limit:String(limit), order:String(order),
        ...(typeof is_archived==='boolean'?{is_archived:String(is_archived)}:{}),
        ...(typeof is_starred==='boolean'?{is_starred:String(is_starred)}:{}),
      });
      return req(`${base()}/backend-api/conversations?${q.toString()}`, {method:'GET'});
    };

    const getConv = (id, projectId)=>{
      const headers = projectId ? {'chatgpt-project-id': projectId} : undefined;
      return req(`${base()}/backend-api/conversation/${id}`, {method:'GET', headers});
    };

    const listGizmosSidebar = (p={}) => {
      const { conversations_per_gizmo=20, owned_only=true, cursor=null } = p;
      const n = Math.min(typeof conversations_per_gizmo==='number'?conversations_per_gizmo:20, 20);
      const q = new URLSearchParams({
        conversations_per_gizmo:String(n),
        owned_only:String(owned_only)
      });
      if (cursor) q.set('cursor', cursor);
      return req(`${base()}/backend-api/gizmos/snorlax/sidebar?${q.toString()}`, {method:'GET'});
    };

    return { list, getConv, listGizmosSidebar };
  })();

  const MD = (() => {
    const roleZh = r => ({user:'用户',assistant:'助手',system:'系统',tool:'工具'})[r] || r || '未知';
    const joinParts = parts => Array.isArray(parts) ? parts.map(x=>String(x||'')).join('\n\n').trim() : String(parts||'').trim();
    const modelFrom = (msg, conv) => (msg?.metadata?.model_slug || msg?.metadata?.default_model_slug || conv?.default_model_slug || '').trim();

    const nodesToArray = (mapping) => {
      const arr = [];
      if (!mapping || typeof mapping!=='object') return arr;
      for (const k of Object.keys(mapping)) {
        const n = mapping[k];
        if (!n || !n.message) continue;
        const m = n.message;
        arr.push({
          id: n.id || m.id || k,
          role: m.author?.role || '',
          create_time: m.create_time ?? null,
          content: m.content || {},
          metadata: m.metadata || {},
          channel: m.channel || null
        });
      }
      return arr;
    };

    const shouldSkip = (msg) => {
      const ct = msg.content?.content_type;
      if (msg.role==='system') {
        if (ct==='text' && joinParts(msg.content?.parts)==='') return true;
        if (ct==='model_editable_context' && !msg.content?.model_set_context) return true;
      }
      if (ct==='text' && joinParts(msg.content?.parts)==='') return true;
      return false;
    };

    const fmtThoughts = (content) => {
      const t = content?.thoughts;
      if (!Array.isArray(t) || t.length===0) return '';
      const lines = [];
      lines.push('> **思考**');
      t.forEach((it,idx)=>{
        const head = it?.summary ? `**${idx+1}. ${it.summary}**` : `**${idx+1}.**`;
        const body = (it?.content || (Array.isArray(it?.chunks)? it.chunks.join('\n'): '') || '').trim();
        if (body) lines.push(`${head}\n\n${body}`);
        else lines.push(head);
      });
      return lines.join('\n\n');
    };

    const fmtRecap = (c) => {
      const s = (typeof c?.content==='string'? c.content : '').trim();
      return s ? `*思考小结:${s}*` : '';
    };

    const fmtText = (c) => joinParts(c?.parts);

    const fmtContext = (c) => {
      const s = (c?.model_set_context || '').trim();
      return s ? `> 上下文\n\n${s}` : '';
    };

    const renderMsg = (m, conv) => {
      if (shouldSkip(m)) return '';
      const role = roleZh(m.role);
      const model = modelFrom(m, conv);
      const tstr = U.ts(m.create_time);
      const head = `**${role}${model?` (${model})`:''}${tstr?` — ${tstr}`:''}**`;
      const ct = m.content?.content_type;
      let body = '';
      if (ct==='text') body = fmtText(m.content);
      else if (ct==='thoughts') body = fmtThoughts(m.content);
      else if (ct==='reasoning_recap') body = fmtRecap(m.content);
      else if (ct==='model_editable_context') body = fmtContext(m.content);
      else body = '```json\n' + JSON.stringify(m.content, null, 2) + '\n```';
      if (!body) return '';
      return `${head}\n\n${body}`;
    };

    const conversationToMD = (conv) => {
      const title = conv?.title || 'untitled';
      const id = conv?.conversation_id || conv?.id || '';
      const projId = conv?.gizmo_id || conv?.conversation_template_id || conv?.project_id || '';
      let linkLine = null;
      if (id) {
        if (projId) linkLine = `- 链接: https://chatgpt.com/g/${projId}/c/${id}`;
        else linkLine = `- 链接: https://chatgpt.com/c/${id}`;
      }
      const meta = [
        `- 会话ID: ${id}`,
        conv?.workspace_id ? `- 工作区: ${conv.workspace_id}` : null,
        conv?.gizmo_type ? `- Gizmo: ${conv.gizmo_type}` : null,
        conv?.create_time ? `- 创建: ${U.ts(conv.create_time)}` : null,
        conv?.update_time ? `- 更新: ${U.ts(conv.update_time)}` : null,
        conv?.default_model_slug ? `- 默认模型: ${conv.default_model_slug}` : null,
        linkLine
      ].filter(Boolean).join('\n');

      const nodes = nodesToArray(conv?.mapping);
      nodes.sort((a,b)=>{
        const ta = a.create_time ?? 0, tb = b.create_time ?? 0;
        if (ta!==tb) return ta - tb;
        return String(a.id).localeCompare(String(b.id));
      });

      const lines = [];
      lines.push(`# ${title}\n`);
      if (meta) lines.push(meta, '\n---\n');
      for (const m of nodes){
        const s = renderMsg(m, conv);
        if (s) lines.push(s, '\n');
      }
      if (Array.isArray(conv?.safe_urls) && conv.safe_urls.length){
        lines.push('---\n**参考链接**\n');
        conv.safe_urls.forEach(u=> lines.push(`- ${u}`));
        lines.push('');
      }
      return lines.join('\n').trim() + '\n';
    };

    return { conversationToMD };
  })();

  const UI = (() => {
    let panel, btn, btnCur, btnAll, btnCurMD, btnAllMD, barWrap, bar, info, badge, stopBtn;
    let exporting=false, cancel=false, opening=false, isOpen=false, autoHideTimer=null;
    const DIST=100, DELAY=2000;

    const css = `
      .cgptx-fab{position:fixed;right:18px;bottom:18px;z-index:2147483647;}
      .cgptx-btn{width:48px;height:48px;border:none;border-radius:14px;cursor:pointer;background:#111827;color:#fff;
        box-shadow:0 8px 22px rgba(0,0,0,.22);display:flex;align-items:center;justify-content:center;transition:.2s;opacity:.95}
      .cgptx-btn:hover{transform:translateY(-1px);opacity:1}

      .cgptx-panel{position:fixed;right:18px;bottom:74px;width:clamp(260px,30vw,320px);
        background:#fffffff5;border:1px solid #e6e6e7;border-radius:18px;box-shadow:0 16px 36px rgba(17,24,39,.18);
        backdrop-filter:saturate(1.1) blur(6px);padding:14px 14px 12px;z-index:2147483647;
        opacity:0;transform:translateY(10px) scale(.98);transition:opacity .4s ease, transform .4s ease;pointer-events:none}
      .cgptx-panel.cgptx-open{opacity:1;transform:translateY(0) scale(1);pointer-events:auto}

      .cgptx-ttl{font-size:15px;color:#0f172a;margin-bottom:10px;font-weight:700;letter-spacing:.2px;text-align:center}

      .cgptx-col{display:flex;flex-direction:column;gap:10px;margin-top:4px}
      .cgptx-a{width:100%;height:42px;border-radius:12px;border:1px solid #e6e6e7;background:#f8fafc;color:#0f172a;cursor:pointer;
        font-size:14px;font-weight:600;letter-spacing:.2px;display:flex;align-items:center;justify-content:center;line-height:1.15;padding:6px 10px;transition:.15s}
      .cgptx-a:hover{background:#f3f5f7;border-color:#dfe3e9;transform:translateY(-0.5px)}
      .cgptx-a:active{transform:translateY(0)}

      .cgptx-progress{margin-top:12px;background:#f2f3f5;border-radius:10px;height:10px;overflow:hidden;display:none}
      .cgptx-bar{height:100%;width:0%;background:#4b8df8;transition:width .25s}
      .cgptx-progtext{margin-top:6px;font-size:12px;color:#4b5563;display:none}

      .cgptx-badges{margin-top:10px}
      .cgptx-badge{width:100%;min-height:42px;border-radius:12px;border:1px solid #e6e6e7;background:#f8fafc;color:#374151;
        display:flex;align-items:center;justify-content:center;gap:8px;font-size:13px;font-weight:600;letter-spacing:.2px;text-align:center;padding:6px 10px;
        user-select:text;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
      .cgptx-badge:hover{background:#f3f5f7;border-color:#dfe3e9}
      .cgptx-chip{font-weight:800}
      .cgptx-chip.ok{color:#0a7d39}
      .cgptx-chip.bad{color:#c02626}

      .cgptx-stop{margin-top:10px;width:100%;height:36px;border-radius:10px;border:1px solid #ffd9d9;background:#fff5f5;color:#b60000;display:none;cursor:pointer}
    `;

    const mount = () => {
      if (U.qs('#cgptx-style')) return;
      document.head.appendChild(U.ce('style', { id:'cgptx-style', textContent: css }));
      const root = U.ce('div', { className:'cgptx-fab' });
      btn = U.ce('button', { className:'cgptx-btn', innerHTML:'⬇︎' });
      panel = U.ce('div', { className:'cgptx-panel' });
      const ttl = U.ce('div', { className:'cgptx-ttl', textContent:'ChatGPT 对话导出' });

      btnCur = U.ce('button', { className:'cgptx-a', textContent:'导出当前对话(JSON)' });
      btnAll = U.ce('button', { className:'cgptx-a', textContent:'导出全部对话(JSON, ZIP)' });
      btnCurMD = U.ce('button', { className:'cgptx-a', textContent:'导出当前对话(MD)' });
      btnAllMD = U.ce('button', { className:'cgptx-a', textContent:'导出全部对话(MD, ZIP)' });
      const col = U.ce('div', { className:'cgptx-col' }); col.append(btnCur, btnAll, btnCurMD, btnAllMD);

      barWrap = U.ce('div', { className:'cgptx-progress' });
      bar = U.ce('div', { className:'cgptx-bar' }); barWrap.append(bar);
      info = U.ce('div', { className:'cgptx-progtext' });
      const badges = U.ce('div', { className:'cgptx-badges' });
      badge = U.ce('div', { className:'cgptx-badge', textContent:'凭证: 初始化中…' });
      badges.append(badge);
      stopBtn = U.ce('button', { className:'cgptx-stop', textContent:'停止导出' });

      panel.append(ttl, col, barWrap, info, badges, stopBtn);
      root.append(btn);
      document.body.append(root, panel);

      btn.onclick = onFabClick;
      btnCur.onclick = exportCurrentJSON;
      btnAll.onclick = exportAllJSON;
      btnCurMD.onclick = exportCurrentMD;
      btnAllMD.onclick = exportAllMD;
      stopBtn.onclick = ()=>{ cancel=true; };

      U.on('cgptx-cred-update', updateBadge);
      tickBadge();
      refreshButtons();
      document.addEventListener('mousemove', onDocMouseMove, {passive:true});
    };

    const onFabClick = async () => {
      if (opening) return;
      opening = true;
      btn.disabled = true;
      try{
        await Cred.ensureViaSession(6);
        updateBadge();
        if (isOpen) closePanel(); else openPanel();
      } finally {
        btn.disabled = false;
        opening = false;
      }
    };

    const openPanel = () => {
      if (isOpen) return;
      clearAutoHide();
      panel.classList.add('cgptx-open');
      isOpen = true;
      refreshButtons();
    };

    const closePanel = () => {
      if (!isOpen) return;
      clearAutoHide();
      panel.classList.remove('cgptx-open');
      isOpen = false;
    };

    const onDocMouseMove = (e) => {
      if (!isOpen) return;
      const r = panel.getBoundingClientRect();
      const insidePad = (
        e.clientX >= r.left - DIST &&
        e.clientX <= r.right + DIST &&
        e.clientY >= r.top - DIST &&
        e.clientY <= r.bottom + DIST
      );
      if (!insidePad) {
        if (!autoHideTimer) autoHideTimer = setTimeout(()=>{ closePanel(); }, DELAY);
      } else {
        clearAutoHide();
      }
    };

    const clearAutoHide = () => {
      if (autoHideTimer){ clearTimeout(autoHideTimer); autoHideTimer=null; }
    };

    const tickBadge = () => {
      const t = setInterval(()=>{
        updateBadge();
        if (Cred.token && Cred.accountId){ clearInterval(t); }
      }, 800);
    };

    const updateBadge = () => {
      const okT = !!Cred.token;
      const okA = !!Cred.accountId;
      badge.innerHTML = `凭证: <span class="cgptx-chip ${okT?'ok':'bad'}">${okT?'Token✔':'Token✖'}</span> / <span class="cgptx-chip ${okA?'ok':'bad'}">${okA?'Account✔':'Account✖'}</span>`;
      badge.title = Cred.debug;
      badge.style.background = (okT && okA) ? '#e8f7ee' : '#fff5f5';
      badge.style.borderColor = (okT && okA) ? '#b7e3c9' : '#ffd9d9';
    };

    const refreshButtons = () => {
      const showCur = U.isConvPage();
      btnCur.style.display = showCur ? 'block' : 'none';
      btnCurMD.style.display = showCur ? 'block' : 'none';
    };

    const setProg = (p, text)=>{ barWrap.style.display='block'; info.style.display='block'; bar.style.width=Math.max(0,Math.min(100,p))+'%'; info.textContent=text||''; };
    const resetProg = ()=>{ barWrap.style.display='none'; info.style.display='none'; bar.style.width='0%'; info.textContent=''; };

    const saveBlob = (blob, name) => {
      if (typeof saveAs === 'function') saveAs(blob, name);
      else { const a = U.ce('a',{href:URL.createObjectURL(blob),download:name}); document.body.appendChild(a); a.click(); setTimeout(()=>URL.revokeObjectURL(a.href),3000); a.remove(); }
    };

    const fetchConvWithRetry = async (id, projectId, retries=2) => {
      let attempt = 0;
      let lastErr = null;
      while (attempt <= retries){
        if (cancel) throw new Error('已停止');
        try{
          return await Net.getConv(id, projectId);
        }catch(e){
          lastErr = e;
          attempt++;
          if (attempt > retries) break;
          const delay = 500 * Math.pow(2, attempt-1);
          await U.sleep(delay);
        }
      }
      throw lastErr || new Error('fetch_failed');
    };

    const fetchAllConversations = async (tasks, concurrency, progressCb) => {
      const total = tasks.length;
      if (!total) return [];
      const results = new Array(total);
      let done = 0;
      let index = 0;
      let fatalErr = null;

      const worker = async () => {
        while (true){
          if (cancel || fatalErr) return;
          const i = index++;
          if (i >= total) return;
          const t = tasks[i];
          try{
            const data = await fetchConvWithRetry(t.id, t.projectId, 2);
            results[i] = data;
            done++;
            const pct = total ? Math.round(done/total*100) : 5;
            if (progressCb) progressCb(pct, `导出中:${done}/${total}`);
          }catch(e){
            fatalErr = e;
            return;
          }
        }
      };

      const n = Math.max(1, Math.min(concurrency || 1, total));
      const workers = [];
      for (let i=0;i<n;i++) workers.push(worker());
      await Promise.all(workers);
      if (fatalErr) throw fatalErr;
      return results;
    };

    const buildProjectFolderNames = (projects) => {
      const map = new Map();
      const counts = {};
      projects.forEach(p=>{
        const base = U.sanitize(p.projectName || p.projectId || 'project');
        counts[base] = (counts[base] || 0) + 1;
      });
      projects.forEach(p=>{
        let baseName = U.sanitize(p.projectName || p.projectId || 'project');
        if (counts[baseName] > 1) {
          const stamp = p.createdAt ? U.isoToStamp(p.createdAt) : '';
          if (stamp) {
            const raw = p.projectName || baseName;
            baseName = U.sanitize(`${raw}_${stamp}`);
          }
        }
        map.set(p.projectId, baseName || 'project');
      });
      return map;
    };

    const exportCurrentJSON = async () => {
      if (exporting) return; exporting=true; cancel=false; setProg(3,'准备中…');
      try{
        await Cred.ensureViaSession(6); updateBadge();
        const id = U.convId(); if (!id) throw new Error('no_conv_id');
        const pid = U.projectId();
        const j = await Net.getConv(id, pid || undefined);
        const title = U.sanitize(j?.title || '');
        const blob = new Blob([JSON.stringify(j,null,2)], {type:'application/json'});
        saveBlob(blob, `${title}_${id}.json`);
        setProg(100,'完成');
      }catch(e){ setProg(0,`失败:${e.message||e}`); }
      finally{ setTimeout(resetProg,1200); exporting=false; }
    };

    const exportAllJSON = async () => {
      if (exporting) return; exporting=true; cancel=false; stopBtn.style.display='block'; setProg(2,'扫描列表…');
      try{
        await Cred.ensureViaSession(6); updateBadge();
        const all = await collectAllIds(setProg);
        const rootIds = all.rootIds || [];
        const projects = Array.isArray(all.projects) ? all.projects : [];
        const tasks = [];
        rootIds.forEach(id => {
          tasks.push({kind:'root', id, projectId:null});
        });
        projects.forEach(p => {
          const convs = Array.isArray(p.convs)?p.convs:[];
          convs.forEach(c=>{
            tasks.push({kind:'proj', id:c.id, projectId:p.projectId});
          });
        });
        const totalConvs = tasks.length;
        if (!totalConvs && !projects.length) throw new Error('列表为空');

        const zip = new JSZip();
        zip.file('summary.json', JSON.stringify({
          exportedAt:new Date().toISOString(),
          total:totalConvs,
          root:{count:rootIds.length, ids:rootIds},
          projects:projects.map(p=>({
            projectId:p.projectId,
            projectName:p.projectName,
            createdAt:p.createdAt || '',
            conversationIds:(p.convs||[]).map(c=>c.id)
          }))
        }, null, 2));

        const results = await fetchAllConversations(tasks, MAX_CONCURRENCY, (pct,text)=>setProg(pct,text));
        if (cancel) throw new Error('已停止');

        const convDataMap = new Map();
        tasks.forEach((t,i)=>{
          if (results[i]) convDataMap.set(t.id, results[i]);
        });

        const folderNameByProjectId = buildProjectFolderNames(projects);

        let idxRoot=0;
        for (const id of rootIds){
          if (cancel) throw new Error('已停止');
          const data = convDataMap.get(id);
          if (!data) continue;
          const title = U.sanitize(data?.title || 'untitled');
          idxRoot++;
          const seq = String(idxRoot).padStart(3,'0');
          zip.file(`${seq}_${title}_${id}.json`, JSON.stringify(data, null, 2));
        }

        projects.forEach(p=>{
          const folderName = folderNameByProjectId.get(p.projectId) || U.sanitize(p.projectName || p.projectId || 'project');
          const folder = zip.folder(folderName);
          if (!folder) return;
          const convs = Array.isArray(p.convs)?p.convs:[];
          let idx = 0;
          convs.forEach(c=>{
            if (cancel) return;
            const data = convDataMap.get(c.id);
            if (!data) return;
            const title = U.sanitize(data?.title || c.title || 'untitled');
            idx++;
            const seq = String(idx).padStart(3,'0');
            folder.file(`${seq}_${title}_${c.id}.json`, JSON.stringify(data, null, 2));
          });
        });

        setProg(98,'压缩中…');
        const blob = await zip.generateAsync({type:'blob', compression:'DEFLATE', compressionOptions:{level:9}});
        saveBlob(blob, `chatgpt-conversations-json-${U.nowStr()}.zip`);
        setProg(100,'完成');
      }catch(e){ setProg(0,`失败:${e.message||e}`); }
      finally{ setTimeout(()=>{ resetProg(); stopBtn.style.display='none'; },1400); exporting=false; cancel=false; }
    };

    const exportCurrentMD = async () => {
      if (exporting) return; exporting=true; cancel=false; setProg(3,'准备中…');
      try{
        await Cred.ensureViaSession(6); updateBadge();
        const id = U.convId(); if (!id) throw new Error('no_conv_id');
        const pid = U.projectId();
        const j = await Net.getConv(id, pid || undefined);
        const md = MD.conversationToMD({...j, conversation_id: id, gizmo_id: pid || j?.gizmo_id});
        const title = U.sanitize(j?.title || '');
        const blob = new Blob([md], {type:'text/markdown;charset=utf-8'});
        saveBlob(blob, `${title}_${id}.md`);
        setProg(100,'完成');
      }catch(e){ setProg(0,`失败:${e.message||e}`); }
      finally{ setTimeout(resetProg,1200); exporting=false; }
    };

    const exportAllMD = async () => {
      if (exporting) return; exporting=true; cancel=false; stopBtn.style.display='block'; setProg(2,'扫描列表…');
      try{
        await Cred.ensureViaSession(6); updateBadge();
        const all = await collectAllIds(setProg);
        const rootIds = all.rootIds || [];
        const projects = Array.isArray(all.projects) ? all.projects : [];
        const tasks = [];
        rootIds.forEach(id => {
          tasks.push({kind:'root', id, projectId:null});
        });
        projects.forEach(p => {
          const convs = Array.isArray(p.convs)?p.convs:[];
          convs.forEach(c=>{
            tasks.push({kind:'proj', id:c.id, projectId:p.projectId});
          });
        });
        const totalConvs = tasks.length;
        if (!totalConvs && !projects.length) throw new Error('列表为空');

        const zip = new JSZip();
        zip.file('summary.json', JSON.stringify({
          exportedAt:new Date().toISOString(),
          total:totalConvs,
          root:{count:rootIds.length, ids:rootIds},
          projects:projects.map(p=>({
            projectId:p.projectId,
            projectName:p.projectName,
            createdAt:p.createdAt || '',
            conversationIds:(p.convs||[]).map(c=>c.id)
          }))
        }, null, 2));

        const results = await fetchAllConversations(tasks, MAX_CONCURRENCY, (pct,text)=>setProg(pct,text));
        if (cancel) throw new Error('已停止');

        const convDataMap = new Map();
        tasks.forEach((t,i)=>{
          if (results[i]) convDataMap.set(t.id, results[i]);
        });

        const folderNameByProjectId = buildProjectFolderNames(projects);

        let idxRoot=0;
        for (const id of rootIds){
          if (cancel) throw new Error('已停止');
          const data = convDataMap.get(id);
          if (!data) continue;
          const md = MD.conversationToMD({...data, conversation_id: id});
          const title = U.sanitize(data?.title || 'untitled');
          idxRoot++;
          const seq = String(idxRoot).padStart(3,'0');
          zip.file(`${seq}_${title}_${id}.md`, md);
        }

        projects.forEach(p=>{
          const folderName = folderNameByProjectId.get(p.projectId) || U.sanitize(p.projectName || p.projectId || 'project');
          const folder = zip.folder(folderName);
          if (!folder) return;
          const convs = Array.isArray(p.convs)?p.convs:[];
          let idx = 0;
          convs.forEach(c=>{
            if (cancel) return;
            const data = convDataMap.get(c.id);
            if (!data) return;
            const md = MD.conversationToMD({...data, conversation_id: c.id, gizmo_id: p.projectId});
            const title = U.sanitize(data?.title || c.title || 'untitled');
            idx++;
            const seq = String(idx).padStart(3,'0');
            folder.file(`${seq}_${title}_${c.id}.md`, md);
          });
        });

        setProg(98,'压缩中…');
        const blob = await zip.generateAsync({type:'blob', compression:'DEFLATE', compressionOptions:{level:9}});
        saveBlob(blob, `chatgpt-conversations-md-${U.nowStr()}.zip`);
        setProg(100,'完成');
      }catch(e){ setProg(0,`失败:${e.message||e}`); }
      finally{ setTimeout(()=>{ resetProg(); stopBtn.style.display='none'; },1400); exporting=false; cancel=false; }
    };

    const collectAllIds = async (progressCb) => {
      const combos = [
        {is_archived:false,is_starred:false},
        {is_archived:true,is_starred:false},
        {is_archived:false,is_starred:true},
        {is_archived:true,is_starred:true},
      ];
      const rootSet = new Set();
      const projectMap = new Map();

      const addRoot = (id) => {
        if (!id) return;
        rootSet.add(id);
      };

      const addProjectConv = (projectId, id, title) => {
        if (!projectId || !id) return;
        let rec = projectMap.get(projectId);
        if (!rec) {
          rec = { projectId, projectName:'', createdAt:'', convs:[] };
          projectMap.set(projectId, rec);
        }
        if (!rec.convs.some(x=>x.id===id)) {
          rec.convs.push({id, title: title || ''});
        }
        if (rootSet.has(id)) rootSet.delete(id);
      };

      for (const c of combos){
        let offset=0, limit=50;
        while(true){
          const page = await Net.list({...c, offset, limit, order:'updated'});
          const arr = Array.isArray(page?.items)?page.items:[];
          arr.forEach(it=>{
            if (!it || !it.id) return;
            const id = it.id;
            const projId = it.conversation_template_id || it.gizmo_id || null;
            if (projId) addProjectConv(projId, id, it.title || '');
            else addRoot(id);
          });
          const total = Number(page?.total||0); const got = offset + arr.length;
          if (progressCb) progressCb(5,`扫描:${Math.min(got,total)}/${total}(档:${c.is_archived?'Y':'N'}/星:${c.is_starred?'Y':'N'})`);
          if (!arr.length || got >= total) break;
          offset += limit; await U.sleep(200);
        }
      }

      try{
        let cursor = null;
        let pageIndex = 0;
        do {
          const sidebar = await Net.listGizmosSidebar({ cursor });
          const items = Array.isArray(sidebar?.items)?sidebar.items:[];
          pageIndex++;
          let idx = 0;

          for (const it of items){
            idx++;
            const g = it && it.gizmo && it.gizmo.gizmo;
            if (!g || !g.id) {
              if (progressCb) progressCb(5, `扫描项目第${pageIndex}页:${idx}/${items.length}`);
              continue;
            }
            const pid = g.id;
            const pname = (g.display && g.display.name) || '';
            const createdAt = g.created_at || '';
            let rec = projectMap.get(pid);
            if (!rec) {
              rec = { projectId: pid, projectName: pname || pid, createdAt, convs:[] };
              projectMap.set(pid, rec);
            } else {
              if (!rec.projectName && pname) rec.projectName = pname;
              if (!rec.createdAt && createdAt) rec.createdAt = createdAt;
            }
            const convItems = it && it.conversations && Array.isArray(it.conversations.items) ? it.conversations.items : [];
            convItems.forEach(cv=>{
              if (!cv || !cv.id) return;
              addProjectConv(pid, cv.id, cv.title || '');
            });
            if (progressCb) progressCb(5, `扫描项目第${pageIndex}页:${idx}/${items.length}`);
          }

          cursor = sidebar && sidebar.cursor ? sidebar.cursor : null;
        } while (cursor);
      }catch(e){}

      const rootIds = Array.from(rootSet);
      const projects = Array.from(projectMap.values());
      return { rootIds, projects };
    };

    const hookHistory = () => {
      const ps = history.pushState, rs = history.replaceState;
      history.pushState = function(){ const r=ps.apply(this,arguments); U.emit('cgptx-url'); return r; };
      history.replaceState = function(){ const r=rs.apply(this,arguments); U.emit('cgptx-url'); return r; };
      window.addEventListener('popstate', ()=>U.emit('cgptx-url'));
      window.addEventListener('cgptx-url', ()=>{ refreshButtons(); });
    };

    return { mount, hookHistory };
  })();

  const boot = async () => {
    if (!U.isHostOK()) return;
    UI.hookHistory();
    document.addEventListener('DOMContentLoaded', UI.mount);
  };
  boot();
})();