ChatGPT Markdown Export

Adds "Copy as Markdown" and "Export as Markdown" to the ChatGPT chat options menu (the 3-dots button in the top-right of a chat).

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         ChatGPT Markdown Export
// @namespace    https://greasyfork.org/en/users/1528865-blati
// @version      0.1.0
// @description  Adds "Copy as Markdown" and "Export as Markdown" to the ChatGPT chat options menu (the 3-dots button in the top-right of a chat).
// @author       Blati
// @license      MIT
// @icon         data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTYgMjU2Ij48cmVjdCB3aWR0aD0iMjU2IiBoZWlnaHQ9IjI1NiIgcng9IjQ4IiBmaWxsPSIjMTBhMzdmIi8+PGcgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMjQgNjQpIiBmaWxsPSIjZmZmIiBzdHJva2U9IiNmZmYiPjxyZWN0IHdpZHRoPSIxOTgiIGhlaWdodD0iMTE4IiB4PSI1IiB5PSI1IiByeT0iMTAiIHN0cm9rZS13aWR0aD0iMTAiIGZpbGw9Im5vbmUiLz48cGF0aCBkPSJNMzAgOThWMzBoMjBsMjAgMjUgMjAtMjVoMjB2NjhIOTBWNTlMNzAgODQgNTAgNTl2Mzl6TTE1NSA5OGwtMzAtMzNoMjBWMzBoMjB2MzVoMjB6IiBzdHJva2Utd2lkdGg9IjAiLz48L2c+PC9zdmc+
// @match        https://chatgpt.com/*
// @match        https://chat.openai.com/*
// @run-at       document-end
// @grant        GM_setClipboard
// @grant        GM_download
// @grant        GM_info
// ==/UserScript==

