// ==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();
}
})();