您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
User-centric ZIP exporter with multi-ID support and automatic Workspace ID detection. Choose your space, and the script handles the rest. (Patched for new API requirements)
// ==UserScript== // @name ChatGPT Universal Exporter // @version 8.1.0 // @description User-centric ZIP exporter with multi-ID support and automatic Workspace ID detection. Choose your space, and the script handles the rest. (Patched for new API requirements) // @author Alex Mercer and Hanashiro (Refactored by Gemini, Patched by Gemini) // @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.zukizuki.org/users/1479633 // ==/UserScript== /* ============================================================ v8.1.0 变更 (API兼容性修复) ------------------------------------------------------------ • [修复] 针对ChatGPT最新API更新,在所有请求中增加了必需的 'oai-device-id' 请求头,解决了因鉴权变更导致的 401 错误。 • [新增] 增加了 getOaiDeviceId 辅助函数,用于从Cookie中 可靠地提取设备ID。 • [增强] 在API调用前增加了对设备ID是否成功获取的检查, 提升了脚本的健壮性。 ========================================================== */ (function () { 'use strict'; // --- 配置与全局变量 --- const BASE_DELAY = 600; const JITTER = 400; const PAGE_LIMIT = 100; let accessToken = null; let capturedWorkspaceIds = new Set(); // 使用Set存储网络拦截到的ID,确保唯一性 // --- 核心:网络拦截与信息捕获 --- (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)) { console.log('🎯 [Fetch] 捕获到 Workspace ID:', 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)) { console.log('🎯 [XHR] 捕获到 Workspace ID:', 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 ')) { accessToken = h.slice(7); } } 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 (_) {} alert('无法获取 Access Token。请刷新页面或打开任意一个对话后再试。'); return null; } // --- 辅助函数 --- const sleep = ms => new Promise(r => setTimeout(r, ms)); const jitter = () => BASE_DELAY + Math.random() * JITTER; const sanitizeFilename = (name) => name.replace(/[\/\\?%*:|"<>]/g, '-').trim(); /** * [新增] 从Cookie中获取 oai-device-id * @returns {string|null} - 返回设备ID或null */ function getOaiDeviceId() { const cookieString = document.cookie; const match = cookieString.match(/oai-did=([^;]+)/); return match ? match[1] : null; } function generateUniqueFilename(convData) { const shortId = convData.conversation_id.split('-').pop(); let baseName = convData.title; if (!baseName || baseName.trim().toLowerCase() === 'new chat') { baseName = 'Untitled Conversation'; } return `${sanitizeFilename(baseName)}_${shortId}.json`; } 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); URL.revokeObjectURL(a.href); } // --- 导出流程核心逻辑 --- async function startExportProcess(mode, workspaceId) { const btn = document.getElementById('gpt-rescue-btn'); btn.disabled = true; if (!await ensureAccessToken()) { btn.disabled = false; btn.textContent = 'Export Conversations'; return; } try { const zip = new JSZip(); 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); zip.file(generateUniqueFilename(convData), JSON.stringify(convData, null, 2)); await sleep(jitter()); } btn.textContent = '🔍 获取项目列表…'; const projects = await getProjects(workspaceId); 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); projectFolder.file(generateUniqueFilename(convData), JSON.stringify(convData, null, 2)); await sleep(jitter()); } } 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); } } // --- API 调用函数 --- async function getProjects(workspaceId) { if (!workspaceId) return []; const deviceId = getOaiDeviceId(); if (!deviceId) { throw new Error('无法获取 oai-device-id,请确保已登录并刷新页面。'); } const headers = { 'Authorization': `Bearer ${accessToken}`, 'ChatGPT-Account-Id': workspaceId, 'oai-device-id': deviceId }; const r = await fetch(`/backend-api/gizmos/snorlax/sidebar`, { headers }); if (!r.ok) { console.warn(`获取项目(Gizmo)列表失败 (${r.status})`); 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; } async function collectIds(btn, workspaceId, gizmoId) { const all = new Set(); const deviceId = getOaiDeviceId(); if (!deviceId) { throw new Error('无法获取 oai-device-id,请确保已登录并刷新页面。'); } const headers = { 'Authorization': `Bearer ${accessToken}`, 'oai-device-id': deviceId }; if (workspaceId) { headers['ChatGPT-Account-Id'] = workspaceId; } if (gizmoId) { let cursor = '0'; do { const r = await fetch(`/backend-api/gizmos/${gizmoId}/conversations?cursor=${cursor}`, { headers }); if (!r.ok) throw new Error(`列举项目对话列表失败 (${r.status})`); const j = await r.json(); j.items?.forEach(it => all.add(it.id)); cursor = j.cursor; await sleep(jitter()); } while (cursor); } else { for (const is_archived of [false, true]) { let offset = 0, has_more = true, page = 0; do { btn.textContent = `📂 项目外对话 (${is_archived ? 'Archived' : 'Active'} p${++page})`; const r = await fetch(`/backend-api/conversations?offset=${offset}&limit=${PAGE_LIMIT}&order=updated${is_archived ? '&is_archived=true' : ''}`, { 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)); has_more = j.items.length === PAGE_LIMIT; offset += j.items.length; } else { has_more = false; } await sleep(jitter()); } while (has_more); } } return Array.from(all); } async function getConversation(id, workspaceId) { const deviceId = getOaiDeviceId(); if (!deviceId) { throw new Error('无法获取 oai-device-id,请确保已登录并刷新页面。'); } const headers = { 'Authorization': `Bearer ${accessToken}`, 'oai-device-id': deviceId }; if (workspaceId) { headers['ChatGPT-Account-Id'] = workspaceId; } const r = await fetch(`/backend-api/conversation/${id}`, { headers }); if (!r.ok) throw new Error(`获取对话详情失败 conv ${id} (${r.status})`); const j = await r.json(); j.__fetched_at = new Date().toISOString(); return j; } // --- UI 相关函数 --- // (UI部分无变动,此处省略以保持简洁) /** * [新增] 全面检测函数,返回所有找到的ID * @returns {string[]} - 返回包含所有唯一Workspace ID的数组 */ function detectAllWorkspaceIds() { const foundIds = new Set(capturedWorkspaceIds); // 从网络拦截的结果开始 // 扫描 __NEXT_DATA__ try { const data = JSON.parse(document.getElementById('__NEXT_DATA__').textContent); // 遍历所有账户信息 const accounts = data?.props?.pageProps?.user?.accounts; if (accounts) { Object.values(accounts).forEach(acc => { if (acc?.account?.id) { foundIds.add(acc.account.id); } }); } } catch (e) {} // 扫描 localStorage 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); if (value && /^[a-z0-9]{2,}-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(value.replace(/"/g, ''))) { const extractedId = value.match(/ws-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/i); if(extractedId) foundIds.add(extractedId[0]); } else if (value && /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(value.replace(/"/g, ''))) { foundIds.add(value.replace(/"/g, '')); } } } } catch(e) {} console.log('🔍 检测到以下 Workspace IDs:', Array.from(foundIds)); return Array.from(foundIds); } /** * [重构] 多步骤、用户主导的导出对话框 */ 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: '450px', fontFamily: 'sans-serif', color: '#333', boxSizing: 'border-box' }); const closeDialog = () => document.body.removeChild(overlay); const renderStep = (step) => { let html = ''; switch (step) { case 'team': const detectedIds = detectAllWorkspaceIds(); html = `<h2 style="margin-top:0; margin-bottom: 20px; font-size: 18px;">导出团队空间</h2>`; if (detectedIds.length > 1) { html += `<div style="background: #eef2ff; border: 1px solid #818cf8; border-radius: 8px; padding: 12px; margin-bottom: 20px;"> <p style="margin: 0 0 12px 0; font-weight: bold; color: #4338ca;">🔎 检测到多个 Workspace,请选择一个:</p> <div id="workspace-id-list">`; detectedIds.forEach((id, index) => { html += `<label style="display: block; margin-bottom: 8px; padding: 8px; border-radius: 6px; cursor: pointer; border: 1px solid #ddd; background: #fff;"> <input type="radio" name="workspace_id" value="${id}" ${index === 0 ? 'checked' : ''}> <code style="margin-left: 8px; font-family: monospace; color: #555;">${id}</code> </label>`; }); html += `</div></div>`; } else if (detectedIds.length === 1) { html += `<div style="background: #f0fdf4; border: 1px solid #4ade80; border-radius: 8px; padding: 12px; margin-bottom: 20px;"> <p style="margin: 0 0 8px 0; font-weight: bold; color: #166534;">✅ 已自动检测到 Workspace ID:</p> <code id="workspace-id-code" style="background: #e0e7ff; padding: 4px 8px; border-radius: 4px; font-family: monospace; color: #4338ca; word-break: break-all;">${detectedIds[0]}</code> </div>`; } else { html += `<div style="background: #fffbeb; border: 1px solid #facc15; border-radius: 8px; padding: 12px; margin-bottom: 20px;"> <p style="margin: 0; color: #92400e;">⚠️ 未能自动检测到 Workspace ID。</p> <p style="margin: 8px 0 0 0; font-size: 12px; color: #92400e;">请尝试刷新页面或打开一个团队对话,或在下方手动输入。</p> </div> <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 (ws-...)" style="width: 100%; padding: 8px; border-radius: 6px; border: 1px solid #ccc; box-sizing: border-box;">`; } html += `<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 24px;"> <button id="back-btn" style="padding: 10px 16px; border: 1px solid #ccc; border-radius: 8px; background: #fff; cursor: pointer;">返回</button> <button id="start-team-export-btn" style="padding: 10px 16px; border: none; border-radius: 8px; background: #10a37f; color: #fff; cursor: pointer; font-weight: bold;">开始导出 (ZIP)</button> </div>`; break; case 'initial': default: html = `<h2 style="margin-top:0; margin-bottom: 20px; font-size: 18px;">选择要导出的空间</h2> <div style="display: flex; flex-direction: column; gap: 16px;"> <button id="select-personal-btn" style="padding: 16px; text-align: left; border: 1px solid #ccc; border-radius: 8px; background: #f9fafb; cursor: pointer; width: 100%;"> <strong style="font-size: 16px;">个人空间</strong> <p style="margin: 4px 0 0 0; color: #666;">导出您个人账户下的所有对话。</p> </button> <button id="select-team-btn" style="padding: 16px; text-align: left; border: 1px solid #ccc; border-radius: 8px; background: #f9fafb; cursor: pointer; width: 100%;"> <strong style="font-size: 16px;">团队空间</strong> <p style="margin: 4px 0 0 0; color: #666;">导出团队空间下的对话,将自动检测ID。</p> </button> </div> <div style="display: flex; justify-content: flex-end; margin-top: 24px;"> <button id="cancel-btn" style="padding: 10px 16px; border: 1px solid #ccc; border-radius: 8px; background: #fff; cursor: pointer;">取消</button> </div>`; break; } dialog.innerHTML = html; attachListeners(step); }; const attachListeners = (step) => { if (step === 'initial') { document.getElementById('select-personal-btn').onclick = () => { closeDialog(); startExportProcess('personal', null); }; document.getElementById('select-team-btn').onclick = () => renderStep('team'); document.getElementById('cancel-btn').onclick = closeDialog; } else if (step === 'team') { document.getElementById('back-btn').onclick = () => renderStep('initial'); document.getElementById('start-team-export-btn').onclick = () => { let workspaceId = ''; const radioChecked = document.querySelector('input[name="workspace_id"]:checked'); const codeEl = document.getElementById('workspace-id-code'); const inputEl = document.getElementById('team-id-input'); if (radioChecked) { workspaceId = radioChecked.value; } else if (codeEl) { workspaceId = codeEl.textContent; } else if (inputEl) { workspaceId = inputEl.value.trim(); } if (!workspaceId) { alert('请选择或输入一个有效的 Team Workspace ID!'); return; } closeDialog(); startExportProcess('team', workspaceId); }; } }; overlay.appendChild(dialog); document.body.appendChild(overlay); overlay.onclick = (e) => { if (e.target === overlay) closeDialog(); }; renderStep('initial'); } 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); })();