ChatGPT Exporter 🔥🚀 (HTML, PDF, MD, JSON)

Downloads ChatGPT chats as MD, JSON, HTML, or PDF. Integrates natively into the ChatGPT top toolbar.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         ChatGPT Exporter 🔥🚀 (HTML, PDF, MD, JSON) 
// @namespace    https://giths.com/random/chatgpt-exporter
// @version      3.0
// @description  Downloads ChatGPT chats as MD, JSON, HTML, or PDF. Integrates natively into the ChatGPT top toolbar.
// @author       Mr005K
// @license      MIT
// @match        https://chatgpt.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=chatgpt.com
// @require      https://cdn.jsdelivr.net/npm/[email protected]/lib/marked.umd.min.js
// @grant        GM_addStyle
// @run-at       document-idle
// ==/UserScript==

(function() {
    'use strict';

    // --- STATE MANAGEMENT ---
    const state = {
        accessToken: null,
        threadData: [],
        chatTitle: "ChatGPT Export",
        isReady: false
    };

    // --- ICONS (Matches Native SVG Style) ---
    // This is a "Download" icon, which is the semantic inverse of "Share"
    const SAVE_ICON = `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="-ms-0.5 icon"><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>`;
    const SPINNER = `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="-ms-0.5 icon" style="animation:spin 1s linear infinite;"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>`;

    // --- CUSTOM CSS (Only for the dropdown menu, button uses native classes) ---
    function injectStyles() {
        const css = `
            @keyframes spin { 100% { transform: rotate(360deg); } }

            /* Wrapper to handle relative positioning for the dropdown */
            #inndex-save-wrapper {
                position: relative;
                display: flex;
                align-items: center;
            }

            /* The Dropdown Menu */
            #inndex-menu {
                position: absolute;
                top: 100%;
                right: 0;
                margin-top: 8px;
                background: var(--token-surface-primary, #171717); /* Fallback to dark if var fails */
                border: 1px solid var(--token-border-light, #333);
                border-radius: 12px;
                padding: 6px;
                display: none;
                flex-direction: column;
                gap: 4px;
                box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
                min-width: 140px;
                z-index: 50;
            }

            #inndex-menu.show { display: flex; }

            .inndex-opt {
                background: transparent;
                color: var(--token-text-primary, #ececec);
                border: none;
                border-radius: 8px;
                padding: 8px 12px;
                font-size: 14px;
                text-align: left;
                cursor: pointer;
                transition: background 0.2s;
            }
            .inndex-opt:hover {
                background: var(--token-surface-hover, #2a2a2a);
            }
        `;
        GM_addStyle(css);
    }

    // --- DOM MANIPULATION ---
    function injectButton(targetContainer) {
        if (document.getElementById('inndex-save-wrapper')) return;

        // Create Wrapper
        const wrapper = document.createElement('div');
        wrapper.id = 'inndex-save-wrapper';

        // Create Button (Using Native Classes)
        const btn = document.createElement('button');
        btn.id = 'inndex-save-btn';
        // These are the exact classes from your snippet
        btn.className = "btn relative btn-ghost text-token-text-primary mx-2";
        btn.setAttribute('aria-label', 'Save Chat');
        btn.innerHTML = `
            <div class="flex w-full items-center justify-center gap-1.5">
                ${SPINNER}
                Save
            </div>
        `;

        // Create Dropdown
        const menu = document.createElement('div');
        menu.id = 'inndex-menu';

        const formats = ['MD', 'JSON', 'HTML', 'PDF'];
        formats.forEach(type => {
            const opt = document.createElement('button');
            opt.className = 'inndex-opt';
            opt.textContent = `Download ${type}`;
            opt.onclick = (e) => {
                e.stopPropagation();
                downloadFile(type.toLowerCase());
                menu.classList.remove('show');
            };
            menu.appendChild(opt);
        });

        // Toggle Logic
        btn.onclick = (e) => {
            e.stopPropagation();
            if (!state.isReady) return;
            // Close other open menus if any (optional)
            const menu = document.getElementById('inndex-menu');
            menu.classList.toggle('show');
        };

        // Close dropdown when clicking elsewhere
        document.addEventListener('click', (e) => {
            if (!wrapper.contains(e.target)) {
                menu.classList.remove('show');
            }
        });

        wrapper.appendChild(btn);
        wrapper.appendChild(menu);

        // Insert into the native container (Insert before the last child or append)
        targetContainer.appendChild(wrapper);
    }

    function updateButtonState(loading) {
        const btn = document.getElementById('inndex-save-btn');
        if (!btn) return;

        const container = btn.querySelector('div');
        if (loading) {
            container.innerHTML = `${SPINNER} Save`;
            btn.style.opacity = "0.7";
            btn.style.cursor = "wait";
        } else {
            container.innerHTML = `${SAVE_ICON} Save`;
            btn.style.opacity = "1";
            btn.style.cursor = "pointer";
        }
    }

    // --- DATA LOGIC ---
    async function loadConversation() {
        state.isReady = false;
        updateButtonState(true);

        const uuid = window.location.pathname.match(/\/c\/([a-f0-9-]{36})/)?.[1];
        if (!uuid) return; // Not in a chat

        // Get Access Token if missing
        if (!state.accessToken) {
            try {
                const r = await fetch('/api/auth/session');
                const d = await r.json();
                state.accessToken = d.accessToken;
            } catch (e) { console.error("Token fetch failed", e); return; }
        }

        try {
            const response = await fetch(`/backend-api/conversation/${uuid}`, {
                headers: { 'Authorization': `Bearer ${state.accessToken}` }
            });
            const data = await response.json();

            state.chatTitle = data.title || document.title || "ChatGPT Export";
            processData(data);

            state.isReady = true;
            updateButtonState(false);

        } catch (err) {
            console.error("Chat fetch failed", err);
            const btn = document.getElementById('inndex-save-btn');
            if(btn) btn.innerText = "Error";
        }
    }

    function processData(data) {
        if (!data.mapping || !data.current_node) return;
        const thread = [];
        let currId = data.current_node;

        while (currId) {
            const node = data.mapping[currId];
            if (!node) break;
            const msg = node.message;
            if (msg && msg.content && msg.content.parts && msg.content.parts.length > 0) {
                if (msg.author.role !== 'system' && msg.recipient === 'all') {
                    let text = typeof msg.content.parts[0] === 'string' ? msg.content.parts[0] : "```\nCode Block\n```";
                    if (text.trim()) {
                        thread.push({
                            role: msg.author.role,
                            text: text
                        });
                    }
                }
            }
            currId = node.parent;
        }
        state.threadData = thread.reverse();
    }

    // --- EXPORT LOGIC ---
    function getFilename(ext) {
        const date = new Date().toISOString().slice(0, 10);
        const safeTitle = state.chatTitle.replace(/[/\\?%*:|"<>]/g, '-').substring(0, 50);
        return `${safeTitle} - ${date}.${ext}`;
    }

    function downloadFile(type) {
        const filename = getFilename(type);

        if (type === 'json') {
            triggerDownload(JSON.stringify({ title: state.chatTitle, messages: state.threadData }, null, 2), 'application/json', filename);
        }
        else if (type === 'md') {
            let content = `# ${state.chatTitle}\n\n`;
            state.threadData.forEach(m => {
                content += `### ${m.role === 'user' ? 'User' : 'ChatGPT'}\n\n${m.text}\n\n---\n\n`;
            });
            triggerDownload(content, 'text/markdown', filename);
        }
        else if (type === 'html' || type === 'pdf') {
            const html = generateHTML(state.threadData);
            if (type === 'html') {
                triggerDownload(html, 'text/html', getFilename('html'));
            } else {
                const win = window.open('', '_blank');
                win.document.write(html);
                win.document.close();
                setTimeout(() => { win.print(); win.close(); }, 500);
            }
        }
    }

    function triggerDownload(content, type, filename) {
        const blob = new Blob([content], { type });
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url;
        a.download = filename;
        a.click();
        URL.revokeObjectURL(url);
    }

    // --- HTML GENERATOR (Kept largely the same, optimized for clean export) ---
    function generateHTML(data) {
        const date = new Date().toISOString().slice(0, 10);
        let bodyContent = data.map(m => {
            const roleClass = m.role === 'user' ? 'user' : 'ai';
            const roleName = m.role === 'user' ? 'User' : 'ChatGPT';
            const htmlText = window.marked ? window.marked.parse(m.text) : m.text;
            return `
                <div class="message ${roleClass}">
                    <div class="role">${roleName}</div>
                    <div class="content">${htmlText}</div>
                </div>
            `;
        }).join('');

        return `
            <!DOCTYPE html>
            <html lang="en" data-theme="dark"> <head>
                <meta charset="UTF-8">
                <meta name="viewport" content="width=device-width, initial-scale=1.0">
                <title>${state.chatTitle} - Export</title>
                <style>
                    :root { --bg-primary: #ffffff; --bg-message: #f7f7f8; --text-primary: #374151; --border: #e5e7eb; --code-bg: #f3f4f6; --user-role: #10a37f; }
                    [data-theme="dark"] { --bg-primary: #171717; --bg-message: #212121; --text-primary: #ececec; --border: #333333; --code-bg: #0d0d0d; --user-role: #10a37f; }
                    body { font-family: system-ui, sans-serif; background-color: var(--bg-primary); color: var(--text-primary); margin: 0; line-height: 1.6; }
                    .container { max-width: 800px; margin: 0 auto; padding: 40px 20px; }
                    h1 { border-bottom: 1px solid var(--border); padding-bottom: 15px; }
                    .meta { color: #888; font-size: 12px; margin-bottom: 30px; }
                    .message { margin-bottom: 30px; }
                    .role { font-weight: 700; font-size: 12px; text-transform: uppercase; margin-bottom: 5px; }
                    .user .role { color: var(--user-role); }
                    pre { background: var(--code-bg); padding: 10px; border-radius: 5px; overflow-x: auto; }
                    @media print { body { background: #fff; color: #000; } .theme-toggle { display: none; } }
                </style>
            </head>
            <body>
                <div class="container">
                    <h1>${state.chatTitle}</h1>
                    <div class="meta">Exported on ${date}</div>
                    ${bodyContent}
                </div>
            </body>
            </html>
        `;
    }

    // --- INITIALIZATION ---
    injectStyles();

    // Observer to handle React's dynamic DOM changes
    const observer = new MutationObserver((mutations) => {
        // We look for the conversation header actions container
        const headerActions = document.querySelector('#conversation-header-actions');

        if (headerActions) {
            // Check if our button is already there
            if (!document.getElementById('inndex-save-wrapper')) {
                injectButton(headerActions);
                // Trigger load if data is missing
                if (!state.isReady) loadConversation();
            }
        }
    });

    observer.observe(document.body, { childList: true, subtree: true });

    // Handle URL changes (SPA navigation)
    let lastUrl = location.href;
    new MutationObserver(() => {
        if (location.href !== lastUrl) {
            lastUrl = location.href;
            loadConversation();
        }
    }).observe(document.body, { subtree: true, childList: true });

})();