ChatGPT Universal Exporter

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

Per 06-06-2025. Zie de nieuwste versie.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey, Greasemonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Userscripts.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een gebruikersscriptbeheerder nodig.

(Ik heb al een user script manager, laat me het downloaden!)

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

(Ik heb al een beheerder - laat me doorgaan met de installatie!)

// ==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://greatest.deepsurf.us/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);

})();