ChatGPT Chat Exporter (beta)

Adds “Export Chat” button to ChatGPT threads (JSON / TXT)

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