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.

Bu betiği kurabilmeniz için Tampermonkey, Greasemonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği yüklemek için Tampermonkey gibi bir uzantı yüklemeniz gerekir.

Bu betiği kurabilmeniz için Tampermonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği kurabilmeniz için Tampermonkey ya da Userscripts gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği indirebilmeniz için ayrıca Tampermonkey gibi bir eklenti kurmanız gerekmektedir.

Bu komut dosyasını yüklemek için bir kullanıcı komut dosyası yöneticisi uzantısı yüklemeniz gerekecek.

(Zaten bir kullanıcı komut dosyası yöneticim var, kurmama izin verin!)

Bu stili yüklemek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için Stylus gibi bir uzantı kurmanız gerekir.

Bu stili yükleyebilmek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı kurmanız gerekir.

Bu stili yükleyebilmek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

(Zateb bir user-style yöneticim var, yükleyeyim!)

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

})();