Gemini2Markdown

Exports Google Gemini chats to Markdown. Captures "Show Thinking", auto-detects model names, loads full history, and supports dual/parallel responses.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Gemini2Markdown
// @namespace    https://greatest.deepsurf.us/en/users/1552401-chipfin
// @version      1.8.2
// @description  Exports Google Gemini chats to Markdown. Captures "Show Thinking", auto-detects model names, loads full history, and supports dual/parallel responses.
// @icon64       https://upload.wikimedia.org/wikipedia/commons/archive/1/1d/20251003211919%21Google_Gemini_icon_2025.svg
// @match        https://gemini.google.com/*
// @grant        GM_registerMenuCommand
// @license      MIT
// @author       Gemini 3 Pro, Claude Sonnet 4.6 Thinking
// ==/UserScript==

(() => {
    'use strict';

    /* ---------------- Utilities ---------------- */

    const sleep = (ms) => new Promise(r => setTimeout(r, ms));
    const trim = s => (s || '').toString().replace(/\r/g, '').trim();

    function getFormattedTimestamp() {
        const now = new Date();
        const pad = (n) => n.toString().padStart(2, '0');
        const tzo = -now.getTimezoneOffset();
        const dif = tzo >= 0 ? '+' : '-';
        const offHour = pad(Math.floor(Math.abs(tzo) / 60));
        const offMin = pad(Math.abs(tzo) % 60);
        return `${now.getFullYear()}-${pad(now.getMonth()+1)}-${pad(now.getDate())}T${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}${dif}${offHour}${offMin}`;
    }

    function cleanMarkdown(text) {
        if (!text) return '';

        text = text.replace(/https:\/\/[^ \n]+filename=([^& \n]+)[^ \n]*/g, (match, filename) => {
            try { return `[Uploaded File: ${decodeURIComponent(filename.replace(/\+/g, ' '))}]`; } catch (e) { return '[Uploaded File]'; }
        });

        text = text
            .replace(/https:\/\/drive\.google\.com\/viewerng\/thumb[^ \n]*/g, '')
            .replace(/https:\/\/contribution\.usercontent\.google\.com\/download[^ \n]*/g, '')
            .replace(/https:\/\/lh3\.googleusercontent\.com\/[^ \n]+/g, '[Image]')
            .replace(/\\(?![\\*_`])/g, '\\\\')
            .replace(/&lt;/g, '<')
            .replace(/&gt;/g, '>')
            .replace(/&amp;/g, '&');

        return text.replace(/\n\s*\n/g, '\n\n').trim();
    }

    function createSvgIcon(width = '24', height = '15') {
        const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
        svg.setAttribute('width', width);
        svg.setAttribute('height', height);
        svg.setAttribute('viewBox', '0 0 208 128');

        const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
        rect.setAttribute('width', '198');
        rect.setAttribute('height', '118');
        rect.setAttribute('x', '5');
        rect.setAttribute('y', '5');
        rect.setAttribute('ry', '10');
        rect.setAttribute('stroke', 'currentColor');
        rect.setAttribute('stroke-width', '10');
        rect.setAttribute('fill', 'none');

        const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
        path.setAttribute('d', 'M30 98V30h20l20 25 20-25h20v68H90V59L70 84 50 59v39zm125 0l-30-33h20V30h20v35h20z');
        path.setAttribute('fill', 'currentColor');

        svg.appendChild(rect);
        svg.appendChild(path);
        return svg;
    }

    /* ---------------- Actions ---------------- */

    function getChatScroller() {
        return document.querySelector('#chat-history.chat-history-scroll-container infinite-scroller.chat-history') ||
               document.querySelector('infinite-scroller.chat-history');
    }

    async function scrollChatToTop(statusCallback) {
        const scroller = getChatScroller();
        if (!scroller) return;

        let stableCount = 0;
        for (let i = 0; i < 55; i++) {
            scroller.scrollTop = 0;
            if (statusCallback) statusCallback(`⬆️ ${i}`);
            await sleep(1300);

            if (scroller.scrollTop !== 0) {
                stableCount = 0;
            } else {
                stableCount++;
            }
            if (stableCount >= 4) break;
        }
    }

    async function expandAllThoughts(statusCallback) {
        if (statusCallback) statusCallback("🧠");
        const buttons = document.querySelectorAll('model-thoughts .thoughts-header-button');

        for (const btn of buttons) {
            const container = btn.closest('model-thoughts');
            if (!container?.querySelector('.thoughts-content') || (btn.textContent && btn.textContent.includes('Show thinking'))) {
                btn.click();
                await sleep(100);
            }
        }
        await sleep(1000);
    }

    async function detectModelForContainer(container) {
        const menuBtn = container.querySelector('.more-menu-button, button[data-test-id="more-actions-button"]');
        if (!menuBtn) return 'Gemini';

        menuBtn.click();
        await sleep(300);

        const overlayItems = [...document.querySelectorAll('.cdk-overlay-pane .mat-mdc-menu-item-text')];
        const modelItem = overlayItems.find(el => el.textContent && el.textContent.trim().startsWith('Model:'));

        let model = 'Gemini';
        if (modelItem) {
            model = modelItem.textContent.trim();
        }

        document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', keyCode: 27, bubbles: true, cancelable: true }));
        await sleep(100);

        return model;
    }

    /* ---------------- Extraction ---------------- */

    function processElement(el) {
        if (!el) return '';
        const clone = el.cloneNode(true);

        clone.querySelectorAll('button, mat-icon, .action-bar, .feedback_buttons, .thoughts-header, .select-button').forEach(e => e.remove());

        clone.querySelectorAll('b, strong').forEach(b => b.textContent = `**${b.textContent}**`);
        clone.querySelectorAll('i, em').forEach(i => i.textContent = `*${i.textContent}*`);

        clone.querySelectorAll('a').forEach(a => {
            const href = a.href;
            const text = a.innerText;
            if (href && text) a.textContent = `[${text}](${href})`;
        });

        clone.querySelectorAll('pre').forEach(pre => {
            const code = pre.innerText;
            const lang = pre.getAttribute('data-language') || '';
            pre.textContent = `\n\`\`\`${lang}\n${code}\n\`\`\`\n`;
        });

        const blockTags = ['p', 'div', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'tr'];
        blockTags.forEach(tag => {
            clone.querySelectorAll(tag).forEach(block => block.after('\n'));
        });

        clone.querySelectorAll('br').forEach(br => br.replaceWith('\n'));

        return cleanMarkdown(clone.textContent);
    }

    /* ---------------- Main Logic ---------------- */

    async function exportToMarkdown() {
        const btn = document.querySelector('#gemini-export-md-icon');

        const setStatus = (text) => {
            if (btn) {
                const labelSpan = btn.querySelector('.dynamic-upsell-label');
                if (labelSpan) {
                    labelSpan.textContent = text;
                } else {
                    btn.textContent = '';
                    const span = document.createElement('span');
                    span.style.fontSize = '11px';
                    span.style.fontWeight = 'bold';
                    span.style.color = 'currentColor';
                    span.textContent = text;
                    btn.appendChild(span);
                }
            } else {
                console.log(`Gemini2Markdown: ${text}`);
            }
        };

        try {
            await scrollChatToTop(setStatus);
            await expandAllThoughts(setStatus);

            const containers = document.querySelectorAll('.conversation-container');
            if (containers.length === 0) throw new Error("No chat found. Please ensure the page is fully loaded.");

            const conversationId = location.pathname.match(/\/app\/([a-zA-Z0-9]+)/)?.[1];

            const titleEl = 
                document.querySelector('.conversation-title-container .gds-title-m') ||
                (conversationId ? document.querySelector(`a.conversation[href*="/app/${conversationId}"] .conversation-title`) : null) ||
                document.querySelector('a.conversation.selected .conversation-title');

            let cleanTitle = '';
            if (titleEl && titleEl.innerText && titleEl.innerText.trim().length > 0) {
                cleanTitle = trim(titleEl.innerText);
            } else {
                cleanTitle = trim(document.title)
                    .replace(/ - Google Gemini$/i, '')
                    .replace(/ - Gemini$/i, '')
                    .replace(/ [-–|].*$/i, '');
            }

            if (/^(google\s*)?gemini$/i.test(cleanTitle) || cleanTitle === '' || cleanTitle.toLowerCase() === 'chats') {
                const firstQuery = document.querySelector('user-query p.query-text-line, user-query .query-text, user-query .query-content, .user-query');
                if (firstQuery && firstQuery.textContent) {
                    let fallbackText = trim(firstQuery.textContent).replace(/^You said\s*/i, '');
                    cleanTitle = fallbackText.split(/[\r\n]+/)[0].substring(0, 64).trim();
                } else {
                    cleanTitle = conversationId ? `chat-${conversationId}` : 'Gemini_Conversation';
                }
            }

            cleanTitle = cleanTitle.replace(/[<>:"/\\|?*]/g, '').replace(/\s+/g, ' ');

            let displayTitle = cleanTitle.substring(0, 64).trim();
            const timestamp = getFormattedTimestamp();

            const toc = [];
            const turnBuffer = [];
            let globalModel = 'Gemini';
            let chatIndex = 1;

            for (let i = 0; i < containers.length; i++) {
                const container = containers[i];
                const userQuery = container.querySelector('user-query .query-content, .user-query');

                const hasDual = !!container.querySelector('dual-model-response');
                const modelResponses = hasDual
                    ? [...container.querySelectorAll('response-selection-panel')]
                    : [...container.querySelectorAll('model-response')];

                const isDual = hasDual && modelResponses.length > 1;

                if (!userQuery && modelResponses.length === 0) continue;

                setStatus(`🔍 ${i+1}/${containers.length}`);

                let currentModel = 'User';
                if (modelResponses.length > 0) {
                    currentModel = hasDual ? 'Gemini' : await detectModelForContainer(container);
                }

                if (i === 0 && currentModel !== 'User') {
                    globalModel = currentModel;
                }

                let turnText = `### chat-${chatIndex}\n\n`;

                if (userQuery) {
                    const text = processElement(userQuery);
                    toc.push(`- [${chatIndex}: ${text.substring(0, 50).replace(/\n/g, ' ')}...](#chat-${chatIndex})`);
                    turnText += `####### User writes:\n\n${text}\n\n`;
                }

                if (modelResponses.length > 0) {
                    modelResponses.forEach((responseNode, rIndex) => {
                        const draftLabel = isDual ? (rIndex === 0 ? ' (Choice A)' : ' (Choice B)') : '';
                        turnText += `####### Gemini (${currentModel})${draftLabel} writes:\n\n`;

                        let hasThoughts = false;
                        const thoughtNode = responseNode.querySelector('model-thoughts');
                        if (thoughtNode) {
                            const thoughtText = processElement(thoughtNode.querySelector('.thoughts-content'));
                            if (thoughtText) {
                                hasThoughts = true;
                                turnText += `**Shown Thinking (Gemini):**\n---\n\n${thoughtText}\n\n`;
                            }
                        }

                        const responseClone = responseNode.cloneNode(true);
                        responseClone.querySelectorAll('model-thoughts, .thoughts-container').forEach(e => e.remove());

                        if (hasThoughts) turnText += `**Response (Gemini):**\n---\n\n`;

                        const contentNode = responseClone.querySelector('message-content') || responseClone.querySelector('structured-content-container') || responseClone;
                        turnText += `${processElement(contentNode)}\n\n`;

                        if (isDual && rIndex < modelResponses.length - 1) {
                            turnText += `---\n\n`;
                        }
                    });
                }

                turnText += `___\n###### [top](#table-of-contents)\n\n`;
                turnBuffer.push(turnText);
                chatIndex++;
            }

            const header = `---\ntitle: ${cleanTitle}\ndate: ${timestamp}\nurl: ${location.href}\nmodel: ${globalModel}\n---\n\n# ${cleanTitle}\n\n`;
            const finalContent = [header, `## Table of Contents\n${toc.join('\n')}\n\n---\n\n`, ...turnBuffer].join('');

            const blob = new Blob([finalContent], { type: 'text/markdown' });
            const url = URL.createObjectURL(blob);
            const a = document.createElement('a');
            a.href = url;
            a.download = `GEMINI_${displayTitle}_${timestamp}.md`;
            a.click();
            URL.revokeObjectURL(url);

        } catch (e) {
            console.error(e);
            alert("Export failed: " + e.message);
        } finally {
            if (btn) {
                const labelSpan = btn.querySelector('.dynamic-upsell-label');
                if (labelSpan) {
                    labelSpan.textContent = 'Markdown';
                } else {
                    btn.textContent = '';
                    btn.appendChild(createSvgIcon('24', '15'));
                }
            }
        }
    }

    /* ---------------- UI Integration ---------------- */

    function addExportButton() {
        if (document.querySelector('#gemini-export-md-icon')) return;

        // Try to hook into the Upsell button (Language Agnostic)
        const upsellBtn = document.querySelector('g1-dynamic-upsell-button button');
        
        if (upsellBtn) {
            upsellBtn.id = 'gemini-export-md-icon';
            upsellBtn.setAttribute('title', 'Export chat as Markdown');
            
            const upsellLabel = upsellBtn.querySelector('.dynamic-upsell-label');
            if (upsellLabel) {
                upsellLabel.textContent = 'Markdown';
            }
            
            const icon = upsellBtn.querySelector('mat-icon');
            if (icon) {
                const svgIcon = createSvgIcon('20', '13');
                svgIcon.style.marginRight = '6px';
                svgIcon.style.verticalAlign = 'middle';
                icon.parentNode.replaceChild(svgIcon, icon);
            }
            
            upsellBtn.setAttribute('role', 'button');
            upsellBtn.removeAttribute('aria-describedby');
            upsellBtn.addEventListener('click', (e) => {
                e.preventDefault();
                e.stopImmediatePropagation();
                exportToMarkdown();
            }, true);
            
            return;
        }

        // Fallback for users without an Upsell button
        const anchor = document.querySelector(
            'studio-sidebar-button, [data-test-id="studio-sidebar-button"], ' +
            'new-chat-button, [data-test-id="new-chat-button-container"], ' +
            'conversation-actions-icon'
        );
        if (!anchor) return;

        try {
            const exportBtn = document.createElement('button');
            exportBtn.id = 'gemini-export-md-icon';
            exportBtn.setAttribute('title', 'Export chat as Markdown');
            exportBtn.style.cssText = `
                display: inline-flex; align-items: center; justify-content: center;
                align-self: center; width: 40px; height: 40px; background: transparent;
                border: none; border-radius: 50%; cursor: pointer; color: inherit;
                transition: background 0.2s; margin: 3px 4px 0 0;
                padding: 0; vertical-align: middle; z-index: 1000;
            `;
            
            exportBtn.appendChild(createSvgIcon('24', '15'));

            exportBtn.addEventListener('mouseenter', () => exportBtn.style.background = 'rgba(154, 160, 166, 0.1)');
            exportBtn.addEventListener('mouseleave', () => exportBtn.style.background = 'transparent');
            exportBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); exportToMarkdown(); });
            anchor.parentNode.insertBefore(exportBtn, anchor);
        } catch (err) {
            console.warn("Gemini2Markdown: Could not inject button.", err);
        }
    }

    if (typeof GM_registerMenuCommand !== 'undefined') {
        GM_registerMenuCommand('Export to Markdown', exportToMarkdown);
    }

    setTimeout(addExportButton, 2000);
    new MutationObserver(addExportButton).observe(document.body, { childList: true, subtree: true });

})();