ChatGPT Universal Exporter

Unified ZIP export for Personal & Team spaces. Sorts conversations by project folders.

// ==UserScript==
// @name         ChatGPT Universal Exporter
// @version      5.0.0
// @description  Unified ZIP export for Personal & Team spaces. Sorts conversations by project folders.
// @author       Alex Mercer and Hanashiro with Gemini's help
// @match        https://chatgpt.com/*
// @match        https://chat.openai.com/*
// @require      https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js
// @grant        none
// @license      MIT
// @namespace https://gf.qytechs.cn/users/1479633
// ==/UserScript==

/* ============================================================
   v5.0.0 变更 (最终整合版)
   ------------------------------------------------------------
   • [功能整合] 将所有导出模式统一为一个强大的ZIP导出功能。
   • [UI简化] 导出选项简化为“个人空间”和“团队空间”。
   • [结构优化] 两种模式都会生成结构化的ZIP:项目对话在文件夹内,
     项目外的对话在根目录。
   • [逻辑重构] 导出流程重构,以分别处理项目内外的对话。
   • 这是功能完善的最终版本,感谢用户的清晰需求和耐心协作!
   ========================================================== */
(function () {
    'use strict';

    const TEAM_WORKSPACE_ID = '';
    const BASE_DELAY = 600;
    const JITTER = 400;
    const PAGE_LIMIT = 100;
    let accessToken = null;

    (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);
    }
    const sleep = ms => new Promise(r => setTimeout(r, ms));
    const jitter = () => BASE_DELAY + Math.random() * JITTER;
    const sanitizeFilename = (name) => name.replace(/[\/\\?%*:|"<>]/g, '-');
    function downloadFile(blob, filename) {
        const a = document.createElement('a');
        a.href = URL.createObjectURL(blob);
        a.download = filename;
        document.body.appendChild(a);
        a.click();
        a.remove();
        URL.revokeObjectURL(a.href);
    }

    // [REWRITTEN in V5.0] Unified export logic
    async function startExportProcess(mode, workspaceId) {
        const btn = document.getElementById('gpt-rescue-btn');
        btn.disabled = true;

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

        try {
            const zip = new JSZip();

            // 1. Get conversations NOT in any project
            btn.textContent = '📂 获取项目外对话…';
            const orphanIds = await collectIds(btn, workspaceId, null);
            for (let i = 0; i < orphanIds.length; i++) {
                btn.textContent = `📥 根目录 (${i + 1}/${orphanIds.length})`;
                const convData = await getConversation(orphanIds[i], workspaceId);
                const filename = sanitizeFilename(convData.title || `conversation-${convData.conversation_id}`) + '.json';
                zip.file(filename, JSON.stringify(convData, null, 2));
                await sleep(jitter());
            }

            // 2. Get list of projects (Gizmos)
            btn.textContent = '🔍 获取项目列表…';
            const projects = await getProjects(workspaceId);

            // 3. Get conversations FOR EACH project
            for (const project of projects) {
                const projectFolder = zip.folder(sanitizeFilename(project.title));
                btn.textContent = `📂 项目: ${project.title}`;
                const projectConvIds = await collectIds(btn, workspaceId, project.id);
                if (projectConvIds.length === 0) continue;

                for (let i = 0; i < projectConvIds.length; i++) {
                    btn.textContent = `📥 ${project.title.substring(0,10)}... (${i + 1}/${projectConvIds.length})`;
                    const convData = await getConversation(projectConvIds[i], workspaceId);
                    const filename = sanitizeFilename(convData.title || `conversation-${convData.conversation_id}`) + '.json';
                    projectFolder.file(filename, JSON.stringify(convData, null, 2));
                    await sleep(jitter());
                }
            }

            // 4. Generate and download the ZIP file
            btn.textContent = '📦 生成 ZIP 文件…';
            const blob = await zip.generateAsync({ type: "blob", compression: "DEFLATE" });
            const date = new Date().toISOString().slice(0, 10);
            const filename = mode === 'team'
                ? `chatgpt_team_backup_${workspaceId}_${date}.zip`
                : `chatgpt_personal_backup_${date}.zip`;
            downloadFile(blob, filename);
            alert(`✅ 导出完成!`);
            btn.textContent = '✅ 完成';

        } catch (e) {
            console.error("导出过程中发生严重错误:", e);
            alert(`导出失败: ${e.message}。详情请查看控制台(F12 -> Console)。`);
            btn.textContent = '⚠️ Error';
        } finally {
            setTimeout(() => {
                btn.disabled = false;
                btn.textContent = 'Export Conversations';
            }, 3000);
        }
    }

    async function getProjects(workspaceId) {
        const headers = { 'Authorization': `Bearer ${accessToken}` };
        if (workspaceId) { headers['ChatGPT-Account-Id'] = workspaceId; }

        const r = await fetch(`/backend-api/gizmos/snorlax/sidebar`, { headers: headers });
        if (!r.ok) throw new Error(`获取项目(Gizmo)列表失败 (${r.status})`);
        const data = await r.json();

        const projects = [];
        if (data && Array.isArray(data.items)) {
            for (const item of data.items) {
                if (item && item.gizmo && item.gizmo.id && item.gizmo.display && item.gizmo.display.name) {
                    projects.push({
                        id: item.gizmo.id,
                        title: item.gizmo.display.name
                    });
                }
            }
        }
        return projects;
    }

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

        if (gizmoId) {
            let cursor = '0';
            do {
                const url = `/backend-api/gizmos/${gizmoId}/conversations?cursor=${cursor}`;
                const r = await fetch(url, { headers: headers });
                if (!r.ok) throw new Error(`列举Gizmo对话列表失败 (${r.status}) for gizmo ${gizmoId}`);

                const j = await r.json();
                if (j.items && j.items.length > 0) {
                    j.items.forEach(it => all.add(it.id));
                }
                cursor = j.cursor;
                await sleep(jitter());
            } while (cursor);
        } else {
            const modes = [{ label: 'active', param: '' }, { label: 'archived', param: '&is_archived=true' }];
            for (const mode of modes) {
                let offset = 0, has_more = true, page = 0;
                do {
                    btn.textContent = `📂 项目外对话 (p${++page})`;
                    let 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();
                    if (j.items && j.items.length > 0) {
                        j.items.forEach(it => all.add(it.id));
                        offset += j.items.length;
                    } else { has_more = false; }
                    if (j.items.length < PAGE_LIMIT) { has_more = false; }
                    await sleep(jitter());
                } while (has_more);
            }
        }
        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;
    }

    // [REWRITTEN in V5.0] Simplified 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; margin-bottom: 8px;"><input type="radio" name="export-mode" value="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;">开始导出 (ZIP)</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或关注我们的公众号极客氢云获取最新地址