Claude 对话导出器 | Claude Conversation Exporter Plus

优雅导出 Claude 对话记录,支持 JSON 和 Markdown 格式,包含思考过程。Elegantly export Claude conversation records, supporting JSON and Markdown formats with thinking process.

Size

19.5 KB

Version

4.6.6

Created

Jan 5, 2026

Updated

about 1 month ago

1// ==UserScript==
2// @name         Claude 对话导出器 | Claude Conversation Exporter Plus
3// @namespace    http://tampermonkey.net/
4// @version      4.6.6
5// @description  优雅导出 Claude 对话记录,支持 JSON 和 Markdown 格式,包含思考过程。Elegantly export Claude conversation records, supporting JSON and Markdown formats with thinking process.
6// @author       Gao + GPT-4 + Claude
7// @license      Custom License
8// @match        https://*.claudesvip.top/chat/*
9// @match        https://*.claude.ai/chat/*
10// @match        https://*.fuclaude.com/chat/*
11// @match        https://*.aikeji.vip/chat/*
12// @match        https://share.mynanian.top/chat/*
13// @grant        none
14// @downloadURL https://update.greasyfork.org/scripts/517832/Claude%20%E5%AF%B9%E8%AF%9D%E5%AF%BC%E5%87%BA%E5%99%A8%20%7C%20Claude%20Conversation%20Exporter%20Plus.user.js
15// @updateURL https://update.greasyfork.org/scripts/517832/Claude%20%E5%AF%B9%E8%AF%9D%E5%AF%BC%E5%87%BA%E5%99%A8%20%7C%20Claude%20Conversation%20Exporter%20Plus.meta.js
16// ==/UserScript==
17
18/*
19 您可以在个人设备上使用和修改该代码。
20 不得将该代码或其修改版本重新分发、再发布或用于其他公众渠道。
21 保留所有权利,未经授权不得用于商业用途。
22*/
23
24/*
25You may use and modify this code on your personal devices.
26You may not redistribute, republish, or use this code or its modified versions in other public channels.
27All rights reserved. Unauthorized commercial use is prohibited.
28*/
29
30(function() {
31    'use strict';
32
33    // 状态追踪
34    let state = {
35        targetResponse: null,
36        lastUpdateTime: null,
37        convertedMd: null
38    };
39
40    // 思考内容包含模式设置
41    // 从localStorage读取设置,默认为true(包含思考内容)
42    let includeThinking = localStorage.getItem('claudeExporterIncludeThinking') !== 'false';
43
44    // 日志函数
45    const log = {
46        info: (msg) => console.log(`[Claude Saver] ${msg}`),
47        error: (msg, e) => console.error(`[Claude Saver] ${msg}`, e)
48    };
49
50    // 正则表达式用于匹配目标 URL
51    const targetUrlPattern = /\/chat_conversations\/[\w-]+\?tree=True&rendering_mode=messages&render_all_tools=true/;
52
53    // 响应处理函数(处理符合匹配模式的响应)
54    function processTargetResponse(text, url) {
55        try {
56            if (targetUrlPattern.test(url)) {
57                state.targetResponse = text;
58                state.lastUpdateTime = new Date().toLocaleTimeString();
59                updateButtonStatus();
60                log.info(`成功捕获目标响应 (${text.length} bytes) 来自: ${url}`);
61
62                // 转换为Markdown
63                state.convertedMd = convertJsonToMd(JSON.parse(text));
64                log.info('成功将JSON转换为Markdown');
65            }
66        } catch (e) {
67            log.error('处理目标响应时出错:', e);
68        }
69    }
70
71    // 更新按钮状态
72    function updateButtonStatus() {
73        const jsonButton = document.getElementById('downloadJsonButton');
74        const mdButton = document.getElementById('downloadMdButton');
75        const modeButton = document.getElementById('thinkingModeButton');
76
77        if (jsonButton && mdButton) {
78            const hasResponse = state.targetResponse !== null;
79            jsonButton.style.backgroundColor = hasResponse ? '#28a745' : '#007bff';
80            mdButton.style.backgroundColor = state.convertedMd ? '#28a745' : '#007bff';
81            const statusText = hasResponse ? `最后更新: ${state.lastUpdateTime}
82数据已准备好` : '等待目标响应中...';
83            jsonButton.title = statusText;
84            mdButton.title = statusText;
85        }
86
87        // 更新模式按钮文本
88        if (modeButton) {
89            modeButton.innerText = includeThinking ? '包含思考' : '不包含思考';
90        }
91    }
92
93    // 创建下载按钮
94function createDownloadButtons() {
95    const buttonContainer = document.createElement('div');
96    const firstRowContainer = document.createElement('div');
97    const jsonButton = document.createElement('button');
98    const mdButton = document.createElement('button');
99    const modeButton = document.createElement('button');
100
101    // 主容器样式
102    Object.assign(buttonContainer.style, {
103        position: 'fixed',
104        top: '45%',
105        right: '10px',
106        zIndex: '9999',
107        display: 'flex',
108        flexDirection: 'column',
109        gap: '10px',
110        opacity: '0.5',
111        transition: 'opacity 0.3s ease',
112        cursor: 'move'
113    });
114
115    // 第一行容器样式
116    Object.assign(firstRowContainer.style, {
117        display: 'flex',
118        flexDirection: 'row',
119        gap: '10px',
120        justifyContent: 'space-between'
121    });
122
123    // 按钮样式
124    const buttonStyles = {
125        padding: '8px 12px',
126        backgroundColor: '#007bff',
127        color: '#ffffff',
128        border: 'none',
129        borderRadius: '5px',
130        cursor: 'pointer',
131        transition: 'all 0.3s ease',
132        fontFamily: 'Arial, sans-serif',
133        boxShadow: '0 2px 5px rgba(0,0,0,0.2)',
134        whiteSpace: 'nowrap',
135        fontSize: '14px',
136        flex: '1'
137    };
138
139    jsonButton.id = 'downloadJsonButton';
140    jsonButton.innerText = 'JSON';
141    mdButton.id = 'downloadMdButton';
142    mdButton.innerText = 'MD';
143
144    // 新增模式切换按钮 - 简化文本
145    modeButton.id = 'thinkingModeButton';
146    modeButton.innerText = includeThinking ? '包含思考' : '不包含思考';
147
148    // 为模式按钮设置特殊颜色以区分
149    const modeButtonStyle = {...buttonStyles};
150    modeButtonStyle.backgroundColor = '#6c757d';
151    modeButtonStyle.width = '100%'; // 设置第二行按钮宽度为100%
152
153    Object.assign(jsonButton.style, buttonStyles);
154    Object.assign(mdButton.style, buttonStyles);
155    Object.assign(modeButton.style, modeButtonStyle);
156
157    // 悬停效果
158    buttonContainer.onmouseenter = () => buttonContainer.style.opacity = '1';
159    buttonContainer.onmouseleave = () => buttonContainer.style.opacity = '0.5';
160
161    // 拖拽功能
162    let isDragging = false;
163    let currentX;
164    let currentY;
165    let initialX;
166    let initialY;
167    let xOffset = 0;
168    let yOffset = 0;
169
170    buttonContainer.onmousedown = dragStart;
171    document.onmousemove = drag;
172    document.onmouseup = dragEnd;
173
174    function dragStart(e) {
175        initialX = e.clientX - xOffset;
176        initialY = e.clientY - yOffset;
177        if (e.target === buttonContainer) {
178            isDragging = true;
179        }
180    }
181
182    function drag(e) {
183        if (isDragging) {
184            e.preventDefault();
185            currentX = e.clientX - initialX;
186            currentY = e.clientY - initialY;
187            xOffset = currentX;
188            yOffset = currentY;
189            setTranslate(currentX, currentY, buttonContainer);
190        }
191    }
192
193    function dragEnd() {
194        isDragging = false;
195    }
196
197    function setTranslate(xPos, yPos, el) {
198        el.style.transform = `translate(${xPos}px, ${yPos}px)`;
199    }
200
201    // 点击事件(保持原来的功能)
202    jsonButton.onclick = function() {
203        if (!state.targetResponse) {
204            alert(`还没有发现有效的对话记录。
205请等待目标响应或进行一些对话。`);
206            return;
207        }
208
209        try {
210            const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);
211            const chatName = "Claude - " + document.title.trim().replace(/\s+-\s+Claude$/, '').replace(/[\/\\?%*:|"<>]/g, '-');
212            const fileName = `${chatName}_${timestamp}.json`;
213
214            const blob = new Blob([state.targetResponse], { type: 'application/json' });
215            const link = document.createElement('a');
216            link.href = URL.createObjectURL(blob);
217            link.download = fileName;
218            link.click();
219
220            log.info(`成功下载文件: ${fileName}`);
221        } catch (e) {
222            log.error('下载过程中出错:', e);
223            alert('下载过程中发生错误,请查看控制台了解详情。');
224        }
225    };
226
227    mdButton.onclick = function() {
228        if (!state.convertedMd) {
229            alert(`还没有发现有效的对话记录。
230请等待目标响应或进行一些对话。`);
231            return;
232        }
233
234        try {
235            const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);
236            const chatName = "Claude - " + document.title.trim().replace(/\s+-\s+Claude$/, '').replace(/[\/\\?%*:|"<>]/g, '-');
237            const fileName = `${chatName}_${timestamp}.md`;
238
239            const blob = new Blob([state.convertedMd], { type: 'text/markdown' });
240            const link = document.createElement('a');
241            link.href = URL.createObjectURL(blob);
242            link.download = fileName;
243            link.click();
244
245            log.info(`成功下载文件: ${fileName}`);
246        } catch (e) {
247            log.error('下载过程中出错:', e);
248            alert('下载过程中发生错误,请查看控制台了解详情。');
249        }
250    };
251
252    // 模式切换按钮点击事件
253    modeButton.onclick = function() {
254        // 切换模式
255        includeThinking = !includeThinking;
256        // 保存设置到localStorage
257        localStorage.setItem('claudeExporterIncludeThinking', includeThinking);
258        // 更新按钮文本
259        modeButton.innerText = includeThinking ? '包含思考' : '不包含思考';
260        // 如果已有对话数据,更新Markdown
261        if (state.targetResponse) {
262            state.convertedMd = convertJsonToMd(JSON.parse(state.targetResponse));
263            log.info(`已切换模式,${includeThinking ? '开启' : '关闭'}思考内容`);
264        }
265        // 刷新页面
266        window.location.reload();
267    };
268
269    // 添加到两行容器中
270    firstRowContainer.appendChild(jsonButton);
271    firstRowContainer.appendChild(mdButton);
272    buttonContainer.appendChild(firstRowContainer);
273    buttonContainer.appendChild(modeButton);
274    document.body.appendChild(buttonContainer);
275
276    updateButtonStatus();
277}
278
279    function convertJsonToMd(data) {
280    let mdContent = [];
281    const title = document.title.trim().replace(/\s+-\s+Claude$/, '');
282    mdContent.push(`# ${title}\n`);
283
284    // 获取当前页面的完整 URL
285    const currentUrl = window.location.href;
286
287    // 提取 URL 前缀(去掉 chat/* 部分)
288    const baseUrl = currentUrl.replace(/\/chat\/.*$/, '');
289
290    for (const message of data['chat_messages']) {
291        const sender = message['sender'].charAt(0).toUpperCase() + message['sender'].slice(1);
292        mdContent.push(`## ${sender}`);
293
294        const createdAt = message['created_at'] || '';
295        const updatedAt = message['updated_at'] || '';
296        const timestamp = createdAt === updatedAt ? `*${createdAt}*` : `*${createdAt} (updated)*`;
297        mdContent.push(timestamp);
298
299        // 处理消息内容
300        if (message['content'] && Array.isArray(message['content'])) {
301            // 寻找思考内容
302            const thinkingContent = message['content'].find(item => item.type === 'thinking');
303
304            // 如果有思考内容且设置为包含思考内容,则处理思考过程
305            if (thinkingContent && message['sender'] === 'assistant' && includeThinking) {
306                // 计算思考用时
307                const startTime = new Date(thinkingContent.start_timestamp);
308                const stopTime = new Date(thinkingContent.stop_timestamp);
309                const thinkingTimeSeconds = (stopTime - startTime) / 1000;
310                const thinkingTimeFormatted = thinkingTimeSeconds.toFixed(2);
311
312                // 添加思考过程标题和用时
313                mdContent.push(`### 思考过程 (${thinkingTimeFormatted}s)`);
314
315                // 添加思考内容,并调整其中的标题级别
316                let thinking = thinkingContent.thinking;
317                thinking = adjustHeadingLevel(thinking, 3); // 确保思考过程中的标题级别比"思考过程"低
318                mdContent.push(thinking);
319
320                // 处理思考概括
321                if (thinkingContent.summaries && Array.isArray(thinkingContent.summaries)) {
322                    mdContent.push(`### 思考概括`);
323                    for (const summary of thinkingContent.summaries) {
324                        mdContent.push(`- ${summary.summary}`);
325                    }
326                }
327
328                // 添加正式回答标题
329                mdContent.push(`### 回答`);
330            }
331
332            // 处理文本内容(正式回答)
333            const textContent = message['content'].find(item => item.type === 'text');
334            if (textContent) {
335                let content = processContent(textContent.text || '');
336                // 调整标题级别,确保回答中的标题级别比"回答"的级别低
337                // 只有在包含思考内容的情况下才需要这个调整,不包含思考时就不调整
338                content = adjustHeadingLevel(content, thinkingContent && includeThinking ? 3 : 2);
339                mdContent.push(`${content}\n`);
340            }
341        } else {
342            // 如果消息内容不是数组格式,按原有逻辑处理
343            let content = processContent(message['content']);
344            // 调整标题级别
345            content = adjustHeadingLevel(content, 2);
346            mdContent.push(`${content}\n`);
347        }
348
349            // === 处理附加文件部分开始 ===
350            if (message['attachments'] && message['attachments'].length > 0) {
351                mdContent.push(`## 附加文件:`);
352
353                for (const attachment of message['attachments']) {
354                    // 判断文件是否有 preview_url 或 document_asset
355                    if (attachment.preview_url) {
356                        // 使用 preview_url 生成链接
357                        const previewLink = `${baseUrl}${attachment.preview_url}`;
358                        mdContent.push(`[${attachment.file_name}](${previewLink})\n`);
359                    } else if (attachment.document_asset && attachment.document_asset.url) {
360                        // 使用 document_asset.url 生成链接
361                        const documentLink = `${baseUrl}${attachment.document_asset.url}`;
362                        mdContent.push(`[${attachment.file_name}](${documentLink})\n`);
363                    } else if (attachment.extracted_content) {
364                        // 有具体内容的文件
365                        mdContent.push(`${attachment.file_name}\n`);
366                        mdContent.push("```\n");
367                        mdContent.push(`${attachment.extracted_content}\n`);
368                        mdContent.push("```\n");
369                    } else {
370                        // 无法提取内容或生成链接的文件
371                        mdContent.push(`${attachment.file_name} (无法提取内容或生成链接)\n`);
372                    }
373                }
374            }
375
376            // === 处理 `files_v2` 部分开始 ===
377            if (message['files_v2'] && message['files_v2'].length > 0) {
378                mdContent.push(`## 附加文件:`);
379
380                for (const file of message['files_v2']) {
381                    if (file.document_asset && file.document_asset.url) {
382                        // 处理 `document_asset` 链接
383                        const documentLink = `${baseUrl}${file.document_asset.url}`;
384                        mdContent.push(`[${file.file_name}](${documentLink})\n`);
385                    } else if (file.preview_url) {
386                        // 处理常规的 `preview_url` 链接
387                        const previewLink = `${baseUrl}${file.preview_url}`;
388                        mdContent.push(`[${file.file_name}](${previewLink})\n`);
389                    } else {
390                        mdContent.push(`${file.file_name} (无法生成预览链接)\n`);
391                    }
392                }
393            }
394            // === 处理 `files_v2` 部分结束 ===
395        }
396
397        return mdContent.join('\n');
398    }
399
400    // 调整Markdown标题级别
401    function adjustHeadingLevel(text, increaseLevel = 2) {
402        const codeBlockPattern = /```[\s\S]*?```/g;
403        let segments = [];
404        let match;
405
406        // 提取代码块,并用占位符替代
407        let lastIndex = 0;
408        while ((match = codeBlockPattern.exec(text)) !== null) {
409            segments.push(text.substring(lastIndex, match.index));
410            segments.push(match[0]); // 保留代码块原样
411            lastIndex = codeBlockPattern.lastIndex;
412        }
413        segments.push(text.substring(lastIndex));
414
415        // 调整标题级别
416        segments = segments.map(segment => {
417            if (segment.startsWith('```')) {
418                return segment; // 保留代码块原样
419            } else {
420                let lines = segment.split('\n');
421                lines = lines.map(line => {
422                    if (line.trim().startsWith('#')) {
423                        const currentLevel = (line.match(/^#+/) || [''])[0].length;
424                        return '#'.repeat(currentLevel + increaseLevel) + line.slice(currentLevel);
425                    }
426                    return line;
427                });
428                return lines.join('\n');
429            }
430        });
431
432        return segments.join('');
433    }
434
435    // 处理消息内容,提取纯文本并处理LaTeX公式
436    function processContent(content) {
437        if (Array.isArray(content)) {
438            let textParts = [];
439            for (const item of content) {
440                if (item.type === 'text') {
441                    let text = item.text || '';
442                    text = processLatex(text);
443                    text = text.replace(/(?<!\n)(\n\| .*? \|\n\|[-| ]+\|\n(?:\| .*? \|\n)+)/g, '\n$1'); // 在表格前插入一个空行
444                    textParts.push(text);
445                }
446            }
447            return textParts.join('\n');
448        }
449        return String(content);
450    }
451
452    // 处理LaTeX公式
453    function processLatex(text) {
454        // 区分行内公式和独立公式
455        text = text.replace(/\$\$(.+?)\$\$/gs, (match, formula) => {
456            if (formula.includes('\n')) {
457                // 这是独立公式
458                return `$$${formula}$$`;
459            } else {
460                // 这是行内公式
461                return `$${formula}$`;
462            }
463        });
464        return text;
465    }
466
467    // 监听 fetch 请求
468    const originalFetch = window.fetch;
469    window.fetch = async function(...args) {
470        const response = await originalFetch.apply(this, args);
471        const url = args[0];
472
473        log.info(`捕获到 fetch 请求: ${url}`);
474
475        if (targetUrlPattern.test(url)) {
476            try {
477                log.info(`匹配到目标 URL: ${url}`);
478                const clonedResponse = response.clone();
479                clonedResponse.text().then(text => {
480                    processTargetResponse(text, url);
481                }).catch(e => {
482                    log.error('解析fetch响应时出错:', e);
483                });
484            } catch (e) {
485                log.error('克隆fetch响应时出错:', e);
486            }
487        }
488        return response;
489    };
490
491    // 页面加载完成后立即创建按钮
492    window.addEventListener('load', function() {
493        createDownloadButtons();
494
495        // 使用 MutationObserver 确保按钮始终存在
496        const observer = new MutationObserver(() => {
497            if (!document.getElementById('downloadJsonButton') || !document.getElementById('downloadMdButton') || !document.getElementById('thinkingModeButton')) {
498                log.info('检测到按钮丢失,正在重新创建...');
499                createDownloadButtons();
500            }
501        });
502
503        observer.observe(document.body, {
504            childList: true,
505            subtree: true
506        });
507
508        log.info('Claude 保存脚本已启动');
509    });
510})();
Claude 对话导出器 | Claude Conversation Exporter Plus | Robomonkey