您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Export Claude conversations using API
当前为
// ==UserScript== // @name Claude Exporter 0.10+ // @namespace http://tampermonkey.net/ // @version 0.10+ // @description Export Claude conversations using API // @author MRL // @match https://claude.ai/chat/* // @grant GM_registerMenuCommand // @grant GM_download // @license MIT // ==/UserScript== (function() { 'use strict'; // ============================================= // UTILITY FUNCTIONS // ============================================= /** * Generates timestamp in format YYYYMMDDHHMMSS for file naming * @returns {string} Formatted timestamp */ function generateTimestamp() { const now = new Date(); return now.getFullYear() + String(now.getMonth() + 1).padStart(2, '0') + String(now.getDate()).padStart(2, '0') + String(now.getHours()).padStart(2, '0') + String(now.getMinutes()).padStart(2, '0') + String(now.getSeconds()).padStart(2, '0'); } /** * Sanitizes filename by removing invalid characters and limiting length * @param {string} name - Original filename * @returns {string} Sanitized filename safe for file system */ function sanitizeFileName(name) { return name.replace(/[\\/:*?"<>|]/g, '_') .replace(/\s+/g, '_') .replace(/__+/g, '_') .replace(/^_+|_+$/g, '') .slice(0, 100); } /** * Downloads content as a file using browser's download functionality * @param {string} filename - Name of the file to download * @param {string} content - Content to save in the file */ 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); } /** * Shows temporary notification to the user * @param {string} message - Message to display * @param {string} type - Type of notification (info, success, error) */ function showNotification(message, type = "info") { const notification = document.createElement('div'); 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); `; if (type === "error") { notification.style.backgroundColor = '#f44336'; } else if (type === "success") { notification.style.backgroundColor = '#4CAF50'; } else { notification.style.backgroundColor = '#2196F3'; } notification.textContent = message; document.body.appendChild(notification); setTimeout(() => { if (notification.parentNode) { notification.parentNode.removeChild(notification); } }, 5000); } // ============================================= // API FUNCTIONS // ============================================= /** * Extracts conversation ID from current URL * @returns {string|null} Conversation ID or null if not found */ function getConversationId() { const match = window.location.pathname.match(/\/chat\/([^/?]+)/); return match ? match[1] : null; } /** * Gets organization ID from browser cookies * @returns {string} Organization ID * @throws {Error} If organization ID not found */ function getOrgId() { const cookies = document.cookie.split(';'); for (const cookie of cookies) { const [name, value] = cookie.trim().split('='); if (name === 'lastActiveOrg') { return value; } } throw new Error('Could not find organization ID'); } /** * Fetches conversation data from Claude API * @returns {Promise<Object>} Complete conversation data including messages and metadata */ async function getConversationData() { const conversationId = getConversationId(); if (!conversationId) { throw new Error('Not in a conversation'); } const orgId = getOrgId(); const response = await fetch(`/api/organizations/${orgId}/chat_conversations/${conversationId}?tree=true&rendering_mode=messages&render_all_tools=true`); if (!response.ok) { throw new Error(`API request failed: ${response.status}`); } return await response.json(); } // ============================================= // BRANCH HANDLING FUNCTIONS // ============================================= /** * Builds conversation tree structure to understand message branches * @param {Array} messages - Array of chat messages * @returns {Object} Tree structure with branch information */ function buildConversationTree(messages) { const messageMap = new Map(); const rootMessages = []; // First pass: create message map messages.forEach(message => { messageMap.set(message.uuid, { ...message, children: [], branchId: null, branchIndex: null }); }); // Second pass: build parent-child relationships messages.forEach(message => { const messageNode = messageMap.get(message.uuid); if (message.parent_message_uuid && messageMap.has(message.parent_message_uuid)) { const parent = messageMap.get(message.parent_message_uuid); parent.children.push(messageNode); } else { rootMessages.push(messageNode); } }); return { messageMap, rootMessages }; } /** * Gets all branch information including branch points * @param {Object} tree - Tree structure from buildConversationTree * @returns {Array} Array of branch information */ function getAllBranchInfo(tree) { const branches = []; let branchCounter = 0; function traverseBranches(node, currentPath = [], branchStartIndex = 0) { const newPath = [...currentPath, node]; if (node.children.length === 0) { // Leaf node - this is a complete branch branchCounter++; branches.push({ branchId: node.uuid, branchIndex: branchCounter, fullPath: newPath, branchStartIndex: branchStartIndex, // Index in fullPath where this branch starts isMainBranch: branchStartIndex === 0 }); } else if (node.children.length === 1) { // Single child - continue same branch traverseBranches(node.children[0], newPath, branchStartIndex); } else { // Multiple children - branch point node.children.forEach((child, childIndex) => { // For first child, continue current branch // For other children, start new branches from this point const newBranchStart = childIndex === 0 ? branchStartIndex : newPath.length; traverseBranches(child, newPath, newBranchStart); }); } } tree.rootMessages.forEach(root => { traverseBranches(root, [], 0); }); return branches; } // ============================================= // TEXT PROCESSING FUNCTIONS // ============================================= /** * Recursively extracts text content from nested content structures * @param {Object} content - Content object to process * @returns {Promise<Array<string>>} Array of text pieces found */ async function getTextFromContent(content) { let textPieces = []; if (content.text) { textPieces.push(content.text); } if (content.input) { textPieces.push(JSON.stringify(content.input)); } if (content.content) { if (Array.isArray(content.content)) { for (const nestedContent of content.content) { textPieces = textPieces.concat(await getTextFromContent(nestedContent)); } } else if (typeof content.content === 'object') { textPieces = textPieces.concat(await getTextFromContent(content.content)); } } return textPieces; } // ============================================= // ARTIFACT PROCESSING FUNCTIONS // ============================================= /** * Extracts artifacts from messages, respecting branch boundaries * @param {Array} branchPath - Full path from root to leaf * @param {number} branchStartIndex - Index where this branch starts (for split branches) * @param {string} branchId - Unique identifier for this branch * @param {boolean} isMainBranch - Whether this is the main branch * @returns {Object} {ownArtifacts: Map, inheritedStates: Map} */ function extractArtifacts(branchPath, branchStartIndex, branchId, isMainBranch) { const ownArtifacts = new Map(); // Artifacts created/modified in this branch const inheritedStates = new Map(); // Final states of artifacts from parent branch // For non-main branches, first collect inherited states from parent path if (!isMainBranch && branchStartIndex > 0) { const parentPath = branchPath.slice(0, branchStartIndex); const parentArtifacts = new Map(); // Extract artifacts from parent path parentPath.forEach((message, messageIndex) => { message.content.forEach(content => { if (content.type === 'tool_use' && content.name === 'artifacts' && content.input) { const input = content.input; const artifactId = input.id; if (!parentArtifacts.has(artifactId)) { parentArtifacts.set(artifactId, []); } const versions = parentArtifacts.get(artifactId); versions.push({ command: input.command, content: input.content || '', old_str: input.old_str || '', new_str: input.new_str || '', title: input.title || `Artifact ${artifactId}`, timestamp: message.created_at }); } }); }); // Build final states from parent artifacts parentArtifacts.forEach((versions, artifactId) => { let currentContent = ''; let currentTitle = `Artifact ${artifactId}`; let versionCount = 0; versions.forEach(version => { versionCount++; switch (version.command) { case 'create': currentContent = version.content; currentTitle = version.title; break; case 'rewrite': currentContent = version.content; currentTitle = version.title; break; case 'update': const updateResult = applyUpdate(currentContent, version.old_str, version.new_str); currentContent = updateResult.content; break; } }); inheritedStates.set(artifactId, { content: currentContent, title: currentTitle, versionCount: versionCount }); }); } // Now extract artifacts from this branch only (starting from branchStartIndex) const branchMessages = branchPath.slice(branchStartIndex); branchMessages.forEach((message, relativeIndex) => { message.content.forEach(content => { if (content.type === 'tool_use' && content.name === 'artifacts' && content.input) { const input = content.input; const artifactId = input.id; if (!ownArtifacts.has(artifactId)) { ownArtifacts.set(artifactId, []); } const versions = ownArtifacts.get(artifactId); // Calculate version number based on inherited versions let versionNumber; if (isMainBranch) { // Main branch: start from 1 versionNumber = versions.length + 1; } else { // Split branch: continue from parent version count const inheritedCount = inheritedStates.has(artifactId) ? inheritedStates.get(artifactId).versionCount : 0; versionNumber = inheritedCount + versions.length + 1; } versions.push({ version: versionNumber, command: input.command, uuid: input.version_uuid, content: input.content || '', old_str: input.old_str || '', new_str: input.new_str || '', title: input.title || `Artifact ${artifactId}`, timestamp: message.created_at, messageUuid: message.uuid, messageIndex: branchStartIndex + relativeIndex, branchId: branchId }); } }); }); return { ownArtifacts, inheritedStates }; } /** * Applies update command to previous content by replacing old_str with new_str * @param {string} previousContent - Content before update * @param {string} oldStr - String to be replaced * @param {string} newStr - String to replace with * @returns {Object} {success: boolean, content: string, info: string} */ function applyUpdate(previousContent, oldStr, newStr) { if (!previousContent || !oldStr) { return { success: false, content: previousContent || '', info: 'Cannot apply update: missing previousContent or oldStr' }; } // Apply the string replacement const updatedContent = previousContent.replace(oldStr, newStr); if (updatedContent === previousContent) { // Try to find similar strings for debugging const lines = previousContent.split('\n'); const oldLines = oldStr.split('\n'); let debugInfo = 'Update did not change content - old string not found'; if (oldLines.length > 0) { const firstOldLine = oldLines[0].trim(); const foundLine = lines.find(line => line.includes(firstOldLine)); if (foundLine) { debugInfo += ` | Found similar line: "${foundLine.trim()}"`; } } return { success: false, content: previousContent, info: debugInfo }; } return { success: true, content: updatedContent, info: `Successfully applied update` }; } /** * Builds complete artifact versions for a specific branch * @param {Map} ownArtifacts - Artifacts created/modified in this branch * @param {Map} inheritedStates - Final states from parent branch * @param {string} branchId - Branch identifier * @param {boolean} isMainBranch - Whether this is the main branch * @returns {Map} Map of artifact ID to processed versions with full content */ function buildArtifactVersions(ownArtifacts, inheritedStates, branchId, isMainBranch) { const processedArtifacts = new Map(); ownArtifacts.forEach((versions, artifactId) => { const processedVersions = []; // Start with inherited content if this is a branch let currentContent = ''; let currentTitle = `Artifact ${artifactId}`; if (!isMainBranch && inheritedStates.has(artifactId)) { const inherited = inheritedStates.get(artifactId); currentContent = inherited.content; currentTitle = inherited.title; } versions.forEach((version, index) => { let changeDescription = ''; let updateInfo = ''; let versionStartContent = currentContent; switch (version.command) { case 'create': currentContent = version.content; currentTitle = version.title; changeDescription = isMainBranch ? 'Created' : 'Created (overriding inherited)'; break; case 'rewrite': currentContent = version.content; currentTitle = version.title; changeDescription = isMainBranch ? 'Rewritten' : 'Rewritten (from inherited)'; break; case 'update': const oldContent = currentContent; const updateResult = applyUpdate(currentContent, version.old_str, version.new_str); currentContent = updateResult.content; updateInfo = updateResult.info; // Create more informative change description const oldPreview = version.old_str ? version.old_str.substring(0, 50).replace(/\n/g, '\\n') + '...' : ''; const newPreview = version.new_str ? version.new_str.substring(0, 50).replace(/\n/g, '\\n') + '...' : ''; changeDescription = `Updated: "${oldPreview}" → "${newPreview}"`; // Add information about character count changes const oldLength = oldContent.length; const newLength = currentContent.length; const lengthDiff = newLength - oldLength; if (lengthDiff > 0) { changeDescription += ` (+${lengthDiff} chars)`; } else if (lengthDiff < 0) { changeDescription += ` (${lengthDiff} chars)`; } if (!updateResult.success) { changeDescription += ` [WARNING: ${updateResult.info}]`; } break; default: console.warn(`Unknown command: ${version.command}`); break; } processedVersions.push({ ...version, fullContent: currentContent, changeDescription: changeDescription, updateInfo: updateInfo, branchId: branchId, isMainBranch: isMainBranch, inheritedContent: versionStartContent }); }); processedArtifacts.set(artifactId, processedVersions); }); return processedArtifacts; } /** * Extracts and processes all artifacts from all branches with proper inheritance * @param {Object} conversationData - Complete conversation data * @returns {Object} {branchArtifacts: Map, branchInfo: Array} */ function extractAllArtifacts(conversationData) { // Build conversation tree const tree = buildConversationTree(conversationData.chat_messages); const branches = getAllBranchInfo(tree); console.log(`Found ${branches.length} conversation branches`); const branchArtifacts = new Map(); // branchId -> Map<artifactId, versions> const branchInfo = []; branches.forEach((branch) => { const { ownArtifacts, inheritedStates } = extractArtifacts( branch.fullPath, branch.branchStartIndex, branch.branchId, branch.isMainBranch ); if (ownArtifacts.size > 0) { // Process artifacts for this branch const processedArtifacts = buildArtifactVersions( ownArtifacts, inheritedStates, branch.branchId, branch.isMainBranch ); branchArtifacts.set(branch.branchId, processedArtifacts); const leafMessage = branch.fullPath[branch.fullPath.length - 1]; branchInfo.push({ branchId: branch.branchId, branchIndex: branch.branchIndex, messageCount: branch.fullPath.length, branchMessageCount: branch.fullPath.length - branch.branchStartIndex, artifactCount: ownArtifacts.size, inheritedArtifactCount: inheritedStates.size, lastMessageTime: leafMessage.created_at, lastMessageUuid: leafMessage.uuid, isMainBranch: branch.isMainBranch, branchStartIndex: branch.branchStartIndex }); } }); return { branchArtifacts, branchInfo }; } // ============================================= // VERSION TRACKING FUNCTIONS // ============================================= /** * Builds version information for messages with alternatives (same parent) * @param {Array} messages - Array of chat messages * @returns {Map} Map of message UUID to version info {version, total} */ function buildVersionInfo(messages) { const versionInfo = new Map(); // Group messages by parent_message_uuid const parentGroups = new Map(); messages.forEach(message => { if (message.parent_message_uuid) { if (!parentGroups.has(message.parent_message_uuid)) { parentGroups.set(message.parent_message_uuid, []); } parentGroups.get(message.parent_message_uuid).push(message); } }); // Process groups with more than one message (alternatives) parentGroups.forEach((siblings, parentUuid) => { if (siblings.length > 1) { // Sort by created_at to determine version numbers siblings.sort((a, b) => new Date(a.created_at) - new Date(b.created_at)); siblings.forEach((message, index) => { versionInfo.set(message.uuid, { version: index + 1, total: siblings.length }); }); } }); return versionInfo; } // ============================================= // EXPORT FUNCTIONS // ============================================= /** * Generates markdown content for the entire conversation * @param {Object} conversationData - Complete conversation data from API * @returns {string} Formatted markdown content */ function generateConversationMarkdown(conversationData) { let markdown = ''; // Header with conversation metadata markdown += `# ${conversationData.name}\n\n`; markdown += `*URL: https://claude.ai/chat/${conversationData.uuid} *\n`; // Project info (if available) if (conversationData.project) { markdown += `*Project:* [${conversationData.project.name}] (https://claude.ai/project/${conversationData.project.uuid})\n`; } markdown += `*Created: ${conversationData.created_at}*\n`; markdown += `*Updated: ${conversationData.updated_at}*\n`; markdown += `*Exported on: ${new Date().toLocaleString()}*\n`; if (conversationData.model) { markdown += `*Model: ${conversationData.model}*\n`; } markdown += `\n`; // Build version info for messages with alternatives const versionInfo = buildVersionInfo(conversationData.chat_messages); // Process each message conversationData.chat_messages.forEach(message => { const role = message.sender === 'human' ? 'Human' : 'Claude'; markdown += `## ${role}\n`; markdown += `*UUID:* \`${message.uuid}\`\n`; markdown += `*Created:* ${message.created_at}\n`; // Add version info if this message has alternatives if (versionInfo.has(message.uuid)) { const info = versionInfo.get(message.uuid); markdown += `*Version:* ${info.version} of ${info.total}\n`; } markdown += `\n`; // Process message content message.content.forEach(content => { if (content.type === 'text') { markdown += content.text + '\n\n'; } else if (content.type === 'tool_use' && content.name === 'artifacts') { const input = content.input; markdown += `**Artifact Created:** ${input.title}\n`; markdown += `*ID:* \`${input.id}\`\n`; markdown += `*Command:* \`${input.command}\`\n\n`; } else if (content.type === 'thinking') { if (content.thinking) { markdown += `*[Claude thinking...]*\n\n`; markdown += `<details>\n<summary>Thinking process</summary>\n\n`; markdown += content.thinking + '\n\n'; markdown += `</details>\n\n`; } else { markdown += `*[Claude thinking...]*\n\n`; } } }); // Process attachments if present if (message.attachments && message.attachments.length > 0) { message.attachments.forEach(attachment => { markdown += `**Attachment:** ${attachment.file_name}\n`; markdown += `*ID:* \`${attachment.id}\`\n\n`; if (attachment.extracted_content) { markdown += `<details>\n<summary>File content</summary>\n\n`; markdown += '```\n'; markdown += attachment.extracted_content + '\n'; markdown += '```\n\n'; markdown += `</details>\n\n`; } }); } }); return markdown; } /** * Exports conversation with artifacts (all versions or final versions only) * @param {boolean} finalVersionsOnly - If true, exports only final artifact versions */ async function exportConversation(finalVersionsOnly = false) { try { showNotification('Fetching conversation data...', 'info'); const conversationData = await getConversationData(); const timestamp = generateTimestamp(); const conversationId = conversationData.uuid; const safeTitle = sanitizeFileName(conversationData.name); // Export main conversation const conversationMarkdown = generateConversationMarkdown(conversationData); const conversationFilename = `${timestamp}_${conversationId}__${safeTitle}.md`; downloadFile(conversationFilename, conversationMarkdown); // Extract and process artifacts from all branches const { branchArtifacts, branchInfo } = extractAllArtifacts(conversationData); if (branchArtifacts.size === 0) { showNotification('No artifacts found in conversation', 'info'); return; } let totalExported = 0; // Export artifacts for each branch branchArtifacts.forEach((artifactsMap, branchId) => { const branchData = branchInfo.find(b => b.branchId === branchId); const branchLabel = branchData ? `branch${branchData.branchIndex}` : 'unknown'; const isMain = branchData ? branchData.isMainBranch : false; artifactsMap.forEach((versions, artifactId) => { const versionsToExport = finalVersionsOnly ? [versions[versions.length - 1]] : // Only last version versions; // All versions versionsToExport.forEach(version => { const safeArtifactTitle = sanitizeFileName(version.title); const mainSuffix = isMain ? '_main' : ''; const filename = `${timestamp}_${conversationId}_${artifactId}_${branchLabel}${mainSuffix}_v${version.version}_${safeArtifactTitle}.md`; let content = `# ${version.title}\n\n`; content += `*Artifact ID:* \`${artifactId}\`\n`; content += `*Branch:* ${branchLabel}${isMain ? ' (main)' : ''} (${branchId.substring(0, 8)}...)\n`; content += `*Version:* ${version.version}\n`; content += `*Command:* \`${version.command}\`\n`; content += `*UUID:* \`${version.uuid}\`\n`; content += `*Created:* ${version.timestamp}\n`; // Add change information if (version.changeDescription) { content += `*Change:* ${version.changeDescription}\n`; } // Add update info if available if (version.updateInfo) { content += `*Update Info:* ${version.updateInfo}\n`; } // Add inherited content info for non-main branches if (!isMain && version.inheritedContent && version.command === 'update') { content += `*Started from inherited content:* ${version.inheritedContent.length} chars\n`; } content += '\n---\n\n'; content += version.fullContent; downloadFile(filename, content); totalExported++; }); }); }); const mode = finalVersionsOnly ? 'final versions' : 'all versions'; const branchCount = branchArtifacts.size; const mainBranches = branchInfo.filter(b => b.isMainBranch).length; const splitBranches = branchCount - mainBranches; showNotification(`Export completed! Downloaded conversation + ${totalExported} artifacts from ${branchCount} branches (${mainBranches} main, ${splitBranches} split) (${mode})`, 'success'); } catch (error) { console.error('Export failed:', error); showNotification(`Export failed: ${error.message}`, 'error'); } } /** * Exports only the conversation without any artifacts */ async function exportConversationOnly() { try { showNotification('Fetching conversation data...', 'info'); const conversationData = await getConversationData(); const timestamp = generateTimestamp(); const conversationId = conversationData.uuid; const safeTitle = sanitizeFileName(conversationData.name); // Export only main conversation const conversationMarkdown = generateConversationMarkdown(conversationData); const conversationFilename = `${timestamp}_${conversationId}__${safeTitle}.md`; downloadFile(conversationFilename, conversationMarkdown); showNotification('Conversation exported successfully!', 'success'); } catch (error) { console.error('Export failed:', error); showNotification(`Export failed: ${error.message}`, 'error'); } } // ============================================= // INITIALIZATION // ============================================= /** * Initializes the script and registers menu commands */ function init() { console.log('[Claude API Exporter] Initializing...'); // Register menu commands GM_registerMenuCommand('Export Conversation Only', exportConversationOnly); GM_registerMenuCommand('Export Conversation + Final Artifact Versions', () => exportConversation(true)); GM_registerMenuCommand('Export Conversation + All Artifact Versions', () => exportConversation(false)); } // Start when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();