ChatGPT-Exporter

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

Você precisará instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Você precisará instalar uma extensão como o Tampermonkey para instalar este script.

Você precisará instalar um gerenciador de scripts de usuário para instalar este script.

(Eu já tenho um gerenciador de scripts de usuário, me deixe instalá-lo!)

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

(Eu já possuo um gerenciador de estilos de usuário, me deixar fazer a instalação!)

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