// ==UserScript==
// @name ChatGPT Chat Exporter (beta)
// @namespace https://github.com/merlinm/claude-exporter
// @version 0.9
// @description Adds “Export Chat” button to ChatGPT threads (JSON / TXT)
// @author Merlin McKean
// @license MIT
// @match https://chat.openai.com/*
// @grant GM_xmlhttpRequest
// @grant GM_download
// ==/UserScript==
(function () {
'use strict';
/* ---------- 1. Helpers ---------- */
// Generic XHR wrapper (same pattern as Claude script)
function apiRequest(method, url, headers = {}, data = null) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method,
url,
headers,
data: data ? JSON.stringify(data) : null,
responseType: 'json',
withCredentials: true, // *include cookies*
onload: ({ status, response }) =>
status >= 200 && status < 300
? resolve(response)
: reject(Error(`HTTP ${status}`)),
onerror: reject,
});
});
}
// Get JWT access token (valid ~30 days)
async function getAccessToken() {
const { accessToken } = await apiRequest('GET', 'https://chat.openai.com/api/auth/session');
if (!accessToken) throw Error('No access token');
return accessToken;
}
// Fetch full conversation JSON
async function getConversation(chatId) {
const token = await getAccessToken();
return await apiRequest(
'GET',
`https://chat.openai.com/backend-api/conversation/${chatId}`,
{ Authorization: `Bearer ${token}` }
);
}
/* ---------- 2. Formatting ---------- */
function mappingToOrderedArray(mapping) {
return Object.values(mapping)
.filter(m => m.message?.content?.parts?.length) // skip system/meta msgs
.map(m => ({
role: m.message.author.role, // 'user' | 'assistant'
text: m.message.content.parts.join('\n'),
time: m.message.create_time,
}))
.sort((a, b) => a.time - b.time);
}
function toTxt(name, msgs) {
let out = `Chat Title: ${name}\nDate: ${new Date().toISOString()}\n\n`;
msgs.forEach(m => {
const who = m.role === 'user' ? 'User' : 'ChatGPT';
out += `${who}:\n${m.text}\n\n`;
});
return out.trim();
}
function download(str, filename) {
const blob = new Blob([str], { type: 'text/plain' });
GM_download({ url: URL.createObjectURL(blob), name: filename });
}
/* ---------- 3. UI ---------- */
function addButton() {
// Avoid duplicates when navigating SPA-style
if (document.getElementById('m_export_btn')) return;
const btn = document.createElement('button');
btn.id = 'm_export_btn';
btn.textContent = 'Export Chat';
Object.assign(btn.style, {
position: 'fixed', bottom: '90px', right: '20px',
padding: '10px 18px', fontSize: '14px',
background: '#202123', color: '#fff',
border: 'none', borderRadius: '6px', cursor: 'pointer',
zIndex: 9_999,
});
btn.onclick = async () => {
const format = prompt('json or txt?', 'json');
if (!/^(json|txt)$/i.test(format)) return alert('Invalid format');
try {
const chatId = getChatIdFromUrl();
const data = await getConversation(chatId);
const msgs = mappingToOrderedArray(data.mapping);
const file = `chatgpt_${chatId}_${Date.now()}.${format}`;
if (format === 'json') {
download(JSON.stringify(data, null, 2), file);
} else {
download(toTxt(data.title, msgs), file);
}
alert('Export complete!');
} catch (e) {
console.error(e);
alert('Export failed—see console.');
}
};
document.body.appendChild(btn);
}
/* ---------- 4. Routing ---------- */
function getChatIdFromUrl() {
// Handles both /c/<id> and /share/<id>
const m = location.pathname.match(/\/(?:c|share)\/([\w-]+)/);
if (!m) throw Error('Cannot find conversation ID in URL');
return m[1];
}
// Observe URL changes in the SPA
let lastUrl = '';
new MutationObserver(() => {
if (location.href !== lastUrl) {
lastUrl = location.href;
setTimeout(addButton, 600); // wait for DOM paint
}
}).observe(document, { childList: true, subtree: true });
// Run once on load
addButton();
})();