ChatGPT Conversation Exporter Plus

优雅导出 ChatGPT 对话记录,支持 JSON 和 Markdown 格式

目前为 2024-11-18 提交的版本。查看 最新版本

// ==UserScript==
// @name         ChatGPT Conversation Exporter Plus
// @namespace    http://tampermonkey.net/
// @version      2.2
// @description  优雅导出 ChatGPT 对话记录,支持 JSON 和 Markdown 格式
// @author       Gao + GPT-4 + Claude
// @license      Custom License
// @match        https://*.dawuai.buzz/*
// @match        https://*.dwai.world/*
// @match        https://chatgpt.com/*
// @grant        none
// ==/UserScript==

/*
 您可以在个人设备上使用和修改该代码。
 不得将该代码或其修改版本重新分发、再发布或用于其他公众渠道。
 保留所有权利,未经授权不得用于商业用途。
*/

(function() {
    'use strict';

    // Python转换函数移植
    function formatTimestamp(timestamp) {
        if (!timestamp) return null;
        const dt = new Date(timestamp * 1000);
        return dt.toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC');
    }

    function getModelInfo(message) {
        if (!message || !message.metadata) return "";
        const { model_slug, default_model_slug } = message.metadata;
        if (model_slug && default_model_slug && model_slug !== default_model_slug) {
            return ` [使用模型: ${model_slug}]`;
        }
        return "";
    }

    function generateFootnotes(text, contentReferences) {
        const footnotes = [];
        let footnoteIndex = 1;
        let updatedText = text;

        for (const ref of contentReferences || []) {
            if (ref.type === 'webpage' && ref.url) {
                const title = ref.title || ref.url;
                updatedText += ` [^${footnoteIndex}]`;
                footnotes.push(`[^${footnoteIndex}]: [${title}](${ref.url})`);
                footnoteIndex++;
            }
        }

        return [footnotes, updatedText];
    }

    function extractMessageParts(message) {
        if (!message || !message.message) return [null, null, []];

        const msg = message.message;
        const timestamp = formatTimestamp(msg.create_time);

        const authorName = msg.author?.name;
        if (authorName && (authorName.startsWith('canmore.') || authorName.startsWith('dalle.'))) {
            return [null, null, []];
        }

        const parts = msg.content?.parts || [];
        if (!parts.length) return [null, null, []];

        let text = parts.join(' ');
        if (!text) return [null, null, []];

        if (parts[0] && typeof parts[0] === 'string' && parts[0].includes("DALL-E displayed")) {
            return [null, null, []];
        }

        const cleanedText = text.replace(/citeturn0news\d+/g, '');
        const contentReferences = msg.metadata?.content_references || [];

        const [footnotes, updatedText] = generateFootnotes(cleanedText, contentReferences);
        return [updatedText, timestamp, footnotes];
    }

    function isCanvasRelated(node) {
        if (!node?.message) return false;

        const message = node.message;
        const authorName = message.author?.name;
        const recipient = message.recipient;

        if ((authorName && String(authorName).includes('canmore.')) ||
            (recipient && String(recipient).includes('canmore.'))) {
            return true;
        }

        const metadata = message.metadata || {};
        return metadata.canvas || String(metadata.command || '').includes('canvas');
    }

    function adjustHeaderLevels(text, increaseBy = 2) {
        return text.replace(/^(#+)(.*?)$/gm, (match, hashes, rest) => {
            return '#'.repeat(hashes.length + increaseBy) + rest;
        });
    }

    // 状态追踪
    let state = {
        largestResponse: null,
        largestResponseSize: 0,
        largestResponseUrl: null,
        lastUpdateTime: null
    };

    // 日志函数
    const log = {
        info: (msg) => console.log(`[Conversation Saver] ${msg}`),
        error: (msg, e) => console.error(`[Conversation Saver] ${msg}`, e)
    };

    // 对话转换为Markdown的核心函数
    function buildConversationTree(mapping, nodeId, indent = 0) {
        if (!mapping[nodeId]) return [];

        const node = mapping[nodeId];
        const conversation = [];

        if (!isCanvasRelated(node)) {
            const [messageContent, timestamp, footnotes] = extractMessageParts(node);
            if (messageContent) {
                const role = node.message?.author?.role;
                if (role === 'user' || role === 'assistant') {
                    const modelInfo = getModelInfo(node.message);
                    const timestampInfo = timestamp ? `\n\n*${timestamp}*` : "";

                    const prefix = role === 'user' ?
                        `## Human${timestampInfo}\n\n` :
                        `## Assistant${modelInfo}${timestampInfo}\n\n`;

                    const adjustedContent = adjustHeaderLevels(messageContent);
                    conversation.push(prefix + adjustedContent);

                    if (footnotes.length) {
                        conversation.push("\n" + footnotes.join("\n"));
                    }
                }
            }
        }

        for (const childId of (node.children || [])) {
            conversation.push(...buildConversationTree(mapping, childId, indent + 1));
        }

        return conversation;
    }
    // 移除特殊标记
    function removeCiteTurnAndNavlistMarkers(data) {
        if (typeof data === 'object' && data !== null) {
            if (data.content && data.content.parts) {
                data.content.parts = data.content.parts
                    .filter(part => typeof part === 'string' && !part.includes("video 袁娅维"));
            }
            for (let key in data) {
                data[key] = removeCiteTurnAndNavlistMarkers(data[key]);
            }
            return data;
        } else if (Array.isArray(data)) {
            return data.map(item => removeCiteTurnAndNavlistMarkers(item));
        } else if (typeof data === 'string') {
            let result = data.replace(/(citeturn|turn)0(news|search)\d+/g, '')
                           .replace(/^video.*?《.*?》MV\s*/gm, '')
                           .replace(/navlist.*?(?=\n|$)/g, '')
                           .replace(/\n\s*\n/g, '\n\n');
            return result.trim();
        }
        return data;
    }

    // 获取默认模型
    function getDefaultModel(data) {
        if (!data?.mapping) return 'unknown';

        for (const node of Object.values(data.mapping)) {
            if (node.message?.author?.role === 'assistant') {
                return node.message.metadata?.default_model_slug || 'unknown';
            }
        }
        return 'unknown';
    }

    // 转换JSON到Markdown
    function convertJsonToMarkdown(jsonData) {
        // 移除PUA字符
        const cleanData = JSON.parse(JSON.stringify(jsonData).replace(/[\uE000-\uF8FF]/g, ''));

        // 移除特定标记
        const processedData = removeCiteTurnAndNavlistMarkers(cleanData);

        // 获取标题和默认模型
        const title = processedData.title || 'Conversation';
        const defaultModel = getDefaultModel(processedData);

        // 获取根节点
        const mapping = processedData.mapping;
        const rootId = Object.keys(mapping).find(nodeId => !mapping[nodeId].parent);

        // 构建对话
        const conversation = buildConversationTree(mapping, rootId);

        // 生成最终的Markdown内容
        let markdownContent = `# ${title}-${defaultModel}\n\n`;
        markdownContent += conversation.join('\n\n').replace(/\n{3,}/g, '\n\n');

        return markdownContent;
    }

    // 响应处理函数
    function processResponse(text, url) {
        try {
            const responseSize = text.length;
            if (responseSize > state.largestResponseSize) {
                state.largestResponse = text;
                state.largestResponseSize = responseSize;
                state.largestResponseUrl = url;
                state.lastUpdateTime = new Date().toLocaleTimeString();
                updateButtonsStatus();
                log.info(`发现更大的响应 (${responseSize} bytes) 来自: ${url}`);
            }
        } catch (e) {
            log.error('处理响应时出错:', e);
        }
    }

    // 监听 fetch 请求
    const originalFetch = window.fetch;
    window.fetch = async function(...args) {
        const response = await originalFetch.apply(this, args);
        const url = args[0];

        if (url.includes('conversation/')) {
            try {
                const clonedResponse = response.clone();
                clonedResponse.text().then(text => {
                    processResponse(text, url);
                }).catch(e => {
                    log.error('解析fetch响应时出错:', e);
                });
            } catch (e) {
                log.error('克隆fetch响应时出错:', e);
            }
        }
        return response;
    };

    // 监听传统的 XHR 请求
    const originalXhrOpen = XMLHttpRequest.prototype.open;
    XMLHttpRequest.prototype.open = function(method, url) {
        if (url.includes('conversation/')) {
            this.addEventListener('load', function() {
                try {
                    processResponse(this.responseText, url);
                } catch (e) {
                    log.error('处理XHR响应时出错:', e);
                }
            });
        }
        return originalXhrOpen.apply(this, arguments);
    };

    // 更新按钮状态
    function updateButtonsStatus() {
        const jsonButton = document.getElementById('downloadJsonButton');
        const mdButton = document.getElementById('downloadMdButton');

        [jsonButton, mdButton].forEach(button => {
            if (button) {
                button.style.backgroundColor = state.largestResponse ? '#28a745' : '#007bff';
                button.title = state.largestResponse
                    ? `最后更新: ${state.lastUpdateTime}\n来源: ${state.largestResponseUrl}\n大小: ${(state.largestResponseSize / 1024).toFixed(2)}KB`
                    : '等待响应中...';
            }
        });
    }

    // 创建下载按钮
    function createDownloadButtons() {
        const buttonStyles = {
            position: 'fixed',
            top: '45%',
            right: '0px',
            zIndex: '9999',
            padding: '10px',
            backgroundColor: '#007bff',
            color: '#ffffff',
            border: 'none',
            borderRadius: '5px',
            cursor: 'pointer',
            transition: 'all 0.3s ease',
            fontFamily: 'Arial, sans-serif',
            boxShadow: '0 2px 5px rgba(0,0,0,0.2)',
            whiteSpace: 'nowrap'
        };

        // JSON下载按钮
        const jsonButton = document.createElement('button');
        jsonButton.id = 'downloadJsonButton';
        jsonButton.innerText = '下载JSON';
        Object.assign(jsonButton.style, buttonStyles);

        // MD下载按钮
        const mdButton = document.createElement('button');
        mdButton.id = 'downloadMdButton';
        mdButton.innerText = '下载MD';
        Object.assign(mdButton.style, buttonStyles);
        mdButton.style.right = '100px'; // 设置MD按钮在JSON按钮左边

        // 鼠标悬停效果
        [jsonButton, mdButton].forEach(button => {
            button.onmouseover = () => {
                button.style.transform = 'scale(1.05)';
                button.style.boxShadow = '0 4px 8px rgba(0,0,0,0.3)';
            };
            button.onmouseout = () => {
                button.style.transform = 'scale(1)';
                button.style.boxShadow = '0 2px 5px rgba(0,0,0,0.2)';
            };
        });

        // 下载功能
        jsonButton.onclick = function() {
            if (!state.largestResponse) {
                alert('还没有发现有效的会话记录。\n请等待页面加载完成或进行一些对话。');
                return;
            }

            try {
                const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);
                const chatName = document.title.trim().replace(/[/\\?%*:|"<>]/g, '-');
                const fileName = `${chatName}_${timestamp}.json`;

                const blob = new Blob([state.largestResponse], { type: 'application/json' });
                const link = document.createElement('a');
                link.href = URL.createObjectURL(blob);
                link.download = fileName;
                link.click();

                log.info(`成功下载JSON文件: ${fileName}`);
            } catch (e) {
                log.error('下载JSON过程中出错:', e);
                alert('下载过程中发生错误,请查看控制台了解详情。');
            }
        };

        mdButton.onclick = function() {
            if (!state.largestResponse) {
                alert('还没有发现有效的会话记录。\n请等待页面加载完成或进行一些对话。');
                return;
            }

            try {
                const jsonData = JSON.parse(state.largestResponse);
                const markdownContent = convertJsonToMarkdown(jsonData);

                const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);
                const chatName = document.title.trim().replace(/[/\\?%*:|"<>]/g, '-');
                const fileName = `${chatName}_${timestamp}.md`;

                const blob = new Blob([markdownContent], { type: 'text/markdown' });
                const link = document.createElement('a');
                link.href = URL.createObjectURL(blob);
                link.download = fileName;
                link.click();

                log.info(`成功下载MD文件: ${fileName}`);
            } catch (e) {
                log.error('下载MD过程中出错:', e);
                alert('下载过程中发生错误,请查看控制台了解详情。');
            }
        };

        document.body.appendChild(jsonButton);
        document.body.appendChild(mdButton);
        updateButtonsStatus();
    }

    // 页面加载完成后初始化
    window.addEventListener('load', function() {
        createDownloadButtons();

        // 使用 MutationObserver 确保按钮始终存在
        const observer = new MutationObserver(() => {
            if (!document.getElementById('downloadJsonButton') || !document.getElementById('downloadMdButton')) {
                log.info('检测到按钮丢失,正在重新创建...');
                createDownloadButtons();
            }
        });

        observer.observe(document.body, {
            childList: true,
            subtree: true
        });

        log.info('增强版会话保存脚本已启动');
    });
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址