ChatGPT Universal Exporter (Pro)

Fixed "No Reaction" issue. Adds immediate scanning UI feedback and robust error handling. Floating UI style.

Du musst eine Erweiterung wie Tampermonkey, Greasemonkey oder Violentmonkey installieren, um dieses Skript zu installieren.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

Sie müssten eine Skript Manager Erweiterung installieren damit sie dieses Skript installieren können

(Ich habe schon ein Skript Manager, Lass mich es installieren!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         ChatGPT Universal Exporter (Pro)
// @version      2.1.1
// @description  Fixed "No Reaction" issue. Adds immediate scanning UI feedback and robust error handling. Floating UI style.
// @author       huhu
// @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
// @source       https://greasyfork.org/scripts/538495-chatgpt-universal-exporter
// @namespace    https://github.com/huhusmang/ChatGPT-Exporter
// ==/UserScript==

(function () {
    'use strict';

    // --- 配置 ---
    const BASE_DELAY = 600;
    const JITTER = 400;
    const PAGE_LIMIT = 50; 
    let accessToken = null;
    let capturedWorkspaceIds = new Set(); 

    // --- 1. 网络拦截 (保持 v1.0.0 的强力检测逻辑) ---
    (function interceptNetwork() {
        const rawFetch = window.fetch;
        window.fetch = async function (resource, options) {
            tryCaptureToken(options?.headers);
            if (options?.headers?.['ChatGPT-Account-Id']) {
                const id = options.headers['ChatGPT-Account-Id'];
                if (id && !capturedWorkspaceIds.has(id)) {
                    capturedWorkspaceIds.add(id);
                }
            }
            return rawFetch.apply(this, arguments);
        };

        const rawOpen = XMLHttpRequest.prototype.open;
        XMLHttpRequest.prototype.open = function () {
            this.addEventListener('readystatechange', () => {
                if (this.readyState === 4) {
                    try {
                        tryCaptureToken(this.getRequestHeader('Authorization'));
                        const id = this.getRequestHeader('ChatGPT-Account-Id');
                        if (id && !capturedWorkspaceIds.has(id)) {
                            capturedWorkspaceIds.add(id);
                        }
                    } catch (_) {}
                }
            });
            return rawOpen.apply(this, arguments);
        };
    })();

    function tryCaptureToken(header) {
        if (!header) return;
        const h = typeof header === 'string' ? header : header instanceof Headers ? header.get('Authorization') : header.Authorization || header.authorization;
        if (h?.startsWith('Bearer ')) {
            const token = h.slice(7);
            if (token && token.toLowerCase() !== 'dummy') accessToken = token;
        }
    }

    async function ensureAccessToken() {
        if (accessToken) return accessToken;
        try {
            const session = await (await fetch('/api/auth/session?unstable_client=true')).json();
            if (session.accessToken) { accessToken = session.accessToken; return accessToken; }
        } catch (e) { console.error('Token fetch error', e); }
        alert('无法获取 Access Token。请刷新页面或打开任意一个对话后再试。');
        return null;
    }

    // --- 2. 辅助函数 ---
    const sleep = ms => new Promise(r => setTimeout(r, ms));
    const jitter = () => BASE_DELAY + Math.random() * JITTER;
    const sanitizeFilename = (name) => name.replace(/[\/\\?%*:|"<>]/g, '-').trim();
    
    function getOaiDeviceId() { 
        const m = document.cookie.match(/oai-did=([^;]+)/); 
        return m ? m[1] : null; 
    }

    // --- 3. 核心 API 请求逻辑 ---

    // 获取项目列表 (GPTs)
    async function getProjects(workspaceId) {
        if (!workspaceId) return [];
        const deviceId = getOaiDeviceId();
        const headers = { 'Authorization': `Bearer ${accessToken}`, 'ChatGPT-Account-Id': workspaceId, 'oai-device-id': deviceId };
        try {
            const r = await fetch(`/backend-api/gizmos/snorlax/sidebar`, { headers });
            if (!r.ok) return [];
            const data = await r.json();
            const projects = [];
            data.items?.forEach(item => {
                if (item?.gizmo?.id && item?.gizmo?.display?.name) {
                    projects.push({ id: item.gizmo.id, title: item.gizmo.display.name });
                }
            });
            return projects;
        } catch (e) { console.error('Get Projects Failed', e); return []; }
    }

    // 通用获取列表函数
    async function fetchListFromEndpoint(endpoint, workspaceId, sourceLabel, statusCallback) {
        const list = [];
        const deviceId = getOaiDeviceId();
        const headers = { 'Authorization': `Bearer ${accessToken}`, 'oai-device-id': deviceId };
        if (workspaceId) headers['ChatGPT-Account-Id'] = workspaceId;

        const isGizmo = endpoint.includes('/gizmos/');
        let hasMore = true;
        let offset = 0;
        let cursor = null;
        let pageCount = 0;

        // 限制扫描页数,防止卡死
        while (hasMore && pageCount < 5) {
            let url = endpoint;
            if (isGizmo) {
                url += `?limit=${PAGE_LIMIT}${cursor ? '&cursor='+cursor : ''}`;
            } else {
                url += `?offset=${offset}&limit=${PAGE_LIMIT}&order=updated`;
            }

            try {
                if(statusCallback) statusCallback(`正在读取 ${sourceLabel} (页数 ${pageCount+1})...`);
                const r = await fetch(url, { headers });
                if (!r.ok) break;
                const j = await r.json();
                
                if (j.items && j.items.length > 0) {
                    j.items.forEach(it => {
                        list.push({
                            id: it.id,
                            title: it.title || 'Untitled',
                            source: sourceLabel 
                        });
                    });
                    
                    if (isGizmo) {
                        if (j.cursor) cursor = j.cursor; else hasMore = false;
                    } else {
                        offset += j.items.length;
                        if (j.items.length < PAGE_LIMIT) hasMore = false;
                    }
                } else {
                    hasMore = false;
                }
                pageCount++;
                await sleep(150);
            } catch (e) { console.error(e); break; }
        }
        return list;
    }

    // [核心] 扫描总指挥
    async function fetchAllConversations(workspaceId, updateStatus) {
        let allConversations = [];

        // 1. 扫描主对话列表
        updateStatus('🔍 正在扫描主对话列表...');
        const mainList = await fetchListFromEndpoint(`/backend-api/conversations`, workspaceId, 'Main Chat', updateStatus);
        allConversations = allConversations.concat(mainList);

        // 2. 扫描项目
        if (workspaceId) {
            updateStatus('🔍 正在获取项目列表...');
            const projects = await getProjects(workspaceId);
            
            for (let i = 0; i < projects.length; i++) {
                const proj = projects[i];
                updateStatus(`🔍 扫描项目 (${i+1}/${projects.length}): ${proj.title.substring(0,10)}...`);
                
                const projList = await fetchListFromEndpoint(
                    `/backend-api/gizmos/${proj.id}/conversations`, 
                    workspaceId, 
                    `Project: ${proj.title}`,
                    null // 子循环不频繁更新UI以免闪烁
                );
                allConversations = allConversations.concat(projList);
                await sleep(jitter());
            }
        }

        return allConversations;
    }

    // --- 4. 数据导出与格式化 ---
    async function getConversation(id, workspaceId) {
        const headers = { 'Authorization': `Bearer ${accessToken}`, 'oai-device-id': getOaiDeviceId() };
        if (workspaceId) headers['ChatGPT-Account-Id'] = workspaceId;
        const r = await fetch(`/backend-api/conversation/${id}`, { headers });
        if(!r.ok) throw new Error('Fetch failed ' + r.status);
        return await r.json();
    }

    function generateUniqueFilename(d) { return `${sanitizeFilename(d.title)}_${d.conversation_id.slice(-6)}.json`; }
    function generateMarkdownFilename(d) { return generateUniqueFilename(d).replace('.json', '.md'); }
    function cleanMessageContent(t) { return t ? t.replace(/\uE200cite.*?\uE201/gi, '').trim() : ''; }
    
    function convertConversationToMarkdown(d) {
        const m = d?.mapping; if (!m) return '';
        const roots = Object.keys(m).filter(k => !m[k].parent);
        let out = [];
        const traverse = (nid) => {
            const node = m[nid];
            if (node?.message?.content?.parts) {
                const role = node.message.author.role;
                const text = node.message.content.parts.join('');
                if (text && role !== 'system') out.push(`### ${role}\n${text}\n`);
            }
            node?.children?.forEach(traverse);
        };
        roots.forEach(traverse);
        return out.join('\n');
    }

    function downloadFile(blob, filename) {
        const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = filename;
        document.body.appendChild(a); a.click(); document.body.removeChild(a);
    }

    // --- 5. UI 逻辑 ---
    
    // [修复] 独立的扫描状态界面
    function renderScanningUI(msg) {
        const dialog = document.getElementById('export-dialog');
        if(!dialog) return;
        dialog.innerHTML = `
            <div style="display:flex; flex-direction:column; align-items:center; justify-content:center; height:100%; padding:20px;">
                <div style="font-size:24px; margin-bottom:15px;">⏳</div>
                <h3 style="margin:0 0 10px 0;">正在扫描对话</h3>
                <p id="cge-scan-status" style="color:#666; font-size:14px; text-align:center;">${msg}</p>
            </div>
        `;
    }

    function updateScanningText(msg) {
        const el = document.getElementById('cge-scan-status');
        if(el) el.textContent = msg;
    }

    // 步骤 1: 开始流程
    async function startScanAndSelect(workspaceId) {
        console.log("Start scanning for workspace:", workspaceId);
        
        // 1. 立即切换界面,防止“无反应”
        renderScanningUI("准备开始...");

        // 2. 检查权限
        if (!await ensureAccessToken()) { 
            alert("获取 Token 失败,请刷新页面");
            showExportDialog(); // 回到主页
            return; 
        }

        try {
            // 3. 执行扫描
            const list = await fetchAllConversations(workspaceId, updateScanningText);
            
            // 4. 渲染结果
            renderSelectionScreen(list, workspaceId);
        } catch (e) {
            console.error(e);
            alert('扫描失败: ' + e.message);
            showExportDialog(); // 回到主页
        }
    }

    // 步骤 2: 渲染列表
    function renderSelectionScreen(list, workspaceId) {
        const dialog = document.getElementById('export-dialog');
        if (!dialog) return;
        
        let html = `
            <h2 style="margin-top:0; font-size:18px; display:flex; justify-content:space-between; align-items:center;">
                <span>选择对话</span>
                <span style="font-size:12px; color:#666; font-weight:normal;">共 ${list.length} 个</span>
            </h2>
            <div style="margin-bottom:10px; display:flex; gap:10px;">
                <input type="text" id="cge-search" placeholder="搜索..." style="flex:1; padding:6px; border:1px solid #ccc; border-radius:4px;">
                <button id="cge-sel-all" style="padding:4px 8px; font-size:12px;">全选</button>
                <button id="cge-sel-none" style="padding:4px 8px; font-size:12px;">清空</button>
            </div>
            <div id="cge-list-container" style="height:300px; overflow-y:auto; border:1px solid #eee; border-radius:4px; padding:5px;">
                ${list.length === 0 ? '<p style="text-align:center; color:#999; margin-top:20px;">未找到任何对话<br>可能是权限不足或列表为空</p>' : ''}
                ${list.map(item => {
                    const isProject = item.source.startsWith('Project');
                    const badgeColor = isProject ? '#e0f2fe' : '#f3f4f6';
                    const badgeText = isProject ? '#0369a1' : '#374151';
                    
                    return `
                    <div class="cge-item" data-title="${item.title.toLowerCase()}" style="display:flex; align-items:center; padding:8px 6px; border-bottom:1px solid #f9f9f9;">
                        <input type="checkbox" class="cge-chk" value="${item.id}" style="margin-right:10px;">
                        <div style="overflow:hidden;">
                            <div style="white-space:nowrap; overflow:hidden; text-overflow:ellipsis; font-size:13px; font-weight:500;" title="${item.title}">${item.title}</div>
                            <div style="font-size:10px; background:${badgeColor}; color:${badgeText}; display:inline-block; padding:1px 4px; border-radius:4px; margin-top:2px;">${item.source}</div>
                        </div>
                    </div>`;
                }).join('')}
            </div>
            <div style="margin-top:15px; display:flex; justify-content:space-between; align-items:center;">
                <button id="cge-back-step" style="padding:8px 12px; border:1px solid #ccc; background:#fff; border-radius:6px; cursor:pointer;">返回</button>
                <button id="cge-confirm-export" style="padding:8px 16px; border:none; background:#10a37f; color:#fff; border-radius:6px; font-weight:bold; cursor:pointer;">
                    导出选中 (0)
                </button>
            </div>
        `;
        dialog.innerHTML = html;

        // 绑定事件
        const updateCount = () => {
            const count = document.querySelectorAll('.cge-chk:checked').length;
            document.getElementById('cge-confirm-export').textContent = `导出选中 (${count})`;
        };
        
        document.querySelectorAll('.cge-chk').forEach(cb => cb.onchange = updateCount);
        
        document.getElementById('cge-sel-all').onclick = () => {
            document.querySelectorAll('.cge-item:not([style*="display: none"]) .cge-chk').forEach(c => c.checked = true);
            updateCount();
        };
        document.getElementById('cge-sel-none').onclick = () => {
            document.querySelectorAll('.cge-chk').forEach(c => c.checked = false);
            updateCount();
        };
        
        document.getElementById('cge-search').oninput = (e) => {
            const term = e.target.value.toLowerCase();
            document.querySelectorAll('.cge-item').forEach(row => {
                row.style.display = row.getAttribute('data-title').includes(term) ? 'flex' : 'none';
            });
        };

        document.getElementById('cge-back-step').onclick = () => {
            showExportDialog(); 
        };

        document.getElementById('cge-confirm-export').onclick = async () => {
            const selectedIds = Array.from(document.querySelectorAll('.cge-chk:checked')).map(c => c.value);
            if(selectedIds.length === 0) return alert('请选择!');
            await executeExport(selectedIds, list, workspaceId, document.getElementById('cge-confirm-export'));
        };
    }

    // 步骤 3: 导出执行
    async function executeExport(ids, fullList, workspaceId, btn) {
        btn.disabled = true;
        const zip = new JSZip();
        try {
            for (let i = 0; i < ids.length; i++) {
                const id = ids[i];
                const meta = fullList.find(x => x.id === id);
                btn.textContent = `处理中 ${i+1}/${ids.length}`;
                
                const data = await getConversation(id, workspaceId);
                if (meta && meta.title) data.title = meta.title;
                
                let folder = zip;
                if (meta.source.startsWith('Project: ')) {
                    const folderName = sanitizeFilename(meta.source.replace('Project: ', ''));
                    folder = zip.folder(folderName);
                }

                folder.file(generateUniqueFilename(data), JSON.stringify(data, null, 2));
                folder.file(generateMarkdownFilename(data), convertConversationToMarkdown(data));
                await sleep(jitter());
            }
            btn.textContent = '打包中...';
            const blob = await zip.generateAsync({ type: "blob", compression: "DEFLATE" });
            downloadFile(blob, `chatgpt_export_${new Date().toISOString().slice(0,10)}.zip`);
            btn.textContent = '✅ 完成';
            setTimeout(() => { 
                // 导出完成后不直接关闭,给用户一个反馈,或者可以选择返回
                alert('导出完成!');
                showExportDialog();
            }, 500);
        } catch(e) { alert('Err: ' + e.message); btn.disabled = false; }
    }

    // --- UI 主入口 ---
    function detectAllWorkspaceIds() {
        const foundIds = new Set(capturedWorkspaceIds);
        try {
            const data = JSON.parse(document.getElementById('__NEXT_DATA__').textContent);
            Object.values(data?.props?.pageProps?.user?.accounts || {}).forEach(acc => {
                if (acc?.account?.id) foundIds.add(acc.account.id);
            });
        } catch (e) {}
        try {
            for (let i = 0; i < localStorage.length; i++) {
                const key = localStorage.key(i);
                if (key && (key.includes('account') || key.includes('workspace'))) {
                    const value = localStorage.getItem(key);
                    const match = value?.match(/ws-[a-f0-9-]{36}|[a-f0-9-]{36}/i);
                    if(match) foundIds.add(match[0]);
                }
            }
        } catch(e) {}
        return Array.from(foundIds);
    }

    function showExportDialog() {
        if (document.getElementById('export-dialog-overlay')) {
            // 如果已存在,先移除旧的,重新渲染,保证状态重置
            document.body.removeChild(document.getElementById('export-dialog-overlay'));
        }

        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: '450px', fontFamily: 'sans-serif', color: '#333' });
        
        const renderStep = (step) => {
            let html = '';
            if (step === 'team') {
                const ids = detectAllWorkspaceIds();
                html = `<h2 style="margin-top:0;">导出团队空间</h2>`;
                if(ids.length > 0) {
                    html += `<div style="background:#eef2ff; border:1px solid #818cf8; border-radius:8px; padding:10px; margin-bottom:15px;"><p style="margin:0 0 5px 0; font-weight:bold; color:#4338ca;">🔎 检测到 ID (选择一个):</p>`;
                    ids.forEach((id, i) => html += `<label style="display:block; margin:5px 0;"><input type="radio" name="ws_id" value="${id}" ${i===0?'checked':''}> <code style="color:#555">${id}</code></label>`);
                    html += `</div>`;
                } else {
                    html += `<p style="color:#92400e; font-size:12px; background:#fffbeb; padding:10px; border-radius:6px;">⚠️ 未检测到 ID,请尝试刷新页面或打开团队对话。或手动输入:</p>
                    <input type="text" id="manual_id" placeholder="ws-..." style="width:100%; padding:8px; margin-bottom:10px; border:1px solid #ccc; border-radius:4px;">`;
                }
                html += `<div style="display:flex; justify-content:space-between; margin-top:20px;"><button id="back-btn" style="padding:8px 12px; border:1px solid #ccc; background:#fff; border-radius:6px;">返回</button><button id="scan-btn" style="padding:8px 16px; border:none; background:#10a37f; color:#fff; border-radius:6px; font-weight:bold;">扫描并选择</button></div>`;
            } else {
                html = `<h2 style="margin-top:0;">选择空间</h2>
                <button id="sel-personal" style="width:100%; padding:15px; margin-bottom:10px; text-align:left; border:1px solid #ccc; border-radius:8px; background:#f9fafb; cursor:pointer;"><strong>个人空间</strong><p style="margin:5px 0 0; color:#666; font-size:12px;">个人账户的所有对话</p></button>
                <button id="sel-team" style="width:100%; padding:15px; text-align:left; border:1px solid #ccc; border-radius:8px; background:#f9fafb; cursor:pointer;"><strong>团队空间</strong><p style="margin:5px 0 0; color:#666; font-size:12px;">团队/企业版对话 (含项目)</p></button>
                <div style="text-align:right; margin-top:20px;"><button id="cancel-btn" style="padding:8px 12px; border:1px solid #ccc; background:#fff; border-radius:6px;">取消</button></div>`;
            }
            dialog.innerHTML = html;
            
            if(step === 'initial') {
                document.getElementById('sel-personal').onclick = () => { startScanAndSelect(null); }; 
                document.getElementById('sel-team').onclick = () => renderStep('team');
                document.getElementById('cancel-btn').onclick = () => document.body.removeChild(overlay);
            } else {
                document.getElementById('back-btn').onclick = () => renderStep('initial');
                document.getElementById('scan-btn').onclick = () => {
                    const radio = document.querySelector('input[name="ws_id"]:checked');
                    const manual = document.getElementById('manual_id');
                    const id = radio ? radio.value : (manual ? manual.value.trim() : null);
                    if(id) {
                        startScanAndSelect(id);
                    } else { alert('请输入或选择 ID'); }
                };
            }
        };
        
        overlay.appendChild(dialog); document.body.appendChild(overlay); renderStep('initial');
    }

    // [关键修改] 修改为悬浮球样式
    function addBtn() {
        if (document.getElementById('gpt-rescue-btn')) return;
        
        // 插入样式
        const style = document.createElement('style');
        style.innerHTML = `
            #gpt-rescue-btn {
                position: fixed;
                bottom: 30px;
                right: 30px;
                width: 50px;
                height: 50px;
                border-radius: 50%;
                background-color: #10a37f;
                color: white;
                border: none;
                cursor: pointer;
                box-shadow: 0 4px 15px rgba(0,0,0,0.2);
                z-index: 99997;
                display: flex;
                align-items: center;
                justify-content: center;
                font-size: 24px;
                transition: transform 0.2s, background-color 0.2s;
            }
            #gpt-rescue-btn:hover {
                transform: scale(1.1);
                background-color: #0d8a6a;
            }
            #gpt-rescue-btn:active {
                transform: scale(0.95);
            }
        `;
        document.head.appendChild(style);

        const b = document.createElement('button');
        b.id = 'gpt-rescue-btn';
        b.innerHTML = '📥'; // 使用图标
        b.title = '导出 ChatGPT 对话';
        b.onclick = showExportDialog;
        document.body.appendChild(b);
    }
    
    setTimeout(addBtn, 2000);
})();