-
-
Save bczhc/c9406492ee7e24d1d60b276185b65fe2 to your computer and use it in GitHub Desktop.
Gemini Save to TXT Tampermonkey
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name Gemini Save to TXT | |
| // @namespace https://github.com/bczhc | |
| // @version 2.0 | |
| // @description Extract current Gemini conversation and download as .txt | |
| // @author bczhc | |
| // @match https://gemini.google.com/app/* | |
| // @icon https://www.google.com/s2/favicons?sz=64&domain=gemini.google.com | |
| // @grant GM_registerMenuCommand | |
| // @grant GM_notification | |
| // @run-at document-idle | |
| // ==/UserScript== | |
| (function() { | |
| 'use strict'; | |
| // ========================================================================= | |
| // Markdown 提取核心 | |
| // ========================================================================= | |
| function collapseWs(s) { | |
| return s.replace(/\s+/g, ' ').trim(); | |
| } | |
| function childrenToMarkdown(node) { | |
| let result = ''; | |
| for (let child = node.firstChild; child; child = child.nextSibling) { | |
| result += nodeToMarkdown(child); | |
| } | |
| return result; | |
| } | |
| function nodeToMarkdown(node) { | |
| if (node.nodeType === Node.TEXT_NODE) return node.textContent; | |
| if (node.nodeType !== Node.ELEMENT_NODE) return ''; | |
| const tag = node.tagName; | |
| const cls = node.className || ''; | |
| if (cls.includes('cdk-visually-hidden') || cls.includes('screen-reader')) return ''; | |
| const skipTags = ['RESPONSE-ELEMENT', 'LINK-BLOCK', 'THINKING-OVERLAY', | |
| 'STRUCTURED-CONTENT-CONTAINER', 'MESSAGE-CONTENT', | |
| 'GEM-ICON-BUTTON', 'GEM-ICON', 'MAT-ICON', | |
| 'THUMB-UP-BUTTON', 'THUMB-DOWN-BUTTON', 'REGENERATE-BUTTON', | |
| 'COPY-BUTTON', 'MESSAGE-ACTIONS', 'SOURCES-LIST', | |
| 'RESPONSE-CONTAINER', 'SENSITIVE-MEMORIES-BANNER']; | |
| if (skipTags.includes(tag)) return childrenToMarkdown(node); | |
| if (tag === 'SPAN' && cls.includes('math-inline')) { | |
| const math = node.getAttribute('data-math') || ''; | |
| return math ? '$' + math + '$' : node.textContent; | |
| } | |
| if (tag === 'SPAN' && cls.includes('math-display')) { | |
| const math = node.getAttribute('data-math') || ''; | |
| return math ? '\n$$\n' + math + '\n$$\n' : node.textContent; | |
| } | |
| if (tag === 'CODE-BLOCK') { | |
| const langSpan = node.querySelector('.code-block-decoration > span'); | |
| const lang = langSpan ? langSpan.textContent.trim() : ''; | |
| const codeEl = node.querySelector('code.code-container.formatted'); | |
| const code = codeEl ? extractCodeText(codeEl) : node.textContent; | |
| return '\n```' + (lang || '') + '\n' + code.replace(/\n{3,}/g, '\n\n').trim() + '\n```\n'; | |
| } | |
| if (/^H[1-6]$/.test(tag)) { | |
| const level = parseInt(tag[1]); | |
| return '\n' + '#'.repeat(level) + ' ' + collapseWs(childrenToMarkdown(node)) + '\n'; | |
| } | |
| if (tag === 'P') { | |
| const content = childrenToMarkdown(node).trim(); | |
| return content ? '\n' + content + '\n' : ''; | |
| } | |
| if (tag === 'B' || tag === 'STRONG') return '**' + childrenToMarkdown(node) + '**'; | |
| if (tag === 'I' || tag === 'EM') return '*' + childrenToMarkdown(node) + '*'; | |
| if (tag === 'S' || tag === 'DEL' || tag === 'STRIKE') return '~~' + childrenToMarkdown(node) + '~~'; | |
| if (tag === 'CODE' && !cls.includes('code-container')) { | |
| if (node.closest('code-block') || node.closest('pre')) return childrenToMarkdown(node); | |
| return '`' + node.textContent + '`'; | |
| } | |
| if (tag === 'A') { | |
| const href = node.getAttribute('href') || ''; | |
| const text = collapseWs(childrenToMarkdown(node)); | |
| if (!text) return ''; | |
| if (href.startsWith('/app/') || href.startsWith('https://gemini.google.com/app/')) return text; | |
| return '[' + text + '](' + href + ')'; | |
| } | |
| if (tag === 'IMG') { | |
| const alt = node.getAttribute('alt') || ''; | |
| const src = node.getAttribute('src') || ''; | |
| return ''; | |
| } | |
| if (tag === 'HR') return '\n---\n'; | |
| if (tag === 'BR') return '\n'; | |
| if (tag === 'UL') { | |
| const items = []; | |
| const lis = Array.from(node.children).filter(c => c.tagName === 'LI'); | |
| for (const li of lis) items.push(processListItem(li, '-', 0)); | |
| return '\n' + items.join('') + '\n'; | |
| } | |
| if (tag === 'OL') { | |
| const items = []; | |
| const start = parseInt(node.getAttribute('start')) || 1; | |
| const lis = Array.from(node.children).filter(c => c.tagName === 'LI'); | |
| lis.forEach((li, i) => items.push(processListItem(li, (start + i) + '.', 0))); | |
| return '\n' + items.join('') + '\n'; | |
| } | |
| if (tag === 'BLOCKQUOTE') { | |
| const content = childrenToMarkdown(node).trim(); | |
| const lines = content.split('\n').filter(l => l.trim()); | |
| return '\n' + lines.map(l => '> ' + l).join('\n') + '\n'; | |
| } | |
| if (tag === 'TABLE') return '\n' + tableToMarkdown(node) + '\n'; | |
| if (tag === 'DIV' && cls.includes('horizontal-scroll-wrapper')) return childrenToMarkdown(node); | |
| if (tag === 'DIV' && cls.includes('markdown-main-panel')) return childrenToMarkdown(node); | |
| if (tag === 'PRE' && !node.closest('code-block')) { | |
| const code = node.querySelector('code'); | |
| const text = code ? code.textContent : node.textContent; | |
| return '\n```\n' + text.trim() + '\n```\n'; | |
| } | |
| return childrenToMarkdown(node); | |
| } | |
| function extractCodeText(codeEl) { | |
| const lines = []; | |
| let currentLine = ''; | |
| function walk(n) { | |
| if (n.nodeType === Node.TEXT_NODE) { | |
| currentLine += n.textContent; | |
| } else if (n.nodeType === Node.ELEMENT_NODE && n.tagName === 'BR') { | |
| lines.push(currentLine); | |
| currentLine = ''; | |
| } else if (n.nodeType === Node.ELEMENT_NODE) { | |
| for (let c = n.firstChild; c; c = c.nextSibling) walk(c); | |
| } | |
| } | |
| walk(codeEl); | |
| if (currentLine) lines.push(currentLine); | |
| return lines.join('\n'); | |
| } | |
| function processListItem(li, prefix, depth) { | |
| const indent = ' '.repeat(depth); | |
| let text = ''; | |
| let nested = ''; | |
| for (let c = li.firstChild; c; c = c.nextSibling) { | |
| if (c.nodeType === Node.ELEMENT_NODE && (c.tagName === 'UL' || c.tagName === 'OL')) { | |
| const subItems = []; | |
| const subLis = Array.from(c.children).filter(ch => ch.tagName === 'LI'); | |
| if (c.tagName === 'UL') { | |
| subLis.forEach(sub => subItems.push(processListItem(sub, '-', depth + 1))); | |
| } else { | |
| const start = parseInt(c.getAttribute('start')) || 1; | |
| subLis.forEach((sub, i) => subItems.push(processListItem(sub, (start + i) + '.', depth + 1))); | |
| } | |
| nested = subItems.join(''); | |
| } else { | |
| text += nodeToMarkdown(c); | |
| } | |
| } | |
| return indent + prefix + ' ' + collapseWs(text) + '\n' + nested; | |
| } | |
| function tableToMarkdown(table) { | |
| const rows = []; | |
| const thead = table.querySelector('thead'); | |
| if (thead) thead.querySelectorAll('tr').forEach(tr => rows.push(getRowCells(tr))); | |
| const tbody = table.querySelector('tbody'); | |
| if (tbody) tbody.querySelectorAll('tr').forEach(tr => rows.push(getRowCells(tr))); | |
| if (rows.length === 0) table.querySelectorAll('tr').forEach(tr => rows.push(getRowCells(tr))); | |
| if (rows.length < 2) return ''; | |
| const maxCols = Math.max(...rows.map(r => r.length)); | |
| rows.forEach(r => { while (r.length < maxCols) r.push(''); }); | |
| const lines = []; | |
| lines.push('| ' + rows[0].map(c => collapseWs(c)).join(' | ') + ' |'); | |
| lines.push('| ' + rows[0].map(() => '---').join(' | ') + ' |'); | |
| for (let i = 1; i < rows.length; i++) { | |
| lines.push('| ' + rows[i].map(c => collapseWs(c)).join(' | ') + ' |'); | |
| } | |
| return lines.join('\n'); | |
| } | |
| function getRowCells(tr) { | |
| return Array.from(tr.querySelectorAll('td, th')).map(cell => cell.textContent.trim()); | |
| } | |
| function cleanMarkdown(md) { | |
| md = md.replace(/\n{4,}/g, '\n\n\n').trim(); | |
| return md.split('\n').map(l => l.trimEnd()).join('\n'); | |
| } | |
| function getCurrentTitle() { | |
| const current = document.querySelector('[aria-current]'); | |
| if (current) { | |
| const txt = current.textContent.trim(); | |
| if (txt && txt.length > 0 && txt.length < 200) return txt; | |
| } | |
| const t = document.title || ''; | |
| return t.replace(/\s*[-–—|]\s*Google\s*Gemini\s*$/i, '').trim() || 'Gemini Chat'; | |
| } | |
| // ========================================================================= | |
| // 提取 & 下载 | |
| // ========================================================================= | |
| function extractAndDownload() { | |
| const chatHistory = document.querySelector('infinite-scroller[data-test-id="chat-history-container"]'); | |
| if (!chatHistory) { | |
| alert('[Gemini Save] 未找到聊天历史。'); | |
| return; | |
| } | |
| const turns = chatHistory.querySelectorAll('.conversation-container'); | |
| if (turns.length === 0) { | |
| alert('[Gemini Save] 未找到任何对话。'); | |
| return; | |
| } | |
| const outputParts = []; | |
| turns.forEach(turn => { | |
| const queryText = turn.querySelector('.query-text'); | |
| if (queryText) { | |
| const lines = queryText.querySelectorAll('p.query-text-line'); | |
| const userText = Array.from(lines) | |
| .map(p => { | |
| if (p.querySelector('br') && !p.textContent.trim()) return ''; | |
| return p.textContent.trim(); | |
| }) | |
| .join('\n'); | |
| if (userText) outputParts.push('[User]\n' + userText); | |
| } | |
| const responseContainers = turn.querySelectorAll('response-container'); | |
| const allDrafts = []; | |
| responseContainers.forEach(rc => { | |
| const containerDiv = rc.querySelector('structured-content-container > div.container'); | |
| if (!containerDiv) return; | |
| const mdParts = []; | |
| for (const child of containerDiv.children) { | |
| if (child.tagName === 'MESSAGE-CONTENT') { | |
| const md = nodeToMarkdown(child); | |
| if (md.trim()) mdParts.push(md); | |
| } else if (child.tagName === 'CODE-BLOCK') { | |
| const md = nodeToMarkdown(child); | |
| if (md.trim()) mdParts.push(md); | |
| } | |
| } | |
| const draftText = cleanMarkdown(mdParts.join('').trim()); | |
| if (draftText) allDrafts.push(draftText); | |
| }); | |
| if (allDrafts.length > 1) { | |
| allDrafts.forEach((draft, di) => { | |
| outputParts.push('[Gemini#' + (di + 1) + ']\n' + draft); | |
| }); | |
| } else if (allDrafts.length === 1) { | |
| outputParts.push('[Gemini]\n' + allDrafts[0]); | |
| } | |
| }); | |
| const title = getCurrentTitle(); | |
| const fullText = '# ' + title + '\n\n' + outputParts.join('\n\n---\n\n'); | |
| const blob = new Blob([fullText], { type: 'text/plain;charset=utf-8' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = title.replace(/[/\\?%*:|"<>]/g, '_') + '.txt'; | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| URL.revokeObjectURL(url); | |
| } | |
| // ========================================================================= | |
| // 注册到 Tampermonkey 菜单 | |
| // ========================================================================= | |
| GM_registerMenuCommand('Save to txt \u{1F4BE}', extractAndDownload); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment