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.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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

})();