Gemini Chat Markdown Exporter (Thoughts Included)

Export the current Gemini chat to Markdown via internal batchexecute RPC (with Thoughts content when present).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Gemini Chat Markdown Exporter (Thoughts Included)
// @namespace    https://github.com/NoahTheGinger/Userscripts/
// @version      0.4.2
// @description  Export the current Gemini chat to Markdown via internal batchexecute RPC (with Thoughts content when present).
// @author       NoahTheGinger
// @match        https://gemini.google.com/*
// @grant        none
// @run-at       document-end
// @license      MIT
// ==/UserScript==

(function () {
    'use strict';

    // ---------------------------
    // Utilities
    // ---------------------------
    function $(sel, root = document) {
        return root.querySelector(sel);
    }
    function getCurrentTimestamp() {
        return new Date().toISOString().slice(0, 19).replace(/:/g, '-');
    }
    function sanitizeFilename(title) {
        return (title || 'Gemini Chat').replace(/[<>:"/\\|?\*]/g, '_').replace(/\s+/g, '_');
    }
    function downloadFile(filename, mime, content) {
        const blob = content instanceof Blob ? content : new Blob([content], { type: mime });
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url;
        a.download = filename;
        document.body.appendChild(a);
        a.click();
        a.remove();
        URL.revokeObjectURL(url);
    }
    function stdLB(text) {
        return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
    }

    // ---------------------------
    // Page state helpers
    // ---------------------------
    /**
     * Detect route and build the correct source-path and account-aware RPC base.
     * Supports:
     *   - /app/:chatId
     *   - /gem/:gemId/:chatId
     *   - /u/:index/app/:chatId
     *   - /u/:index/gem/:gemId/:chatId
     *
     * Returns:
     *   {
     *     kind: 'app'|'gem',
     *     chatId: string,
     *     gemId?: string,
     *     userIndex?: string,
     *     basePrefix: '' | '/u/:index',
     *     sourcePath: string
     *   }
     * or null when not on a conversation page.
     */
    function getRouteFromUrl() {
        const path = location.pathname.replace(/\/+$/, ''); // trim trailing slash(es)
        const segs = path.split('/').filter(Boolean);       // remove empty segments

        if (segs.length === 0) return null;

        let basePrefix = '';
        let userIndex = null;
        let i = 0;

        // Optional "/u/:index" prefix
        if (segs[0] === 'u' && /^\d+$/.test(segs[1] || '')) {
            userIndex = segs[1];
            basePrefix = `/u/${userIndex}`;
            i = 2;
        }

        // /app/:chatId
        if (segs[i] === 'app' && segs[i + 1]) {
            const chatId = segs[i + 1];
            return {
                kind: 'app',
                chatId,
                userIndex,
                basePrefix,
                sourcePath: `${basePrefix}/app/${chatId}`
            };
        }

        // /gem/:gemId/:chatId
        if (segs[i] === 'gem' && segs[i + 1] && segs[i + 2]) {
            const gemId = segs[i + 1];
            const chatId = segs[i + 2];
            return {
                kind: 'gem',
                gemId,
                chatId,
                userIndex,
                basePrefix,
                sourcePath: `${basePrefix}/gem/${gemId}/${chatId}`
            };
        }

        return null;
    }

    function getLang() {
        return document.documentElement.lang || 'en';
    }
    function getAtToken() {
        const input = $('input[name="at"]');
        if (input?.value) return input.value;

        const html = document.documentElement.innerHTML;
        let m = html.match(/"SNlM0e":"([^"]+)"/);
        if (m) return m[1];
        try {
            if (window.WIZ_global_data?.SNlM0e) return window.WIZ_global_data.SNlM0e;
        } catch {}
        return null;
    }

    function getBatchUrl(route) {
        const prefix = route.basePrefix || '';
        return `${prefix}/_/BardChatUi/data/batchexecute`;
    }

    // ---------------------------
    // Batchexecute calls
    // ---------------------------
    async function fetchConversationPayload(route) {
        const at = getAtToken();
        if (!at) throw new Error('Could not find anti-CSRF token "at" on the page.');

        const chatId = route.chatId;
        const convKey = chatId.startsWith('c_') ? chatId : `c_${chatId}`;

        // Keep a large page size so long histories export in one go.
        // Aligning shape with current RPC (5th arg [1]) but using 1000 for size.
        const innerArgs = JSON.stringify([convKey, 1000, null, 1, [1], [4], null, 1]);
        const fReq = [[["hNvQHb", innerArgs, null, "generic"]]];
        const params = new URLSearchParams({
            rpcids: 'hNvQHb',
            'source-path': route.sourcePath,
            hl: getLang(),
            rt: 'c'
        });
        const body = new URLSearchParams({ 'f.req': JSON.stringify(fReq), at });

        const res = await fetch(`${getBatchUrl(route)}?${params.toString()}`, {
            method: 'POST',
            headers: {
                'content-type': 'application/x-www-form-urlencoded;charset=UTF-8',
                'x-same-domain': '1',
                'accept': '*/*'
            },
            body: body.toString() + '&'
        });
        if (!res.ok) {
            const t = await res.text().catch(() => '');
            throw new Error(`batchexecute failed: ${res.status} ${res.statusText}${t ? `\n${t.slice(0, 300)}` : ''}`);
        }
        return res.text();
    }

    async function fetchConversationTitle(route) {
        const at = getAtToken();
        if (!at) return null;

        const fullChatId = route.chatId.startsWith('c_') ? route.chatId : `c_${route.chatId}`;

        // Try the argument patterns we see in Gem pages first, then fallback.
        const tryArgsList = [
            JSON.stringify([13, null, [0, null, 1]]),  // what Gem pages use
            JSON.stringify([200, null, [0, null, 1]]), // larger page size, helps if the chat is older
            null                                       // legacy null-args (works for /app in many cases)
        ];

        for (const innerArgs of tryArgsList) {
            try {
                const fReq = [[["MaZiqc", innerArgs, null, "generic"]]];
                const params = new URLSearchParams({
                    rpcids: 'MaZiqc',
                    'source-path': route.sourcePath,
                    hl: getLang(),
                    rt: 'c'
                });
                const body = new URLSearchParams({ 'f.req': JSON.stringify(fReq), at });

                const res = await fetch(`${getBatchUrl(route)}?${params.toString()}`, {
                    method: 'POST',
                    headers: {
                        'content-type': 'application/x-www-form-urlencoded;charset=UTF-8',
                        'x-same-domain': '1',
                        'accept': '*/*'
                    },
                    body: body.toString() + '&'
                });

                if (!res.ok) continue;

                const text = await res.text();
                const payloads = parseBatchExecute(text, 'MaZiqc');

                for (const payload of payloads) {
                    const title = findTitleInPayload(payload, fullChatId);
                    if (title) return title;
                }
            } catch {
                // Try next argument pattern
            }
        }
        return null;
    }

    function findTitleInPayload(root, fullChatId) {
        let found = null;
        (function walk(node) {
            if (found) return;
            if (Array.isArray(node)) {
                if (node.length >= 2 &&
                    typeof node[0] === 'string' &&
                    node[0] === fullChatId &&
                    typeof node[1] === 'string' &&
                    node[1].trim()) {
                    found = node[1].trim();
                    return;
                }
                for (const child of node) walk(child);
            }
        })(root);
        return found;
    }

    // ---------------------------
    // Google batchexecute parser
    // ---------------------------
    function parseBatchExecute(text, targetRpcId = 'hNvQHb') {
        if (text.startsWith(")]}'\n")) {
            const nl = text.indexOf('\n');
            text = nl >= 0 ? text.slice(nl + 1) : '';
        }
        const lines = text.split('\n').filter(l => l.trim().length > 0);
        const payloads = [];

        for (let i = 0; i < lines.length; ) {
            const lenStr = lines[i++];
            const len = parseInt(lenStr, 10);
            if (!isFinite(len)) break;
            const jsonLine = lines[i++] || '';
            let segment;
            try {
                segment = JSON.parse(jsonLine);
            } catch {
                continue;
            }
            if (Array.isArray(segment)) {
                for (const entry of segment) {
                    if (Array.isArray(entry) && entry[0] === 'wrb.fr' && entry[1] === targetRpcId) {
                        const s = entry[2];
                        if (typeof s === 'string') {
                            try {
                                const inner = JSON.parse(s);
                                payloads.push(inner);
                            } catch {
                                // ignore
                            }
                        }
                    }
                }
            }
        }
        return payloads;
    }

    // ---------------------------
    // Conversation extraction (block-based, with Thoughts)
    // ---------------------------
    function isUserMessageNode(node) {
        return (
            Array.isArray(node) &&
            node.length >= 2 &&
            Array.isArray(node[0]) &&
            node[0].length >= 1 &&
            node[0].every(p => typeof p === 'string') &&
            (node[1] === 2 || node[1] === 1)
        );
    }

    function getUserTextFromNode(userNode) {
        try {
            return userNode[0].join('\n');
        } catch {
            return '';
        }
    }

    function isAssistantNode(node) {
        return (
            Array.isArray(node) &&
            node.length >= 2 &&
            typeof node[0] === 'string' &&
            node[0].startsWith('rc_') &&
            Array.isArray(node[1]) &&
            typeof node[1][0] === 'string'
        );
    }

    function isAssistantContainer(node) {
        return (
            Array.isArray(node) &&
            node.length >= 1 &&
            Array.isArray(node[0]) &&
            node[0].length >= 1 &&
            isAssistantNode(node[0][0])
        );
    }

    function getAssistantNodeFromContainer(container) {
        try {
            return container[0][0];
        } catch {
            return null;
        }
    }

    function getAssistantTextFromNode(assistantNode) {
        try {
            return assistantNode[1][0] || '';
        } catch {
            return '';
        }
    }

    function extractReasoningFromAssistantNode(assistantNode) {
        if (!Array.isArray(assistantNode)) return null;
        for (let k = assistantNode.length - 1; k >= 0; k--) {
            const child = assistantNode[k];
            if (Array.isArray(child)) {
                if (
                    child.length >= 2 &&
                    Array.isArray(child[1]) &&
                    child[1].length >= 1 &&
                    Array.isArray(child[1][0]) &&
                    child[1][0].length >= 1 &&
                    child[1][0].every(x => typeof x === 'string')
                ) {
                    const txt = child[1][0].join('\n\n').trim();
                    if (txt) return txt;
                }
                if (
                    Array.isArray(child[0]) &&
                    child[0].length >= 1 &&
                    child[0].every(x => typeof x === 'string')
                ) {
                    const txt = child[0].join('\n\n').trim();
                    if (txt) return txt;
                }
            }
        }
        return null;
    }

    function isTimestampPair(arr) {
        return Array.isArray(arr) && arr.length === 2 && typeof arr[0] === 'number' && typeof arr[1] === 'number' && arr[0] > 1_600_000_000;
    }

    function cmpTimestampAsc(a, b) {
        if (!a.tsPair && !b.tsPair) return 0;
        if (!a.tsPair) return -1;
        if (!b.tsPair) return 1;
        if (a.tsPair[0] !== b.tsPair[0]) return a.tsPair[0] - b.tsPair[0];
        return a.tsPair[1] - b.tsPair[1];
    }

    function detectBlock(node) {
        if (!Array.isArray(node)) return null;
        let userNode = null;
        let assistantContainer = null;
        let tsCandidate = null;

        for (const child of node) {
            if (isUserMessageNode(child) && !userNode) userNode = child;
            if (isAssistantContainer(child) && !assistantContainer) assistantContainer = child;
            if (isTimestampPair(child)) {
                if (!tsCandidate || child[0] > tsCandidate[0] || (child[0] === tsCandidate[0] && child[1] > tsCandidate[1])) {
                    tsCandidate = child;
                }
            }
        }
        if (userNode && assistantContainer) {
            const assistantNode = getAssistantNodeFromContainer(assistantContainer);
            if (!assistantNode) return null;
            const userText = getUserTextFromNode(userNode);
            const assistantText = getAssistantTextFromNode(assistantNode);
            const thoughtsText = extractReasoningFromAssistantNode(assistantNode);
            return {
                userText,
                assistantText,
                thoughtsText: thoughtsText || null,
                tsPair: tsCandidate || null
            };
        }
        return null;
    }

    function extractBlocksFromPayloadRoot(root) {
        const blocks = [];
        const seenComposite = new Set();

        function scan(node) {
            if (!Array.isArray(node)) return;
            const block = detectBlock(node);
            if (block) {
                const key = JSON.stringify([
                    block.userText,
                    block.assistantText,
                    block.thoughtsText || '',
                    block.tsPair?.[0] || 0,
                    block.tsPair?.[1] || 0
                ]);
                if (!seenComposite.has(key)) {
                    seenComposite.add(key);
                    blocks.push(block);
                }
            }
            for (const child of node) scan(child);
        }
        scan(root);
        return blocks;
    }

    function extractAllBlocks(payloads) {
        let blocks = [];
        for (const p of payloads) {
            const b = extractBlocksFromPayloadRoot(p);
            blocks = blocks.concat(b);
        }
        const withIndex = blocks.map((b, i) => ({ ...b, _i: i }));
        withIndex.sort((a, b) => {
            const c = cmpTimestampAsc(a, b);
            return c !== 0 ? c : a._i - b._i;
        });
        return withIndex.map(({ _i, ...rest }) => rest);
    }

    // ---------------------------
    // Markdown formatter
    // With dividers between blocks
    // ---------------------------
    function blocksToMarkdown(blocks, title = 'Gemini Chat') {
        const parts = [];

        for (let i = 0; i < blocks.length; i++) {
            const blk = blocks[i];
            const u = (blk.userText || '').trim();
            const a = (blk.assistantText || '').trim();
            const t = (blk.thoughtsText || '').trim();

            const blockParts = [];
            if (u) blockParts.push(`#### User:\n${u}`);
            if (t) blockParts.push(`#### Thoughts:\n${t}`);
            if (a) blockParts.push(`#### Assistant:\n${a}`);

            if (blockParts.length > 0) {
                parts.push(blockParts.join('\n\n---\n\n'));
                if (i < blocks.length - 1) {
                    parts.push('---');
                }
            }
        }

        return `# ${title}\n\n${parts.join('\n\n')}\n`;
    }

    // ---------------------------
    // Button UI
    // ---------------------------
    function createExportButton() {
        const btn = document.createElement('button');
        btn.id = 'gemini-export-btn';
        btn.textContent = 'Export';
        btn.title = 'Export current Gemini chat to Markdown';
        Object.assign(btn.style, {
            position: 'fixed',
            bottom: '20px',
            right: '20px',
            zIndex: 100000,
            background: '#1a73e8',
            color: '#fff',
            border: 'none',
            borderRadius: '6px',
            padding: '10px 14px',
            fontSize: '14px',
            fontWeight: '600',
            cursor: 'pointer',
            boxShadow: '0 2px 8px rgba(0,0,0,0.15)'
        });
        btn.onmouseenter = () => { btn.style.background = '#1558c0'; };
        btn.onmouseleave = () => { btn.style.background = '#1a73e8'; };
        btn.onclick = doExport;
        return btn;
    }

    function injectButton() {
        if ($('#gemini-export-btn')) return;
        document.body.appendChild(createExportButton());
    }

    // ---------------------------
    // Main export flow
    // ---------------------------
    async function doExport() {
        try {
            const route = getRouteFromUrl();
            if (!route || !route.chatId) {
                alert('Open a chat at /app/:chatId or /gem/:gemId/:chatId before exporting.');
                return;
            }

            // Fetch conversation data
            const raw = await fetchConversationPayload(route);
            const payloads = parseBatchExecute(raw);
            if (!payloads.length) throw new Error('No conversation payloads found in batchexecute response.');

            const blocks = extractAllBlocks(payloads);
            if (!blocks.length) throw new Error('Could not extract any User/Assistant message pairs.');

            // Try to fetch the actual conversation title
            let title = await fetchConversationTitle(route);
            if (!title) {
                // Fallback to document title or default
                title = document.title?.trim() || 'Gemini Chat';
                if (title.includes(' - Gemini')) {
                    title = title.split(' - Gemini')[0].trim();
                }
                if (title === 'Gemini' || title === 'Google Gemini') {
                    title = 'Gemini Chat';
                }
            }

            const md = stdLB(blocksToMarkdown(blocks, title));
            const filename = `${sanitizeFilename(title)}_${getCurrentTimestamp()}.md`;
            downloadFile(filename, 'text/markdown', md);
        } catch (err) {
            console.error('[Gemini Exporter] Error:', err);
            alert(`Export failed: ${err?.message || err}`);
        }
    }

    // ---------------------------
    // Boot
    // ---------------------------
    function init() {
        injectButton();
        let lastHref = location.href;
        setInterval(() => {
            if (location.href !== lastHref) {
                lastHref = location.href;
                setTimeout(injectButton, 800);
            }
        }, 1000);
    }
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }
})();