优雅导出 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})();