// ==UserScript== // @name Claude Chat Exporter // @name:zh-CN Claude 聊天记录导出工具 // @namespace https://github.com/LHT-balabala/ai-chat-exporter // @version 1.0.0 // @description Export Claude chat conversations with content filter options. Supports JSON/Markdown/TXT/HTML. // @description:zh-CN 一键导出 Claude 聊天记录,支持 JSON/Markdown/TXT/HTML 四种格式与内容过滤 // @author ypyf + mobile + mod // @match https://claude.ai/* // @icon https://claude.ai/favicon.ico // @grant none // @run-at document-idle // @homepage https://github.com/LHT-balabala/ai-chat-exporter // @supportURL https://github.com/LHT-balabala/ai-chat-exporter/issues // @license MIT // ==/UserScript== (function() { 'use strict'; function _getMessage(key) { const msgs = { 'exportButtonText': '导出对话', 'exportAsJSON': '导出为 JSON', 'exportAsMarkdown': '导出为 Markdown', 'exportAsText': '导出为纯文本', 'exportAsHTML': '导出为 HTML', 'exportingProcessing': '处理中…', 'exportingLoading': '正在准备导出…', 'exportingScrolling': '正在加载所有消息…', 'noMessagesFound': '没有找到聊天消息', 'exportError': '导出时发生错误', 'exportFailed': '导出失败', 'filterSectionTitle': '导出内容筛选', 'includeUserQuestions': '保留用户问题', 'includeThinkingProcess': '保留思考过程', 'onlyReplyContent': '只保留回复信息' }; return msgs[key] || key; } const SETTINGS_DEFAULTS = { includeUserQuestions: true, includeThinkingProcess: true, onlyReplyContent: false }; // ---- Claude Chat ID extraction ---- function getClaudeChatId() { try { const url = window.location.href; const m = url.match(/\/chat\/([a-zA-Z0-9_-]+)/); if (m) return m[1]; const m2 = url.match(/\/project\/[^/]+\/chat\/([a-zA-Z0-9_-]+)/); if (m2) return m2[1]; return ''; } catch(e) { return ''; } } function getClaudeConversationTitle() { // Try to find the conversation title in the sidebar or header const titleEl = document.querySelector('[data-testid="chat-header-title"]'); if (titleEl) return titleEl.textContent.trim(); const sidebarTitle = document.querySelector('[data-testid="conversation-title"]'); if (sidebarTitle) return sidebarTitle.textContent.trim(); // Fallback: any h1 or prominent text const h1 = document.querySelector('h1'); if (h1) return h1.textContent.trim(); return getClaudeChatId() || 'Claude Chat'; } // ---- Image popup ---- function showImagePopup(title, imgSrc) { const overlay = document.createElement("div"); overlay.style.cssText = "position:fixed;inset:0;z-index:2147483647;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.5);backdrop-filter:blur(3px)"; overlay.onclick = function(e) { if (e.target === overlay) overlay.remove(); }; const box = document.createElement("div"); box.style.cssText = "background:#fff;border-radius:16px;padding:0;max-width:92vw;max-height:92vh;overflow:hidden;box-shadow:0 8px 40px rgba(0,0,0,0.25);position:relative;text-align:center"; const closeBtn = document.createElement("button"); closeBtn.innerHTML = "✕"; closeBtn.style.cssText = "position:absolute;top:8px;right:12px;border:none;background:rgba(0,0,0,0.5);color:#fff;width:28px;height:28px;border-radius:14px;font-size:16px;cursor:pointer;z-index:10"; closeBtn.onclick = function() { overlay.remove(); }; box.appendChild(closeBtn); const img = document.createElement("img"); img.src = imgSrc; img.style.cssText = "display:block;max-width:100%;max-height:85vh;width:auto;height:auto"; box.appendChild(img); overlay.appendChild(box); document.body.appendChild(overlay); } function showDonatePopup() { const overlay = document.createElement("div"); overlay.style.cssText = "position:fixed;inset:0;z-index:2147483647;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.5);backdrop-filter:blur(3px)"; overlay.onclick = function(e) { if (e.target === overlay) overlay.remove(); }; const box = document.createElement("div"); box.style.cssText = "background:#fff;border-radius:16px;padding:24px;text-align:center;box-shadow:0 8px 40px rgba(0,0,0,0.25);max-width:360px;position:relative"; const closeBtn = document.createElement("button"); closeBtn.innerHTML = "✕"; closeBtn.style.cssText = "position:absolute;top:8px;right:12px;border:none;background:rgba(0,0,0,0.5);color:#fff;width:28px;height:28px;border-radius:14px;font-size:16px;cursor:pointer"; closeBtn.onclick = function() { overlay.remove(); }; box.appendChild(closeBtn); const title = document.createElement("h3"); title.textContent = "赞赏支持"; title.style.cssText = "margin:0 0 8px;font-size:18px;color:#333"; const desc = document.createElement("p"); desc.textContent = "如果这个工具对你有帮助,欢迎扫码赞赏"; desc.style.cssText = "margin:0 0 16px;font-size:13px;color:#666"; box.appendChild(title); box.appendChild(desc); const img = document.createElement("img"); img.src = "https://raw.githubusercontent.com/LHT-balabala/ai-chat-exporter/main/assets/donate-qr.png"; img.style.cssText = "display:block;max-width:260px;width:100%;height:auto;margin:0 auto;border-radius:8px"; box.appendChild(img); overlay.appendChild(box); document.body.appendChild(overlay); } // ---- Message extraction ---- function extractClaudeMessages() { const messages = []; // Claude uses specific data attributes and structure // Try multiple selector strategies to find messages // Strategy 1: data-testid attributes const userMsgs = document.querySelectorAll('[data-testid="user-message"]'); const assistantMsgs = document.querySelectorAll('[data-testid="assistant-message"]'); // Strategy 2: font- classes (Claude's internal class naming) const allMsgElements = document.querySelectorAll('.font-user-message, .font-claude-message, [class*="message"]'); // Strategy 3: role-based approach - look for structured message pairs if (userMsgs.length > 0 || assistantMsgs.length > 0) { // Collect all messages in order by DOM position const allNodes = []; document.querySelectorAll('[data-testid="user-message"], [data-testid="assistant-message"]').forEach(el => { allNodes.push(el); }); allNodes.forEach(el => { const isUser = el.getAttribute('data-testid') === 'user-message'; const content = extractMessageContent(el); if (content && content.trim()) { messages.push({ role: isUser ? 'user' : 'assistant', content: content.trim() }); } }); return messages; } // Strategy 4: Claude's React-based structure // Look for the main chat content area and parse its children const chatContainer = document.querySelector('[data-testid="chat-view"]') || document.querySelector('.chat-view') || document.querySelector('main') || document.querySelector('[class*="chat"]'); if (chatContainer) { // Try to find message blocks by looking for role indicators const proseBlocks = chatContainer.querySelectorAll('.prose, [class*="prose"]'); proseBlocks.forEach(block => { const parent = block.closest('[class*="group"]') || block.parentElement; if (!parent) return; // Determine role by checking for user-specific classes const isUserBlock = parent.querySelector('[data-testid="user-message"]') !== null || parent.classList.contains('font-user-message') || parent.innerHTML.includes('user-message'); const content = block.textContent.trim(); if (content) { messages.push({ role: isUserBlock ? 'user' : 'assistant', content: content }); } }); } // Strategy 5: Generic fallback if (messages.length === 0) { const mainArea = document.querySelector('main'); if (mainArea) { const textBlocks = mainArea.querySelectorAll('p, div[class*="text"], div[class*="content"]'); let currentRole = 'user'; textBlocks.forEach(block => { const text = block.textContent.trim(); if (text.length > 1 && !text.match(/^(Send|Copy|Retry|Edit|Thumbs)/)) { messages.push({ role: currentRole, content: text }); currentRole = currentRole === 'user' ? 'assistant' : 'user'; } }); } } return messages; } function extractMessageContent(el) { // For Claude, the actual content is usually in a prose div const prose = el.querySelector('.prose'); if (prose) return prose.textContent.trim(); // Check for markdown content const mdContent = el.querySelector('[class*="markdown"]'); if (mdContent) return mdContent.textContent.trim(); // Fallback to full text content return el.textContent.trim(); } function extractClaudeMessagesWithDOM() { const messages = []; const mainArea = document.querySelector('main'); if (!mainArea) return messages; // Claude's message groups - each group typically has a role const groups = mainArea.querySelectorAll('[class*="group"], [data-testid$="-message"]'); if (groups.length > 0) { groups.forEach(group => { const isUser = group.getAttribute('data-testid') === 'user-message' || group.querySelector('[data-testid="user-message"]') !== null; const prose = group.querySelector('.prose, [class*="markdown"], [class*="text-content"]'); const content = prose ? prose.textContent.trim() : group.textContent.trim(); // Skip control buttons text if (content && content.length > 2 && !/^(Copy|Retry|Edit|Like|Dislike)$/i.test(content)) { messages.push({ role: isUser ? 'user' : 'assistant', content: content }); } }); } return messages; } // ---- Scroll collection ---- function getChatScrollContainer() { const main = document.querySelector('main'); if (main && main.scrollHeight > main.clientHeight) return main; const scrollables = Array.from(document.querySelectorAll('div')).filter(el => { const style = window.getComputedStyle(el); return (style.overflowY === 'auto' || style.overflowY === 'scroll') && el.scrollHeight > el.clientHeight + 10; }); scrollables.sort((a, b) => (b.clientHeight * b.clientWidth) - (a.clientHeight * a.clientWidth)); return scrollables[0] || document.body; } function delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } function getScrollTop(el) { if (el === document.body || el === document.documentElement) return window.scrollY; return el.scrollTop; } function getClientHeight(el) { if (el === document.body || el === document.documentElement) return window.innerHeight; return el.clientHeight; } function getScrollHeight(el) { if (el === document.body || el === document.documentElement) return Math.max(document.documentElement.scrollHeight, document.body.scrollHeight); return el.scrollHeight; } function setScrollTop(el, v) { if (el === document.body || el === document.documentElement) { window.scrollTo(0, v); return; } el.scrollTop = v; } async function collectAllMessagesFromChat() { const container = getChatScrollContainer(); if (!container) { return { title: getClaudeConversationTitle(), messages: extractClaudeMessagesWithDOM() }; } const originalTop = getScrollTop(container); const startedAt = Date.now(); const timeoutMs = 45000; let messages = []; let messageSignatures = []; // Stop button let shouldStop = false; const stopBtn = document.createElement('div'); stopBtn.style.cssText = 'position:fixed;bottom:30px;left:50%;transform:translateX(-50%);z-index:1000001;background:#e74c3c;color:#fff;border:none;border-radius:20px;padding:10px 24px;font-size:14px;font-weight:600;cursor:pointer;box-shadow:0 4px 12px rgba(0,0,0,0.3);font-family:-apple-system,sans-serif'; stopBtn.textContent = '⏹ 停止'; stopBtn.onclick = function() { shouldStop = true; stopBtn.textContent = '正在停止...'; }; document.body.appendChild(stopBtn); try { setScrollTop(container, 0); await delay(500); let lastLength = 0; let idlePasses = 0; while (Date.now() - startedAt < timeoutMs && !shouldStop) { const snapshot = extractClaudeMessagesWithDOM(); const snapshotSignatures = snapshot.map(m => m.role + '::' + m.content.substring(0, 80)); // Merge deduplication const existSet = new Set(messageSignatures); snapshot.forEach((msg, i) => { if (!existSet.has(snapshotSignatures[i])) { messages.push(msg); messageSignatures.push(snapshotSignatures[i]); } }); updateExportingOverlay((_getMessage('exportingProcessing') || 'Processing…') + ' ' + messages.length); const currentTop = getScrollTop(container); const clientHeight = getClientHeight(container); const scrollHeight = getScrollHeight(container); const atBottom = currentTop + clientHeight >= scrollHeight - 4; if (atBottom) { if (messages.length === lastLength) { idlePasses++; } else { idlePasses = 0; lastLength = messages.length; } if (idlePasses >= 8) break; } const step = clientHeight * 0.85; const nextTop = Math.min(scrollHeight - clientHeight, currentTop + step); setScrollTop(container, nextTop); await delay(600); } if (stopBtn.parentNode) stopBtn.parentNode.removeChild(stopBtn); if (messages.length === 0) { return { title: getClaudeConversationTitle(), messages: extractClaudeMessagesWithDOM() }; } return { title: getClaudeConversationTitle(), messages }; } finally { try { setScrollTop(container, originalTop); } catch(e) {} try { if (stopBtn.parentNode) stopBtn.parentNode.removeChild(stopBtn); } catch(e) {} } } // ---- Export overlay ---- function showExportingOverlay(message) { if (document.getElementById('claude-export-loading')) return; const overlay = document.createElement('div'); overlay.id = 'claude-export-loading'; overlay.style.cssText = 'position:fixed;inset:0;z-index:2147483647;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.45);backdrop-filter:blur(2px)'; const box = document.createElement('div'); box.style.cssText = 'background:#fff;border-radius:14px;padding:28px 36px;display:flex;flex-direction:column;align-items:center;gap:16px;box-shadow:0 8px 32px rgba(0,0,0,0.18);min-width:220px'; const spinner = document.createElement('div'); spinner.style.cssText = 'width:36px;height:36px;border:3px solid #e0e0e0;border-top-color:#d97706;border-radius:50%;animation:claude-spin 0.8s linear infinite'; const text = document.createElement('div'); text.id = 'claude-export-loading-text'; text.style.cssText = 'font-size:14px;font-family:-apple-system,sans-serif;color:#333;text-align:center'; text.textContent = message || (_getMessage('exportingLoading') || 'Preparing export…'); // Add keyframe if (!document.getElementById('claude-spin-style')) { const s = document.createElement('style'); s.id = 'claude-spin-style'; s.textContent = '@keyframes claude-spin{to{transform:rotate(360deg)}}'; document.head.appendChild(s); } box.appendChild(spinner); box.appendChild(text); overlay.appendChild(box); document.body.appendChild(overlay); } function updateExportingOverlay(message) { const el = document.getElementById('claude-export-loading-text'); if (el) el.textContent = message; } function hideExportingOverlay() { const el = document.getElementById('claude-export-loading'); if (el) el.remove(); } // ---- Settings ---- function getSettings() { return new Promise(resolve => { try { const raw = localStorage.getItem('claude_exporter_settings'); const parsed = raw ? JSON.parse(raw) : {}; resolve({ ...SETTINGS_DEFAULTS, ...parsed }); } catch(e) { resolve({ ...SETTINGS_DEFAULTS }); } }); } // ---- Message filtering ---- function filterMessages(messages, settings) { if (!Array.isArray(messages)) return messages; const includeUser = !!(settings && settings.includeUserQuestions); const includeThink = !!(settings && settings.includeThinkingProcess); const onlyReply = !!(settings && settings.onlyReplyContent); if (includeUser && includeThink && !onlyReply) return messages; let filtered = messages; if (onlyReply) { filtered = filtered.filter(m => m.role === 'assistant'); } else if (!includeUser) { filtered = filtered.filter(m => m.role !== 'user'); } // includeThinkingProcess is less relevant for Claude but kept for consistency return filtered; } // ---- File download ---- function sanitizeFilename(name) { return name.replace(/[/\\?%*:|"<>]/g, '-').substring(0, 200); } function convertToMarkdown(data) { let md = '# ' + (data.title || 'Claude Chat') + '\n\n'; md += '_' + (data.date || new Date().toISOString()) + '_ \n'; md += '_' + (data.url || '') + '_\n\n---\n\n'; data.messages.forEach(msg => { if (msg.role === 'user') { md += '### 🧑 用户\n\n' + msg.content + '\n\n'; } else { md += '### 🤖 Claude\n\n' + msg.content + '\n\n'; } }); return md; } function convertToPlainText(data) { let txt = '=== ' + (data.title || 'Claude Chat') + ' ===\n\n'; data.messages.forEach(msg => { txt += (msg.role === 'user' ? '[用户]' : '[Claude]') + '\n' + msg.content + '\n\n---\n\n'; }); return txt; } function convertToHTML(data) { let html = '
' + data.date + ' | ' + data.url + '