Greasy Fork is available in English.
Export any Claude.ai conversation as Markdown, Plain Text, or JSON with one click — adds a clean Export button to the chat toolbar.
// ==UserScript==
// @name Claude Chat Exporter
// @namespace foxbinner
// @version 1.3.0
// @description Export any Claude.ai conversation as Markdown, Plain Text, or JSON with one click — adds a clean Export button to the chat toolbar.
// @license MIT
// @match *://claude.ai/*
// @icon https://claude.ai/favicon.ico
// @grant none
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
// ── Styles ─────────────────────────────────────────────────────────────────
function injectStyles() {
if (document.getElementById('_exp_styles')) return;
const s = document.createElement('style');
s.id = '_exp_styles';
s.textContent = `
#_exp_wrap { position: relative; display: inline-flex; }
#_exp_menu {
display: none;
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: 9999;
min-width: 148px;
padding: 4px;
border-radius: 8px;
overflow: hidden;
background: #2b2b2b;
border: 1px solid rgba(255,255,255,0.09);
box-shadow: 0 4px 12px rgba(0,0,0,0.38);
}
#_exp_menu.open { display: block; }
.exp-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 10px;
border-radius: 5px;
border: none;
background: transparent;
cursor: pointer;
text-align: left;
font-size: 12px;
font-weight: 600;
font-family: inherit;
color: rgba(255,255,255,0.82);
transition: background 100ms ease, color 100ms ease;
}
.exp-item svg { flex-shrink: 0; opacity: 0.65; transition: opacity 100ms ease; }
.exp-item:hover { background: rgba(255,255,255,0.10); color: #fff; }
.exp-item:hover svg { opacity: 0.90; }
.exp-item:disabled { opacity: 0.4; cursor: not-allowed; pointer-events: none; }
`;
document.head.appendChild(s);
}
// ── Icons ──────────────────────────────────────────────────────────────────
const ICON = {
download: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`,
markdown: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/></svg>`,
plain: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h16M4 12h16M4 18h12"/></svg>`,
json: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5c0 1.1.9 2 2 2h1"/><path d="M16 3h1a2 2 0 0 1 2 2v5a2 2 0 0 0 2 2 2 2 0 0 0-2 2v5a2 2 0 0 1-2 2h-1"/></svg>`,
};
// ── API Data Fetching ───────────────────────────────────────────────────────
function getConversationId() {
const m = location.pathname.match(/\/chat\/([0-9a-f-]{16,})/i);
return m?.[1] ?? null;
}
function getOrgId() {
// Claude stores the active org in a cookie: lastActiveOrg=<uuid>
return document.cookie.match(/lastActiveOrg=([^;]+)/)?.[1] ?? null;
}
async function fetchMessages() {
const convId = getConversationId();
const orgId = getOrgId();
if (!convId || !orgId) {
throw new Error(
`Could not resolve IDs — convId: ${convId}, orgId: ${orgId}. ` +
`Make sure you are on a /chat/<id> page and logged in.`
);
}
const url = `/api/organizations/${orgId}/chat_conversations/${convId}` +
`?tree=true&rendering_mode=messages&render_all_tools=true`;
const res = await fetch(url, {
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
});
if (!res.ok) throw new Error(`API returned ${res.status} ${res.statusText}`);
const data = await res.json();
return { data, thread: buildActiveThread(data) };
}
// Walk the message tree from the current leaf → root, then reverse.
// This gives only the active branch, ignoring edited/branched siblings.
function buildActiveThread(data) {
const messages = data?.chat_messages ?? [];
if (!messages.length) return [];
const byUuid = new Map(messages.map(m => [m.uuid, m]));
const leafId = data.current_leaf_message_uuid ?? findLeaf(messages);
const chain = [];
const seen = new Set();
let cur = byUuid.get(leafId) ?? messages[messages.length - 1];
while (cur && !seen.has(cur.uuid)) {
seen.add(cur.uuid);
chain.push(cur);
cur = cur.parent_message_uuid ? byUuid.get(cur.parent_message_uuid) : null;
}
return chain.reverse();
}
function findLeaf(messages) {
const hasChildren = new Set(messages.map(m => m.parent_message_uuid).filter(Boolean));
const leaves = messages.filter(m => !hasChildren.has(m.uuid));
leaves.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
return leaves[0]?.uuid ?? messages[messages.length - 1]?.uuid;
}
// ── Title Resolution ────────────────────────────────────────────────────────
function resolveTitle(data) {
const api = data?.name?.trim();
if (api && !/^new conversation$/i.test(api)) return api;
const dom = document.querySelector(
'[data-testid="chat-title-button"] .truncate, [data-testid="chat-title-button"]'
)?.textContent?.trim();
if (dom && !/^(claude|new conversation)$/i.test(dom)) return dom;
return document.title.replace(/\s*[–-]\s*Claude.*$/i, '').trim() || 'claude-chat';
}
// ── Content Rendering ───────────────────────────────────────────────────────
// Internal Claude tool names — system plumbing, not conversation content.
// Exporting their inputs/results pollutes files with unrelated data
// (memory search dumps from other chats, file reads, bash output, etc.)
const INTERNAL_TOOLS = new Set([
'conversation_search', 'recent_chats',
'web_search', 'web_fetch',
'bash_tool', 'view', 'str_replace',
'create_file', 'present_files',
'image_search', 'weather_fetch',
'places_search', 'places_map_display_v0',
'fetch_sports_data', 'ask_user_input_v0',
'search_mcp_registry', 'suggest_connectors',
'visualize:read_me', 'visualize:show_widget',
]);
// Flatten a message's structured content into plain text.
// Only text blocks and user-uploaded attachments are kept.
// Internal tool calls and ALL tool_results are stripped.
function messageToText(msg) {
const parts = Array.isArray(msg.content) ? msg.content : [];
const out = [];
for (const p of parts) {
if (p?.type === 'text' && p.text) {
out.push(p.text.trim());
} else if (p?.type === 'thinking') {
const t = (p.thinking ?? p.text ?? '').trim();
if (t) out.push(`[thinking]\n${t}`);
} else if (p?.type === 'tool_use') {
// Skip internal system tools entirely
if (INTERNAL_TOOLS.has(p.name)) continue;
// Non-internal tool_use (rare in normal chat) — mention briefly
out.push(`[tool: ${p.name ?? 'unknown'}]`);
} else if (p?.type === 'tool_result') {
// Always skip — contains raw system data irrelevant to the conversation
continue;
} else if (p?.type === 'image') {
out.push('[image]');
}
}
// User-uploaded attachments are part of the conversation, keep them
for (const att of [...(msg.attachments ?? []), ...(msg.files_v2 ?? [])]) {
const name = att.file_name ?? att.name ?? 'attachment';
const content = att.extracted_content ?? att.content ?? '';
out.push(content ? `[attachment: ${name}]\n${content}` : `[attachment: ${name}]`);
}
return out.join('\n\n').replace(/\n{3,}/g, '\n\n').trim();
}
// ── Export Formatters ───────────────────────────────────────────────────────
// Single source of truth for all metadata — every format pulls from this.
function buildMeta(thread, title, url, data) {
const timestamps = thread.map(m => m.created_at).filter(Boolean);
return {
title,
url,
exportedAt: new Date().toISOString(),
conversation_id: data?.uuid ?? null,
model: data?.model ?? null,
message_count: thread.length,
started_at: timestamps[0] ?? null,
ended_at: timestamps[timestamps.length - 1] ?? null,
};
}
function fmtTs(iso) {
if (!iso) return null;
try {
return new Date(iso).toLocaleString(undefined, {
year: 'numeric', month: 'short', day: 'numeric',
hour: 'numeric', minute: '2-digit',
});
} catch { return iso; }
}
function toMarkdown(thread, title, url, data) {
const m = buildMeta(thread, title, url, data);
const header = [
`# ${m.title}`,
'',
`| Field | Value |`,
`|---|---|`,
`| Conversation ID | \`${m.conversation_id ?? '—'}\` |`,
`| Model | ${m.model ?? '—'} |`,
`| Messages | ${m.message_count} |`,
`| Started | ${fmtTs(m.started_at) ?? '—'} |`,
`| Ended | ${fmtTs(m.ended_at) ?? '—'} |`,
`| Exported | ${fmtTs(m.exportedAt)} |`,
`| Source | ${m.url} |`,
'',
'---',
'',
].join('\n');
const body = thread.map(msg => {
const label = msg.sender === 'human' ? '🧑 You' : '🤖 Claude';
const ts = fmtTs(msg.created_at);
const heading = ts ? `## ${label} — ${ts}` : `## ${label}`;
return `${heading}\n\n${messageToText(msg) || '*[empty]*'}\n`;
}).join('\n---\n\n');
return header + body;
}
function toPlain(thread, title, url, data) {
const m = buildMeta(thread, title, url, data);
const sep = '='.repeat(Math.min(title.length, 60));
const header = [
m.title,
sep,
`Conversation ID : ${m.conversation_id ?? '—'}`,
`Model : ${m.model ?? '—'}`,
`Messages : ${m.message_count}`,
`Started : ${fmtTs(m.started_at) ?? '—'}`,
`Ended : ${fmtTs(m.ended_at) ?? '—'}`,
`Exported : ${fmtTs(m.exportedAt)}`,
`Source : ${m.url}`,
sep,
'',
].join('\n');
const body = thread.map(msg => {
const label = msg.sender === 'human' ? 'You' : 'Claude';
const ts = fmtTs(msg.created_at);
const heading = ts ? `[${label} — ${ts}]` : `[${label}]`;
return `${heading}\n${messageToText(msg) || '(empty)'}`;
}).join('\n\n');
return header + body + '\n';
}
function toJSON(thread, title, url, data) {
const m = buildMeta(thread, title, url, data);
return JSON.stringify({
...m,
messages: thread.map(msg => ({
uuid: msg.uuid,
sender: msg.sender,
created_at: msg.created_at,
text: messageToText(msg),
content: msg.content, // raw structured content for power users
})),
}, null, 2);
}
// ── Download ────────────────────────────────────────────────────────────────
function triggerDownload(filename, content, mime) {
const url = URL.createObjectURL(new Blob([content], { type: mime }));
Object.assign(document.createElement('a'), { href: url, download: filename }).click();
setTimeout(() => URL.revokeObjectURL(url), 3000);
}
// ── Export Entry Point ──────────────────────────────────────────────────────
async function runExport(format) {
const items = document.querySelectorAll('.exp-item');
items.forEach(b => (b.disabled = true));
try {
const { data, thread } = await fetchMessages();
if (!thread.length) throw new Error('The API returned 0 messages.');
const title = resolveTitle(data);
const url = location.href;
if (format === 'markdown') {
triggerDownload(`${title}.md`, toMarkdown(thread, title, url, data), 'text/markdown');
} else if (format === 'plain') {
triggerDownload(`${title}.txt`, toPlain(thread, title, url, data), 'text/plain');
} else if (format === 'json') {
triggerDownload(`${title}.json`, toJSON(thread, title, url, data), 'application/json');
}
} catch (err) {
alert(`Export failed: ${err.message}`);
console.error('[Claude Exporter]', err);
} finally {
items.forEach(b => (b.disabled = false));
}
}
// ── UI ──────────────────────────────────────────────────────────────────────
const FORMATS = [
{ label: 'Markdown', icon: ICON.markdown, fmt: 'markdown' },
{ label: 'Plain Text', icon: ICON.plain, fmt: 'plain' },
{ label: 'JSON', icon: ICON.json, fmt: 'json' },
];
function buildWidget() {
const wrap = document.createElement('div');
wrap.id = '_exp_wrap';
const btn = document.createElement('button');
btn.id = '_exp_btn';
btn.type = 'button';
btn.className =
'inline-flex items-center justify-center relative isolate shrink-0 can-focus select-none ' +
'disabled:pointer-events-none disabled:opacity-50 disabled:shadow-none disabled:drop-shadow-none ' +
'font-base-bold border-0.5 overflow-hidden transition duration-100 backface-hidden ' +
'h-8 rounded-md px-3 min-w-[4rem] whitespace-nowrap !text-xs _fill_10ocf_9 _secondary_10ocf_72';
btn.innerHTML = `${ICON.download}Export`;
const menu = document.createElement('div');
menu.id = '_exp_menu';
menu.role = 'menu';
for (const { label, icon, fmt } of FORMATS) {
const item = document.createElement('button');
item.type = 'button';
item.className = 'exp-item';
item.role = 'menuitem';
item.innerHTML = `${icon}${label}`;
item.addEventListener('click', () => {
menu.classList.remove('open');
runExport(fmt);
});
menu.appendChild(item);
}
wrap.append(btn, menu);
btn.addEventListener('click', e => { e.stopPropagation(); menu.classList.toggle('open'); });
document.addEventListener('click', e => { if (!wrap.contains(e.target)) menu.classList.remove('open'); });
document.addEventListener('keydown', e => { if (e.key === 'Escape') menu.classList.remove('open'); });
return wrap;
}
// ── Bootstrap ───────────────────────────────────────────────────────────────
function inject() {
if (!/^\/chat\//.test(location.pathname)) {
document.getElementById('_exp_wrap')?.remove();
return;
}
if (document.getElementById('_exp_wrap')) return;
const shareBtn = document.querySelector('[data-testid="wiggle-controls-actions-share"]');
if (!shareBtn) return;
injectStyles();
shareBtn.before(buildWidget());
}
// Intercept pushState for SPA sidebar navigations
const _origPush = history.pushState.bind(history);
history.pushState = function (...args) {
_origPush(...args);
setTimeout(inject, 300);
};
window.addEventListener('popstate', () => setTimeout(inject, 300));
inject();
new MutationObserver(inject).observe(document.body, { childList: true, subtree: true });
})();