(function () {
    'use strict';

    // ------------------------------------------------------------------
    // Constants
    // ------------------------------------------------------------------

    const MENU_SELECTOR = '[role="menu"][data-radix-menu-content]';
    const CONV_MENU_PROBE = '[data-testid="delete-chat-menu-item"]';
    const TRIGGER_SELECTOR = '[data-testid="conversation-options-button"]';
    const INJECTED_MARK = 'data-md-export-injected';

    // Inline 20x20 stroke icons in Lucide style — matches ChatGPT's icon weight.
    const ICON_COPY = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`;
    const ICON_DOWNLOAD = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`;

    // ------------------------------------------------------------------
    // Auth + API
    // ------------------------------------------------------------------

    let cachedToken = null;
    let cachedTokenAt = 0;
    const TOKEN_TTL_MS = 5 * 60 * 1000;

    async function getAccessToken() {
        const now = Date.now();
        if (cachedToken && now - cachedTokenAt < TOKEN_TTL_MS) return cachedToken;
        const r = await fetch('/api/auth/session', { credentials: 'same-origin' });
        if (!r.ok) throw new Error(`auth/session failed: ${r.status}`);
        const j = await r.json();
        if (!j.accessToken) throw new Error('no accessToken in session response (not signed in?)');
        cachedToken = j.accessToken;
        cachedTokenAt = now;
        return cachedToken;
    }

    async function fetchConversation(conversationId) {
        const token = await getAccessToken();
        const r = await fetch(`/backend-api/conversation/${conversationId}`, {
            headers: { Authorization: `Bearer ${token}` },
            credentials: 'same-origin',
        });
        if (!r.ok) throw new Error(`conversation fetch failed: ${r.status}`);
        return r.json();
    }

    function getCurrentConversationId() {
        const m = location.pathname.match(/^\/c\/([0-9a-f-]+)/i);
        return m ? m[1] : null;
    }

    // ------------------------------------------------------------------
    // Conversation tree -> visible thread
    // ------------------------------------------------------------------

    function buildVisibleChain(conv) {
        const chain = [];
        let cur = conv.current_node;
        const seen = new Set();
        while (cur && !seen.has(cur)) {
            seen.add(cur);
            const node = conv.mapping[cur];
            if (!node) break;
            chain.push(node);
            cur = node.parent;
        }
        chain.reverse();
        return chain;
    }

    // ------------------------------------------------------------------
    // Node -> markdown
    // ------------------------------------------------------------------

    function joinParts(parts) {
        if (!Array.isArray(parts)) return '';
        const out = [];
        for (const p of parts) {
            if (typeof p === 'string') {
                out.push(p);
            } else if (p && typeof p === 'object') {
                if (p.content_type === 'image_asset_pointer') {
                    const name = p.metadata?.dalle?.gen_id || p.asset_pointer || 'image';
                    out.push(`\n\n*[image: ${name}]*\n\n`);
                } else if (p.content_type === 'audio_transcription' && typeof p.text === 'string') {
                    out.push(p.text);
                } else if (typeof p.text === 'string') {
                    out.push(p.text);
                }
            }
        }
        return out.join('');
    }

    function renderCitations(meta) {
        const refs = [];
        const cites = Array.isArray(meta?.citations) ? meta.citations : [];
        for (const c of cites) {
            const m = c?.metadata || {};
            const url = m.url || m.extra?.cited_message_idx_url;
            const title = m.title || m.text || url;
            if (url) refs.push({ title: title || url, url });
        }
        const contentRefs = Array.isArray(meta?.content_references) ? meta.content_references : [];
        for (const cr of contentRefs) {
            const items = cr?.items || cr?.refs || [];
            for (const item of items) {
                const url = item?.url || item?.snippet_url;
                const title = item?.title || item?.snippet || url;
                if (url) refs.push({ title: title || url, url });
            }
            if (cr?.url) refs.push({ title: cr.title || cr.url, url: cr.url });
        }
        const seen = new Set();
        const unique = refs.filter(r => {
            if (seen.has(r.url)) return false;
            seen.add(r.url);
            return true;
        });
        if (!unique.length) return '';
        const lines = unique.map((r, i) => `${i + 1}. [${r.title}](${r.url})`);
        return `\n\n**Sources**\n\n${lines.join('\n')}\n`;
    }

    function nodeToMarkdown(node) {
        const msg = node?.message;
        if (!msg) return null;
        const role = msg.author?.role;
        if (role === 'system' || role === 'tool') return null;
        if (role === 'assistant' && msg.channel && msg.channel !== 'final') return null;

        const content = msg.content;
        if (!content) return null;
        const ctype = content.content_type;

        let body = '';
        if (ctype === 'text' || ctype === 'multimodal_text') {
            body = joinParts(content.parts);
        } else if (ctype === 'user_editable_context') {
            return null;
        } else {
            return null;
        }
        body = body.trim();
        if (!body) return null;

        const heading = role === 'user'
            ? '## User'
            : `## ChatGPT${msg.metadata?.model_slug ? ` (${msg.metadata.model_slug})` : ''}`;

        const citations = role === 'assistant' ? renderCitations(msg.metadata) : '';
        return `${heading}\n\n${body}${citations}`;
    }

    // ------------------------------------------------------------------
    // Build full export
    // ------------------------------------------------------------------

    function isoDate(epochSec) {
        if (!epochSec) return '';
        try { return new Date(epochSec * 1000).toISOString(); } catch { return ''; }
    }

    function yamlEscape(s) {
        return String(s ?? '').replace(/\\/g, '\\\\').replace(/"/g, '\\"');
    }

    function buildExport(conv) {
        const chain = buildVisibleChain(conv);
        const turns = [];
        for (const node of chain) {
            const md = nodeToMarkdown(node);
            if (md) turns.push(md);
        }
        const frontmatter = [
            '---',
            `title: "${yamlEscape(conv.title || 'ChatGPT conversation')}"`,
            `conversation_id: ${conv.conversation_id || ''}`,
            `created: ${isoDate(conv.create_time)}`,
            `exported: ${new Date().toISOString()}`,
            `model: ${conv.default_model_slug || ''}`,
            `url: https://chatgpt.com/c/${conv.conversation_id || ''}`,
            '---',
            '',
        ].join('\n');
        const title = conv.title ? `# ${conv.title}\n\n` : '';
        return frontmatter + title + turns.join('\n\n');
    }

    function sanitizeFilename(s) {
        return String(s || 'chatgpt-conversation')
            .replace(/[\u0000-\u001f\u007f]/g, '')
            .replace(/[<>:"/\\|?*]+/g, '-')
            .replace(/\s+/g, ' ')
            .trim()
            .slice(0, 120) || 'chatgpt-conversation';
    }

    function dateStamp() {
        const d = new Date();
        const pad = n => String(n).padStart(2, '0');
        return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
    }

    // ------------------------------------------------------------------
    // Actions
    // ------------------------------------------------------------------

    async function withConversation(action) {
        const id = getCurrentConversationId();
        if (!id) {
            toast('Not in a chat — open a conversation first.', 'error');
            return;
        }
        try {
            const conv = await fetchConversation(id);
            const md = buildExport(conv);
            await action(md, conv);
        } catch (e) {
            console.error('[md-export]', e);
            toast(`Export failed: ${e.message || e}`, 'error');
        }
    }

    async function copyAsMarkdown() {
        await withConversation(async (md) => {
            if (typeof GM_setClipboard === 'function') {
                GM_setClipboard(md, { type: 'text', mimetype: 'text/plain' });
                toast('Copied conversation as Markdown.');
                return;
            }
            try {
                await navigator.clipboard.writeText(md);
                toast('Copied conversation as Markdown.');
            } catch (e) {
                const ta = document.createElement('textarea');
                ta.value = md;
                ta.style.position = 'fixed';
                ta.style.opacity = '0';
                document.body.appendChild(ta);
                ta.select();
                document.execCommand('copy');
                ta.remove();
                toast('Copied conversation as Markdown.');
            }
        });
    }

    async function downloadAsMarkdown() {
        await withConversation(async (md, conv) => {
            const filename = `${sanitizeFilename(conv.title)}-${dateStamp()}.md`;
            const blob = new Blob([md], { type: 'text/markdown;charset=utf-8' });
            const url = URL.createObjectURL(blob);
            if (typeof GM_download === 'function') {
                GM_download({
                    url,
                    name: filename,
                    saveAs: false,
                    onload: () => { setTimeout(() => URL.revokeObjectURL(url), 1000); toast(`Saved ${filename}`); },
                    onerror: () => { fallbackAnchor(url, filename); },
                });
                return;
            }
            fallbackAnchor(url, filename);
            toast(`Saved ${filename}`);
        });

        function fallbackAnchor(url, filename) {
            const a = document.createElement('a');
            a.href = url;
            a.download = filename;
            document.body.appendChild(a);
            a.click();
            a.remove();
            setTimeout(() => URL.revokeObjectURL(url), 1000);
        }
    }

    // ------------------------------------------------------------------
    // Menu injection
    // ------------------------------------------------------------------

    function buildMenuItem(label, iconSVG, onActivate) {
        const item = document.createElement('div');
        item.setAttribute('role', 'menuitem');
        item.setAttribute('tabindex', '0');
        item.className = 'group __menu-item hoverable gap-1.5';
        item.setAttribute('data-orientation', 'vertical');

        const iconWrap = document.createElement('div');
        iconWrap.className = 'flex items-center justify-center [opacity:var(--menu-item-icon-opacity,1)] icon';
        iconWrap.innerHTML = iconSVG;
        item.appendChild(iconWrap);
        item.appendChild(document.createTextNode(label));

        const fire = (e) => {
            e.preventDefault();
            e.stopPropagation();
            // Close the Radix menu by sending Escape — keeps focus management consistent.
            document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
            // Defer the action so the menu can unmount cleanly first.
            setTimeout(() => { onActivate(); }, 0);
        };
        item.addEventListener('click', fire);
        item.addEventListener('keydown', (e) => {
            if (e.key === 'Enter' || e.key === ' ') fire(e);
        });
        return item;
    }

    function injectIntoMenu(menu) {
        if (menu.hasAttribute(INJECTED_MARK)) return;
        if (!menu.querySelector(CONV_MENU_PROBE)) return; // wrong menu
        if (!getCurrentConversationId()) return; // not in a chat
        menu.setAttribute(INJECTED_MARK, '1');

        const groups = menu.querySelectorAll(':scope > [role="group"]');
        if (!groups.length) return;
        const reference = groups[0].querySelector('[role="group"]') || groups[0]; // safety
        const groupClass = groups[groups.length - 1].className; // last group has the divider classes we want

        const group = document.createElement('div');
        group.setAttribute('role', 'group');
        group.className = groupClass;

        group.appendChild(buildMenuItem('Copy as Markdown', ICON_COPY, copyAsMarkdown));
        group.appendChild(buildMenuItem('Export as Markdown', ICON_DOWNLOAD, downloadAsMarkdown));

        // Insert before the destructive group (the one containing Delete).
        const deleteItem = menu.querySelector('[data-testid="delete-chat-menu-item"]');
        const destructiveGroup = deleteItem ? deleteItem.closest('[role="group"]') : null;
        if (destructiveGroup && destructiveGroup.parentNode === menu) {
            menu.insertBefore(group, destructiveGroup);
        } else {
            menu.appendChild(group);
        }
    }

    const menuObserver = new MutationObserver((records) => {
        for (const rec of records) {
            for (const n of rec.addedNodes) {
                if (!(n instanceof HTMLElement)) continue;
                if (n.matches?.(MENU_SELECTOR)) injectIntoMenu(n);
                const nested = n.querySelectorAll?.(MENU_SELECTOR);
                if (nested) nested.forEach(injectIntoMenu);
            }
        }
    });
    menuObserver.observe(document.body, { childList: true, subtree: true });

    // ------------------------------------------------------------------
    // Toast
    // ------------------------------------------------------------------

    let toastEl = null;
    let toastTimer = null;
    function toast(message, kind = 'info') {
        if (!toastEl) {
            toastEl = document.createElement('div');
            Object.assign(toastEl.style, {
                position: 'fixed',
                left: '50%',
                bottom: '32px',
                transform: 'translateX(-50%)',
                padding: '10px 16px',
                borderRadius: '12px',
                fontSize: '14px',
                fontFamily: 'inherit',
                color: '#fff',
                background: 'rgba(32,33,35,0.95)',
                boxShadow: '0 4px 20px rgba(0,0,0,0.3)',
                zIndex: '2147483647',
                opacity: '0',
                transition: 'opacity 180ms ease',
                pointerEvents: 'none',
            });
            document.body.appendChild(toastEl);
        }
        toastEl.textContent = message;
        toastEl.style.background = kind === 'error' ? 'rgba(180,40,40,0.95)' : 'rgba(32,33,35,0.95)';
        requestAnimationFrame(() => { toastEl.style.opacity = '1'; });
        clearTimeout(toastTimer);
        toastTimer = setTimeout(() => {
            toastEl.style.opacity = '0';
        }, 2200);
    }
})();