您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Export current ChatGPT chat as JSON / Markdown / HTML via backend fetch. Button sits beside Share in the header.
// ==UserScript== // @name ChatGPT Multi Format Exporter // @namespace https://giths.com/random/chatgpt-exporter // @version 2.0 // @description Export current ChatGPT chat as JSON / Markdown / HTML via backend fetch. Button sits beside Share in the header. // @author Mr005K // @license MIT // @match https://chatgpt.com/* // @match https://chat.openai.com/* // @grant GM_addStyle // @grant GM_download // @grant GM_info // @connect chatgpt.com // @connect chat.openai.com // @run-at document-idle // ==/UserScript== (function () { 'use strict'; // ---------- mini utils ---------- const $ = (sel, root = document) => root.querySelector(sel); const sleep = (ms) => new Promise(r => setTimeout(r, ms)); const log = (...a) => console.log('[CGPT Export]', ...a); function canGMDownloadBlob() { try { if (typeof GM_download !== 'function') return false; const v = (GM_info && GM_info.scriptHandler === 'Tampermonkey' && GM_info.version) || ''; const num = parseFloat((v.match(/^\d+\.\d+/) || ['0'])[0]); return num >= 5.4; // TM 5.4+ accepts Blob/File directly } catch { return false; } } function downloadBlobSmart(filename, blob) { if (canGMDownloadBlob()) { GM_download({ url: blob, name: filename }); return; } const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 1500); } function esc(s) { return String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); } function safeTitle(s) { return (s || 'chat').replace(/[<>:"/\\|?*\x00-\x1F]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 80) || 'chat'; } function getConversationIdFromUrl() { const m = location.pathname.match(/\/c\/([0-9a-f-]{36})/i) || location.pathname.match(/\/conversation\/([0-9a-f-]{36})/i); return m ? m[1] : null; } async function getAccessToken() { const candidates = ['/api/auth/session', '/auth/session']; for (const ep of candidates) { try { const res = await fetch(ep, { credentials: 'include' }); if (res.ok) { const data = await res.json(); if (data && data.accessToken) return data.accessToken; } } catch {} } return null; // cookie-auth may still work } async function fetchConversation(convId, accessToken) { const endpoints = [ `/backend-api/conversation/${convId}`, `/backend-api/conversations/${convId}` ]; let lastErr; for (const ep of endpoints) { try { const res = await fetch(ep, { method: 'GET', headers: Object.assign( { 'Content-Type': 'application/json' }, accessToken ? { 'Authorization': `Bearer ${accessToken}` } : {} ), credentials: 'include' }); if (res.ok) { const json = await res.json(); if (json && (json.mapping || json.messages)) return json; lastErr = new Error('Malformed conversation payload'); } else { lastErr = new Error(`HTTP ${res.status} at ${ep}`); } } catch (e) { lastErr = e; } } throw lastErr || new Error('Failed to fetch conversation.'); } function messageToText(msg) { try { const c = msg?.content || {}; if (Array.isArray(c.parts)) { return c.parts.map(p => (typeof p === 'string' ? p : p?.text || '')).filter(Boolean).join('\n'); } if (typeof c?.text === 'string') return c.text; return JSON.stringify(c); } catch { return ''; } } function linearizeMessages(data) { const out = []; if (data?.mapping && data?.current_node) { const map = data.mapping; const chain = []; const seen = new Set(); let node = data.current_node; while (node && map[node] && !seen.has(node)) { seen.add(node); chain.push(node); node = map[node].parent; } chain.reverse(); for (const id of chain) { const nodeObj = map[id]; if (!nodeObj?.message) continue; const msg = nodeObj.message; const role = msg.author?.role || 'unknown'; if (role === 'tool' || role === 'function') continue; out.push({ id, role, author: msg.author?.name || role, text: messageToText(msg), create_time: msg.create_time ? new Date(msg.create_time * 1000).toISOString() : '' }); } return out; } if (Array.isArray(data?.messages)) { const msgs = data.messages.slice().sort((a, b) => (a.create_time || 0) - (b.create_time || 0)); for (const msg of msgs) { const role = msg.author?.role || 'unknown'; if (role === 'tool' || role === 'function') continue; out.push({ id: msg.id, role, author: msg.author?.name || role, text: messageToText(msg), create_time: msg.create_time ? new Date(msg.create_time * 1000).toISOString() : '' }); } } return out; } function toMarkdown(meta, messages) { let md = `# ${meta.title || 'Chat'}\n\n`; md += `- **ID:** ${meta.id}\n`; if (meta.create_time) md += `- **Created:** ${meta.create_time}\n`; if (meta.update_time) md += `- **Updated:** ${meta.update_time}\n`; if (meta.model) md += `- **Model:** ${meta.model}\n`; md += `\n---\n\n`; for (const m of messages) { const who = m.role === 'assistant' ? 'Assistant' : m.role === 'user' ? 'User' : (m.author || m.role); md += `## ${who}${m.create_time ? ` • ${m.create_time}` : ''}\n\n`; md += `${m.text || ''}\n\n`; } return md; } function renderMarkdownToHtml(md) { let s = String(md ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); s = s.replace(/```(\w+)?\n([\s\S]*?)```/g, (_, lang, code) => `<pre><code class="language-${lang || ''}">${code}</code></pre>`); s = s.replace(/`([^`]+)`/g, '<code>$1</code>'); s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>'); s = s.replace(/\*([^*]+)\*/g, '<em>$1</em>'); s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>'); s = s.replace(/\n/g, '<br>'); return s; } function toHTML(meta, messages) { return `<!doctype html> <html> <head> <meta charset="utf-8"> <title>${esc(meta.title || 'Chat')}</title> <meta name="viewport" content="width=device-width, initial-scale=1"> <style> body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,"Helvetica Neue",Arial,sans-serif;max-width:920px;margin:0 auto;padding:24px;line-height:1.55;background:#fff;color:#111} h1{margin:0 0 8px} .meta{font-size:12px;color:#666;margin:4px 0 20px} .msg{border:1px solid #e5e7eb;border-radius:12px;padding:16px;margin:14px 0} .msg.user{background:#f9fafb} .msg.assistant{background:#f5faff} .who{font-weight:600;margin-bottom:8px} pre{overflow:auto;padding:12px;background:#0b1021;color:#e6eaf2;border-radius:8px} code{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace} a{color:#2563eb} hr{border:none;border-top:1px solid #e5e7eb;margin:28px 0} </style> </head> <body> <h1>${esc(meta.title || 'Chat')}</h1> <div class="meta"> ID: ${esc(meta.id)} ${meta.create_time ? ` · Created: ${esc(meta.create_time)}` : ''} ${meta.update_time ? ` · Updated: ${esc(meta.update_time)}` : ''} ${meta.model ? ` · Model: ${esc(meta.model)}` : ''} </div> <hr/> ${messages.map(m => ` <div class="msg ${esc(m.role)}"> <div class="who">${esc(m.role === 'assistant' ? 'Assistant' : m.role === 'user' ? 'User' : (m.author || m.role))}${m.create_time ? ` • ${esc(m.create_time)}` : ''}</div> <div class="text">${renderMarkdownToHtml(m.text || '')}</div> </div> `).join('\n')} </body> </html>`; } // ---------- styles (popover uses token vars w/ fallbacks + fixed positioning) ---------- GM_addStyle(` #cgpt-export-button.btn { /* inherit ChatGPT button look via classes; no hard colors here */ } .cgpt-export-menu { position: fixed; /* anchored to viewport based on button rect */ min-width: 200px; background: var(--token-surface, var(--surface-primary, #ffffff)); color: var(--token-text-primary, #111111); border: 1px solid var(--token-border-light, #e5e7eb); border-radius: 10px; padding: 6px; box-shadow: var(--shadow-floating, 0 12px 40px rgba(0,0,0,.25)); z-index: 100000; display: none; } .cgpt-export-menu.open { display: block; } .cgpt-export-item { display: block; width: 100%; text-align: left; background: transparent; color: inherit; border: none; padding: 10px 12px; border-radius: 8px; cursor: pointer; font: inherit; } .cgpt-export-item:hover { background: var(--token-surface-hover, #f3f4f6); } `); // ---------- UI mount beside Share ---------- function findHeaderActions() { // primary target per your snippet return document.getElementById('conversation-header-actions') || document.querySelector('[data-testid="conversation-header-actions"]') || document.querySelector('#__next header div[id*="actions"]') || null; } function ensureMenuSingleton() { let menu = document.getElementById('cgpt-export-menu'); if (!menu) { menu = document.createElement('div'); menu.id = 'cgpt-export-menu'; menu.className = 'cgpt-export-menu'; menu.setAttribute('role', 'menu'); menu.innerHTML = ` <button class="cgpt-export-item" data-format="json" role="menuitem">Export as JSON</button> <button class="cgpt-export-item" data-format="md" role="menuitem">Export as Markdown</button> <button class="cgpt-export-item" data-format="html" role="menuitem">Export as HTML</button> `; document.body.appendChild(menu); } return menu; } function positionMenuToButton(menu, btn) { const r = btn.getBoundingClientRect(); const margin = 6; const top = Math.round(r.bottom + margin); let left = Math.round(r.left); // keep on-screen const width = menu.offsetWidth || 220; const maxLeft = window.innerWidth - width - 8; if (left > maxLeft) left = maxLeft; menu.style.top = `${top}px`; menu.style.left = `${left}px`; } function ensureHeaderButton() { const host = findHeaderActions(); if (!host) return false; if ($('#cgpt-export-button', host)) return true; // Build button that visually matches Share (class names copied) const btn = document.createElement('button'); btn.id = 'cgpt-export-button'; btn.className = 'btn relative btn-ghost text-token-text-primary mx-2'; btn.setAttribute('aria-label', 'Export'); btn.setAttribute('data-testid', 'export-chat-button'); btn.innerHTML = ` <div class="flex w-full items-center justify-center gap-1.5"> <svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" aria-label="" class="-ms-0.5 icon"> <path d="M10.0002 2.5c.368 0 .666.298.666.666v7.395l2.2-2.199a.666.666 0 1 1 .942.942l-3.333 3.333a.666.666 0 0 1-.942 0L6.2 9.304a.666.666 0 0 1 .943-.942l2.2 2.199V3.166c0-.368.298-.666.666-.666Z"/> <path d="M4.167 12.5c-.92 0-1.667.746-1.667 1.666v1.667c0 .92.746 1.667 1.667 1.667h11.666c.92 0 1.667-.746 1.667-1.667v-1.667c0-.92-.746-1.666-1.667-1.666h-.167a.667.667 0 0 0 0 1.333h.167c.184 0 .333.149.333.333v1.667c0 .184-.149.333-.333.333H4.167a.333.333 0 0 1-.334-.333v-1.667c0-.184.15-.333.334-.333h.166a.667.667 0 0 0 0-1.333H4.167Z"/> </svg> Export </div> `; // Insert just before the 3-dot options button if present, else append const optionsBtn = host.querySelector('[data-testid="conversation-options-button"]') || host.lastElementChild; if (optionsBtn?.parentElement === host) { host.insertBefore(btn, optionsBtn); } else { host.appendChild(btn); } // Menu (fixed to body) const menu = ensureMenuSingleton(); // Open/close & actions btn.addEventListener('click', async (e) => { e.stopPropagation(); if (!menu.classList.contains('open')) { menu.classList.add('open'); // ensure it has dimensions before positioning menu.style.visibility = 'hidden'; menu.style.display = 'block'; await sleep(0); positionMenuToButton(menu, btn); menu.style.visibility = ''; } else { menu.classList.remove('open'); menu.style.display = 'none'; } }); document.addEventListener('click', (ev) => { if (!menu.classList.contains('open')) return; const t = ev.target; if (t === btn || btn.contains(t)) return; if (t === menu || menu.contains(t)) return; menu.classList.remove('open'); menu.style.display = 'none'; }); window.addEventListener('resize', () => { if (menu.classList.contains('open')) positionMenuToButton(menu, btn); }); window.addEventListener('scroll', () => { if (menu.classList.contains('open')) positionMenuToButton(menu, btn); }); menu.addEventListener('click', async (e) => { const item = e.target.closest('.cgpt-export-item'); if (!item) return; menu.classList.remove('open'); menu.style.display = 'none'; try { const convId = getConversationIdFromUrl(); if (!convId) return alert('No conversation ID in URL. Open a chat first.'); const token = await getAccessToken(); // may be null const data = await fetchConversation(convId, token); const messages = linearizeMessages(data); const meta = { id: data.id || convId, title: data.title || document.title.replace(/ - ChatGPT$/, ''), create_time: data.create_time ? new Date(data.create_time * 1000).toISOString() : '', update_time: data.update_time ? new Date(data.update_time * 1000).toISOString() : '', model: data.current_model || data.model_slug || data.model || '' }; const base = safeTitle(meta.title); const ts = new Date().toISOString().replace(/[:.]/g, '-'); switch (item.dataset.format) { case 'json': { const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); downloadBlobSmart(`${base}.${ts}.json`, blob); break; } case 'md': { const md = toMarkdown(meta, messages); const blob = new Blob([md], { type: 'text/markdown' }); downloadBlobSmart(`${base}.${ts}.md`, blob); break; } case 'html': { const html = toHTML(meta, messages); const blob = new Blob([html], { type: 'text/html' }); downloadBlobSmart(`${base}.${ts}.html`, blob); break; } } } catch (err) { console.error(err); alert('Export failed: ' + (err?.message || err)); } }); return true; } async function boot() { // Give chatgpt.js a beat to attach (you asked to integrate it; we don't rely on it heavily) for (let i = 0; i < 30; i++) { if (window.chatgpt) break; await sleep(100); } ensureHeaderButton(); // React re-renders: keep our button alive const obs = new MutationObserver(() => ensureHeaderButton()); obs.observe(document.documentElement, { childList: true, subtree: true }); // Route changes without reload let lastPath = location.pathname; setInterval(() => { if (location.pathname !== lastPath) { lastPath = location.pathname; ensureHeaderButton(); } }, 600); } boot(); })();