Claude Chat Exporter

Export any Claude.ai conversation as Markdown, Plain Text, or JSON with one click — adds a clean Export button to the chat toolbar.

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

You will need to install an extension such as Tampermonkey to install this script.

Tendrás que 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.

Tendrás que instalar una extensión como Tampermonkey antes de poder 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)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

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

// ==UserScript==
// @name         Claude Chat Exporter
// @namespace    foxbinner
// @version      1.3.0
// @description  Export any Claude.ai conversation as Markdown, Plain Text, or JSON with one click — adds a clean Export button to the chat toolbar.
// @license      MIT
// @match        *://claude.ai/*
// @icon         https://claude.ai/favicon.ico
// @grant        none
// @run-at       document-idle
// ==/UserScript==

(function () {
  'use strict';

  // ── Styles ─────────────────────────────────────────────────────────────────

  function injectStyles() {
    if (document.getElementById('_exp_styles')) return;
    const s = document.createElement('style');
    s.id = '_exp_styles';
    s.textContent = `
      #_exp_wrap { position: relative; display: inline-flex; }

      #_exp_menu {
        display: none;
        position: absolute;
        top: calc(100% + 6px);
        right: 0;
        z-index: 9999;
        min-width: 148px;
        padding: 4px;
        border-radius: 8px;
        overflow: hidden;
        background: #2b2b2b;
        border: 1px solid rgba(255,255,255,0.09);
        box-shadow: 0 4px 12px rgba(0,0,0,0.38);
      }
      #_exp_menu.open { display: block; }

      .exp-item {
        display: flex;
        align-items: center;
        gap: 8px;
        width: 100%;
        padding: 6px 10px;
        border-radius: 5px;
        border: none;
        background: transparent;
        cursor: pointer;
        text-align: left;
        font-size: 12px;
        font-weight: 600;
        font-family: inherit;
        color: rgba(255,255,255,0.82);
        transition: background 100ms ease, color 100ms ease;
      }
      .exp-item svg { flex-shrink: 0; opacity: 0.65; transition: opacity 100ms ease; }
      .exp-item:hover { background: rgba(255,255,255,0.10); color: #fff; }
      .exp-item:hover svg { opacity: 0.90; }
      .exp-item:disabled { opacity: 0.4; cursor: not-allowed; pointer-events: none; }
    `;
    document.head.appendChild(s);
  }

  // ── Icons ──────────────────────────────────────────────────────────────────

  const ICON = {
    download: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px"><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>`,
    markdown: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/></svg>`,
    plain:    `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h16M4 12h16M4 18h12"/></svg>`,
    json:     `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5c0 1.1.9 2 2 2h1"/><path d="M16 3h1a2 2 0 0 1 2 2v5a2 2 0 0 0 2 2 2 2 0 0 0-2 2v5a2 2 0 0 1-2 2h-1"/></svg>`,
  };

  // ── API Data Fetching ───────────────────────────────────────────────────────

  function getConversationId() {
    const m = location.pathname.match(/\/chat\/([0-9a-f-]{16,})/i);
    return m?.[1] ?? null;
  }

  function getOrgId() {
    // Claude stores the active org in a cookie: lastActiveOrg=<uuid>
    return document.cookie.match(/lastActiveOrg=([^;]+)/)?.[1] ?? null;
  }

  async function fetchMessages() {
    const convId = getConversationId();
    const orgId  = getOrgId();

    if (!convId || !orgId) {
      throw new Error(
        `Could not resolve IDs — convId: ${convId}, orgId: ${orgId}. ` +
        `Make sure you are on a /chat/<id> page and logged in.`
      );
    }

    const url = `/api/organizations/${orgId}/chat_conversations/${convId}` +
      `?tree=true&rendering_mode=messages&render_all_tools=true`;

    const res = await fetch(url, {
      credentials: 'include',
      headers: { 'Content-Type': 'application/json' },
    });

    if (!res.ok) throw new Error(`API returned ${res.status} ${res.statusText}`);

    const data = await res.json();
    return { data, thread: buildActiveThread(data) };
  }

  // Walk the message tree from the current leaf → root, then reverse.
  // This gives only the active branch, ignoring edited/branched siblings.
  function buildActiveThread(data) {
    const messages = data?.chat_messages ?? [];
    if (!messages.length) return [];

    const byUuid = new Map(messages.map(m => [m.uuid, m]));
    const leafId = data.current_leaf_message_uuid ?? findLeaf(messages);

    const chain = [];
    const seen  = new Set();
    let cur = byUuid.get(leafId) ?? messages[messages.length - 1];

    while (cur && !seen.has(cur.uuid)) {
      seen.add(cur.uuid);
      chain.push(cur);
      cur = cur.parent_message_uuid ? byUuid.get(cur.parent_message_uuid) : null;
    }

    return chain.reverse();
  }

  function findLeaf(messages) {
    const hasChildren = new Set(messages.map(m => m.parent_message_uuid).filter(Boolean));
    const leaves = messages.filter(m => !hasChildren.has(m.uuid));
    leaves.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
    return leaves[0]?.uuid ?? messages[messages.length - 1]?.uuid;
  }

  // ── Title Resolution ────────────────────────────────────────────────────────

  function resolveTitle(data) {
    const api = data?.name?.trim();
    if (api && !/^new conversation$/i.test(api)) return api;

    const dom = document.querySelector(
      '[data-testid="chat-title-button"] .truncate, [data-testid="chat-title-button"]'
    )?.textContent?.trim();
    if (dom && !/^(claude|new conversation)$/i.test(dom)) return dom;

    return document.title.replace(/\s*[–-]\s*Claude.*$/i, '').trim() || 'claude-chat';
  }

  // ── Content Rendering ───────────────────────────────────────────────────────

  // Internal Claude tool names — system plumbing, not conversation content.
  // Exporting their inputs/results pollutes files with unrelated data
  // (memory search dumps from other chats, file reads, bash output, etc.)
  const INTERNAL_TOOLS = new Set([
    'conversation_search', 'recent_chats',
    'web_search', 'web_fetch',
    'bash_tool', 'view', 'str_replace',
    'create_file', 'present_files',
    'image_search', 'weather_fetch',
    'places_search', 'places_map_display_v0',
    'fetch_sports_data', 'ask_user_input_v0',
    'search_mcp_registry', 'suggest_connectors',
    'visualize:read_me', 'visualize:show_widget',
  ]);

  // Flatten a message's structured content into plain text.
  // Only text blocks and user-uploaded attachments are kept.
  // Internal tool calls and ALL tool_results are stripped.
  function messageToText(msg) {
    const parts = Array.isArray(msg.content) ? msg.content : [];
    const out   = [];

    for (const p of parts) {
      if (p?.type === 'text' && p.text) {
        out.push(p.text.trim());

      } else if (p?.type === 'thinking') {
        const t = (p.thinking ?? p.text ?? '').trim();
        if (t) out.push(`[thinking]\n${t}`);

      } else if (p?.type === 'tool_use') {
        // Skip internal system tools entirely
        if (INTERNAL_TOOLS.has(p.name)) continue;
        // Non-internal tool_use (rare in normal chat) — mention briefly
        out.push(`[tool: ${p.name ?? 'unknown'}]`);

      } else if (p?.type === 'tool_result') {
        // Always skip — contains raw system data irrelevant to the conversation
        continue;

      } else if (p?.type === 'image') {
        out.push('[image]');
      }
    }

    // User-uploaded attachments are part of the conversation, keep them
    for (const att of [...(msg.attachments ?? []), ...(msg.files_v2 ?? [])]) {
      const name    = att.file_name ?? att.name ?? 'attachment';
      const content = att.extracted_content ?? att.content ?? '';
      out.push(content ? `[attachment: ${name}]\n${content}` : `[attachment: ${name}]`);
    }

    return out.join('\n\n').replace(/\n{3,}/g, '\n\n').trim();
  }

  // ── Export Formatters ───────────────────────────────────────────────────────

  // Single source of truth for all metadata — every format pulls from this.
  function buildMeta(thread, title, url, data) {
    const timestamps = thread.map(m => m.created_at).filter(Boolean);
    return {
      title,
      url,
      exportedAt:      new Date().toISOString(),
      conversation_id: data?.uuid  ?? null,
      model:           data?.model ?? null,
      message_count:   thread.length,
      started_at:      timestamps[0]                   ?? null,
      ended_at:        timestamps[timestamps.length - 1] ?? null,
    };
  }

  function fmtTs(iso) {
    if (!iso) return null;
    try {
      return new Date(iso).toLocaleString(undefined, {
        year: 'numeric', month: 'short', day: 'numeric',
        hour: 'numeric', minute: '2-digit',
      });
    } catch { return iso; }
  }

  function toMarkdown(thread, title, url, data) {
    const m = buildMeta(thread, title, url, data);
    const header = [
      `# ${m.title}`,
      '',
      `| Field | Value |`,
      `|---|---|`,
      `| Conversation ID | \`${m.conversation_id ?? '—'}\` |`,
      `| Model           | ${m.model ?? '—'} |`,
      `| Messages        | ${m.message_count} |`,
      `| Started         | ${fmtTs(m.started_at) ?? '—'} |`,
      `| Ended           | ${fmtTs(m.ended_at)   ?? '—'} |`,
      `| Exported        | ${fmtTs(m.exportedAt)} |`,
      `| Source          | ${m.url} |`,
      '',
      '---',
      '',
    ].join('\n');

    const body = thread.map(msg => {
      const label = msg.sender === 'human' ? '🧑 You' : '🤖 Claude';
      const ts    = fmtTs(msg.created_at);
      const heading = ts ? `## ${label} — ${ts}` : `## ${label}`;
      return `${heading}\n\n${messageToText(msg) || '*[empty]*'}\n`;
    }).join('\n---\n\n');

    return header + body;
  }

  function toPlain(thread, title, url, data) {
    const m = buildMeta(thread, title, url, data);
    const sep = '='.repeat(Math.min(title.length, 60));
    const header = [
      m.title,
      sep,
      `Conversation ID : ${m.conversation_id ?? '—'}`,
      `Model           : ${m.model           ?? '—'}`,
      `Messages        : ${m.message_count}`,
      `Started         : ${fmtTs(m.started_at) ?? '—'}`,
      `Ended           : ${fmtTs(m.ended_at)   ?? '—'}`,
      `Exported        : ${fmtTs(m.exportedAt)}`,
      `Source          : ${m.url}`,
      sep,
      '',
    ].join('\n');

    const body = thread.map(msg => {
      const label = msg.sender === 'human' ? 'You' : 'Claude';
      const ts    = fmtTs(msg.created_at);
      const heading = ts ? `[${label} — ${ts}]` : `[${label}]`;
      return `${heading}\n${messageToText(msg) || '(empty)'}`;
    }).join('\n\n');

    return header + body + '\n';
  }

  function toJSON(thread, title, url, data) {
    const m = buildMeta(thread, title, url, data);
    return JSON.stringify({
      ...m,
      messages: thread.map(msg => ({
        uuid:       msg.uuid,
        sender:     msg.sender,
        created_at: msg.created_at,
        text:       messageToText(msg),
        content:    msg.content,   // raw structured content for power users
      })),
    }, null, 2);
  }

  // ── Download ────────────────────────────────────────────────────────────────

  function triggerDownload(filename, content, mime) {
    const url = URL.createObjectURL(new Blob([content], { type: mime }));
    Object.assign(document.createElement('a'), { href: url, download: filename }).click();
    setTimeout(() => URL.revokeObjectURL(url), 3000);
  }

  // ── Export Entry Point ──────────────────────────────────────────────────────

  async function runExport(format) {
    const items = document.querySelectorAll('.exp-item');
    items.forEach(b => (b.disabled = true));

    try {
      const { data, thread } = await fetchMessages();

      if (!thread.length) throw new Error('The API returned 0 messages.');

      const title = resolveTitle(data);
      const url   = location.href;

      if (format === 'markdown') {
        triggerDownload(`${title}.md`,   toMarkdown(thread, title, url, data), 'text/markdown');
      } else if (format === 'plain') {
        triggerDownload(`${title}.txt`,  toPlain(thread, title, url, data),    'text/plain');
      } else if (format === 'json') {
        triggerDownload(`${title}.json`, toJSON(thread, title, url, data), 'application/json');
      }
    } catch (err) {
      alert(`Export failed: ${err.message}`);
      console.error('[Claude Exporter]', err);
    } finally {
      items.forEach(b => (b.disabled = false));
    }
  }

  // ── UI ──────────────────────────────────────────────────────────────────────

  const FORMATS = [
    { label: 'Markdown',   icon: ICON.markdown, fmt: 'markdown' },
    { label: 'Plain Text', icon: ICON.plain,     fmt: 'plain'    },
    { label: 'JSON',       icon: ICON.json,      fmt: 'json'     },
  ];

  function buildWidget() {
    const wrap = document.createElement('div');
    wrap.id = '_exp_wrap';

    const btn = document.createElement('button');
    btn.id = '_exp_btn';
    btn.type = 'button';
    btn.className =
      'inline-flex items-center justify-center relative isolate shrink-0 can-focus select-none ' +
      'disabled:pointer-events-none disabled:opacity-50 disabled:shadow-none disabled:drop-shadow-none ' +
      'font-base-bold border-0.5 overflow-hidden transition duration-100 backface-hidden ' +
      'h-8 rounded-md px-3 min-w-[4rem] whitespace-nowrap !text-xs _fill_10ocf_9 _secondary_10ocf_72';
    btn.innerHTML = `${ICON.download}Export`;

    const menu = document.createElement('div');
    menu.id = '_exp_menu';
    menu.role = 'menu';

    for (const { label, icon, fmt } of FORMATS) {
      const item = document.createElement('button');
      item.type = 'button';
      item.className = 'exp-item';
      item.role = 'menuitem';
      item.innerHTML = `${icon}${label}`;
      item.addEventListener('click', () => {
        menu.classList.remove('open');
        runExport(fmt);
      });
      menu.appendChild(item);
    }

    wrap.append(btn, menu);

    btn.addEventListener('click', e => { e.stopPropagation(); menu.classList.toggle('open'); });
    document.addEventListener('click', e => { if (!wrap.contains(e.target)) menu.classList.remove('open'); });
    document.addEventListener('keydown', e => { if (e.key === 'Escape') menu.classList.remove('open'); });

    return wrap;
  }

  // ── Bootstrap ───────────────────────────────────────────────────────────────

  function inject() {
    if (!/^\/chat\//.test(location.pathname)) {
      document.getElementById('_exp_wrap')?.remove();
      return;
    }

    if (document.getElementById('_exp_wrap')) return;

    const shareBtn = document.querySelector('[data-testid="wiggle-controls-actions-share"]');
    if (!shareBtn) return;

    injectStyles();
    shareBtn.before(buildWidget());
  }

  // Intercept pushState for SPA sidebar navigations
  const _origPush = history.pushState.bind(history);
  history.pushState = function (...args) {
    _origPush(...args);
    setTimeout(inject, 300);
  };
  window.addEventListener('popstate', () => setTimeout(inject, 300));

  inject();
  new MutationObserver(inject).observe(document.body, { childList: true, subtree: true });

})();