Easy ChatGPT Markdown & JSON Exporter

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

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

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