Add an export button to Claude.ai conversations to save chats as text, json, or PDF
// ==UserScript==
// @name Export Claude Chat
// @namespace https://claude.ai/
// @version 1.2
// @description Add an export button to Claude.ai conversations to save chats as text, json, or PDF
// @author https://github.com/o-az
// @match *://claude.ai/*
// @match *://*.claude.ai/*
// @icon https://claude.ai/favicon.ico
// @homepageURL https://github.com/o-az/userscripts
// @supportURL https://github.com/o-az/userscripts/issues
// @license MIT
// @grant none
// @run-at document-idle
// @noframes
// ==/UserScript==
;(() => {
'use strict'
const EXPORT_BUTTON_ID = 'export-claude-chat-btn'
const STYLE_ID = 'export-claude-chat-styles'
const MENU_ID = 'export-claude-menu'
/** @param {string} str */
function escapeHtml(str) {
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
}
function addStyles() {
if (document.getElementById(STYLE_ID)) return
const styles = document.createElement('style')
styles.id = STYLE_ID
styles.textContent = /* css */ `
#${EXPORT_BUTTON_ID} {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 9999;
background: #d97757;
color: white;
border: none;
border-radius: 8px;
padding: 10px 16px;
font-family: system-ui, -apple-system, sans-serif;
font-size: 14px;
font-weight: 500;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
box-shadow: 0 4px 12px rgba(217, 119, 87, 0.4);
transition: all 0.2s ease;
}
#${EXPORT_BUTTON_ID}:hover {
background: #c46a4e;
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(217, 119, 87, 0.5);
}
#${EXPORT_BUTTON_ID}:active {
transform: translateY(0);
}
#${EXPORT_BUTTON_ID} svg {
width: 16px;
height: 16px;
}
.export-claude-menu {
position: fixed;
bottom: 70px;
right: 20px;
background: #1a1a1a;
border: 1px solid #333;
border-radius: 8px;
padding: 8px 0;
min-width: 140px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
z-index: 9998;
display: none;
}
.export-claude-menu.visible {
display: block;
}
.export-claude-menu button {
display: block;
width: 100%;
padding: 10px 16px;
background: transparent;
border: none;
color: #e0e0e0;
font-family: system-ui, -apple-system, sans-serif;
font-size: 13px;
text-align: left;
cursor: pointer;
transition: background 0.15s;
}
.export-claude-menu button:hover {
background: #333;
color: white;
}
`
document.head.appendChild(styles)
}
function createExportMenu() {
const existing = document.getElementById(MENU_ID)
if (existing) return existing
const menu = document.createElement('div')
Object.assign(menu, {
id: MENU_ID,
className: 'export-claude-menu',
})
const txtBtn = document.createElement('button')
txtBtn.textContent = '📄 Export as TXT'
txtBtn.onclick = () => {
exportAsText()
menu.classList.remove('visible')
}
const pdfBtn = document.createElement('button')
pdfBtn.textContent = '📑 Export as PDF'
pdfBtn.onclick = () => {
exportAsPDF()
menu.classList.remove('visible')
}
const jsonBtn = document.createElement('button')
jsonBtn.textContent = '🔧 Export as JSON'
jsonBtn.onclick = () => {
exportAsJSON()
menu.classList.remove('visible')
}
menu.appendChild(txtBtn)
menu.appendChild(pdfBtn)
menu.appendChild(jsonBtn)
document.body.appendChild(menu)
return menu
}
function addExportButton() {
if (document.getElementById(EXPORT_BUTTON_ID)) return
addStyles()
const menu = createExportMenu()
const button = document.createElement('button')
button.id = EXPORT_BUTTON_ID
button.innerHTML = /* html */ `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<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>
Export Chat
`
button.onclick = (e) => {
e.stopPropagation()
menu.classList.toggle('visible')
}
document.body.appendChild(button)
}
// Single global click listener to dismiss the menu — registered once
document.addEventListener('click', (event) => {
const target = /** @type {Node|null} */ (event.target)
const button = document.getElementById(EXPORT_BUTTON_ID)
const menu = document.getElementById(MENU_ID)
if (button && menu && !button.contains(target) && !menu.contains(target)) {
menu.classList.remove('visible')
}
})
function extractChatContent() {
const messages = []
const processedTexts = new Set()
// Strategy 1: Find user messages by data-testid, then look for nearby Claude responses
const userMessageElements = document.querySelectorAll(
'[data-testid="user-message"]',
)
for (const userEl of userMessageElements) {
// Get user message text
const userText = /** @type {HTMLElement} */ (userEl).innerText?.trim()
if (userText && !processedTexts.has(userText)) {
processedTexts.add(userText)
messages.push({
role: 'You',
content: userText,
timestamp: new Date().toISOString(),
})
}
// Find Claude's response - it's typically in a sibling or nearby container
// Look for the font-claude-response class
let parent = userEl.parentElement
let claudeResponse = null
// Walk up to find container with both messages
while (parent && !claudeResponse) {
// Check siblings after the user message container
let sibling = parent.nextElementSibling
while (sibling) {
const claudeEl = sibling.querySelector(
'.font-claude-response, [class*="font-claude-response"]',
)
if (claudeEl) {
claudeResponse = claudeEl
break
}
// Also check if the sibling itself has the class
if (
sibling.classList.contains('font-claude-response') ||
sibling.matches('[class*="font-claude-response"]')
) {
claudeResponse = sibling
break
}
sibling = sibling.nextElementSibling
}
if (!claudeResponse) {
parent = parent.parentElement
}
}
if (claudeResponse) {
const claudeText = /** @type {HTMLElement} */ (
claudeResponse
).innerText?.trim()
if (claudeText && !processedTexts.has(claudeText)) {
processedTexts.add(claudeText)
messages.push({
role: 'Claude',
content: claudeText,
timestamp: new Date().toISOString(),
})
}
}
}
// Strategy 2: Directly find all Claude responses that might have been missed
const allClaudeResponses = document.querySelectorAll(
'.font-claude-response, [class*="font-claude-response"]',
)
for (const claudeEl of allClaudeResponses) {
const text = /** @type {HTMLElement} */ (claudeEl).innerText?.trim()
if (text && !processedTexts.has(text)) {
// Check this isn't just a fragment we've already captured
let isNew = true
for (const existing of messages) {
if (
existing.role === 'Claude' &&
existing.content.includes(text.slice(0, 50))
) {
isNew = false
break
}
}
if (isNew) {
processedTexts.add(text)
messages.push({
role: 'Claude',
content: text,
timestamp: new Date().toISOString(),
})
}
}
}
// Strategy 3: Look for streaming response containers (Claude's responses often have data-is-streaming)
const streamingContainers = document.querySelectorAll('[data-is-streaming]')
for (const container of streamingContainers) {
const claudeEl = container.querySelector('.font-claude-response')
if (claudeEl) {
const text = /** @type {HTMLElement} */ (claudeEl).innerText?.trim()
if (text && !processedTexts.has(text)) {
// Check if this is new
let isNew = true
for (const existing of messages) {
if (
existing.role === 'Claude' &&
existing.content.includes(text.slice(0, 50))
) {
isNew = false
break
}
}
if (isNew) {
processedTexts.add(text)
messages.push({
role: 'Claude',
content: text,
timestamp: new Date().toISOString(),
})
}
}
}
}
return {
title:
document.title.replace(/\s+-\s+Claude$/, '').trim() || 'Claude Chat',
url: window.location.href,
exportedAt: new Date().toISOString(),
messages,
}
}
/**
* @param {{title: string, url: string, exportedAt: string, messages: Array<{role: string, content: string}>}} data
*/
function formatAsText(data) {
let output = `${data.title}\n`
output += `${'='.repeat(data.title.length)}\n\n`
output += `URL: ${data.url}\n`
output += `Exported: ${new Date(data.exportedAt).toLocaleString()}\n\n`
output += `${'-'.repeat(50)}\n\n`
for (const msg of data.messages) {
output += `[${msg.role}]\n`
output += `${msg.content}\n\n`
}
return output
}
function exportAsText() {
const data = extractChatContent()
const text = formatAsText(data)
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const fileBaseName = getExportFileBaseName(data.title)
const a = document.createElement('a')
Object.assign(a, {
href: url,
download: `${fileBaseName}.txt`,
})
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
function exportAsJSON() {
const data = extractChatContent()
const json = JSON.stringify(data, null, 2)
const blob = new Blob([json], { type: 'application/json;charset=utf-8' })
const url = URL.createObjectURL(blob)
const fileBaseName = getExportFileBaseName(data.title)
const a = document.createElement('a')
Object.assign(a, {
href: url,
download: `${fileBaseName}.json`,
})
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
/** @param {string} title */
function getExportFileBaseName(title) {
try {
const normalizedTitle = title
.trim()
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-_]/g, '')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
if (!normalizedTitle) {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
return `claude-chat-${timestamp}`
}
return normalizedTitle.startsWith('claude-')
? normalizedTitle
: `claude-${normalizedTitle}`
} catch {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
return `claude-chat-${timestamp}`
}
}
function exportAsPDF() {
const data = extractChatContent()
const fileBaseName = getExportFileBaseName(data.title)
// Create a printable window
const printWindow = window.open('', '_blank')
if (!printWindow) {
alert('Please allow popups to export as PDF')
return
}
printWindow.document.write(/* html */ `
<!DOCTYPE html>
<html>
<head>
<title>${escapeHtml(fileBaseName)}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
max-width: 800px;
margin: 40px auto;
padding: 20px;
color: #333;
}
h1 {
border-bottom: 2px solid #d97757;
padding-bottom: 10px;
margin-bottom: 20px;
}
.meta {
color: #666;
font-size: 14px;
margin-bottom: 30px;
}
.message {
margin: 20px 0;
padding: 15px;
background: #f8f8f8;
border-radius: 8px;
border-left: 4px solid #d97757;
}
.message.user {
border-left-color: #4a90d9;
background: #f0f7ff;
}
.role {
font-weight: 600;
color: #d97757;
margin-bottom: 8px;
font-size: 14px;
text-transform: uppercase;
}
.message.user .role {
color: #4a90d9;
}
.content {
white-space: pre-wrap;
}
@media print {
body { margin: 0; }
.no-print { display: none; }
}
</style>
</head>
<body>
<button class="no-print" onclick="window.print()" style="
position: fixed;
top: 20px;
right: 20px;
padding: 10px 20px;
background: #d97757;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
">Print / Save as PDF</button>
<h1>${escapeHtml(data.title)}</h1>
<div class="meta">
<strong>URL:</strong> ${escapeHtml(data.url)}<br>
<strong>Exported:</strong> ${new Date(data.exportedAt).toLocaleString()}
</div>
<hr>
${data.messages
.map(
(msg) => /* html */ `
<div class="message ${msg.role === 'You' ? 'user' : ''}">
<div class="role">${escapeHtml(msg.role)}</div>
<div class="content">${escapeHtml(msg.content)}</div>
</div>
`,
)
.join('')}
</body>
</html>
`)
printWindow.document.close()
}
// Initialize
function init() {
// Wait for the conversation to load
const checkInterval = setInterval(() => {
// Look for conversation container or messages
const hasConversation =
document.querySelector('[data-testid="user-message"]') ||
document.querySelector('.font-claude-response') ||
document.querySelector('[data-is-streaming]') ||
(document.querySelector('main')?.querySelectorAll('div')?.length ?? 0) >
5
if (hasConversation || document.querySelector('main')) {
clearInterval(checkInterval)
addExportButton()
}
}, 1_000)
// Stop checking after 30 seconds
setTimeout(() => clearInterval(checkInterval), 30_000)
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init)
} else {
init()
}
// Re-add button if removed by page navigation (SPA)
const observer = new MutationObserver(() => {
if (!document.getElementById(EXPORT_BUTTON_ID)) {
addExportButton()
}
})
observer.observe(document.body, { childList: true, subtree: true })
})()