Easy ChatGPT Markdown & JSON Exporter

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

// ==UserScript==
// @name         Easy ChatGPT Markdown & JSON Exporter
// @namespace    https://github.com/NoahTheGinger/Userscripts/
// @version      1.6.0
// @description  Export ChatGPT conversations (incl. thoughts, tool calls & custom instructions) to clean Markdown or raw JSON.
// @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, and button logic fixed by Grok 4 via API. JSON export feature added by Claude 4 Sonnet as the Cursor Agent.
// @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, contentType = "text/markdown") {
      const url = URL.createObjectURL(
        new Blob([data], { type: `${contentType};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 actions */
    async function exportToMarkdown() {
      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, "text/markdown");
      } catch (e) {
        console.error(e);
        alert("Markdown export failed – see console.");
      } finally {
        if (btn) {
          btn.textContent = "Export";
          btn.disabled = false;
        }
      }
    }
 
    async function exportToJSON() {
      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 jsonContent = JSON.stringify(raw, null, 2);
        downloadFile(`${sanitizeFilename(raw.title)}.json`, jsonContent, "application/json");
      } catch (e) {
        console.error(e);
        alert("JSON export failed – see console.");
      } finally {
        if (btn) {
          btn.textContent = "Export";
          btn.disabled = false;
        }
      }
    }
 
    function showExportDialog() {
      // Create modal dialog
      const modal = document.createElement("div");
      modal.style.cssText = `
        position: fixed;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
        background: rgba(0, 0, 0, 0.5);
        z-index: 10000;
        display: flex;
        align-items: center;
        justify-content: center;
      `;
 
      const dialog = document.createElement("div");
      dialog.style.cssText = `
        background: var(--surface-primary, white);
        border-radius: 8px;
        padding: 24px;
        min-width: 300px;
        box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
        color: var(--text-primary, #333);
        border: 1px solid var(--border-light, #e5e7eb);
      `;
 
      dialog.innerHTML = `
        <h3 style="margin: 0 0 16px 0; color: var(--text-primary, #333); font-size: 18px;">Choose Export Format</h3>
        <p style="margin: 0 0 20px 0; color: var(--text-secondary, #666); font-size: 14px;">Select the format you'd like to export this conversation in:</p>
        <div style="display: flex; gap: 12px; justify-content: flex-end;">
          <button id="export-markdown-btn" style="
            background: var(--accent-primary, #10a37f);
            color: white;
            border: none;
            border-radius: 6px;
            padding: 8px 16px;
            font-size: 14px;
            cursor: pointer;
            transition: background-color 0.2s;
          ">Markdown (.md)</button>
          <button id="export-json-btn" style="
            background: var(--accent-secondary, #2563eb);
            color: white;
            border: none;
            border-radius: 6px;
            padding: 8px 16px;
            font-size: 14px;
            cursor: pointer;
            transition: background-color 0.2s;
          ">JSON (.json)</button>
          <button id="export-cancel-btn" style="
            background: var(--bg-secondary, #6b7280);
            color: white;
            border: none;
            border-radius: 6px;
            padding: 8px 16px;
            font-size: 14px;
            cursor: pointer;
            transition: background-color 0.2s;
          ">Cancel</button>
        </div>
      `;
 
      modal.appendChild(dialog);
      document.body.appendChild(modal);
 
      // Add event listeners
      const markdownBtn = dialog.querySelector("#export-markdown-btn");
      const jsonBtn = dialog.querySelector("#export-json-btn");
      const cancelBtn = dialog.querySelector("#export-cancel-btn");
 
      markdownBtn.addEventListener("click", () => {
        document.body.removeChild(modal);
        exportToMarkdown();
      });
 
      jsonBtn.addEventListener("click", () => {
        document.body.removeChild(modal);
        exportToJSON();
      });
 
      cancelBtn.addEventListener("click", () => {
        document.body.removeChild(modal);
      });
 
      // Close on background click
      modal.addEventListener("click", (e) => {
        if (e.target === modal) {
          document.body.removeChild(modal);
        }
      });
 
      // Close on Escape key
      const handleEscape = (e) => {
        if (e.key === "Escape") {
          document.body.removeChild(modal);
          document.removeEventListener("keydown", handleEscape);
        }
      };
      document.addEventListener("keydown", handleEscape);
    }
 
    /* button */
    function createButton() {
      const b = document.createElement("button");
      b.id = "simplified-markdown-exporter-button";
      b.textContent = "Export";
      b.className = "btn relative btn-neutral rounded-md px-3 py-1.5 text-sm font-medium transition-colors duration-150";
      b.style.backgroundColor = "var(--bg-elevated-secondary)";
      b.style.border = "1px solid var(--border-light)";
      b.style.cursor = "pointer";
      b.style.display = "inline-flex";
      b.style.alignItems = "center";
      b.style.justifyContent = "center";
      b.style.lineHeight = "1.5";
      b.addEventListener("click", showExportDialog);
      return b;
    }
 
    function init() {
      // This selector targets the container for the buttons on the right side of the composer.
      sentinel.on("div[data-testid='composer-trailing-actions'] > .ms-auto", (buttonContainer) => {
        if (document.getElementById("simplified-markdown-exporter-button")) {
          return;
        }
 
        const newButton = createButton();
        // The first element in this container is the dictate button's span.
        const referenceNode = buttonContainer.firstChild;
 
        if (referenceNode) {
          // Insert our button before the first existing button.
          buttonContainer.insertBefore(newButton, referenceNode);
        } else {
          // Fallback if the container is somehow empty when found.
          buttonContainer.appendChild(newButton);
        }
      });
    }
 
    init();
  })();

QingJ © 2025

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