Chat Transcript Exporter (JSON+RTF)

Export current ChatGPT chat to JSON (AI-friendly) and RTF (Unicode, links, tables). Adds timestamps and message IDs.

// ==UserScript==
// @name         Chat Transcript Exporter (JSON+RTF)
// @namespace    TamperMonkey
// @version      0.4.5
// @description  Export current ChatGPT chat to JSON (AI-friendly) and RTF (Unicode, links, tables). Adds timestamps and message IDs.
// @author       Jeroen Bazuin
// @match        https://chat.openai.com/*
// @match        https://chatgpt.com/*
// @run-at       document-start
// @license      MIT
// @grant        GM_registerMenuCommand
// @grant        GM_addStyle
// ==/UserScript==

(function () {
  'use strict';
  console.info('[Chat Transcript Exporter] Boot v0.4.5');

  // ---------- Settings ----------
  const FILL_SYNTHETIC_TIMES = true; // fill missing timestamps (+1s monotonic)
  const SYNTHETIC_STEP_MS = 1000;

  // ---------- Styles (two blue buttons, top-right) ----------
  GM_addStyle(`
    .jb-toolbar{position:fixed;top:84px;right:16px;z-index:2147483647;display:flex;gap:6px;align-items:center}
    .jb-btn{background:#2563eb;color:#fff;border:1px solid #1d4ed8;border-radius:8px;padding:6px 10px;
      font:600 12px/1 system-ui,-apple-system,Segoe UI,Roboto,Arial;cursor:pointer;box-shadow:0 4px 12px rgba(0,0,0,.12)}
    .jb-btn:hover{filter:brightness(1.05)}
    .jb-toast{position:fixed;left:50%;transform:translateX(-50%);top:12px;background:#111827;color:#fff;padding:8px 12px;
      border-radius:8px;border:1px solid #e5e7eb;z-index:2147483647;font:500 12px/1 system-ui,-apple-system,Segoe UI,Roboto,Arial;pointer-events:none}
  `);

  // ---------- Utils ----------
  const sleep = (ms) => new Promise(r => setTimeout(r, ms));
  const pad = (n) => String(n).padStart(2, '0');
  const dateStamp = () => {
    const d = new Date();
    return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}_${pad(d.getHours())}-${pad(d.getMinutes())}-${pad(d.getSeconds())}`;
  };
  function toast(msg, ms = 1200) {
    if (!document.body) return;
    const el = document.createElement('div');
    el.className = 'jb-toast';
    el.textContent = msg;
    document.body.appendChild(el);
    setTimeout(() => el.remove(), ms);
  }
  function download(filename, data, type = 'application/octet-stream') {
    const blob = new Blob([data], { type });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = filename;
    (document.body || document.documentElement).appendChild(a);
    a.click();
    a.remove();
    setTimeout(() => URL.revokeObjectURL(url), 1500);
  }

  // ---------- Load all messages ----------
  async function ensureAllMessagesLoaded(maxLoops = 28) {
    let last = 0;
    for (let i = 0; i < maxLoops; i++) {
      const c = document.querySelectorAll('[data-message-author-role]').length;
      if (c > last) {
        last = c;
        window.scrollTo({ top: 0, behavior: 'auto' });
        await sleep(650);
      } else break;
    }
  }
  const getMessageNodes = () => Array.from(document.querySelectorAll('[data-message-author-role]'));

  // ---------- Extractors ----------
  function sanitizeClone(node) {
    const clone = node.cloneNode(true);
    clone.querySelectorAll('*').forEach(el => {
      [...el.attributes].forEach(a => { if (/^on/i.test(a.name)) el.removeAttribute(a.name); });
    });
    return clone;
  }
  function stripUI(root) {
    [
      'button','svg','textarea','input','[role="menu"]',
      '[data-testid="copy-code-button"]','[data-testid="followup-actions"]',
      '[data-testid="toolbar"]','nav','header','footer',
      'figure:has(img[alt*="avatar"])','[aria-label="Actions"]'
    ].forEach(sel => root.querySelectorAll(sel).forEach(n => n.remove()));
  }
  function pickContentEl(node) {
    return node.querySelector('article')
        || node.querySelector('.markdown, .prose, [data-testid="conversation-turn-content"]')
        || node;
  }
  function extractCodeBlocks(root) {
    const blocks = [];
    root.querySelectorAll('pre').forEach(pre => {
      const code = pre.querySelector('code');
      const text = (code ? code.textContent : pre.textContent) || '';
      let lang = '';
      if (code) {
        const ds = code.getAttribute('data-language');
        if (ds) lang = ds.toLowerCase();
        if (!lang && code.className) {
          const m = code.className.match(/language-([a-z0-9]+)/i);
          if (m) lang = m[1].toLowerCase();
        }
      }
      blocks.push({ lang, text });
    });
    return blocks;
  }
  function htmlToPlainText(html) {
    const c = document.createElement('div');
    c.innerHTML = html;
    c.querySelectorAll('pre').forEach(pre => {
      pre.replaceWith(document.createTextNode('\n' + pre.textContent + '\n'));
    });
    c.querySelectorAll('br').forEach(br => br.replaceWith(document.createTextNode('\n')));
    c.querySelectorAll('li').forEach(li => li.insertAdjacentText('afterbegin', '• '));
    return c.innerText.replace(/\n{3,}/g, '\n\n').trim();
  }
  function extractTimestamp(node) {
    const t = node.querySelector('time[datetime]');
    if (t && t.getAttribute('datetime')) return t.getAttribute('datetime');
    for (const a of ['data-message-timestamp','data-timestamp','data-created','data-created-at']) {
      const v = node.getAttribute(a) || (node.dataset && node.dataset[a.replace('data-','').replace(/-([a-z])/g,(_,c)=>c.toUpperCase())]);
      if (v) return v;
    }
    const t2 = node.querySelector('time');
    if (t2 && t2.textContent) {
      const d = new Date(t2.textContent.trim());
      if (!isNaN(d.getTime())) return d.toISOString();
    }
    return null;
  }
  function extractMessageId(node) {
    const self = node.getAttribute('data-message-id');
    if (self) return self;
    const desc = node.querySelector('[data-message-id]');
    if (desc) return desc.getAttribute('data-message-id');
    const anc = node.closest('[data-message-id]');
    if (anc) return anc.getAttribute('data-message-id');
    const idLike = node.id || node.getAttribute('id');
    if (idLike && /message|msg|node/i.test(idLike)) return idLike;
    return null;
  }

  // AI-friendly fragments
  function extractFragments(root) {
    const out = [];
    const el = root.cloneNode(true);
    stripUI(el);

    function push(f) {
      if (!f) return;
      if (f.text && !f.text.trim()) return;
      out.push(f);
    }
    function getText(n) {
      return (n.innerText || '').replace(/\s+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
    }
    function readList(listEl, ordered) {
      const items = Array.from(listEl.children).map(li => getText(li)).filter(Boolean);
      return { type: ordered ? 'ordered_list' : 'unordered_list', items };
    }
    function readTable(tbl) {
      const rows = Array.from(tbl.querySelectorAll('tr')).map(tr =>
        Array.from(tr.children).map(td => getText(td))
      );
      const hasHeader = rows.length && tbl.querySelector('th') != null;
      return { type: 'table', header: hasHeader ? rows[0] : null, rows: hasHeader ? rows.slice(1) : rows };
    }

    const blocks = Array.from(el.children.length ? el.children : [el]);
    blocks.forEach(node => {
      if (node.nodeType !== 1) return;
      const tag = node.tagName;
      if (/^H[1-6]$/.test(tag)) push({ type: 'heading', level: +tag[1], text: getText(node) });
      else if (tag === 'P') push({ type: 'paragraph', text: getText(node) });
      else if (tag === 'UL') push(readList(node, false));
      else if (tag === 'OL') push(readList(node, true));
      else if (tag === 'PRE') {
        const code = node.querySelector('code');
        let lang = '';
        if (code) {
          const ds = code.getAttribute('data-language');
          if (ds) lang = ds.toLowerCase();
          if (!lang && code.className) {
            const m = code.className.match(/language-([a-z0-9]+)/i);
            if (m) lang = m[1].toLowerCase();
          }
        }
        push({ type: 'code_block', lang, code: code ? code.textContent : node.textContent });
      }
      else if (tag === 'BLOCKQUOTE') push({ type: 'blockquote', text: getText(node) });
      else if (tag === 'TABLE') push(readTable(node));
    });

    if (!out.length) out.push({ type: 'paragraph', text: getText(el) });
    return out;
  }

  function extractMessage(node) {
    const role = node.getAttribute('data-message-author-role') || 'unknown';
    const message_id = extractMessageId(node);
    const created_at_raw = extractTimestamp(node);
    const contentEl = pickContentEl(node);
    const clean = sanitizeClone(contentEl);
    stripUI(clean);
    const html = clean.innerHTML;
    const text = htmlToPlainText(html);
    const code_blocks = extractCodeBlocks(clean);
    const fragments = extractFragments(clean);
    return { role, message_id, created_at: created_at_raw || null, html, text, code_blocks, fragments };
  }

  async function collectTranscript() {
    await ensureAllMessagesLoaded();
    const nodes = getMessageNodes();
    let messages = nodes.map((n, i) => ({ index: i + 1, ...extractMessage(n) })).filter(m => m.text || m.html);

    if (messages.some(m => !m.created_at) && FILL_SYNTHETIC_TIMES) {
      const start = Date.now() - (messages.length * SYNTHETIC_STEP_MS);
      messages = messages.map((m, i) =>
        m.created_at
          ? { ...m, created_at_synthetic: false }
          : { ...m, created_at: new Date(start + i * SYNTHETIC_STEP_MS).toISOString(), created_at_synthetic: true }
      );
    } else {
      messages = messages.map(m => ({ ...m, created_at_synthetic: false }));
    }

    return {
      source_url: location.href,
      title: document.title || 'ChatGPT chat',
      exported_at: new Date().toISOString(),
      messages
    };
  }

  // ---------- RTF helpers (Unicode, links, lists, tables) ----------
  function escapeRtfU(str) {
    let out = '';
    for (let i = 0; i < str.length; i++) {
      let cp = str.codePointAt(i);
      if (cp > 0xFFFF) i++;
      const ch = String.fromCodePoint(cp);
      if (ch === '\\'){ out += '\\\\'; continue; }
      if (ch === '{'){ out += '\\{'; continue; }
      if (ch === '}'){ out += '\\}'; continue; }
      if (ch === '\r'){ continue; }
      if (ch === '\n'){ out += '\\line '; continue; }
      if (cp > 127){ out += `\\u${cp}?`; continue; }
      out += ch;
    }
    return out;
  }
  function fldinstEscape(s) { return String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"'); }

  function rtfTableFromDOM(table) {
    // Word-friendly: per-cell \\pard\\intbl ... \\cell, then \\row
    const rows = Array.from(table.querySelectorAll('tr')).map(tr => Array.from(tr.children));
    if (!rows.length) return '';
    const maxCols = Math.max(...rows.map(r => r.length));
    const cellW = Math.round(9000 / Math.max(1, maxCols)); // twips
    let r = '';
    rows.forEach(tr => {
      r += '\\trowd\\trgaph108\\trleft0 ';
      let x = 0;
      for (let i = 0; i < maxCols; i++) { x += cellW; r += `\\cellx${x} `; }
      tr.forEach((cell) => {
        const isHead = cell.tagName === 'TH';
        const txt = (cell.innerText || '').trim();
        if (isHead) r += '\\b ';
        r += `\\pard\\intbl ${escapeRtfU(txt)}\\cell `;
        if (isHead) r += '\\b0 ';
      });
      for (let k = tr.length; k < maxCols; k++) r += '\\pard\\intbl \\cell ';
      r += '\\row ';
    });
    return r;
  }

  function richFragmentToRtf(html) {
    const root = document.createElement('div');
    root.innerHTML = html;
    let out = '';

    function walk(node, ctx = { inList: false }) {
      if (node.nodeType === 3) { out += escapeRtfU(node.nodeValue); return; }
      if (node.nodeType !== 1) return;
      const tag = node.tagName;

      switch (tag) {
        case 'BR': out += '\\line '; break;
        case 'P':
          Array.from(node.childNodes).forEach(n => walk(n, ctx));
          if (!ctx.inList) out += '\\par ';
          break;
        case 'H1': case 'H2': case 'H3':
          out += '\\b '; if (tag === 'H1') out += '\\fs36 '; if (tag === 'H2') out += '\\fs32 '; if (tag === 'H3') out += '\\fs28 ';
          Array.from(node.childNodes).forEach(n => walk(n, ctx));
          out += '\\b0 \\fs24 \\par ';
          break;
        case 'STRONG': case 'B': out += '\\b '; Array.from(node.childNodes).forEach(n => walk(n, ctx)); out += ' \\b0 '; break;
        case 'EM': case 'I': out += '\\i '; Array.from(node.childNodes).forEach(n => walk(n, ctx)); out += ' \\i0 '; break;

        case 'UL':
          Array.from(node.children).forEach(li => {
            out += '\\pard\\tx720\\li720\\fi-360 \\bullet\\tab ';
            Array.from(li.childNodes).forEach(n => {
              if (n.nodeType === 1 && n.tagName === 'P') Array.from(n.childNodes).forEach(nn => walk(nn, { inList: true }));
              else walk(n, { inList: true });
            });
            out += '\\par ';
          });
          out += '\\pard ';
          break;

        case 'OL': {
          let counter = 1;
          Array.from(node.children).forEach(li => {
            out += `\\pard\\tx720\\li720\\fi-360 ${escapeRtfU(String(counter) + '.')}\\tab `;
            Array.from(li.childNodes).forEach(n => {
              if (n.nodeType === 1 && n.tagName === 'P') Array.from(n.childNodes).forEach(nn => walk(nn, { inList: true }));
              else walk(n, { inList: true });
            });
            out += '\\par ';
            counter++;
          });
          out += '\\pard ';
          break;
        }

        case 'BLOCKQUOTE':
          out += '\\li360 ';
          Array.from(node.childNodes).forEach(n => walk(n, ctx));
          out += '\\li0 \\par ';
          break;

        case 'PRE': {
          const code = node.querySelector('code');
          const text = code ? code.textContent : node.textContent;
          out += '{\\pard\\f1\\cb2 ' + escapeRtfU(text) + ' \\par} ';
          break;
        }
        case 'CODE': {
          if (node.parentElement && node.parentElement.tagName === 'PRE') break;
          out += '{\\f1 ' + escapeRtfU(node.textContent) + '} ';
          break;
        }
        case 'A': {
          const href = node.getAttribute('href') || '';
          const label = node.textContent || href;
          out += `{\\field{\\*\\fldinst HYPERLINK "${fldinstEscape(href)}"}{\\fldrslt ${escapeRtfU(label)}}}`;
          break;
        }
        case 'TABLE': {
          out += rtfTableFromDOM(node);
          break;
        }
        default:
          Array.from(node.childNodes).forEach(n => walk(n, ctx));
      }
    }

    Array.from(root.childNodes).forEach(n => walk(n));
    return out;
  }

  function buildRTF(doc) {
    let body = '';
    doc.messages.forEach(m => {
      const roleLabel = m.role === 'user' ? 'User' : m.role === 'assistant' ? 'ChatGPT' : m.role === 'system' ? 'System' : m.role;
      const approx = m.created_at_synthetic ? ' ~' : '';
      const metaParts = [];
      if (m.created_at) metaParts.push(`${m.created_at}${approx}`);
      if (m.message_id) metaParts.push(`id: ${m.message_id}`);
      const meta = metaParts.join('; ');

      // Role + small inline meta in parentheses
      body += `\\b ${escapeRtfU(roleLabel)} \\b0`;
      if (meta) body += ` {\\fs18 (${escapeRtfU(meta)})}\\par `;
      else body += ' \\par ';

      body += richFragmentToRtf(m.html || m.text || '');
      body += '\\par\\par '; // blank line between messages
    });

    return '{\\rtf1\\ansi\\ansicpg1252\\deff0\\uc1'
      + '{\\fonttbl{\\f0 Segoe UI;}{\\f1 Courier New;}}'
      + '{\\colortbl;\\red0\\green0\\blue0;\\red243\\green244\\blue246;}' // \cb2 for code
      + '\\fs24 ' + body + '}';
  }

  // ---------- Export actions ----------
  async function exportJSON() {
    try {
      toast('Collecting transcript (JSON)…');
      const data = await collectTranscript();
      const titleSlug = (document.title || 'chat_transcript').replace(/[^\p{L}\p{N} _-]+/gu,'').replace(/\s+/g,'_').slice(0,70) || 'chat_transcript';
      const fname = `${titleSlug}_${dateStamp()}.json`;
      download(fname, JSON.stringify(data, null, 2), 'application/json;charset=utf-8');
      toast('JSON downloaded ✔');
    } catch (e) {
      console.error('[Chat Transcript Exporter] JSON error', e);
      toast('JSON export failed (see console).');
    }
  }
  async function exportRTF() {
    try {
      toast('Collecting transcript (RTF)…');
      const data = await collectTranscript();
      const rtf = buildRTF(data);
      const titleSlug = (document.title || 'chat_transcript').replace(/[^\p{L}\p{N} _-]+/gu,'').replace(/\s+/g,'_').slice(0,70) || 'chat_transcript';
      const fname = `${titleSlug}_${dateStamp()}.rtf`;
      download(fname, rtf, 'application/rtf;charset=utf-8');
      toast('RTF downloaded ✔');
    } catch (e) {
      console.error('[Chat Transcript Exporter] RTF error', e);
      toast('RTF export failed (see console).');
    }
  }

  // ---------- UI (top-right) ----------
  function topbarBottomY() {
    for (const sel of ['header[role="banner"]','[data-testid="top-nav"]','nav[role="navigation"]','header']) {
      const el = document.querySelector(sel);
      if (el) { const r = el.getBoundingClientRect(); if (r.bottom > 0) return Math.max(r.bottom + 12, 60); }
    }
    return 84;
  }
  function ensureToolbar() {
    if (!document.body) return;
    let bar = document.querySelector('.jb-toolbar');
    const top = topbarBottomY();
    if (!bar) {
      bar = document.createElement('div');
      bar.className = 'jb-toolbar';

      const btnJson = document.createElement('button');
      btnJson.className = 'jb-btn';
      btnJson.textContent = 'Export JSON';
      btnJson.title = 'Download transcript as JSON';
      btnJson.addEventListener('click', exportJSON);

      const btnRtf = document.createElement('button');
      btnRtf.className = 'jb-btn';
      btnRtf.textContent = 'Export RTF';
      btnRtf.title = 'Download transcript as RTF';
      btnRtf.addEventListener('click', exportRTF);

      bar.append(btnJson, btnRtf);
      document.body.appendChild(bar);
      toast('Exporter active');
      console.info('[Chat Transcript Exporter] Toolbar ready');
    }
    bar.style.top = `${top}px`;
  }

  // Tampermonkey menu
  if (typeof GM_registerMenuCommand === 'function') {
    GM_registerMenuCommand('Export JSON', exportJSON);
    GM_registerMenuCommand('Export RTF', exportRTF);
  }

  // Lifecycle
  const boot = () => {
    const readyInt = setInterval(() => {
      if (document.body) { ensureToolbar(); clearInterval(readyInt); }
    }, 200);
    const mo = new MutationObserver(() => ensureToolbar());
    mo.observe(document.documentElement, { childList: true, subtree: true });
    window.addEventListener('resize', ensureToolbar);
    window.addEventListener('scroll', ensureToolbar);
  };
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', boot, { once: true });
  } else {
    boot();
  }
})();