Gemini to Markdown Copier (Fix Inline Code)

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

Du musst eine Erweiterung wie Tampermonkey, Greasemonkey oder Violentmonkey installieren, um dieses Skript zu installieren.

You will need to install an extension such as Tampermonkey 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.

Sie müssten eine Skript Manager Erweiterung installieren damit sie dieses Skript installieren können

(Ich habe schon ein Skript Manager, Lass mich es installieren!)

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         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'));

})();