Gemini Chat Markdown Exporter (Thoughts Included)

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

当前为 2025-09-25 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

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

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Gemini Chat Markdown Exporter (Thoughts Included)
// @namespace    https://github.com/NoahTheGinger/Userscripts/
// @version      0.4.1
// @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 for batchexecute.
     * Supports:
     *   - /app/:chatId
     *   - /gem/:gemId/:chatId
     *
     * Returns:
     *   { kind: 'app'|'gem', chatId: string, gemId?: string, sourcePath: string }
     * or null when not on a conversation page.
     */
    function getRouteFromUrl() {
        const path = location.pathname.replace(/\/+$/, ''); // trim trailing slash
        // /app/:chatId
        let m = path.match(/^\/app\/([a-z0-9_]+)/i);
        if (m) {
            const chatId = m[1];
            return { kind: 'app', chatId, sourcePath: `/app/${chatId}` };
        }
        // /gem/:gemId/:chatId
        m = path.match(/^\/gem\/([a-z0-9]+)\/([a-z0-9_]+)/i);
        if (m) {
            const gemId = m[1];
            const chatId = m[2];
            return { kind: 'gem', gemId, chatId, sourcePath: `/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;
    }

    // ---------------------------
    // 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 1000 like before so large histories export in one go (works for /app and /gem).
        const innerArgs = JSON.stringify([convKey, 1000, null, 1, [0], [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(`/_/BardChatUi/data/batchexecute?${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(`/_/BardChatUi/data/batchexecute?${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');

				// Robust scan: look anywhere in the payloads for ["c_xxx","Title",...]
				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)) {
				// Direct match: ["c_...", "Title", ...]
				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;
	}

    function findConversationList(node) {
        // MaZiqc response contains an array of conversations like:
        // [["c_xxx", "Title", null, ...], ["c_yyy", "Another Title", ...], ...]
        if (Array.isArray(node)) {
            if (node.length > 0 && Array.isArray(node[0]) &&
                typeof node[0][0] === 'string' && node[0][0].startsWith('c_') &&
                typeof node[0][1] === 'string') {
                return node;
            }
            for (const child of node) {
                const result = findConversationList(child);
                if (result) return result;
            }
        }
        return null;
    }

    // ---------------------------
    // 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';
                // Remove common prefixes/suffixes
                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();
    }
})();