复制到油猴脚本即可,支持以下站点
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();
})();