Chat Transcript Exporter (JSON+RTF)

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

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

Necesitarás instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Necesitará instalar una extensión como Tampermonkey para instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

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