// ==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();
})();