导出Markdown格式的AI对话(ChatGPT / Gemini / Grok)

Export conversations from ChatGPT / Gemini / Grok (X AI) to clean Markdown with auto full-scroll, code fences, KaTeX, timestamps.

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            导出Markdown格式的AI对话(ChatGPT / Gemini / Grok)
// @namespace       https://github.com/YunAsimov
// @version         1.0.1
// @description     Export conversations from ChatGPT / Gemini / Grok (X AI) to clean Markdown with auto full-scroll, code fences, KaTeX, timestamps.
// @author          YunAsimov
// @license         MIT
// @homepageURL     https://github.com/YunAsimov/AI-Chat-Md-Export
// @source          https://github.com/YunAsimov/AI-Chat-Md-Export
// @supportURL      https://github.com/YunAsimov/AI-Chat-Md-Export/issues
// @icon            https://chat.openai.com/favicon.ico
// @match           https://chat.openai.com/*
// @match           https://chatgpt.com/*
// @match           https://poe.com/*
// @match           https://gemini.google.com/*
// @match           https://ai.google.com/*
// @match           https://*.google.com/chat/*
// @match           https://x.com/i/grok*
// @match           https://x.com/*
// @match           https://grok.x.ai/*
// @run-at          document_idle
// @grant           GM_setClipboard
// @grant           GM_registerMenuCommand
// @grant           GM_addStyle
// ==/UserScript==

