Gemini to Markdown Copier (Fix Inline Code)

Export Gemini chat to Markdown with LaTeX support, Dark Mode Preview, Round Selection & Export Mode.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Gemini to Markdown Copier (Fix Inline Code)
// @namespace    http://tampermonkey.net/
// @version      3.6
// @description  Export Gemini chat to Markdown with LaTeX support, Dark Mode Preview, Round Selection & Export Mode.
// @author       Gemini & You
// @match        https://gemini.google.com/*
// @icon         https://www.gstatic.com/images/branding/product/1x/gemini_gradient_icon_48dp.png
// @grant        GM_setClipboard
// @grant        GM_registerMenuCommand
// @grant        GM_addStyle
// @run-at       document-idle
// @license MIT
// ==/UserScript==

(function() {
    'use strict';

    // === 全局配置 ===
    let selectedRoundOption = '1';
    let exportMode = 'full';

    // === 1. 样式表 (保持不变) ===
    const STYLES = `
        #gemini-md-toolbar {
            position: fixed; bottom: 20px; right: 20px; z-index: 9990;
            display: flex; gap: 8px; font-family: 'Google Sans', sans-serif;
            align-items: center; background: rgba(30, 30, 30, 0.85);
            padding: 8px 12px; border-radius: 30px; backdrop-filter: blur(5px);
            border: 1px solid #444; box-shadow: 0 4px 12px rgba(0,0,0,0.3);
            transition: opacity 0.3s;
        }
        .gmd-btn {
            background-color: #1a73e8; color: white; border: none; border-radius: 20px;
            padding: 6px 14px; box-shadow: 0 2px 4px rgba(0,0,0,0.3); cursor: pointer;
            font-size: 13px; font-weight: 500; transition: all 0.2s ease;
            display: flex; align-items: center; gap: 5px; white-space: nowrap;
        }
        .gmd-btn:hover { background-color: #1557b0; transform: translateY(-1px); }
        .gmd-btn.secondary { background-color: #2d2e30; color: #8ab4f8; border: 1px solid #5f6368; }
        .gmd-btn.secondary:hover { background-color: #3c4043; border-color: #8ab4f8; }
        .gmd-select {
            background-color: #2d2e30; color: #e1e1e1; border: 1px solid #5f6368;
            border-radius: 16px; padding: 6px 10px; font-size: 12px; outline: none;
            cursor: pointer; transition: border 0.2s, background 0.2s;
            appearance: none; text-align: center; min-width: 80px;
        }
        .gmd-select:hover { border-color: #8ab4f8; background-color: #3c4043; }
        .gmd-select option { background-color: #2d2e30; color: #fff; text-align: left;}
        .gmd-divider { width: 1px; height: 18px; background: #555; margin: 0 2px; }
        .gmd-modal-overlay {
            position: fixed; top: 0; left: 0; width: 100%; height: 100%;
            background: rgba(0,0,0,0.7); z-index: 9998; display: flex;
            justify-content: center; align-items: center; backdrop-filter: blur(4px);
        }
        .gmd-modal {
            background: #1e1e1e; width: 80%; max-width: 900px; height: 85%;
            border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,0.5);
            display: flex; flex-direction: column; overflow: hidden;
            border: 1px solid #444; animation: gmdFadeIn 0.2s ease-out; color: #d4d4d4;
        }
        .gmd-header { padding: 15px 20px; border-bottom: 1px solid #333; display: flex; justify-content: space-between; align-items: center; background: #252526; }
        .gmd-title { font-weight: bold; color: #e1e1e1; font-size: 16px; }
        .gmd-close { cursor: pointer; font-size: 22px; color: #aaa; padding: 0 8px; user-select: none; }
        .gmd-close:hover { color: #fff; }
        .gmd-body { flex: 1; padding: 0; position: relative; }
        .gmd-textarea {
            width: 100%; height: 100%; border: none; padding: 20px;
            font-family: 'Consolas', 'Monaco', 'Fira Code', monospace;
            font-size: 14px; line-height: 1.6; resize: none; outline: none;
            box-sizing: border-box; background: #1e1e1e; color: #d4d4d4; color-scheme: dark;
        }
        .gmd-footer { padding: 15px 20px; border-top: 1px solid #333; display: flex; justify-content: flex-end; gap: 10px; align-items: center; background: #252526; }
        .gmd-toast {
            position: fixed; bottom: 80px; right: 20px; background: #333; color: #fff;
            padding: 10px 20px; border-radius: 8px; font-size: 14px; z-index: 10000;
            opacity: 0; transition: opacity 0.3s; pointer-events: none; border: 1px solid #555;
        }
        .gmd-toast.show { opacity: 1; }
        @keyframes gmdFadeIn { from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); } }
    `;

    if (typeof GM_addStyle !== 'undefined') {
        GM_addStyle(STYLES);
    } else {
        const styleEl = document.createElement('style');
        styleEl.textContent = STYLES;
        document.head.appendChild(styleEl);
    }

    // === 2. 内容解析器 ===
    function parseContent(element) {
        if (!element) return '';
        try {
            const clone = element.cloneNode(true);

            // 2.1 垃圾清理
            const selectorsToRemove = [
                '.export-sheets-button-container',
                '.buttons',
                'button',
                '.action-button',
                '.sources-list',
                '.file-preview-container',
                'sensitive-memories-banner',
                'gap-container'
            ];
            selectorsToRemove.forEach(sel => Array.from(clone.querySelectorAll(sel)).forEach(e => e.remove()));

            const replaceWithText = (nodes, formatFn) => {
                Array.from(nodes).forEach(el => el.replaceWith(document.createTextNode(formatFn(el))));
            };

            // 2.2 优先处理复杂块

            // 代码块
            Array.from(clone.querySelectorAll('code-block')).forEach(block => {
                const langSpan = block.querySelector('.code-block-decoration span');
                const lang = langSpan ? langSpan.innerText.trim() : '';
                const codeElem = block.querySelector('pre code');
                let codeText = codeElem ? codeElem.innerText : block.innerText.replace(lang, '').trim();
                codeText = codeText.replace(/\s+$/, '');
                block.replaceWith(document.createTextNode(`\n\n\`\`\`${lang}\n${codeText}\n\`\`\`\n`));
            });

            // 残留 Pre
            Array.from(clone.querySelectorAll('pre')).forEach(pre => {
                if (pre.closest('code-block')) return;
                let preText = pre.innerText.replace(/\s+$/, '');
                pre.replaceWith(document.createTextNode(`\n\n\`\`\`\n${preText}\n\`\`\`\n`));
            });

            // 表格
            Array.from(clone.querySelectorAll('table')).forEach(table => {
                let mdTable = '\n\n';
                const rows = Array.from(table.querySelectorAll('tr'));
                rows.forEach((row, rowIndex) => {
                    const cells = Array.from(row.querySelectorAll('th, td'));
                    const rowContent = cells.map(c => c.innerText.trim().replace(/\n/g, '<br>').replace(/\|/g, '\\|')).join(' | ');
                    mdTable += `| ${rowContent} |\n`;
                    if (rowIndex === 0) {
                        mdTable += `| ${cells.map(() => '---').join(' | ')} |\n`;
                    }
                });
                mdTable += '\n';
                table.replaceWith(document.createTextNode(mdTable));
            });

            // 2.3 数学公式
            // 行内公式
            replaceWithText(clone.querySelectorAll('.math-inline[data-math]'), el => `$${el.getAttribute('data-math')}$`);

            // 块级公式:检测是否在列表内
            Array.from(clone.querySelectorAll('.math-block[data-math]')).forEach(el => {
                const math = el.getAttribute('data-math');
                const isInsideList = el.closest('li');

                if (isInsideList) {
                    el.replaceWith(document.createTextNode(` $$${math}$$ `));
                } else {
                    el.replaceWith(document.createTextNode(`\n\n$$${math}$$\n\n`));
                }
            });

            // 2.4 行内元素
            Array.from(clone.querySelectorAll('a')).forEach(el => {
                if (el.href && !el.href.startsWith('javascript:') && !el.innerText.includes('http')) {
                    el.replaceWith(document.createTextNode(`[${el.innerText}](${el.href})`));
                }
            });
            replaceWithText(clone.querySelectorAll('b, strong'), el => ` **${el.innerText.trim()}** `);
            replaceWithText(clone.querySelectorAll('i, em'), el => ` *${el.innerText.trim()}* `);

            // [Fix V3.6: 新增] 行内代码 (Inline Code) 支持
            // 注意:必须在 code-block 和 pre 处理完之后运行,以免误伤块级代码
            replaceWithText(clone.querySelectorAll('code'), el => ` \`${el.innerText}\` `);

            // 2.5 块级元素
            ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].forEach((tag, idx) => {
                replaceWithText(clone.querySelectorAll(tag), el => `\n\n${'#'.repeat(idx + 1)} ${el.innerText.trim()}\n\n`);
            });

            Array.from(clone.querySelectorAll('p')).forEach(p => {
                const text = p.innerText.trim();
                if (p.closest('li')) {
                    p.replaceWith(document.createTextNode(text));
                } else {
                    p.replaceWith(document.createTextNode(`\n\n${text}\n\n`));
                }
            });

            // 2.6 列表处理
            const listItems = Array.from(clone.querySelectorAll('li'));
            const getDepth = (el) => {
                let depth = 0;
                let p = el.parentElement;
                while (p && p !== clone) {
                    if (p.tagName === 'UL' || p.tagName === 'OL') depth++;
                    p = p.parentElement;
                }
                return depth;
            };
            listItems.sort((a, b) => getDepth(b) - getDepth(a));

            listItems.forEach(li => {
                const depth = getDepth(li);
                const indent = '    '.repeat(Math.max(0, depth - 1));
                const parent = li.parentElement;
                const isOrdered = parent && parent.tagName === 'OL';
                let marker = '-';

                if (isOrdered) {
                    let idx = 1;
                    let sib = li.previousElementSibling;
                    while (sib) {
                        if (sib.tagName === 'LI') idx++;
                        sib = sib.previousElementSibling;
                    }
                    marker = `${idx}.`;
                }

                let content = li.innerText.trim();
                if (content.includes('\n')) {
                     const lines = content.split('\n');
                     content = lines.map((line, i) => i === 0 ? line.trim() : `    ${line.trim()}`).join('\n');
                }

                li.replaceWith(document.createTextNode(`\n${indent}${marker} ${content}`));
            });

            Array.from(clone.querySelectorAll('ul, ol')).forEach(list => {
                list.replaceWith(document.createTextNode(list.innerText));
            });

            // 2.7 最终清洗
            let text = clone.innerText;
            text = text.replace(/([::]) *-(?!\s)/g, '$1\n\n-');
            text = text.replace(/&gt;/g, '>').replace(/&lt;/g, '<').replace(/&amp;/g, '&');
            text = text.replace(/\n{3,}/g, '\n\n');

            return text.split('\n').map(line => line.replace(/\s+$/, '')).join('\n').trim();

        } catch (e) {
            console.error("Gemini Copier Parse Error:", e);
            return "[Parse Error]";
        }
    }

    // === 3. 生成 Markdown (保持不变) ===
    function generateMarkdown() {
        let allMessages = Array.from(document.querySelectorAll('user-query, model-response'));
        if (allMessages.length === 0) return null;

        let messagesToProcess = allMessages;
        if (selectedRoundOption !== 'all') {
            const rounds = parseInt(selectedRoundOption, 10);
            const sliceCount = rounds * 2;
            const startIndex = Math.max(0, allMessages.length - sliceCount);
            messagesToProcess = allMessages.slice(startIndex);
        }

        const mdOutput = [];
        let validMsgCount = 0;

        messagesToProcess.forEach(msg => {
            let role = 'Unknown';
            let textElement = null;

            if (msg.tagName.toLowerCase() === 'user-query') {
                role = 'User';
                textElement = msg.querySelector('.query-text') || msg;
            } else if (msg.tagName.toLowerCase() === 'model-response') {
                role = 'Gemini';
                textElement = msg.querySelector('.markdown') || msg.querySelector('.model-response-text') || msg;
            }

            if (exportMode === 'ai_only' && role === 'User') return;

            const content = parseContent(textElement);
            if (content) {
                if (exportMode === 'ai_only') {
                    mdOutput.push(`${content}\n\n---\n`);
                } else {
                    mdOutput.push(`## ${role}\n\n${content}\n\n---\n`);
                }
                validMsgCount++;
            }
        });

        return { text: mdOutput.join('\n'), count: validMsgCount };
    }

    // === 4. UI 交互 (保持不变) ===
    function showToast(message) {
        let toast = document.getElementById('gmd-toast');
        if (!toast) {
            toast = document.createElement('div');
            toast.id = 'gmd-toast';
            toast.className = 'gmd-toast';
            document.body.appendChild(toast);
        }
        toast.textContent = message;
        toast.classList.add('show');
        setTimeout(() => toast.classList.remove('show'), 2000);
    }

    function createButton(text, icon, className, onClick) {
        const btn = document.createElement('button');
        btn.className = className;
        const iconSpan = document.createElement('span');
        iconSpan.textContent = icon;
        btn.appendChild(iconSpan);
        btn.appendChild(document.createTextNode(' ' + text));
        btn.onclick = onClick;
        return btn;
    }

    function showPreviewModal(mdText) {
        const oldOverlay = document.querySelector('.gmd-modal-overlay');
        if (oldOverlay) oldOverlay.remove();
        const overlay = document.createElement('div');
        overlay.className = 'gmd-modal-overlay';
        overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
        const modal = document.createElement('div');
        modal.className = 'gmd-modal';

        const header = document.createElement('div');
        header.className = 'gmd-header';
        const title = document.createElement('span');
        header.append(title, (() => {
            const btn = document.createElement('span');
            btn.className = 'gmd-close';
            btn.textContent = '×';
            btn.onclick = () => overlay.remove();
            return btn;
        })());
        title.className = 'gmd-title';
        title.textContent = 'Markdown 预览';

        const body = document.createElement('div');
        body.className = 'gmd-body';
        const textarea = document.createElement('textarea');
        textarea.className = 'gmd-textarea';
        textarea.spellcheck = false;
        textarea.value = mdText;
        body.appendChild(textarea);

        const footer = document.createElement('div');
        footer.className = 'gmd-footer';
        const copyBtn = createButton('复制', '', 'gmd-btn secondary', () => {
            GM_setClipboard(textarea.value, 'text');
            showToast('✅ 内容已复制');
        });
        const closeFooterBtn = createButton('关闭', '', 'gmd-btn', () => overlay.remove());
        footer.append(copyBtn, closeFooterBtn);
        modal.append(header, body, footer);
        overlay.appendChild(modal);
        document.body.appendChild(overlay);
        textarea.select();
    }

    function handleExport(mode = 'copy') {
        requestAnimationFrame(() => {
            const result = generateMarkdown();
            if (!result || result.text.length === 0) {
                showToast('⚠️ 未检测到内容');
                return;
            }
            if (mode === 'copy') {
                GM_setClipboard(result.text, 'text');
                showToast(`✅ 已复制 (共 ${result.count} 条消息)`);
            } else if (mode === 'preview') {
                showPreviewModal(result.text);
            }
        });
    }

    function createRoundSelect() {
        const select = document.createElement('select');
        select.className = 'gmd-select';
        select.title = "选择要导出的对话轮数";
        const options = [
            { val: '1', text: '最近 1 轮' },
            { val: '2', text: '最近 2 轮' },
            { val: '3', text: '最近 3 轮' },
            { val: '5', text: '最近 5 轮' },
            { val: 'all', text: '全部对话' }
        ];
        options.forEach(opt => {
            const option = document.createElement('option');
            option.value = opt.val;
            option.textContent = opt.text;
            if (opt.val === selectedRoundOption) option.selected = true;
            select.appendChild(option);
        });
        select.onchange = (e) => {
            selectedRoundOption = e.target.value;
            showToast(`范围: ${e.target.options[e.target.selectedIndex].text}`);
        };
        return select;
    }

    function createModeSelect() {
        const select = document.createElement('select');
        select.className = 'gmd-select';
        select.title = "选择导出内容模式";
        const options = [
            { val: 'full', text: '双人 (User+AI)' },
            { val: 'ai_only', text: '仅 AI 回复' }
        ];
        options.forEach(opt => {
            const option = document.createElement('option');
            option.value = opt.val;
            option.textContent = opt.text;
            if (opt.val === exportMode) option.selected = true;
            select.appendChild(option);
        });
        select.onchange = (e) => {
            exportMode = e.target.value;
            showToast(`模式: ${e.target.options[e.target.selectedIndex].text}`);
        };
        return select;
    }

    function initToolbar() {
        if (document.getElementById('gemini-md-toolbar')) return;

        const toolbar = document.createElement('div');
        toolbar.id = 'gemini-md-toolbar';

        toolbar.appendChild(createRoundSelect());
        toolbar.appendChild(createModeSelect());

        const divider = document.createElement('div');
        divider.className = 'gmd-divider';
        toolbar.appendChild(divider);

        const btnPreview = createButton('预览', '👁️', 'gmd-btn secondary', () => handleExport('preview'));
        const btnCopy = createButton('复制', '📋', 'gmd-btn', () => handleExport('copy'));

        toolbar.appendChild(btnPreview);
        toolbar.appendChild(btnCopy);

        document.body.appendChild(toolbar);
    }

    function startScheduler() {
        setInterval(() => {
            const chatExists = document.querySelector('user-query, model-response');
            const toolbar = document.getElementById('gemini-md-toolbar');

            if (chatExists) {
                if (!toolbar) {
                    initToolbar();
                } else if (toolbar.style.display === 'none') {
                    toolbar.style.display = 'flex';
                }
            } else {
                if (toolbar) {
                    toolbar.style.display = 'none';
                }
            }
        }, 1000);
    }

    startScheduler();
    GM_registerMenuCommand("复制 Markdown (当前设置)", () => handleExport('copy'));

})();