编辑
2026-04-04
软件
00

复制到油猴脚本即可,支持以下站点

js
// ==UserScript== // @name Gemini 聊天记录导出(标题化Markdown+复制) // @namespace https://tampermonkey.net/ // @version 1.3.0 // @description 提取当前会话聊天记录;去掉+1/+2引用标记;保留Markdown;支持导出和复制 // @author You // @match https://gemini-d-google-d-com-s-gmn.d.tuangouai.com/app* // @match https://gemini.google.com/app* // @grant none // @require https://cdn.jsdelivr.net/npm/turndown/dist/turndown.js // ==/UserScript== (function () { 'use strict'; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); function normalizeText(s) { return (s || '') .replace(/\u200B/g, '') .replace(/\r/g, '') .replace(/\n{3,}/g, '\n\n') .trim(); } function oneLine(s) { return normalizeText(s).replace(/\n+/g, ' ').trim(); } function cleanupCitationTokensInMarkdown(md) { if (!md) return ''; return md // 删除独立的 +1 +2 +23 标记(前后是空白/标点/行首行尾) .replace(/(^|[\s((\[])\+\d+(?=([\s)\]).,,;;::!?!?]|$))/g, '$1') // 删除整行只有 +1 +2 的行 .replace(/^\s*(\+\d+\s*)+$/gm, '') .replace(/\n{3,}/g, '\n\n') .trim(); } function getTurndown() { if (typeof TurndownService !== 'undefined') { return new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced', emDelimiter: '*', bulletListMarker: '-', hr: '---' }); } return null; } function cleanModelNode(node) { const clone = node.cloneNode(true); // 仅移除明显“非正文”控件,避免误删回答正文 const removeSelectors = [ 'button', 'mat-icon', 'message-actions', 'sources-carousel', 'source-list', 'fact-check', 'style', 'script', 'noscript', 'svg' ]; clone.querySelectorAll(removeSelectors.join(',')).forEach((el) => el.remove()); // 只删除文本本身是 +数字 的小节点 clone.querySelectorAll('*').forEach((el) => { const t = (el.textContent || '').trim(); if (/^\+\d+$/.test(t)) el.remove(); }); return clone; } function htmlToMarkdownFromModelNode(modelNode) { if (!modelNode) return ''; const cleaned = cleanModelNode(modelNode); const td = getTurndown(); if (td) { try { // 直接传 DOM,避免在启用 Trusted Types 的页面里 parseFromString 被拦截 return cleanupCitationTokensInMarkdown(td.turndown(cleaned)); } catch (_) { // 回退:保底使用纯文本 return cleanupCitationTokensInMarkdown(normalizeText(cleaned.innerText || cleaned.textContent || '')); } } return cleanupCitationTokensInMarkdown(normalizeText(cleaned.innerText || cleaned.textContent || '')); } function getConversationTitle() { // 1) 优先使用侧边栏当前选中的会话标题(最稳定) const currentPath = location.pathname; const selectedConversation = document.querySelector(`a[data-test-id="conversation"][href="${currentPath}"] .conversation-title`) || document.querySelector('a[data-test-id="conversation"][aria-current="true"] .conversation-title') || document.querySelector('a[data-test-id="conversation"].selected .conversation-title') || document.querySelector('.conversations-container .conversation.selected .conversation-title'); const selectedTitle = oneLine(selectedConversation?.textContent || ''); if (selectedTitle) return selectedTitle; // 2) 再尝试顶部标题区域 const candidates = [ '.conversation-title-button .mdc-button__label', '.conversation-title-button', '.project-conversation-title', '.conversation-title-container .conversation-title', 'conversation-title button', '[data-test-id="conversation-title"]', 'h1' ]; for (const sel of candidates) { const el = document.querySelector(sel); const t = oneLine(el?.textContent || ''); if (t) return t; } const dt = oneLine(document.title || ''); if (dt) return dt.replace(/\s*[-||]\s*Gemini.*$/i, '').trim() || dt; return '聊天记录导出'; } async function tryLoadMoreHistory() { const scroller = document.querySelector('#chat-history') || document.querySelector('.chat-history-scroll-container') || document.querySelector('infinite-scroller'); if (!scroller || !(scroller instanceof HTMLElement)) return; let stableCount = 0; let lastHeight = 0; for (let i = 0; i < 25; i++) { scroller.scrollTop = 0; await sleep(450); const h = scroller.scrollHeight; if (h === lastHeight) stableCount++; else { stableCount = 0; lastHeight = h; } if (stableCount >= 3) break; } } function extractChats() { const containers = Array.from(document.querySelectorAll('.conversation-container')); const result = []; containers.forEach((c) => { const qLines = Array.from(c.querySelectorAll('user-query-content .query-text-line')) .map((x) => normalizeText(x.innerText)) .filter(Boolean); const userText = qLines.join('\n') || normalizeText(c.querySelector('user-query-content .query-content')?.innerText || ''); const modelRoot = c.querySelector('model-response message-content .markdown-main-panel') || c.querySelector('model-response message-content .markdown') || c.querySelector('model-response message-content') || c.querySelector('model-response .response-content'); const modelMarkdown = htmlToMarkdownFromModelNode(modelRoot); if (!userText && !modelMarkdown) return; result.push({ user: userText, model: modelMarkdown }); }); return result; } function toMarkdown(items, conversationTitle) { function normalizeModelHeadingLevels(md) { if (!md) return ''; const lines = md.split('\n'); let inFence = false; for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (/^\s*```/.test(line)) { inFence = !inFence; continue; } if (inFence) continue; const m = line.match(/^(\s*)(#{1,6})(\s+)(.*)$/); if (!m) continue; const indent = m[1]; const level = m[2].length; const space = m[3]; const text = m[4]; const newLevel = level < 3 ? 3 : level; lines[i] = `${indent}${'#'.repeat(newLevel)}${space}${text}`; } return lines.join('\n'); } const lines = []; lines.push(`# ${conversationTitle || '聊天记录导出'}`); lines.push(''); for (const it of items) { const qTitle = oneLine(it.user) || '(空提问)'; lines.push(`## ${qTitle}`); lines.push(''); lines.push(normalizeModelHeadingLevels(it.model || '(空)')); lines.push(''); } return lines.join('\n').trim() + '\n'; } function toJsonString(items, conversationTitle) { return JSON.stringify( { title: conversationTitle || '聊天记录导出', exportedAt: new Date().toISOString(), total: items.length, items }, null, 2 ); } function download(filename, content, type = 'text/plain;charset=utf-8') { const blob = new Blob([content], { type }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); } function showToast(message) { const id = 'tm-chat-export-toast'; let toast = document.getElementById(id); if (!toast) { toast = document.createElement('div'); toast.id = id; toast.style.cssText = ` position: fixed; top: 20px; right: 20px; z-index: 1000000; background: rgba(0, 0, 0, 0.85); color: #fff; padding: 10px 14px; border-radius: 8px; font-size: 13px; line-height: 1.4; box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2); opacity: 0; transform: translateY(-6px); transition: opacity 0.2s ease, transform 0.2s ease; pointer-events: none; `; document.body.appendChild(toast); } toast.textContent = message; toast.style.opacity = '1'; toast.style.transform = 'translateY(0)'; clearTimeout(showToast._timer); showToast._timer = setTimeout(() => { toast.style.opacity = '0'; toast.style.transform = 'translateY(-6px)'; }, 1600); } async function copyToClipboard(text) { try { await navigator.clipboard.writeText(text); showToast('已复制到剪贴板'); } catch (_) { const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.left = '-9999px'; document.body.appendChild(ta); ta.select(); const copied = document.execCommand('copy'); ta.remove(); if (copied) showToast('已复制到剪贴板'); else alert('复制失败,请手动复制。'); } } async function buildDataOrAlert() { await tryLoadMoreHistory(); const data = extractChats(); if (!data.length) { alert('未提取到聊天内容,请先打开有聊天记录的会话。'); return null; } return { title: getConversationTitle(), items: data }; } async function exportJSON() { const data = await buildDataOrAlert(); if (!data) return; download( `gemini_chat_${Date.now()}.json`, toJsonString(data.items, data.title), 'application/json;charset=utf-8' ); } async function exportMD() { const data = await buildDataOrAlert(); if (!data) return; download( `gemini_chat_${Date.now()}.md`, toMarkdown(data.items, data.title), 'text/markdown;charset=utf-8' ); } async function copyJSON() { const data = await buildDataOrAlert(); if (!data) return; await copyToClipboard(toJsonString(data.items, data.title)); } async function copyMD() { const data = await buildDataOrAlert(); if (!data) return; await copyToClipboard(toMarkdown(data.items, data.title)); } function addPanel() { if (document.getElementById('tm-chat-export-panel')) return; const panel = document.createElement('div'); panel.id = 'tm-chat-export-panel'; panel.style.cssText = ` position: fixed; top: 88px; right: 20px; z-index: 999999; display: flex; gap: 8px; flex-wrap: wrap; background: #000; border: 1px solid #000; border-radius: 10px; padding: 8px; box-shadow: 0 6px 20px rgba(0,0,0,.12); font-family: sans-serif; max-width: 360px; `; function mkBtn(text, onClick) { const b = document.createElement('button'); b.textContent = text; b.style.cssText = 'padding:6px 10px; cursor:pointer; border-radius:8px; border:1px solid #000; background:#000; color:#fff;'; b.onclick = onClick; return b; } panel.appendChild(mkBtn('导出 JSON', exportJSON)); panel.appendChild(mkBtn('导出 MD', exportMD)); panel.appendChild(mkBtn('复制 JSON', copyJSON)); panel.appendChild(mkBtn('复制 MD', copyMD)); document.body.appendChild(panel); } const observer = new MutationObserver(() => addPanel()); observer.observe(document.documentElement, { childList: true, subtree: true }); addPanel(); })();