(function () {
	'use strict';

    if (typeof trustedTypes !== 'undefined' && trustedTypes.createPolicy) {
        try { trustedTypes.createPolicy('default', { createHTML: (s) => s, createScriptURL: (s) => s, createScript: (s) => s }); } catch (e) {}
    }

    const config = {
        LONG_LOAD_DELAY: 5000,
        SCROLL_JIGGLES: 4,
        MAX_SCROLL_TRIES: 300,
    };

	const CommonUtil = {
	  addStyle: function(style) { GM_addStyle(style); },
	  createElement: function(tag, options = {}) {
	    const element = document.createElement(tag);
	    if (options.text) { element.textContent = options.text; }
	    if (options.html) { element.innerHTML = options.html; }
	    if (options.style) { Object.assign(element.style, options.style); }
	    if (options.className) { element.className = options.className; }
	    if (options.attributes) { for (let [key, value] of Object.entries(options.attributes)) { element.setAttribute(key, value); } }
	    if (options.childrens) { options.childrens.forEach((child) => { element.appendChild(child); });}
	    return element;
	  }
	};

	const HtmlToMarkdown = {
      to: function(html, platform) {
	    const parser = new DOMParser();
	    const doc = parser.parseFromString(html, "text/html");
	    const isGemini = platform === "gemini";
	    if (!isGemini) {
	      doc.querySelectorAll("span.katex-html").forEach((element) => element.remove());
	    }
	    doc.querySelectorAll("mrow").forEach((mrow) => mrow.remove());
	    doc.querySelectorAll('annotation[encoding="application/x-tex"]').forEach((element) => {
	      if (element.closest(".katex-display")) {
	        const latex = element.textContent;
	        const trimmedLatex = latex.trim();
	        element.replaceWith(`\n$$\n${trimmedLatex}\n$$\n`);
	      } else {
	        const latex = element.textContent;
	        const trimmedLatex = latex.trim();
	        element.replaceWith(`$${trimmedLatex}$`);
	      }
	    });
	    doc.querySelectorAll("strong, b").forEach((bold) => {
	      const markdownBold = `**${bold.textContent}**`;
	      bold.parentNode.replaceChild(document.createTextNode(markdownBold), bold);
	    });
	    doc.querySelectorAll("em, i").forEach((italic) => {
	      const markdownItalic = `*${italic.textContent}*`;
	      italic.parentNode.replaceChild(document.createTextNode(markdownItalic), italic);
	    });
	    doc.querySelectorAll("p code").forEach((code) => {
	      const markdownCode = `\`${code.textContent}\``;
	      code.parentNode.replaceChild(document.createTextNode(markdownCode), code);
	    });
	    doc.querySelectorAll("a").forEach((link) => {
	      const markdownLink = `[${link.textContent}](${link.href})`;
	      link.parentNode.replaceChild(document.createTextNode(markdownLink), link);
	    });
	    doc.querySelectorAll("img").forEach((img) => {
	      const markdownImage = `![${img.alt}](${img.src})`;
	      img.parentNode.replaceChild(document.createTextNode(markdownImage), img);
	    });
	    if (platform === "chatGPT") {
	      doc.querySelectorAll("pre").forEach((pre) => {
	        const codeType = pre.querySelector("div > div:first-child")?.textContent || "";
	        const markdownCode = pre.querySelector("div > div:nth-child(3) > code")?.textContent || pre.textContent;
	        pre.innerHTML = `\n\`\`\`${codeType}\n${markdownCode}\n\`\`\``;
	      });
	    } else if (platform === "grok") {
	      doc.querySelectorAll("div.not-prose").forEach((div) => {
	        const codeType = div.querySelector("div > div > span")?.textContent || "";
	        const markdownCode = div.querySelector("div > div:nth-child(3) > code")?.textContent || div.textContent;
	        div.innerHTML = `\n\`\`\`${codeType}\n${markdownCode}\n\`\`\``;
	      });
	    } else if (isGemini) {
	      doc.querySelectorAll("code-block").forEach((div) => {
	        const codeType = div.querySelector("div > div > span")?.textContent || "";
	        const markdownCode = div.querySelector("div > div:nth-child(2) > div > pre")?.textContent || div.textContent;
	        div.innerHTML = `\n\`\`\`${codeType}\n${markdownCode}\n\`\`\``;
	      });
	    }
	    doc.querySelectorAll("ul").forEach((ul) => {
	      let markdown2 = "";
	      ul.querySelectorAll(":scope > li").forEach((li) => {
	        markdown2 += `- ${li.textContent.trim()}\n`;
	      });
	      ul.parentNode.replaceChild(document.createTextNode("\n" + markdown2.trim()), ul);
	    });
	    doc.querySelectorAll("ol").forEach((ol) => {
	      let markdown2 = "";
	      ol.querySelectorAll(":scope > li").forEach((li, index) => {
	        markdown2 += `${index + 1}. ${li.textContent.trim()}\n`;
	      });
	      ol.parentNode.replaceChild(document.createTextNode("\n" + markdown2.trim()), ol);
	    });
	    for (let i = 1; i <= 6; i++) {
	      doc.querySelectorAll(`h${i}`).forEach((header) => {
	        const markdownHeader = `\n${"#".repeat(i)} ${header.textContent}\n`;
	        header.parentNode.replaceChild(document.createTextNode(markdownHeader), header);
	      });
	    }
	    doc.querySelectorAll("p").forEach((p) => {
	      const markdownParagraph = "\n" + p.textContent + "\n";
	      p.parentNode.replaceChild(document.createTextNode(markdownParagraph), p);
	    });
	    doc.querySelectorAll("table").forEach((table) => {
	      let markdown2 = "";
	      table.querySelectorAll("thead tr").forEach((tr) => {
	        tr.querySelectorAll("th").forEach((th) => { markdown2 += `| ${th.textContent} `; });
	        markdown2 += "|\n";
	        tr.querySelectorAll("th").forEach(() => { markdown2 += "| ---- "; });
	        markdown2 += "|\n";
	      });
	      table.querySelectorAll("tbody tr").forEach((tr) => {
	        tr.querySelectorAll("td").forEach((td) => { markdown2 += `| ${td.textContent} `; });
	        markdown2 += "|\n";
	      });
	      table.parentNode.replaceChild(document.createTextNode("\n" + markdown2.trim() + "\n"), table);
	    });
	    let markdown = doc.body.innerHTML.replace(/<[^>]*>/g, "");
	    return markdown.replaceAll(/- &gt;/g,"- $\\gt$").replaceAll(/>/g,">").replaceAll(/</g,"<").replaceAll(/≥/g,">=").replaceAll(/≤/g,"<=").replaceAll(/≠/g,"\\neq").trim()
      }
    };

	const Download = {
      start: function(data, filename, type) {
	    var file = new Blob([data], { type });
	    if (window.navigator.msSaveOrOpenBlob) {
	      window.navigator.msSaveOrOpenBlob(file, filename);
	    } else {
	      var a = document.createElement("a"), url = URL.createObjectURL(file);
	      a.href = url; a.download = filename;
	      document.body.appendChild(a); a.click();
	      setTimeout(function() { document.body.removeChild(a); window.URL.revokeObjectURL(url); }, 0);
	    }
	  }
    };

	const Chat = {
      findScrollableContainer: function(log) {
          const messageSelectors = 'user-query, model-response, div[data-message-id]';
          const firstMessage = document.querySelector(messageSelectors);
          if (!firstMessage) {
              log('Could not find a message element to start search from.');
              return null;
          }

          let parent = firstMessage.parentElement;
          while (parent && parent !== document.body) {
              if (parent.scrollHeight > parent.clientHeight) {
                  log(`Found scrollable container: <${parent.tagName.toLowerCase()}.${parent.className}>`);
                  return parent;
              }
              parent = parent.parentElement;
          }
          log('No specific scroll container found, will attempt to scroll window.');
          return window;
      },

      scrollToTopAndLoadAll: async function() {
        const log = (message) => console.log(`%c[Export script] ${message}`, 'color: #007bff; font-weight: bold;');
        const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));

        const scrollContainer = this.findScrollableContainer(log);
        if (!scrollContainer) return;

        const getMessageCount = () => document.querySelectorAll('user-query, model-response, div[data-message-id]').length;

        let tries = 0;
        log('Starting aggressive & patient scroll to load entire conversation...');
        while (tries < config.MAX_SCROLL_TRIES) {
            const lastMessageCount = getMessageCount();

            log(`Scrolling up aggressively (Attempt #${tries + 1})...`);
            for (let i = 0; i < config.SCROLL_JIGGLES; i++) {
                scrollContainer.scrollTo({ top: 0 });
                await delay(50);
            }

            log(`Waiting ${config.LONG_LOAD_DELAY}ms for content to load...`);
            await delay(config.LONG_LOAD_DELAY);

            const currentMessageCount = getMessageCount();

            if (currentMessageCount === lastMessageCount && lastMessageCount > 0) {
                log(`Message count is stable at ${currentMessageCount}. Assuming all content is loaded.`);
                break;
            } else {
                 log(`New content loaded. Count: ${lastMessageCount} -> ${currentMessageCount}. Will try again.`);
            }
            tries++;
        }

        if (tries >= config.MAX_SCROLL_TRIES) { log('Reached max scroll tries. Proceeding with export.'); }
        log('Auto-scroll finished.');
    },

	  sanitizeFilename: function(input, replacement = "_") {
	    const illegalRe = /[\/\\\?\%\*\:\|"<>\.]/g;
	    const controlRe = /[\x00-\x1f\x80-\x9f]/g;
	    const reservedRe = /^\.+$/;
	    const windowsReservedRe = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
	    let name = (input || "").replace(illegalRe, replacement).replace(controlRe, replacement).replace(/\s+/g, " ").trim();
	    if (reservedRe.test(name)) { name = "file"; }
	    if (windowsReservedRe.test(name)) { name = `file_${name}`; }
	    return name || "untitled";
	  },
      getConversationElements: function() {
	    const currentUrl = window.location.href;
	    const result = []; let platform = ""; let title = "";
	    if (currentUrl.includes("openai.com") || currentUrl.includes("chatgpt.com")) {
	      platform = "chatGPT";
          title = document.querySelector('div[class*="react-scroll-to-bottom"] h1')?.textContent || document.querySelector('#history a[data-active]')?.textContent || document.title;
	      result.push(...document.querySelectorAll("div[data-message-id]"));
	    } else if (currentUrl.includes("grok.com")) {
	      platform = "grok";
          title = document.title;
	      result.push(...document.querySelectorAll("div.message-bubble"));
	    } else if (currentUrl.includes("gemini.google.com")) {
	      platform = "gemini";
          title = document.querySelector('conversations-list div.selected')?.textContent || document.querySelector('div.conversation-title')?.textContent || document.title;
	      const userQueries = document.querySelectorAll("user-query");
	      const modelResponses = document.querySelectorAll("model-response");
	      for (let i = 0; i < userQueries.length; i++) {
	        result.push(userQueries[i], modelResponses[i] || userQueries[i]);
	      }
	    }
	    return { result, platform, title };
	  },
	  exportChatAsMarkdown: async function() {
        await this.scrollToTopAndLoadAll();
	    let markdownContent = "";
	    const { result, platform, title } = this.getConversationElements();
        const now = new Date();
        const timestamp = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,"0")}-${String(now.getDate()).padStart(2,"0")}_${String(now.getHours()).padStart(2,"0")}-${String(now.getMinutes()).padStart(2,"0")}-${String(now.getSeconds()).padStart(2,"0")}`;
        const filename = `${timestamp}_${this.sanitizeFilename(title) || "chat_export"}.md`;
	    for (let i = 0; i < result.length; i += 2) {
	      if (!result[i + 1]) break;
	      let userText = result[i].textContent.trim();
	      let answerHtml = result[i + 1].innerHTML.trim();
	      userText = HtmlToMarkdown.to(userText, platform);
	      answerHtml = HtmlToMarkdown.to(answerHtml, platform);
	      markdownContent += `\n# Q:\n${userText}\n# A:\n${answerHtml}`;
	    }
	    markdownContent = markdownContent.replace(/&amp;/g, "&");
	    if (markdownContent.trim()) {
	      Download.start(markdownContent.trim(), filename, "text/markdown");
	    } else {
            alert('Export failed: No conversation content was found. Please check the browser console (F12) for error messages.');
        }
	  }
	};

	const css_248z = `
        .chat-gpt-document-block {
            background-color: var(--gm-background, #FFFFFF); color: var(--gm-text-color, #000000);
            align-items: center; border: 1px solid #9c9c9c; border-radius: 35px;
            cursor: pointer; display: flex; font-size: 14px; justify-content: center;
            left: 50%; padding: 6px 16px; position: fixed; top: 10px;
            transform: translateX(-50%); z-index: 99999999999 !important;
            box-shadow: 0 1px 3px rgba(0,0,0,0.1);
        }
        @media (prefers-color-scheme: dark) {
            .chat-gpt-document-block {
                background-color: var(--gm-background-dark, #2d2d2d); color: var(--gm-text-color-dark, #E0E0E0);
                border-color: #555;
            }
        }
        .chat-gpt-document-icon-sm { margin-right: 8px; color: currentColor; width: 16px; height: 16px; }
        .chat-gpt-document-btn-content { align-items: center; display: flex; }
        .chat-gpt-document-block.loading { cursor: not-allowed; background-color: #f0f0f0; opacity: 0.7; color: #555; }
    `;

	const Export = {
	  addStyle: function() { CommonUtil.addStyle(css_248z); },
	  createSvgIcon: function() {
	    const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
	    svg.setAttribute("class", "chat-gpt-document-icon-sm");
	    svg.setAttribute("viewBox", "0 0 24 24");
	    svg.setAttribute("fill", "none");
	    svg.setAttribute("stroke-width", "1.5");
	    svg.setAttribute("stroke", "currentColor");
	    const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
	    path.setAttribute("stroke-linecap", "round");
	    path.setAttribute("stroke-linejoin", "round");
	    path.setAttribute("d", "M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m.75 12 3 3m0 0 3-3m-3 3v-6m-1.5-9H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z");
	    svg.appendChild(path);
	    return svg;
	  },
      generateHtml: function() {
        const originalButtonText = 'Save Conversation';
        const buttonTextElement = CommonUtil.createElement("div", { className:"chat-gpt-document-btn-content", text: originalButtonText });
        const outerDiv = CommonUtil.createElement("div", { className:"chat-gpt-document-block", childrens:[this.createSvgIcon(), buttonTextElement] });
        (document.body||document.documentElement).appendChild(outerDiv);
        outerDiv.addEventListener("click",async function(){
            if(outerDiv.classList.contains("loading")) return;
            outerDiv.classList.add("loading"); buttonTextElement.textContent="Loading full chat...";
            try { await Chat.exportChatAsMarkdown() }
            catch(e){ console.error("Export script error:",e); alert("An error occurred during export. Check the console (F12) for details.") }
            finally{ outerDiv.classList.remove("loading"); buttonTextElement.textContent=originalButtonText }
        });
      },
	  start: function(){ this.addStyle(); this.generateHtml(); }
	};

    const run = () => Export.start();
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', run);
    } else {
        run();
    }

}());