ChatGPT Copy as Markdown with MathJax Support

Copy the chatGPT Q&A content as a markdown text, with MathJax Render Support, you can use this together with 'OpenAI-ChatGPT LaTeX Auto Render (with MathJax V2)' that adds support for math render, based on 'chatGPT Markdown' by 赵巍໖.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         ChatGPT Copy as Markdown with MathJax Support
// @name:zh-CN  支持数学公式的ChatGPT Markdown一键复制
// @namespace    http://tampermonkey.net/
// @version      0.6
// @description  Copy the chatGPT Q&A content as a markdown text, with MathJax Render Support, you can use this together with 'OpenAI-ChatGPT LaTeX Auto Render (with MathJax V2)' that adds support for math render, based on 'chatGPT Markdown' by 赵巍໖.
// @description:zh-cn  将chatGPT问答内容复制成markdown文本,并支持MathJax渲染内容导出,与'OpenAI-ChatGPT LaTeX Auto Render(with MathJax V2)'一起使用可以渲染公式, 基于赵巍໖的'chatGPT Markdown'。
// @license MIT
// @author       jbji
// @match        https://chat.openai.com/chat
// @match        https://chat.openai.com/chat/*
// @icon         https://chat.openai.com/favicon-32x32.png
// @grant        none
// ==/UserScript==

