ChatGPT Team Rescue Exporter

Export all conversations from Personal or Team Workspace with a UI selector.

目前為 2025-06-06 提交的版本,檢視 最新版本

// ==UserScript==
// @name         ChatGPT Team Rescue Exporter
// @version      2.0.0
// @description  Export all conversations from Personal or Team Workspace with a UI selector.
// @author       Hanashiro and Alex Mercer
// @match        https://chatgpt.com/*
// @match        https://chat.openai.com/*
// @grant        none
// @license      MIT
// @namespace https://gf.qytechs.cn/users/1479633
// ==/UserScript==

/* ============================================================
   v2.0.0 变更 (由 Alex Mercer 指导 Gemini 修改)
   ------------------------------------------------------------
   • 新增 UI 对话框,允许用户选择导出个人空间或 Team 空间。
   • 支持在 UI 中直接输入 Team Workspace ID。
   • 如果脚本中预设了 Workspace ID,UI 输入框会自动填充。
   • 重构代码,以支持两种导出模式。
   • 更新了所有相关函数,使其能够动态处理 personal/team 请求。
   ========================================================== */

(function () {
  'use strict';

  /******************** 默认配置 *****************************/
  // 可选:在这里预填你的 Team 工作空间 ID,UI中会自动填充。
  const TEAM_WORKSPACE_ID = ''; // 例如 'ws-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'

  const BASE_DELAY = 600;
  const JITTER       = 400;
  const PAGE_LIMIT   = 100;
  const MODES = [
    { label: 'active',   param: '' },
    { label: 'archived', param: '&is_archived=true' }
  ];

  /******************** 全局状态 *************************/
  let accessToken = null;

  /***************** 1. 捕获 accessToken (无变动) ****************/
  (function interceptNetwork() {
    const rawFetch = window.fetch;
    window.fetch = async function (res, opt = {}) { tryCapture(opt?.headers); return rawFetch.apply(this, arguments); };
    const rawOpen  = XMLHttpRequest.prototype.open;
    XMLHttpRequest.prototype.open = function () { this.addEventListener('readystatechange', () => { try { tryCapture(this.getRequestHeader('Authorization')); } catch(_){} }); return rawOpen.apply(this, arguments); };
  })();

  async function ensureAccessToken() {
    if (accessToken) return accessToken;
    try { const nd = JSON.parse(document.getElementById('__NEXT_DATA__').textContent); accessToken = nd?.props?.pageProps?.accessToken; } catch(_){}
    if (accessToken) return accessToken;
    try { const r = await fetch('/api/auth/session?unstable_client=true'); if (r.ok) accessToken = (await r.json()).accessToken; } catch(_){}
    return accessToken;
  }
  function tryCapture(header) {
    if (!header) return;
    const h = typeof header === 'string' ? header : header instanceof Headers ? header.get('Authorization') : header.Authorization || header.authorization;
    if (h?.startsWith('Bearer ')) accessToken = h.slice(7);
  }

  /***************** 2. 小工具 (无变动) ***************************/
  const sleep = ms => new Promise(r => setTimeout(r, ms));
  const jitter = () => BASE_DELAY + Math.random() * JITTER;

  function download(payload, mode, workspaceId) {
    const date = new Date().toISOString().slice(0, 10);
    const filename = mode === 'team'
      ? `chatgpt_team_backup_${workspaceId}_${date}.json`
      : `chatgpt_personal_backup_${date}.json`;

    const a = document.createElement('a');
    a.href = URL.createObjectURL(new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }));
    a.download = filename;
    document.body.appendChild(a); a.click(); a.remove();
    URL.revokeObjectURL(a.href);
    alert(`✅ ${mode === 'team' ? 'Team 空间' : '个人空间'}导出完成,共 ${payload.length} 条对话。`);
  }

  /***************** 3. 导出流程 (重构) *************************/
  async function startExportProcess(mode, workspaceId) {
    const btn = document.getElementById('gpt-rescue-btn');
    btn.disabled = true;
    btn.textContent = '⏳ 准备中…';

    if (!await ensureAccessToken()) {
      alert('尚未捕获 accessToken,请先打开或刷新任意一条对话再试');
      btn.disabled = false;
      btn.textContent = 'Export Conversations';
      return;
    }

    try {
      const ids = await collectIds(btn, workspaceId);
      const data = [];
      for (let i = 0; i < ids.length; i++) {
        btn.textContent = `⏳ ${i + 1}/${ids.length}`;
        data.push(await getConversation(ids[i], workspaceId));
        await sleep(jitter());
      }
      download(data, mode, workspaceId);
      btn.textContent = '✅ 完成';
    } catch (e) {
      console.error(e);
      alert('导出失败,详情请查看控制台(F12 -> Console)。可能是 Workspace ID 不正确或网络问题。');
      btn.textContent = '⚠️ Error';
    } finally {
      setTimeout(() => {
        btn.disabled = false;
        btn.textContent = 'Export Conversations';
      }, 3000);
    }
  }

  async function collectIds(btn, workspaceId) {
    const all = new Set();
    const headers = { 'Authorization': `Bearer ${accessToken}` };
    if (workspaceId) {
        headers['ChatGPT-Account-Id'] = workspaceId;
    }

    for (const mode of MODES) {
      let offset = 0, total = Infinity, page = 0;
      do {
        btn.textContent = `🔍 ${mode.label} p${++page}`;
        const url = `/backend-api/conversations?offset=${offset}&limit=${PAGE_LIMIT}&order=updated${mode.param}`;
        const r = await fetch(url, { headers: headers });

        if (!r.ok) throw new Error(`列举对话列表失败 (${r.status})`);
        const j = await r.json();
        total = j.total;
        if (j.items) {
          j.items.forEach(it => all.add(it.id));
          offset += j.items.length;
        } else {
          break;
        }
        await sleep(jitter());
      } while (offset < total);
    }
    return Array.from(all);
  }

  async function getConversation(id, workspaceId) {
    const headers = { 'Authorization': `Bearer ${accessToken}` };
    if (workspaceId) {
        headers['ChatGPT-Account-Id'] = workspaceId;
    }
    const r = await fetch(`/backend-api/conversation/${id}`, { headers: headers });
    if (!r.ok) throw new Error(`获取对话详情失败 conv ${id} (${r.status})`);
    const j = await r.json();
    j.__fetched_at = new Date().toISOString();
    return j;
  }

  /***************** 4. UI (新增对话框) ****************************/
  function showExportDialog() {
    // 避免重复创建
    if (document.getElementById('export-dialog-overlay')) return;

    // 创建遮罩层
    const overlay = document.createElement('div');
    overlay.id = 'export-dialog-overlay';
    Object.assign(overlay.style, {
        position: 'fixed', top: '0', left: '0', width: '100%', height: '100%',
        backgroundColor: 'rgba(0, 0, 0, 0.5)', zIndex: '99998',
        display: 'flex', alignItems: 'center', justifyContent: 'center'
    });

    // 创建对话框
    const dialog = document.createElement('div');
    dialog.id = 'export-dialog';
    Object.assign(dialog.style, {
        background: '#fff', padding: '24px', borderRadius: '12px',
        boxShadow: '0 5px 15px rgba(0,0,0,.3)', width: '400px',
        fontFamily: 'sans-serif', color: '#333'
    });

    dialog.innerHTML = `
      <h2 style="margin-top:0; margin-bottom: 20px; font-size: 18px;">选择要导出的空间</h2>
      <div style="margin-bottom: 20px;">
        <label style="display: block; margin-bottom: 8px;">
          <input type="radio" name="export-mode" value="personal" checked> 个人空间
        </label>
        <label style="display: block;">
          <input type="radio" name="export-mode" value="team"> 团队空间 (Team)
        </label>
      </div>
      <div id="team-id-container" style="display: none; margin-bottom: 24px;">
        <label for="team-id-input" style="display: block; margin-bottom: 8px; font-weight: bold;">Team Workspace ID:</label>
        <input type="text" id="team-id-input" placeholder="请粘贴你的 Workspace ID" style="width: 100%; padding: 8px; border-radius: 6px; border: 1px solid #ccc; box-sizing: border-box;">
      </div>
      <div style="display: flex; justify-content: flex-end; gap: 12px;">
        <button id="cancel-export-btn" style="padding: 10px 16px; border: 1px solid #ccc; border-radius: 8px; background: #fff; cursor: pointer;">取消</button>
        <button id="start-export-btn" style="padding: 10px 16px; border: none; border-radius: 8px; background: #10a37f; color: #fff; cursor: pointer; font-weight: bold;">开始导出</button>
      </div>
    `;

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

    // --- 对话框逻辑 ---
    const teamIdContainer = document.getElementById('team-id-container');
    const teamIdInput = document.getElementById('team-id-input');
    const radios = document.querySelectorAll('input[name="export-mode"]');

    // 预填充
    teamIdInput.value = TEAM_WORKSPACE_ID;

    radios.forEach(radio => {
        radio.onchange = (e) => {
            teamIdContainer.style.display = e.target.value === 'team' ? 'block' : 'none';
        };
    });

    const closeDialog = () => document.body.removeChild(overlay);

    document.getElementById('cancel-export-btn').onclick = closeDialog;
    overlay.onclick = (e) => { if (e.target === overlay) closeDialog(); };

    document.getElementById('start-export-btn').onclick = () => {
        const mode = document.querySelector('input[name="export-mode"]:checked').value;
        let workspaceId = null;

        if (mode === 'team') {
            workspaceId = teamIdInput.value.trim();
            if (!workspaceId) {
                alert('请输入 Team Workspace ID!');
                return;
            }
        }
        closeDialog();
        startExportProcess(mode, workspaceId);
    };
  }

  function addBtn() {
    if (document.getElementById('gpt-rescue-btn')) return;
    const b = document.createElement('button');
    b.id = 'gpt-rescue-btn';
    b.textContent = 'Export Conversations'; // 通用文本
    Object.assign(b.style, {
      position: 'fixed', bottom: '24px', right: '24px', zIndex: '99997',
      padding: '10px 14px', borderRadius: '8px', border: 'none', cursor: 'pointer',
      fontWeight: 'bold', background: '#10a37f', color: '#fff', fontSize: '14px',
      boxShadow: '0 3px 12px rgba(0,0,0,.15)', userSelect: 'none'
    });
    b.onclick = showExportDialog; // 点击按钮时显示对话框
    document.body.appendChild(b);
  }

  // 延迟执行,确保页面加载完成
  setTimeout(addBtn, 2000);

})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址