您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Export ChatGPT conversation & images in ZIP
// ==UserScript== // @name ChatGPT Exporter // @namespace http://tampermonkey.net/ // @version 0.1.3 // @description Export ChatGPT conversation & images in ZIP // @author MLR // @match https://chatgpt.com/* // @grant GM_registerMenuCommand // @grant GM_download // @require https://cdn.jsdelivr.net/npm/[email protected]/umd/index.js // @license MIT // ==/UserScript== (function() { 'use strict'; // ============================================= // ZIP ARCHIVE FUNCTIONALITY // ============================================= /** * Creates and manages ZIP archive for exports using fflate */ class ArchiveManager { constructor() { this.files = new Map(); this.fileCount = 0; console.log('[ChatGPT Exporter] ArchiveManager initialized with fflate'); } async addFile(filename, content, useFolder = false, folderName = '') { let finalFilename = filename; // Create with folder (but first need to remove folder path from api filename and keep only filename) // if (useFolder && folderName) { // finalFilename = `${sanitizeFileName(folderName)}/${filename}`; // } console.log(`[ChatGPT Exporter] Adding file to archive: ${finalFilename}`); // Convert blob to Uint8Array for fflate let fileData; if (content instanceof Blob) { fileData = new Uint8Array(await content.arrayBuffer()); } else if (typeof content === 'string') { fileData = new TextEncoder().encode(content); } else { fileData = content; } this.files.set(finalFilename, fileData); this.fileCount++; console.log(`[ChatGPT Exporter] File added. Total files: ${this.fileCount}`); } async downloadArchive(archiveName) { if (this.fileCount === 0) { throw new Error('No files to archive'); } console.log(`[ChatGPT Exporter] Creating archive with ${this.fileCount} files...`); showNotification(`Creating archive with ${this.fileCount} files...`, 'info', 'archive-progress'); try { console.log('[ChatGPT Exporter] Generating ZIP with fflate...'); // Convert Map to object for fflate const filesObject = {}; for (const [filename, data] of this.files) { filesObject[filename] = data; } // Use fflate.zipSync for immediate generation const zipData = fflate.zipSync(filesObject, { level: 1, // Fast compression mem: 8 // Memory level }); console.log('[ChatGPT Exporter] ZIP generated successfully, size:', zipData.length); const zipBlob = new Blob([zipData], { type: 'application/zip' }); const url = URL.createObjectURL(zipBlob); const link = document.createElement('a'); link.href = url; link.download = archiveName; document.body.appendChild(link); link.click(); document.body.removeChild(link); console.log('[ChatGPT Exporter] Archive download initiated'); setTimeout(() => { URL.revokeObjectURL(url); console.log('[ChatGPT Exporter] Archive cleanup completed'); }, 1000); removeNotification('archive-progress'); } catch (error) { console.error('[ChatGPT Exporter] Archive generation failed:', error); removeNotification('archive-progress'); throw new Error(`Archive generation failed: ${error.message}`); } } } /** * Downloads image and returns blob data for ZIP archive */ async function downloadImageForArchive(url, filename) { try { console.log(`[ChatGPT Exporter] Fetching image for archive: ${filename} from URL: ${url}`); if (!url || url === 'undefined') { throw new Error('Invalid download URL'); } const response = await fetch(url); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const blob = await response.blob(); if (blob.size === 0) { throw new Error('Downloaded file is empty'); } console.log(`[ChatGPT Exporter] Fetched blob size: ${blob.size} bytes for ${filename}`); // Remove folder path from api filename and keep only filename const cleanFilename = filename.split('/').pop(); return { success: true, filename: cleanFilename, blob, size: blob.size }; } catch (error) { console.error(`[ChatGPT Exporter] Failed to fetch image ${filename}:`, error); return { success: false, filename, error: error.message }; } } // ============================================= // UTILITY FUNCTIONS // ============================================= /** * Removes invalid characters from filename and limits length */ function sanitizeFileName(name) { return name.replace(/[\\/:*?"<>|]/g, '_') .replace(/\s+/g, '_') .replace(/__+/g, '_') .replace(/^_+|_+$/g, '') .slice(0, 100); } /** * Converts timestamp to readable date format */ function formatDate(dateInput) { if (!dateInput) return ''; // ChatGPT API returns Unix timestamps in seconds, convert to milliseconds let timestamp; if (typeof dateInput === 'string') { timestamp = parseFloat(dateInput) * 1000; } else if (typeof dateInput === 'number') { timestamp = dateInput * 1000; } else if (dateInput instanceof Date) { return dateInput.toLocaleString(); } else { return ''; } const date = new Date(timestamp); return date.toLocaleString(); } /** * Downloads content as file using browser download API */ function downloadFile(filename, content) { const blob = new Blob([content], { type: 'text/plain' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = filename; link.click(); setTimeout(() => URL.revokeObjectURL(url), 100); } /** * Downloads image from URL with specified filename */ async function downloadImage(url, filename) { try { console.log(`[ChatGPT Exporter] Downloading image: ${filename} from URL: ${url}`); if (!url || url === 'undefined') { throw new Error('Invalid download URL'); } const response = await fetch(url); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const blob = await response.blob(); if (blob.size === 0) { throw new Error('Downloaded file is empty'); } console.log(`[ChatGPT Exporter] Downloaded blob size: ${blob.size} bytes for ${filename}`); const blobUrl = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = blobUrl; link.download = filename; link.click(); setTimeout(() => URL.revokeObjectURL(blobUrl), 100); console.log(`[ChatGPT Exporter] Successfully downloaded: ${filename}`); return { success: true, filename, size: blob.size }; } catch (error) { console.error(`[ChatGPT Exporter] Failed to download image ${filename}:`, error); return { success: false, filename, error: error.message }; } } /** * Shows temporary notification to user with ability to update content */ function showNotification(message, type = "info", id = null) { // If ID provided, try to update existing notification if (id) { const existing = document.getElementById(id); if (existing) { existing.textContent = message; return existing; } } const notification = document.createElement('div'); notification.className = 'chatgpt-notification'; if (id) notification.id = id; const colors = { error: '#f44336', success: '#4CAF50', info: '#2196F3' }; notification.style.cssText = ` position: fixed; top: 20px; right: 20px; padding: 15px 20px; border-radius: 5px; color: white; font-family: system-ui, -apple-system, sans-serif; font-size: 14px; z-index: 10000; max-width: 400px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); background-color: ${colors[type] || colors.info}; `; notification.textContent = message; document.body.appendChild(notification); // Auto-remove after 5 seconds unless it has an ID (persistent notifications) if (!id) { setTimeout(() => { if (notification.parentNode) { notification.parentNode.removeChild(notification); } }, 5000); } return notification; } /** * Removes notification by ID */ function removeNotification(id) { const notification = document.getElementById(id); if (notification && notification.parentNode) { notification.parentNode.removeChild(notification); } } // ============================================= // API FUNCTIONS // ============================================= /** * Extracts conversation ID from current URL */ function getConversationId() { const match = window.location.pathname.match(/\/c\/([^/?]+)/); return match ? match[1] : null; } /** * Gets session data from ChatGPT API */ async function getSession() { const response = await fetch("https://chatgpt.com/api/auth/session"); return await response.json(); } /** * Retrieves bearer token for API authentication */ async function getBearerToken() { try { const session = await getSession(); if (!session.accessToken) { throw new Error('No access token found. Please log in to ChatGPT.'); } return session.accessToken; } catch (error) { throw new Error(`Failed to get bearer token: ${error.message}`); } } /** * Fetches conversation data from ChatGPT API with full tree structure */ async function getChatGPTConversationData(conversationId = null) { const id = conversationId || getConversationId(); if (!id) { throw new Error('Not in a conversation'); } try { const token = await getBearerToken(); const response = await fetch(`https://chatgpt.com/backend-api/conversation/${id}`, { "headers": { "accept": "*/*", "accept-language": "en-US,en;q=0.9", "authorization": "Bearer " + token, }, "method": "GET" }); if (!response.ok) { throw new Error(`API request failed: ${response.status}`); } return await response.json(); } catch (error) { console.error('[ChatGPT Exporter] Failed to get conversation data:', error); throw error; } } /** * Gets image download URL for given file ID */ async function getChatGPTImageUrl(fileId, conversationId) { try { console.log(`[ChatGPT Exporter] Getting image URL for fileId: ${fileId}, conversationId: ${conversationId}`); const token = await getBearerToken(); const response = await fetch(`https://chatgpt.com/backend-api/files/download/${fileId}?conversation_id=${conversationId}&inline=false`, { "headers": { "accept": "*/*", "accept-language": "en-US,en;q=0.9", "authorization": "Bearer " + token, }, "method": "GET" }); console.log(`[ChatGPT Exporter] Image API response status: ${response.status} for fileId: ${fileId}`); if (!response.ok) { const errorText = await response.text(); console.error(`[ChatGPT Exporter] Image API request failed: ${response.status} - ${errorText}`); throw new Error(`Image API request failed: ${response.status} - ${response.statusText}`); } const data = await response.json(); console.log(`[ChatGPT Exporter] Image API response data:`, data); if (!data.download_url) { console.error(`[ChatGPT Exporter] No download_url in response for fileId: ${fileId}`, data); throw new Error('No download URL provided by API'); } return { downloadUrl: data.download_url, fileName: data.file_name, fileSize: data.file_size_bytes }; } catch (error) { console.error(`[ChatGPT Exporter] Failed to get image URL for fileId: ${fileId}:`, error); return { error: error.message, fileId: fileId }; } } // ============================================= // MESSAGE PARSING AND PROCESSING // ============================================= /** * Extracts file ID from asset pointer strings */ function extractFileId(assetPointer) { console.log(`[ChatGPT Exporter] Extracting fileId from asset_pointer: ${assetPointer}`); // Extract file ID from asset_pointer patterns let match = assetPointer.match(/file_([a-f0-9]+)/); if (match) { const fileId = `file_${match[1]}`; console.log(`[ChatGPT Exporter] Extracted fileId (pattern 1): ${fileId}`); return fileId; } match = assetPointer.match(/file-([A-Za-z0-9]+)/); if (match) { const fileId = `file-${match[1]}`; console.log(`[ChatGPT Exporter] Extracted fileId (pattern 2): ${fileId}`); return fileId; } console.error(`[ChatGPT Exporter] Could not extract fileId from asset_pointer: ${assetPointer}`); return null; } /** * Extracts content from message content object */ function extractContent(content, messageType = 'text') { if (!content) return ''; if (content.content_type === 'text') { return content.parts?.[0] || ''; } if (content.content_type === 'multimodal_text') { return content.parts?.map(part => { if (typeof part === 'string') return part; if (part.content_type === 'text') { return part.text || part; } if (part.content_type === 'image_asset_pointer') { return '[Image]'; } return ''; }).join('') || ''; } if (content.content_type === 'thoughts') { // Extract thinking content if (content.thoughts && Array.isArray(content.thoughts)) { return content.thoughts.map(thought => { let thinkingText = ''; if (thought.summary) { thinkingText += `**${thought.summary}**\n`; } if (thought.content) { thinkingText += thought.content; } return thinkingText; }).join('\n\n'); } return ''; } // Handle code content type which may contain JSON image prompts if (content.content_type === 'code') { return content.text || ''; } return ''; } /** * Parses JSON image generation prompts from both text and code content types */ function parseImagePrompt(content) { try { let jsonStr = ''; // Handle text content type if (content.content_type === 'text' && content.parts?.[0]) { jsonStr = content.parts[0]; } // Handle code content type with JSON else if (content.content_type === 'code' && content.text) { jsonStr = content.text; } if (jsonStr && jsonStr.startsWith('{') && jsonStr.includes('prompt')) { const promptData = JSON.parse(jsonStr); return { prompt: promptData.prompt, size: promptData.size, n: promptData.n }; } } catch (e) { // Not a valid JSON prompt } return null; } /** * Checks if message should be included in conversation */ function shouldIncludeMessage(message) { if (!message) return false; if (message.metadata?.is_visually_hidden_from_conversation) return false; if (message.metadata?.is_contextual_answers_system_message) return false; // Exclude thinking messages - they should be attached to main messages if (message.content?.content_type === 'thoughts') return false; // Include user messages and assistant messages with content if (message.author?.role === 'user') return true; if (message.author?.role === 'assistant') return true; if (message.author?.role === 'system' && message.content?.parts?.length > 0) return true; if (message.author?.role === 'tool') return true; return false; } /** * Checks if node should be included in technical analysis */ function shouldIncludeNodeInAnalysis(message) { if (!message) return false; // Include ALL messages for technical analysis return true; } /** * Finds logical parent of a message, skipping hidden system nodes */ function findLogicalParent(nodeId, mapping) { const node = mapping[nodeId]; if (!node || !node.parent) return null; const parent = mapping[node.parent]; if (!parent || !parent.message) return node.parent; return node.parent; } /** * Gets actual parent node - for technical analysis */ function getActualParent(nodeId, mapping) { const node = mapping[nodeId]; return node?.parent || null; } /** * Finds all logical children of a parent node, traversing through hidden system nodes */ function findLogicalChildren(parentId, mapping) { const children = []; function collectChildren(nodeId) { const node = mapping[nodeId]; if (!node || !node.children) return; for (const childId of node.children) { const child = mapping[childId]; if (!child || !child.message) continue; // Include ALL children, don't skip system messages children.push({ nodeId: childId, message: child.message, role: child.message.author?.role, createTime: child.message.create_time || 0 }); } } collectChildren(parentId); return children; } /** * Builds version information for messages by finding alternatives at each level * Messages with the same logical parent and role get version numbers */ function buildVersionInfo(mapping) { const versionInfo = new Map(); // Handle ChatGPT response versions (based on end_turn completion) const completedResponses = []; for (const [nodeId, node] of Object.entries(mapping)) { if (node.message && node.message.author?.role === 'assistant' && node.message.end_turn === true && shouldIncludeMessage(node.message)) { completedResponses.push({ nodeId: nodeId, message: node.message, createTime: node.message.create_time || 0 }); } } // Group completed responses by the user message they're responding to const responseGroups = new Map(); for (const response of completedResponses) { const userMessageId = findOriginatingUserMessage(response.nodeId, mapping); if (userMessageId) { if (!responseGroups.has(userMessageId)) { responseGroups.set(userMessageId, []); } responseGroups.get(userMessageId).push(response); } } // Assign version numbers to ChatGPT response groups with multiple responses for (const [userMessageId, responses] of responseGroups.entries()) { if (responses.length > 1) { responses.sort((a, b) => a.createTime - b.createTime); responses.forEach((response, index) => { versionInfo.set(response.message.id, { version: index + 1, total: responses.length }); }); } } // Handle user message versions (traditional sibling approach) const processedParents = new Set(); for (const [nodeId, node] of Object.entries(mapping)) { if (!node.message || !shouldIncludeMessage(node.message) || node.message.author?.role !== 'user') continue; const logicalParent = findLogicalParent(nodeId, mapping); if (!logicalParent || processedParents.has(logicalParent)) continue; const siblings = findLogicalChildren(logicalParent, mapping); processedParents.add(logicalParent); const userSiblings = siblings.filter(s => s.role === 'user'); if (userSiblings.length > 1) { userSiblings.sort((a, b) => (a.createTime || 0) - (b.createTime || 0)); userSiblings.forEach((sibling, index) => { versionInfo.set(sibling.message.id, { version: index + 1, total: userSiblings.length }); }); } } return versionInfo; } /** * Traces back from a ChatGPT response node to find the originating user message */ function findOriginatingUserMessage(nodeId, mapping) { let currentId = nodeId; const visited = new Set(); while (currentId && !visited.has(currentId)) { visited.add(currentId); const parentId = getActualParent(currentId, mapping); if (!parentId) break; const parentNode = mapping[parentId]; if (!parentNode || !parentNode.message) break; // If we found a user message, this is our target if (parentNode.message.author?.role === 'user') { return parentId; } // Continue traversing up the tree currentId = parentId; } return null; } /** * Finds thinking messages associated with a specific message */ function findThinkingForMessage(mapping, messageNodeId) { const thinkingMessages = []; const visited = new Set(); // Look backwards in the conversation tree to find thinking messages function searchForThinking(nodeId, depth = 0) { if (depth > 5 || visited.has(nodeId)) return; // Prevent infinite loops visited.add(nodeId); const node = mapping[nodeId]; if (!node) return; // If this node is a thinking message, add it if (node.message && node.message.content?.content_type === 'thoughts') { thinkingMessages.push(node.message); } // Search parent if (node.parent) { searchForThinking(node.parent, depth + 1); } } // Search from the message node searchForThinking(messageNodeId); // Sort thinking messages by creation time thinkingMessages.sort((a, b) => (a.create_time || 0) - (b.create_time || 0)); return thinkingMessages; } /** * Collects all nodes that compose a ChatGPT response leading to end_turn */ function collectResponseNodes(finalNodeId, mapping) { const nodes = []; const visited = new Set(); // Traverse back from final node to collect all nodes in this response chain function collectBackward(nodeId) { if (visited.has(nodeId)) return; visited.add(nodeId); const node = mapping[nodeId]; if (!node || !node.message) return; // Include ALL nodes that are part of the technical chain if (shouldIncludeNodeInAnalysis(node.message)) { nodes.unshift({ id: nodeId, shortId: nodeId.substring(0, 8), contentType: node.message.content?.content_type || 'text', role: node.message.author?.role || 'unknown', createTime: node.message.create_time || 0 }); } // Continue with parent if it's part of the same response chain const actualParent = getActualParent(nodeId, mapping); if (actualParent) { const parentNode = mapping[actualParent]; if (parentNode && parentNode.message && parentNode.message.author?.role !== 'user') { collectBackward(actualParent); } } } collectBackward(finalNodeId); return nodes.sort((a, b) => a.createTime - b.createTime); } /** * Builds complete message chain using chain-based approach * Each chain represents a dialogue path from user message to assistant completion */ function buildMessageChain(conversationData) { const mapping = conversationData.mapping; const messages = []; const chains = []; // Find root node function findRootNode() { for (const [id, node] of Object.entries(mapping)) { if (!node.parent || node.parent === null) return id; } return null; } // Build chains by traversing tree to end_turn points function buildChains(nodeId, currentChain = [], visited = new Set()) { if (visited.has(nodeId)) return; const node = mapping[nodeId]; if (!node) return; const localVisited = new Set([...visited, nodeId]); // Add current message to chain if it should be included if (node.message && shouldIncludeMessage(node.message)) { const messageContent = { id: node.message.id, role: node.message.author?.role || 'unknown', authorName: node.message.author?.name, recipient: node.message.recipient, content: extractContent(node.message.content), rawContent: node.message.content, created_at: node.message.create_time, metadata: node.message.metadata || {}, parent: node.parent, children: node.children || [], uuid: node.message.id, parent_message_uuid: node.parent, end_turn: node.message.end_turn, nodeId: nodeId }; currentChain = [...currentChain, messageContent]; } // Check for end_turn completion or empty content with end_turn const isEndTurn = node.message?.end_turn === true; const isAssistant = node.message?.author?.role === 'assistant'; const hasEmptyContent = !node.message?.content?.parts?.[0] || node.message.content.parts[0] === ''; if (isEndTurn && isAssistant && (shouldIncludeMessage(node.message) || hasEmptyContent)) { if (currentChain.length > 0) { chains.push([...currentChain]); } } // Continue with children if (node.children && node.children.length > 0) { for (const childId of node.children) { buildChains(childId, [...currentChain], localVisited); } } else if (currentChain.length > 0 && currentChain[currentChain.length - 1].role === 'assistant') { // If no children and last message is assistant, save chain chains.push([...currentChain]); } } const rootId = findRootNode(); if (rootId) { buildChains(rootId); } // Process chains to extract unique messages with proper composition const processedMessages = new Set(); for (const chain of chains) { for (const message of chain) { if (processedMessages.has(message.id)) continue; // For assistant messages, collect all composing nodes if (message.role === 'assistant') { const composingNodes = collectResponseNodes(message.nodeId, mapping); message.composingNodes = composingNodes; // Find thinking messages const thinkingMessages = findThinkingForMessage(mapping, message.nodeId); message.thinkingMessages = thinkingMessages; } else { // For user messages, just single node message.composingNodes = [{ id: message.nodeId, shortId: message.nodeId.substring(0, 8), contentType: message.rawContent?.content_type || 'text', role: message.role, createTime: message.created_at || 0 }]; } messages.push(message); processedMessages.add(message.id); } } return messages; } /** * Processes ChatGPT messages using chain-based approach and extracts images if present */ async function parseChatGPTMessages(conversationData) { const messages = buildMessageChain(conversationData); const images = []; // Build version information using tree mapping structure const versionInfo = buildVersionInfo(conversationData.mapping); // Add version information to messages messages.forEach(message => { if (versionInfo.has(message.uuid)) { message.versionInfo = versionInfo.get(message.uuid); } }); // Parse image prompts from all messages for later association for (let i = 0; i < messages.length; i++) { const message = messages[i]; // Check for image generation prompts and store them with message reference if (message.role === 'assistant' && message.recipient === 't2uay3k.sj1i4kz') { const promptData = parseImagePrompt(message.rawContent); if (promptData) { message.imagePrompt = promptData; } } } // Collect image placeholders and associate with final messages in chains const imagePromises = []; const processedImages = new Set(); // Track processed image IDs to prevent duplicates const imageToFinalMessage = new Map(); // Map images to their final assistant message // Build map of image generations to their final assistant messages for (let i = 0; i < messages.length; i++) { const message = messages[i]; // Find tool messages with images and map them to the final assistant message in the chain if (message.rawContent?.content_type === 'multimodal_text') { const parts = message.rawContent.parts || []; for (const part of parts) { if (part.content_type === 'image_asset_pointer') { // Only process generated images (sediment://), skip uploaded images (file-service://) if (!part.asset_pointer.startsWith('sediment://')) { console.log(`[ChatGPT Exporter] Skipping uploaded image: ${part.asset_pointer}`); continue; } const fileId = extractFileId(part.asset_pointer); if (fileId && !processedImages.has(fileId)) { processedImages.add(fileId); // Find the final assistant message in this generation chain let finalAssistantMessage = null; let promptText = null; let promptParams = null; // Look forward to find the final assistant message with end_turn=true for (let j = i + 1; j < messages.length; j++) { if (messages[j].role === 'assistant' && messages[j].end_turn === true) { finalAssistantMessage = messages[j]; break; } } // Look backward to find prompt in the chain if no forward final message if (!finalAssistantMessage) { for (let j = i - 1; j >= 0; j--) { if (messages[j].role === 'assistant' && messages[j].end_turn === true) { finalAssistantMessage = messages[j]; break; } } } // Find prompt by searching backwards in the chain from current position for (let j = i - 1; j >= 0; j--) { if (messages[j].imagePrompt) { promptText = messages[j].imagePrompt.prompt; promptParams = messages[j].imagePrompt; break; } } const realFileSize = part.size_bytes || null; const imageTitle = message.metadata?.image_gen_title || null; const imageData = { messageId: finalAssistantMessage ? finalAssistantMessage.id : message.id, parentId: message.parent, fileId: fileId, downloadUrl: null, // Will be populated by API call fileName: null, // No filename initially - will be populated with real path from API response (user-XXX/uuid.png format) fileSize: realFileSize, width: part.width, height: part.height, metadata: part.metadata, prompt: promptText, promptParams: promptParams, imageGenTitle: imageTitle }; images.push(imageData); // Queue API call for download URL imagePromises.push({ imageIndex: images.length - 1, promise: getChatGPTImageUrl(fileId, conversationData.conversation_id) }); } } } } } // Filter messages for display (user, assistant with text, but NOT standalone image prompts) const displayMessages = messages.filter(m => { // Include user messages if (m.role === 'user') return true; // Include assistant messages with actual content (not just prompts) if (m.role === 'assistant' && m.content && !m.imagePrompt) return true; // Include assistant messages with empty content but end_turn=true if (m.role === 'assistant' && m.end_turn === true && (!m.content || m.content === '')) return true; // Exclude standalone image prompts - they will be merged with final response return false; }); return { messages: displayMessages, images, allMessages: messages, imagePromises }; } // ============================================= // MARKDOWN GENERATION // ============================================= /** * Generates markdown content for conversation */ function generateConversationMarkdown(conversationData, messages, images) { const parts = []; // Create mapping index for API order numbering const mappingIndex = new Map(); let indexCounter = 0; for (const [nodeId, node] of Object.entries(conversationData.mapping)) { mappingIndex.set(nodeId, indexCounter++); } // Header section const header = [ `# ${conversationData.title || 'ChatGPT Conversation'}`, `*URL:* https://chatgpt.com/c/${conversationData.conversation_id}`, `*Created:* ${formatDate(conversationData.create_time)}`, `*Updated:* ${formatDate(conversationData.update_time)}`, `*Exported:* ${formatDate(new Date())}` ]; if (conversationData.default_model_slug) { header.push(`*Model:* \`${conversationData.default_model_slug}\` `); } parts.push(header.join('\n')); parts.push('---'); // Messages section for (const message of messages) { const messageLines = []; // Determine role display name let role = message.role; if (message.role === 'user') { role = 'Human '; } else if (message.role === 'assistant') { role = message.imagePrompt ? 'Image Generation Prompt ' : 'ChatGPT '; } // Get API mapping index for this message const apiIndex = mappingIndex.get(message.id) || 0; // Message header messageLines.push(`## ${apiIndex} - ${role} `); messageLines.push(`*UUID:* \`${message.id}\` `); // Parent UUID if (message.parent_message_uuid) { messageLines.push(`*Parent UUID:* \`${message.parent_message_uuid}\` `); } messageLines.push(`*Created:* ${formatDate(message.created_at)} `); // Version information if (message.versionInfo) { messageLines.push(`*Version:* ${message.versionInfo.version} of ${message.versionInfo.total} `); } // Node composition information if (message.composingNodes && message.composingNodes.length > 0) { messageLines.push(`*Composed from nodes:* ${message.composingNodes.length} `); // Node details const nodeDetails = message.composingNodes.map(node => { const apiIdx = mappingIndex.get(node.id) || 'unknown'; return `${apiIdx}:${node.shortId}(${node.role}:${node.contentType})`; }).join(', '); messageLines.push(`*Node details:* ${nodeDetails} `); } // Add thinking content if present if (message.thinkingMessages && message.thinkingMessages.length > 0) { messageLines.push(''); // Empty line before thinking messageLines.push('### Thinking Process'); message.thinkingMessages.forEach(thinking => { messageLines.push(''); messageLines.push('<details>'); messageLines.push('<summary>ChatGPT thinking...</summary>'); messageLines.push(''); const thinkingContent = extractContent(thinking.content); if (thinkingContent) { messageLines.push(thinkingContent); } messageLines.push(''); messageLines.push('</details>'); }); messageLines.push(''); } // Message content if (message.content && !message.imagePrompt) { messageLines.push(''); // Empty line before content messageLines.push(message.content); } // Find images associated with this message (either direct or through chain composition) const relatedImages = images.filter(img => { // Direct message association (for tool messages with images) if (img.messageId === message.id) return true; // Check if any of the composing nodes are associated with images if (message.composingNodes) { for (const node of message.composingNodes) { if (img.messageId === node.id) return true; } } return false; }); for (const image of relatedImages) { messageLines.push(''); // Empty line before image section messageLines.push('### Generated Image '); if (image.imageGenTitle) { messageLines.push(`**Title:** ${image.imageGenTitle} `); } if (image.prompt) { messageLines.push(`**Prompt:** ${image.prompt} `); } if (image.promptParams) { messageLines.push(`**Generation Size:** ${image.promptParams.size} `); messageLines.push(`**Count:** ${image.promptParams.n} `); } if (image.width && image.height) { messageLines.push(`**Dimensions:** ${image.width}x${image.height} `); } if (image.fileSize && image.fileSize > 0) { messageLines.push(`**Size:** ${Math.round(image.fileSize / 1024)} KB `); } // Display file information populated with real path from API response (user-XXX/uuid.png format) if (image.fileName) { messageLines.push(`**File:** ${image.fileName} `); } messageLines.push(`**File ID:** ${image.fileId} `); } parts.push(messageLines.join('\n')); // Add message separator except for last message if (messages.indexOf(message) < messages.length - 1) { parts.push('__________'); } } return parts.join('\n\n'); } /** * Generates filename based on conversation title and date */ function generateFileName(conversationData) { const title = sanitizeFileName(conversationData.title || 'ChatGPT_Conversation'); const date = new Date().toISOString().split('T')[0].replace(/-/g, ''); return `${title}_${date}.md`; } // ============================================= // EXPORT FUNCTIONS // ============================================= /** * Main export function for conversation */ async function exportConversation() { try { showNotification('Fetching conversation data...', 'info'); const conversationData = await getChatGPTConversationData(); const { messages, images, imagePromises } = await parseChatGPTMessages(conversationData); // Generate markdown content const markdown = generateConversationMarkdown(conversationData, messages, images); const filename = generateFileName(conversationData); // If no images, export markdown as separate file (existing behavior) if (images.length === 0) { downloadFile(filename, markdown); showNotification(`Conversation exported: ${filename}`, 'success'); return; } // If there are images, ask user about ZIP archive const downloadChoice = confirm(`Found ${images.length} images. Download conversation and images as ZIP archive?`); if (!downloadChoice) { // User declined ZIP, export markdown only downloadFile(filename, markdown); showNotification(`Conversation exported: ${filename}`, 'success'); return; } // ZIP archive - proceed with images and markdown if (imagePromises && imagePromises.length > 0) { const progressNotification = showNotification(`Processing image URLs... (0/${imagePromises.length})`, 'info', 'image-progress'); const urlErrors = []; const downloadErrors = []; const archive = new ArchiveManager(); // Process image promises with progress indication for (let idx = 0; idx < imagePromises.length; idx++) { const { imageIndex, promise } = imagePromises[idx]; const image = images[imageIndex]; showNotification(`Processing image URLs... (${idx + 1}/${imagePromises.length}) - ${image.fileName}`, 'info', 'image-progress'); try { const imageInfo = await promise; if (imageInfo && !imageInfo.error) { images[imageIndex].downloadUrl = imageInfo.downloadUrl; images[imageIndex].fileName = imageInfo.fileName || images[imageIndex].fileName; images[imageIndex].fileSize = imageInfo.fileSize || images[imageIndex].fileSize; console.log(`[ChatGPT Exporter] Successfully got URL for: ${images[imageIndex].fileName}`); } else { const error = imageInfo ? imageInfo.error : 'Unknown error'; console.error(`[ChatGPT Exporter] Failed to get URL for ${image.fileName}: ${error}`); urlErrors.push({ fileName: image.fileName, fileId: image.fileId, error }); } } catch (error) { console.error(`[ChatGPT Exporter] Exception getting URL for ${image.fileName}:`, error); urlErrors.push({ fileName: image.fileName, fileId: image.fileId, error: error.message }); } // Small delay between API calls to avoid rate limiting await new Promise(resolve => setTimeout(resolve, 200)); } // Report URL processing results const successfulUrls = images.filter(img => img.downloadUrl && img.downloadUrl !== 'undefined').length; console.log(`[ChatGPT Exporter] URL processing complete: ${successfulUrls}/${images.length} successful`); if (urlErrors.length > 0) { console.error(`[ChatGPT Exporter] URL processing errors:`, urlErrors); showNotification(`URL processing complete: ${successfulUrls}/${images.length} successful, ${urlErrors.length} failed`, 'info', 'image-progress'); await new Promise(resolve => setTimeout(resolve, 2000)); // Show error summary } // Generate updated markdown with correct filenames from API response // Note: this after processing imagePromises because images[].fileName // gets updated with real paths (user-XXX/uuid.png) instead of metadata gen_id names (dalle_xxx.png) const updatedMarkdown = generateConversationMarkdown(conversationData, messages, images); await archive.addFile(filename, updatedMarkdown, false); console.log(`[ChatGPT Exporter] Added markdown to archive: ${filename}`); // Download processed images and add to ZIP let downloadedCount = 0; const totalDownloads = images.filter(img => img.downloadUrl && img.downloadUrl !== 'undefined').length; if (totalDownloads > 0) { showNotification(`Adding images to archive... (0/${totalDownloads})`, 'info', 'image-progress'); for (const image of images) { if (image.downloadUrl && image.downloadUrl !== 'undefined') { showNotification(`Adding images to archive... (${downloadedCount + 1}/${totalDownloads}) - ${image.fileName}`, 'info', 'image-progress'); const result = await downloadImageForArchive(image.downloadUrl, image.fileName); if (result.success) { await archive.addFile(result.filename, result.blob, false); downloadedCount++; console.log(`[ChatGPT Exporter] Added to archive: ${result.filename} (${result.size} bytes)`); } else { downloadErrors.push({ fileName: result.filename, error: result.error }); console.error(`[ChatGPT Exporter] Failed to add to archive: ${result.filename} - ${result.error}`); } await new Promise(resolve => setTimeout(resolve, 300)); // Small delay } } } // Create and download the ZIP archive (always, even if no images downloaded, because we have markdown) removeNotification('image-progress'); const title = sanitizeFileName(conversationData.title || 'ChatGPT_Conversation'); const date = new Date().toISOString().split('T')[0].replace(/-/g, ''); const zipFilename = `${title}_complete_${date}.zip`; try { await archive.downloadArchive(zipFilename); const fileCount = 1 + downloadedCount; // 1 markdown + downloaded images showNotification(`Archive created: ${zipFilename} with conversation and ${downloadedCount} images`, 'success'); } catch (error) { console.error(`[ChatGPT Exporter] Archive creation failed:`, error); showNotification(`Archive creation failed: ${error.message}`, 'error'); } // Show final results with detailed error information let resultMessage = `Archive created with conversation file and ${downloadedCount}/${images.length} images`; if (urlErrors.length > 0 || downloadErrors.length > 0) { resultMessage += '\n\nErrors:'; if (urlErrors.length > 0) { resultMessage += `\nURL errors (${urlErrors.length}): `; resultMessage += urlErrors.map(e => `${e.fileName} (${e.error})`).join(', '); } if (downloadErrors.length > 0) { resultMessage += `\nDownload errors (${downloadErrors.length}): `; resultMessage += downloadErrors.map(e => `${e.fileName} (${e.error})`).join(', '); } resultMessage += '\n\nCheck console for detailed logs.'; // Show as alert if any images failed alert(resultMessage); } } } catch (error) { console.error('[ChatGPT Exporter] Export failed:', error); showNotification(`Export failed: ${error.message}`, 'error'); } } /** * Exports raw conversation data as JSON */ async function exportRawData() { try { showNotification('Fetching raw conversation data...', 'info'); const conversationData = await getChatGPTConversationData(); const filename = `Raw_${generateFileName(conversationData)}.json`; const jsonContent = JSON.stringify(conversationData, null, 2); downloadFile(filename, jsonContent); showNotification(`Raw data exported: ${filename}`, 'success'); } catch (error) { console.error('[ChatGPT Exporter] Raw export failed:', error); showNotification(`Raw export failed: ${error.message}`, 'error'); } } // ============================================= // INITIALIZATION // ============================================= /** * Initializes userscript and registers menu commands */ function init() { console.log('[ChatGPT Exporter] Initializing...'); GM_registerMenuCommand('📄 Export Conversation', exportConversation); GM_registerMenuCommand('🔧 Export Raw Data', exportRawData); console.log('[ChatGPT Exporter] Ready!'); } // Start when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();