Gemini2Markdown

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

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         Gemini2Markdown
// @namespace    https://greasyfork.org/en/users/1552401-chipfin
// @version      1.6.6
// @description  Exports Google Gemini chats to Markdown. Captures "Show Thinking", auto-detects model names, and loads full history.
// @icon64       https://upload.wikimedia.org/wikipedia/commons/thumb/1/1d/Google_Gemini_icon_2025.svg/64px-Google_Gemini_icon_2025.svg.png
// @match        https://gemini.google.com/*
// @grant        none
// @license      MIT
// @author       Gemini 3 Pro, ChatGPT-5.2
// ==/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();
    }

    /* ---------------- 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').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 originalIcon = btn.innerHTML;
        const setStatus = (text) => { btn.innerHTML = `<span style="font-size:11px; font-weight:bold; color:#444;">${text}</span>`; };
        try {
            await scrollChatToTop(setStatus);
            await expandAllThoughts(setStatus);
            const containers = document.querySelectorAll('.conversation-container');
            if (containers.length === 0) throw new Error("No chat found.");
            const titleEl = document.querySelector('.conversation-title');
            let cleanTitle = trim(titleEl ? titleEl.innerText : document.title).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 modelResponse = container.querySelector('model-response .message-content, model-response');
                if (!userQuery && !modelResponse) continue;
                setStatus(`🔍 ${i+1}/${containers.length}`);
                const currentModel = modelResponse ? await detectModelForContainer(container) : 'User';
                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 (modelResponse) {
                    turnText += `####### Gemini (${currentModel}) writes:\n\n`;
                    let hasThoughts = false;
                    const thoughtNode = container.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 = modelResponse.cloneNode(true);
                    responseClone.querySelectorAll('model-thoughts, .thoughts-container').forEach(e => e.remove());
                    if (hasThoughts) turnText += `**Response (Gemini):**\n---\n\n`;
                    turnText += `${processElement(responseClone)}\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 {
            btn.innerHTML = originalIcon;
        }
    }

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

    function addExportButton() {
        if (document.querySelector('#gemini-export-md-icon')) return;
        const anchor = document.querySelector('studio-sidebar-button, [data-test-id="studio-sidebar-button"], new-chat-button, [data-test-id="new-chat-button-container"]');
        if (!anchor) return;

        const exportBtn = document.createElement('button');
        exportBtn.id = 'gemini-export-md-icon';
        exportBtn.setAttribute('title', 'Export chat as Markdown');

        // CSS optimization for v1.6.5:
        // Changed margin to 3px 4px 0 0 to nudge the 15px icon down.
        // align-self: center ensures the 40px container is centered, then the 3px margin shifts the icon.
        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;
            transition: background 0.2s;
            margin: 3px 4px 0 0;
            padding: 0;
            vertical-align: middle;
        `;

        exportBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="15" viewBox="0 0 208 128" style="opacity:0.75;"><rect width="198" height="118" x="5" y="5" ry="10" stroke="#000" stroke-width="10" fill="none"/><path d="M30 98V30h20l20 25 20-25h20v68H90V59L70 84 50 59v39zm125 0l-30-33h20V30h20v35h20z"/></svg>`;
        exportBtn.addEventListener('mouseenter', () => exportBtn.style.background = 'rgba(154, 160, 166, 0.1)');
        exportBtn.addEventListener('mouseleave', () => exportBtn.style.background = 'transparent');
        exportBtn.addEventListener('click', exportToMarkdown);

        anchor.parentNode.insertBefore(exportBtn, anchor);
    }

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

})();