(function () {
    'use strict';
    var mathFixEnabled = true;
    function toMarkdown() {
        var main = document.querySelector("main");
        var article = main.querySelector("div > div > div > div");
        var chatBlocks = Array.from(article.children)
            .filter(v => v.getAttribute("class").indexOf("border") >= 0);
        // for chatgpt plus
        if (chatBlocks.length > 0 && chatBlocks[0].classList.contains("items-center")) {
            chatBlocks.shift(); // remove first element from array
        }
        var new_replacements = [
            //['\\', '\\\\', 'backslash'], //Don't need this any more cause it would be checked.
            ['`', '\\`', 'codeblocks'],
            ['*', '\\*', 'asterisk'],
            ['_', '\\_', 'underscores'],
            ['{', '\\{', 'crulybraces'],
            ['}', '\\}', 'crulybraces'],
            ['[', '\\[', 'square brackets'],
            [']', '\\]', 'square brackets'],
            ['(', '\\(', 'parentheses'],
            [')', '\\)', 'parentheses'],
            ['#', '\\#', 'number signs'],
            ['+', '\\+', 'plussign'],
            ['-', '\\-', 'hyphen'],
            ['.', '\\.', 'dot'],
            ['!', '\\!', 'exclamation mark'],
            ['>', '\\>', 'angle brackets']
        ];

        // A State Machine used to match string and do replacement
        function replacementSkippingMath(string, char_pattern, replacement) {
            var inEquationState = 0; // 0:not in equation, 1:inline equation expecting $, 2: line euqation expecting $$
            var result = "";
            for (let i = 0; i < string.length; i++) {
                if(string[i] == '\\'){
                    result += string[i];
                    if (i+1 < string.length) result += string[i+1];
                    i++; // one more add to skip escaped char
                    continue;
                }
                switch(inEquationState){
                    case 1:
                        result += string[i];
                        if(string[i] === '$'){
                            inEquationState = 0; //simply exit and don't do further check
                            continue;
                        }
                        break;
                    case 2:
                        result += string[i];
                        if(string[i] === '$'){
                            if (i+1 < string.length && string[i+1] === '$'){ //matched $$
                                result += '$';
                                inEquationState = 0;
                                i++; // one more add
                            }
                            //else is unexpected behavior
                            continue;
                        }
                        break;
                    default:
                        if(string[i] === '$'){
                            if (i+1 < string.length && string[i+1] === '$'){//matched $$
                                result += '$$';
                                inEquationState = 2;
                                i++; // one more add
                            }else{ //matched $
                                result += '$';
                                inEquationState = 1;
                            }
                            continue;
                        }else if(string[i] === char_pattern[0]){ //do replacement
                            result += replacement;
                        }else{
                            result += string[i];
                        }
                }
            }

            return result;
        }

        function markdownEscape(string, skips) {
            skips = skips || []
            //reduce function applied the function in the first with the second as input
            //this applies across the array with the first element inside as the initial 2nd param for the reduce func.
            return new_replacements.reduce(function (string, replacement) {
                var name = replacement[2]
                if (name && skips.indexOf(name) !== -1) {
                    return string;
                } else {
                    return replacementSkippingMath(string, replacement[0], replacement[1]);
                }
            }, string)
        }

        function replaceInnerNode(element) {
            if (element.outerHTML) {
                var htmlBak = element.outerHTML;
                if(mathFixEnabled){
                    //replace mathjax stuff
                    var mathjaxBeginRegExp = /(<span class="MathJax_Preview".*?)<scr/s; //this is lazy
                    var match = mathjaxBeginRegExp.exec(htmlBak);
                    while(match){
                        htmlBak = htmlBak.replace(match[1], '');
                        //repalace math equations
                        var latexMath;
                        //match new line equations first
                        var latexMathNLRegExp = /<script type="math\/tex; mode=display" id="MathJax-Element-\d+">(.*?)<\/script>/s;
                        match = latexMathNLRegExp.exec(htmlBak);
                        if(match){
                            latexMath = "$$" + match[1] + "$$";
                            htmlBak = htmlBak.replace(match[0], latexMath);
                        }else{
                            //then inline equations
                            var latexMathRegExp = /<script type="math\/tex" id="MathJax-Element-\d+">(.*?)<\/script>/s;
                            match = latexMathRegExp.exec(htmlBak);
                            if(match){
                                latexMath = "$" + match[1] + "$";
                                htmlBak = htmlBak.replace(match[0], latexMath);
                            }
                        }
                        match = mathjaxBeginRegExp.exec(htmlBak);
                    }
                }

                var parser = new DOMParser();
                //default code block replacement
                var nextDomString = htmlBak.replace(/<code>([\w\s-]*)<\/code>/g, (match) => {
                    var doc = parser.parseFromString(match, "text/html");
                    return "`" + (doc.body.textContent) + "`";
                });
                return parser.parseFromString(nextDomString, "text/html").body.children[0];
            }
            return element;
        }

        var elementMap = {
            "P": function (element, result) {
                let p = replaceInnerNode(element);
                result += markdownEscape(p.textContent, ["codeblocks", "number signs"]);
                result += `\n\n`;
                return result;
            },
            //this should be unordered!
            "UL": function (element, result) {
                let ul = replaceInnerNode(element);
                Array.from(ul.querySelectorAll("li")).forEach((li, index) => {
                    result += `- ${markdownEscape(li.textContent, ["codeblocks", "number signs"])}`;
                    result += `\n`;
                });
                result += `\n\n`;
                return result;
            },
            "OL": function (element, result) {
                let ol = replaceInnerNode(element);
                var olStart = parseInt(ol.getAttribute("start") || "1"); //bug fix thanks to original author
                Array.from(ol.querySelectorAll("li")).forEach((li, index) => {
                    result += `${index + olStart}. ${markdownEscape(li.textContent, ["codeblocks", "number signs"])}`;
                    result += `\n`;
                });
                result += `\n\n`;
                return result;
            },
            "PRE": function (element, result) {
                var codeBlocks = element.querySelectorAll("code");
                //first get class name
                var regex = /^language-/;
                var codeType = '';
                for(var c of codeBlocks){
                    var classNameStr = c.className.split(' ')[2];
                    if (regex.test(classNameStr)){
                        codeType = classNameStr.substr(9);
                    }
                }
                //then generate the markdown codeblock
                result += "```" + codeType + "\n";
                Array.from(codeBlocks).forEach(block => {
                    result += `${block.textContent}`;
                });
                result += "```\n";
                result += `\n\n`;
                return result;
            }
        };
        var TEXT_BLOCKS = Object.keys(elementMap);

        var mdContent = chatBlocks.reduce((result, nextBlock, i) => {
            if (i % 2 === 0) { // title
                let p = replaceInnerNode(nextBlock);
                let text = markdownEscape(p.textContent, ["codeblocks", "number signs"]);
                let lines = text.split('\n');
                for (let j = 0; j < lines.length; j++) {
                    result += `> ${lines[j]}\n`;
                }
                result += '\n';
            }else{
                //try to parse the block
                var iterator = document.createNodeIterator(
                    nextBlock,
                    NodeFilter.SHOW_ELEMENT,
                    {
                        acceptNode: element => TEXT_BLOCKS.indexOf(element.tagName.toUpperCase()) >= 0
                    },
                    false,
                );
                let next = iterator.nextNode();
                while (next) {
                    result = elementMap[next.tagName.toUpperCase()](next, result);
                    next = iterator.nextNode();
                }
            }
            return result;
        }, "");
        return mdContent;
    }
    //for copy button
    //var copyHtml = `<div id="__copy__" style="cursor:pointer;position: fixed;bottom: 210px;left: 20px;width: 100px;height: 35px;background: #333333;border: 1px solid #555555;border-radius: 5px;color: white;display: flex;justify-content: center;align-items: center;transition: all 0.2s ease-in-out;"><span>Copy .md</span></div>`;
    // for copy function
    //var copyElement = document.createElement("div");
    //document.body.appendChild(copyElement);
    //copyElement.outerHTML = copyHtml;

    // listen and add element
    // select the body element
    var body = document.querySelector('body');

    // create a new MutationObserver instance
    var observer = new MutationObserver(function(mutations) {
        // iterate over the mutations array
        mutations.forEach(function(mutation) {
            // if a div element was added to the body
            if (mutation.type === 'childList'){
                //TypeError: undefined is not an object (evaluating 'mutation.addedNodes[0].nodeName')
                if(mutation.addedNodes[0] && mutation.addedNodes[0].nodeName === 'DIV'
                   && mutation.addedNodes[0].id === 'headlessui-portal-root') {
                    // do something
                    setTimeout(function(){var navListHidden = document.querySelector('#headlessui-portal-root').querySelector('div > div > div > div.flex > div.flex > div.flex > nav');
                                          addCopyButton(navListHidden);},300);
                }
            }
        });
    });

    // set the observer options
    var options = {
        childList: true, // listen for changes to child nodes
        subtree: true // listen for changes in all descendant nodes
    };

    // start observing the body element
    observer.observe(body, options);

    function addCopyButton(navigationList) {
        if(navigationList.childNodes[2].text == 'Copy .md'){ //avoid duplicate
            return;
        }
        var date = new Date();
        var time = date.getTime();
        var id = "__copy__" + time;
        var copyButton = document.createElement("a");
        copyButton.id = id;
        copyButton.innerHTML = '<svg stroke="currentColor" fill="none" stroke-width="2" viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" class="w-4 h-4" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path><rect x="8" y="2" width="8" height="4" rx="1" ry="1"></rect></svg>'
            +'<span>Copy .md</span>';
        copyButton.className = 'flex py-3 px-3 items-center gap-3 rounded-md hover:bg-gray-500/10 transition-colors duration-200 text-white cursor-pointer text-sm';
        navigationList.insertBefore(copyButton, navigationList.childNodes[2]);

        //for anchor
        var copyAnchor = document.getElementById(id);
        copyAnchor.addEventListener("click", () => {
            // Get the `span` element inside the `div`
            let span = copyAnchor.querySelector("span");

            // Change the text of the `span` to "Done"
            span.innerText = "Copied!";

            // Use `setTimeout` to change the text back to its original value after 3 seconds
            setTimeout(() => {
                span.innerText = "Copy .md";
            }, 1000);

            // Perform the rest of the original code
            navigator.clipboard.writeText(toMarkdown()).then(() => {
                //alert("done");
            });
        });
    }
    //default case
    setTimeout(function(){
                var navList = document.querySelector('#__next').querySelector("div > div.hidden > div > div > nav");
                addCopyButton(navList);
    },600);
    //ensure next conversation works.
    setTimeout(function(){
        var nextConversationObserver = new MutationObserver(function(mutations) {
            mutations.forEach(function(mutation) {
                //console.log(" Mutation detected. Trying to add copy button...");
            });
            setTimeout(function(){
                var navList = document.querySelector('#__next').querySelector("div > div.hidden > div > div > nav");
                addCopyButton(navList);
            },400);
        });
        //console.log("Trying to setup observation...");
        nextConversationObserver.observe(document.querySelector("#__next"), { childList: true });
        //console.log("Over.");
    },1100);
    /**
    window.addEventListener("load", function (event) {
        // Your code here, for example:
        console.log("Page loaded");
    });
    **/
})();