// ==UserScript== // @name ChatGPT Chat Exporter // @name:zh-CN ChatGPT 聊天记录导出工具 // @namespace https://github.com/LHT-balabala/ai-chat-exporter // @version 1.0.0 // @description Export ChatGPT chat conversations with content filter options. Supports JSON/Markdown/TXT/HTML. // @description:zh-CN 一键导出 ChatGPT 聊天记录,支持 JSON/Markdown/TXT/HTML 四种格式与内容过滤 // @author ypyf + mobile + mod // @match https://chatgpt.com/* // @match https://chat.openai.com/* // @icon https://chatgpt.com/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 }; // ---- ChatGPT helpers ---- function getChatGPTTitle() { // Try the page title first (ChatGPT includes conversation title) const title = document.title || ''; const cleaned = title.replace(' - ChatGPT', '').replace('ChatGPT - ', '').replace('ChatGPT', '').trim(); if (cleaned && cleaned !== 'ChatGPT') return cleaned; // Try sidebar active item const sidebarActive = document.querySelector('nav .bg-token-sidebar-surface-secondary, nav [class*="active"]'); if (sidebarActive) { const text = sidebarActive.textContent.trim(); if (text && text.length < 100) return text; } return 'ChatGPT Chat'; } function getChatGPTId() { try { const url = window.location.href; const m = url.match(/\/c\/([a-zA-Z0-9_-]+)/); if (m) return m[1]; const m2 = url.match(/\/([a-f0-9-]{36})/i); if (m2) return m2[1]; return ''; } catch(e) { return ''; } } // ---- 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 parsing ---- function extractMarkdownFromElement(el) { // ChatGPT renders messages as markdown converted to HTML // Try to reconstruct markdown from the DOM if (!el) return ''; // Check for code blocks const codeBlocks = el.querySelectorAll('pre'); codeBlocks.forEach(pre => { const code = pre.querySelector('code'); if (code) { const lang = code.className.replace('language-', '').replace('!', ''); const langTag = lang ? lang + '\n' : ''; pre.setAttribute('data-raw-code', langTag + code.textContent); } }); // Get the raw text but preserve structure let text = ''; const walk = (node) => { if (node.nodeType === Node.TEXT_NODE) { text += node.textContent; return; } if (node.nodeType !== Node.ELEMENT_NODE) return; const tag = node.tagName.toLowerCase(); // Skip copy buttons, etc. if (node.classList.contains('copy-btn') || node.classList.contains('flex') && node.textContent.trim() === 'Copy code') { return; } if (tag === 'pre' && node.getAttribute('data-raw-code')) { text += '\n```' + node.getAttribute('data-raw-code') + '\n```\n'; return; } if (tag === 'code' && node.closest('pre')) { // Code inside pre is handled by pre return; } if (tag === 'code') { text += '`' + node.textContent + '`'; return; } if (tag === 'strong' || tag === 'b') { text += '**'; Array.from(node.childNodes).forEach(walk); text += '**'; return; } if (tag === 'em' || tag === 'i') { text += '*'; Array.from(node.childNodes).forEach(walk); text += '*'; return; } if (tag === 'a') { const href = node.getAttribute('href') || ''; text += '['; Array.from(node.childNodes).forEach(walk); text += '](' + href + ')'; return; } if (tag === 'h1') { text += '\n# '; Array.from(node.childNodes).forEach(walk); text += '\n'; return; } if (tag === 'h2') { text += '\n## '; Array.from(node.childNodes).forEach(walk); text += '\n'; return; } if (tag === 'h3') { text += '\n### '; Array.from(node.childNodes).forEach(walk); text += '\n'; return; } if (tag === 'h4') { text += '\n#### '; Array.from(node.childNodes).forEach(walk); text += '\n'; return; } if (tag === 'ul' || tag === 'ol') { text += '\n'; Array.from(node.childNodes).forEach(walk); text += '\n'; return; } if (tag === 'li') { text += '- '; Array.from(node.childNodes).forEach(walk); text += '\n'; return; } if (tag === 'p') { text += '\n'; Array.from(node.childNodes).forEach(walk); text += '\n'; return; } if (tag === 'br') { text += '\n'; return; } if (tag === 'blockquote') { text += '\n> '; Array.from(node.childNodes).forEach(walk); text += '\n'; return; } if (tag === 'hr') { text += '\n---\n'; return; } if (tag === 'table') { text += '\n'; const rows = node.querySelectorAll('tr'); rows.forEach((row, ri) => { const cells = row.querySelectorAll('td, th'); text += '| ' + Array.from(cells).map(c => c.textContent.trim()).join(' | ') + ' |\n'; if (ri === 0) text += '| ' + Array.from(cells).map(() => '---').join(' | ') + ' |\n'; }); text += '\n'; return; } if (tag === 'img') { const alt = node.getAttribute('alt') || ''; const src = node.getAttribute('src') || ''; text += ''; return; } Array.from(node.childNodes).forEach(walk); }; walk(el); return text.replace(/\n{3,}/g, '\n\n').trim(); } function extractChatGPTMessages() { const messages = []; // ChatGPT uses data-message-author-role attribute const msgElements = document.querySelectorAll('[data-message-author-role]'); if (msgElements.length > 0) { msgElements.forEach(el => { const role = el.getAttribute('data-message-author-role'); // Skip system messages if (role === 'system') return; // Get the markdown content div const markdownDiv = el.querySelector('.markdown'); let content = ''; if (markdownDiv) { content = extractMarkdownFromElement(markdownDiv); } else { // Fallback: get text content from the message body const body = el.querySelector('[data-message-content-role]') || el; content = body.textContent.trim(); } if (content && content.trim()) { messages.push({ role: role === 'user' ? 'user' : 'assistant', content: content.trim() }); } }); return messages; } // Fallback strategy for older ChatGPT UI or DOM changes const mainContent = document.querySelector('main') || document.body; const articleElements = mainContent.querySelectorAll('article'); if (articleElements.length > 0) { articleElements.forEach((article, index) => { const role = index % 2 === 0 ? 'user' : 'assistant'; const markdown = article.querySelector('.markdown, .prose, [class*="text-message"]'); const content = markdown ? markdown.textContent.trim() : article.textContent.trim(); if (content) { messages.push({ role: role, content: content }); } }); } return messages; } // ---- Scroll loading ---- function getScrollContainer() { 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(r => setTimeout(r, ms)); } function getTop(el) { return (el === document.body || el === document.documentElement) ? window.scrollY : el.scrollTop; } function getCH(el) { return (el === document.body || el === document.documentElement) ? window.innerHeight : el.clientHeight; } function getSH(el) { return (el === document.body || el === document.documentElement) ? Math.max(document.documentElement.scrollHeight, document.body.scrollHeight) : el.scrollHeight; } function setTop(el, v) { if (el === document.body || el === document.documentElement) { window.scrollTo(0, v); return; } el.scrollTop = v; } function dedupe(existing, incoming) { const sigs = new Set(existing.map(m => m.role + '::' + m.content.substring(0, 100))); const result = existing.slice(); incoming.forEach(m => { const sig = m.role + '::' + m.content.substring(0, 100); if (!sigs.has(sig)) { result.push(m); sigs.add(sig); } }); return result; } async function collectAllMessages() { const container = getScrollContainer(); if (!container) return { title: getChatGPTTitle(), messages: extractChatGPTMessages() }; const origTop = getTop(container); const timeoutMs = 60000; let messages = []; let lastLen = 0; let idlePasses = 0; 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; }; document.body.appendChild(stopBtn); try { setTop(container, 0); await delay(800); const deadline = Date.now() + timeoutMs; while (Date.now() < deadline && !shouldStop) { const snap = extractChatGPTMessages(); messages = dedupe(messages, snap); updateOverlay((_getMessage('exportingProcessing') || 'Processing…') + ' ' + messages.length); const top = getTop(container); const ch = getCH(container); const sh = getSH(container); const atBottom = top + ch >= sh - 4; if (atBottom) { if (messages.length === lastLen) { idlePasses++; } else { idlePasses = 0; lastLen = messages.length; } if (idlePasses >= 10) break; } setTop(container, Math.min(sh - ch, top + ch * 0.8)); await delay(700); } if (stopBtn.parentNode) stopBtn.remove(); if (messages.length === 0) messages = extractChatGPTMessages(); return { title: getChatGPTTitle(), messages }; } finally { try { setTop(container, origTop); } catch(e) {} try { if (stopBtn.parentNode) stopBtn.remove(); } catch(e) {} } } // ---- Overlay ---- function showExportingOverlay(msg) { if (document.getElementById('gpt-export-loading')) return; const overlay = document.createElement('div'); overlay.id = 'gpt-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 spin = document.createElement('div'); spin.style.cssText = 'width:36px;height:36px;border:3px solid #e0e0e0;border-top-color:#10a37f;border-radius:50%;animation:gpt-spin 0.8s linear infinite'; const txt = document.createElement('div'); txt.id = 'gpt-export-loading-text'; txt.style.cssText = 'font-size:14px;font-family:-apple-system,sans-serif;color:#333'; txt.textContent = msg || 'Preparing export…'; if (!document.getElementById('gpt-spin-style')) { const s = document.createElement('style'); s.id = 'gpt-spin-style'; s.textContent = '@keyframes gpt-spin{to{transform:rotate(360deg)}}'; document.head.appendChild(s); } box.appendChild(spin); box.appendChild(txt); overlay.appendChild(box); document.body.appendChild(overlay); } function updateOverlay(msg) { const e = document.getElementById('gpt-export-loading-text'); if (e) e.textContent = msg; } function hideOverlay() { const e = document.getElementById('gpt-export-loading'); if (e) e.remove(); } // ---- Settings ---- function getSettings() { return new Promise(resolve => { try { const raw = localStorage.getItem('gpt_exporter_settings'); const parsed = raw ? JSON.parse(raw) : {}; resolve({ ...SETTINGS_DEFAULTS, ...parsed }); } catch(e) { resolve({ ...SETTINGS_DEFAULTS }); } }); } function filterMessages(msgs, settings) { if (!Array.isArray(msgs)) return msgs; const includeUser = !!(settings && settings.includeUserQuestions); const onlyReply = !!(settings && settings.onlyReplyContent); if (includeUser && !onlyReply) return msgs; let filtered = msgs; if (onlyReply) filtered = filtered.filter(m => m.role === 'assistant'); else if (!includeUser) filtered = filtered.filter(m => m.role !== 'user'); return filtered; } function sanitizeFilename(name) { return name.replace(/[/\\?%*:|"<>]/g, '-').substring(0, 200); } // ---- Format converters ---- function toMarkdown(data) { let md = '# ' + (data.title || 'ChatGPT Chat') + '\n\n'; md += '_' + (data.date || new Date().toISOString()) + '_ | '; md += '_' + (data.url || '') + '_\n\n---\n\n'; data.messages.forEach(msg => { if (msg.role === 'user') md += '### 🧑 You\n\n' + msg.content + '\n\n'; else md += '### 🤖 ChatGPT\n\n' + msg.content + '\n\n'; }); return md; } function toText(data) { let txt = '=== ' + (data.title || 'ChatGPT Chat') + ' ===\n\n'; data.messages.forEach(msg => { txt += (msg.role === 'user' ? '[You]' : '[ChatGPT]') + '\n' + msg.content + '\n\n---\n\n'; }); return txt; } function toHTML(data) { let html = '
' + data.date + ' | ' + data.url + '