Easy ChatGPT Markdown Exporter

Export ChatGPT conversations (incl. thoughts, tool calls & custom instructions) to clean Markdown.

当前为 2025-07-29 提交的版本,查看 最新版本

// ==UserScript==
// @name         Easy ChatGPT Markdown Exporter
// @namespace    https://github.com/NoahTheGinger/Userscripts/
// @version      1.4
// @description  Export ChatGPT conversations (incl. thoughts, tool calls & custom instructions) to clean Markdown.
// @author       NoahTheGinger
// @note         Original development assistance from Gemini 2.5 Pro in AI Studio, and a large logic fix for tool calls by o3 (high reasoning effort) in OpenAI's Chat Playground
// @match        https://chat.openai.com/*
// @match        https://chatgpt.com/*
// @grant        none
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/sentinel.min.js
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';

  /* ---------- 1. authentication & fetch ---------- */

  async function getAccessToken() {
    const r = await fetch('/api/auth/session');
    if (!r.ok) throw new Error('Not authorised – log-in again');
    const j = await r.json();
    if (!j.accessToken) throw new Error('No access token');
    return j.accessToken;
  }

  function getChatIdFromUrl() {
    const m = location.pathname.match(/\/c\/([a-zA-Z0-9-]+)/);
    return m ? m[1] : null;
  }

  async function fetchConversation(id) {
    const token = await getAccessToken();
    const resp = await fetch(`${location.origin}/backend-api/conversation/${id}`, {
      headers: { Authorization: `Bearer ${token}` }
    });
    if (!resp.ok) throw new Error(resp.statusText);
    return resp.json();
  }

  /* ---------- 2. processing & markdown ---------- */

  function processConversation(raw) {
    const title = raw.title || 'ChatGPT Conversation';
    const nodes = [];
    let cur = raw.current_node;
    while (cur) {
      const n = raw.mapping[cur];
      if (n && n.message && n.message.author?.role !== 'system') nodes.unshift(n);
      cur = n?.parent;
    }
    return { title, nodes };
  }

  /* message --> markdown */
  function transformMessage(msg) {
    if (!msg || !msg.content) return '';
    const { content, metadata, author } = msg;

    switch (content.content_type) {
      case 'text':
        return content.parts?.join('\n') || '';

      case 'code': { // tool-call or normal snippet
        const raw = content.text || '';
        const looksJson = raw.trim().startsWith('{') && raw.trim().endsWith('}');
        const lang =
          content.language ||
          metadata?.language ||
          (looksJson ? 'json' : '') ||
          'txt';

        const header = looksJson ? '**Tool Call:**\n' : '';
        return `${header}\`\`\`${lang}\n${raw}\n\`\`\``;
      }

      case 'thoughts':
        return content.thoughts
          .map(
            t =>
              `**${t.summary}**\n\n> ${t.content.replace(/\n/g, '\n> ')}`
          )
          .join('\n\n');

      case 'multimodal_text':
        return (
          content.parts
            ?.map(p => {
              if (typeof p === 'string') return p;
              if (p.content_type === 'image_asset_pointer') return '![Image]';
              if (p.content_type === 'code')
                return `\`\`\`\n${p.text || ''}\n\`\`\``;
              return `[Unsupported: ${p.content_type}]`;
            })
            .join('\n') || ''
        );

      /* noise we always skip */
      case 'model_editable_context':
      case 'reasoning_recap':
        return '';
      default:
        return `[Unsupported content type: ${content.content_type}]`;
    }
  }

  /* whole conversation --> markdown */
  function conversationToMarkdown({ title, nodes }) {
    let md = `# ${title}\n\n`;

    /* prepend custom instructions (user_editable_context) --------- */
    const idx = nodes.findIndex(
      n => n.message?.content?.content_type === 'user_editable_context'
    );
    if (idx > -1) {
      const ctx = nodes[idx].message.content;
      md += '### User Editable Context:\n\n';
      if (ctx.user_profile)
        md += `**About User:**\n\`\`\`\n${ctx.user_profile}\n\`\`\`\n\n`;
      if (ctx.user_instructions)
        md += `**About GPT:**\n\`\`\`\n${ctx.user_instructions}\n\`\`\`\n\n`;
      md += '---\n\n';
      nodes.splice(idx, 1); // remove so we don’t re-process it
    }

    /* main loop --------------------------------------------------- */
    for (let i = 0; i < nodes.length; ) {
      const n = nodes[i];
      const m = n.message;
      if (!m || m.recipient !== 'all') {
        i++;
        continue;
      }

      if (m.author.role === 'user') {
        md += `### User:\n\n${transformMessage(m)}\n\n---\n\n`;
        i++;
        continue;
      }

      if (m.author.role === 'assistant') {
        /* gather reasoning (thoughts & tool-call code) ------------- */
        if (m.content.content_type !== 'text') {
          md += '### Thoughts:\n\n';
          while (
            i < nodes.length &&
            ['assistant', 'tool'].includes(nodes[i].message.author.role) &&
            nodes[i].message.content.content_type !== 'text'
          ) {
            const chunk = transformMessage(nodes[i].message);
            if (chunk) md += `${chunk}\n\n`;
            i++;
          }
          md += '---\n\n';
          continue;
        }

        /* final assistant reply ------------------------------------ */
        md += `### ChatGPT:\n\n${transformMessage(m)}\n\n---\n\n`;
        i++;
        continue;
      }

      /* tool messages that slipped through and weren’t handled */
      if (m.author.role === 'tool') {
        const chunk = transformMessage(m);
        if (chunk) md += `### Thoughts:\n\n${chunk}\n\n---\n\n`;
      }
      i++;
    }

    return md.trimEnd();
  }

  /* ---------- 3. UI / download ---------- */

  const sanitizeFilename = s => s.replace(/[\/\\?<>:*|"]/g, '-');

  function downloadFile(name, data) {
    const url = URL.createObjectURL(
      new Blob([data], { type: 'text/markdown;charset=utf-8' })
    );
    const a = Object.assign(document.createElement('a'), {
      href: url,
      download: name
    });
    document.body.appendChild(a);
    a.click();
    a.remove();
    URL.revokeObjectURL(url);
  }

  /* export action */
  async function handleExport() {
    const btn = document.getElementById('simplified-markdown-exporter-button');
    if (btn) {
      btn.textContent = 'Exporting...';
      btn.disabled = true;
    }
    try {
      const id = getChatIdFromUrl();
      if (!id) return alert('No conversation ID found.');
      const raw = await fetchConversation(id);
      const md = conversationToMarkdown(processConversation(raw));
      downloadFile(`${sanitizeFilename(raw.title)}.md`, md);
    } catch (e) {
      console.error(e);
      alert('Export failed – see console.');
    } finally {
      if (btn) {
        btn.textContent = 'Export Markdown';
        btn.disabled = false;
      }
    }
  }

  /* button */
  function createButton() {
    const b = document.createElement('button');
    b.id = 'simplified-markdown-exporter-button';
    b.textContent = 'Export Markdown';
    b.className = 'btn relative btn-neutral';
    b.style.margin = '0 8px';
    b.addEventListener('click', handleExport);
    return b;
  }

  function init() {
    sentinel.on('form > div > div:last-child > div', div => {
      if (!document.getElementById('simplified-markdown-exporter-button'))
        div.appendChild(createButton());
    });
  }

  init();
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址