Claude Exporter 0.9+

Export Claude conversations using API

当前为 2025-07-09 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

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

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Claude Exporter 0.9+
// @namespace    http://tampermonkey.net/
// @version      0.9+
// @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();
    }

    // =============================================
    // 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 all artifacts from conversation data and organizes them by ID
     * @param {Object} conversationData - Complete conversation data
     * @returns {Map} Map of artifact ID to array of artifact versions
     */
    function extractArtifacts(conversationData) {
        const artifacts = new Map(); // Map<artifactId, Array<{version, command, uuid, content, title, old_str, new_str}>>

        conversationData.chat_messages.forEach(message => {
            message.content.forEach(content => {
                if (content.type === 'tool_use' && content.name === 'artifacts' && content.input) {
                    const input = content.input;
                    const artifactId = input.id;

                    if (!artifacts.has(artifactId)) {
                        artifacts.set(artifactId, []);
                    }

                    const versions = artifacts.get(artifactId);
                    versions.push({
                        version: versions.length + 1,
                        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
                    });
                }
            });
        });

        return artifacts;
    }

    /**
     * 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 {string} Updated content
     */
    function applyUpdate(previousContent, oldStr, newStr) {
        if (!previousContent || !oldStr) {
            console.warn('Cannot apply update: missing previousContent or oldStr');
            return previousContent || '';
        }

        // Apply the string replacement
        const updatedContent = previousContent.replace(oldStr, newStr);

        if (updatedContent === previousContent) {
            console.warn('Update did not change content - old string not found');
            console.warn('Looking for:', oldStr.substring(0, 100) + '...');
            console.warn('In content length:', previousContent.length);
            
            // Try to find similar strings for debugging
            const lines = previousContent.split('\n');
            const oldLines = oldStr.split('\n');
            if (oldLines.length > 0) {
                const firstOldLine = oldLines[0].trim();
                const foundLine = lines.find(line => line.includes(firstOldLine));
                if (foundLine) {
                    console.warn('Found similar line:', foundLine);
                }
            }
        }

        return updatedContent;
    }

    /**
     * Builds complete artifact versions by applying updates sequentially
     * @param {Map} artifacts - Raw artifacts from extractArtifacts()
     * @returns {Map} Map of artifact ID to processed versions with full content
     */
    function buildArtifactVersions(artifacts) {
        const processedArtifacts = new Map();

        artifacts.forEach((versions, artifactId) => {
            const processedVersions = [];
            let currentContent = '';

            versions.forEach((version, index) => {
                let changeDescription = '';
                
                switch (version.command) {
                    case 'create':
                        currentContent = version.content;
                        changeDescription = 'Created';
                        break;
                    case 'rewrite':
                        currentContent = version.content;
                        changeDescription = 'Rewritten';
                        break;
                    case 'update':
                        const oldContent = currentContent;
                        currentContent = applyUpdate(currentContent, version.old_str, version.new_str);
                        
                        // Create more informative change description
                        const oldPreview = version.old_str ? version.old_str.substring(0, 100) + '...' : '';
                        const newPreview = version.new_str ? version.new_str.substring(0, 100) + '...' : '';
                        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)`;
                        }
                        break;
                    default:
                        console.warn(`Unknown command: ${version.command}`);
                        break;
                }

                processedVersions.push({
                    ...version,
                    fullContent: currentContent,
                    changeDescription: changeDescription
                });
            });

            processedArtifacts.set(artifactId, processedVersions);
        });

        return processedArtifacts;
    }

    // =============================================
    // 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 += `*Сreated: ${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
            const rawArtifacts = extractArtifacts(conversationData);
            const processedArtifacts = buildArtifactVersions(rawArtifacts);

            if (processedArtifacts.size === 0) {
                showNotification('No artifacts found in conversation', 'info');
                return;
            }

            let totalExported = 0;

            // Export artifacts
            processedArtifacts.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 filename = `${timestamp}_${conversationId}_${artifactId}_v${version.version}_${safeArtifactTitle}.md`;

                    let content = `# ${version.title}\n\n`;
                    content += `*Artifact ID:* \`${artifactId}\`\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`;
                    }

                    content += '\n---\n\n';
                    content += version.fullContent;

                    downloadFile(filename, content);
                    totalExported++;
                });
            });

            const mode = finalVersionsOnly ? 'final versions' : 'all versions';
            showNotification(`Export completed! Downloaded conversation + ${totalExported} artifacts (${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 + All Artifact Versions', () => exportConversation(false));
    }

    // Start when DOM is ready
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }
})();