Exports Google Gemini chats to Markdown. Captures "Thinking steps", auto-detects model names, loads full history, and supports dual/parallel responses.
// ==UserScript==
// @name Gemini2Markdown
// @namespace https://greasyfork.org/en/users/1552401-chipfin
// @version 2.0.1
// @description Exports Google Gemini chats to Markdown. Captures "Thinking steps", auto-detects model names, loads full history, and supports dual/parallel responses.
// @icon64 https://upload.wikimedia.org/wikipedia/commons/archive/1/1d/20251003211919%21Google_Gemini_icon_2025.svg
// @match https://gemini.google.com/*
// @grant GM_registerMenuCommand
// @license MIT
// @author Gemini 3 Pro, Claude Sonnet 4.6 Thinking
// ==/UserScript==
(() => {
'use strict';
let isExporting = false;
let toastTimeoutId = null;
let toastFadeTimeoutId = null;
/* ---------------- Utilities ---------------- */
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
const trim = s => (s || '').toString().replace(/\r/g, '').trim();
function getFormattedTimestamp() {
const now = new Date();
const pad = (n) => n.toString().padStart(2, '0');
const tzo = -now.getTimezoneOffset();
const dif = tzo >= 0 ? '+' : '-';
const offHour = pad(Math.floor(Math.abs(tzo) / 60));
const offMin = pad(Math.abs(tzo) % 60);
return `${now.getFullYear()}-${pad(now.getMonth()+1)}-${pad(now.getDate())}T${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}${dif}${offHour}${offMin}`;
}
function waitForNonEmptyElement(selector, timeout = 6000) {
return new Promise(resolve => {
const check = () => {
const el = document.querySelector(selector);
if (el && el.textContent && el.textContent.trim().length > 10) return el;
return null;
};
const existing = check();
if (existing) return resolve(existing);
const observer = new MutationObserver(() => {
const el = check();
if (el) { observer.disconnect(); resolve(el); }
});
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
setTimeout(() => { observer.disconnect(); resolve(null); }, timeout);
});
}
function waitForElement(selector, timeout = 3000) {
return new Promise(resolve => {
const existing = document.querySelector(selector);
if (existing) return resolve(existing);
const observer = new MutationObserver(() => {
const el = document.querySelector(selector);
if (el) { observer.disconnect(); resolve(el); }
});
observer.observe(document.body, { childList: true, subtree: true });
setTimeout(() => { observer.disconnect(); resolve(null); }, timeout);
});
}
function cleanMarkdown(text) {
if (!text) return '';
text = text.replace(/https:\/\/[^ \n]+filename=([^& \n]+)[^ \n]*/g, (match, filename) => {
try { return `[Uploaded File: ${decodeURIComponent(filename.replace(/\+/g, ' '))}]`; } catch (e) { return '[Uploaded File]'; }
});
text = text
.replace(/https:\/\/drive\.google\.com\/viewerng\/thumb[^ \n]*/g, '')
.replace(/https:\/\/contribution\.usercontent\.google\.com\/download[^ \n]*/g, '')
.replace(/https:\/\/lh3\.googleusercontent\.com\/[^ \n]+/g, '[Image]')
.replace(/\\(?![\\*_`])/g, '\\\\')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/&/g, '&');
return text.replace(/\n\s*\n/g, '\n\n').trim();
}
function createSvgIcon(width = '24', height = '15') {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('width', width);
svg.setAttribute('height', height);
svg.setAttribute('viewBox', '0 0 208 128');
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
rect.setAttribute('width', '198');
rect.setAttribute('height', '118');
rect.setAttribute('x', '5');
rect.setAttribute('y', '5');
rect.setAttribute('ry', '10');
rect.setAttribute('stroke', 'currentColor');
rect.setAttribute('stroke-width', '10');
rect.setAttribute('fill', 'none');
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', 'M30 98V30h20l20 25 20-25h20v68H90V59L70 84 50 59v39zm125 0l-30-33h20V30h20v35h20z');
path.setAttribute('fill', 'currentColor');
svg.appendChild(rect);
svg.appendChild(path);
return svg;
}
/* ---------------- Actions ---------------- */
function getChatScroller() {
return document.querySelector('#chat-history.chat-history-scroll-container infinite-scroller.chat-history') ||
document.querySelector('infinite-scroller.chat-history');
}
async function scrollChatToTop(statusCallback, cancelState) {
const scroller = getChatScroller();
if (!scroller) return;
let stableCount = 0;
for (let i = 0; i < 55; i++) {
if (cancelState && cancelState.cancel) {
if (statusCallback) statusCallback(`⏹️ Scrolling stopped.`);
await sleep(400);
break;
}
scroller.scrollTop = 0;
if (statusCallback) statusCallback(`⬆️ Scrolling... ${i}`);
await sleep(1300);
if (scroller.scrollTop !== 0) {
stableCount = 0;
} else {
stableCount++;
}
if (stableCount >= 4) break;
}
}
async function extractDetailsFromSidebar(conversationContainer) {
let details = { model: 'Gemini', thoughts: '' };
const menuBtn = conversationContainer.querySelector(
'gem-icon-button[data-test-id="more-menu-button"] button, ' +
'button[aria-label="Show more options"], ' +
'.more-menu-button-container button, ' +
'button[data-test-id="more-actions-button"]'
);
if (!menuBtn) return details;
menuBtn.click();
await sleep(400);
const overlayLabels = [...document.querySelectorAll('.cdk-overlay-pane gem-menu-item .label, .cdk-overlay-pane .mat-mdc-menu-item-text')];
const oldModelItem = overlayLabels.find(el => el.textContent && el.textContent.trim().startsWith('Model:'));
if (oldModelItem) {
details.model = oldModelItem.textContent.trim().replace(/^Model:\s*/i, '');
}
const thinkingBtn = document.querySelector('.cdk-overlay-pane gem-menu-item[data-test-id="thinking-steps-button"]');
const responseDetailsBtn = document.querySelector('.cdk-overlay-pane gem-menu-item[data-test-id="response-details-button"]');
const genericBtn = document.querySelector('.cdk-overlay-pane gem-menu-item[value="thoughts"]')
|| [...document.querySelectorAll('.cdk-overlay-pane gem-menu-item')].find(el => {
const t = (el.textContent || '').toLowerCase();
return t.includes('thinking') || t.includes('steps');
});
const hasThoughts = !!thinkingBtn || (!responseDetailsBtn && !!genericBtn &&
(genericBtn.textContent || '').toLowerCase().includes('thinking'));
const detailsBtn = thinkingBtn || responseDetailsBtn || genericBtn;
if (!detailsBtn) {
document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', keyCode: 27, bubbles: true, cancelable: true }));
await sleep(150);
return details;
}
detailsBtn.click();
if (hasThoughts) {
const markdownEl = await waitForNonEmptyElement(
'context-sidebar:not([style*="opacity: 0"]) [data-test-id="thought-steps"] .markdown.markdown-main-panel',
6000
);
if (markdownEl) {
const activeSidebar = document.querySelector('context-sidebar:not([style*="opacity: 0"])');
if (activeSidebar) {
const modelSpan = activeSidebar.querySelector('[data-test-id="model-line"] span.ng-star-inserted');
if (modelSpan && modelSpan.textContent) {
details.model = modelSpan.textContent.trim()
.replace(/^Used\s+/i, '')
.replace(/\s*model$/i, '')
.trim();
}
const thoughtSteps = activeSidebar.querySelectorAll('[data-test-id="thought-steps"] .thought-step-container');
if (thoughtSteps.length > 0) {
const allThoughts = [];
thoughtSteps.forEach(step => {
const mdDiv = step.querySelector('.markdown.markdown-main-panel') || step;
allThoughts.push(processThoughtMarkdown(mdDiv));
});
details.thoughts = allThoughts.filter(Boolean).join('\n\n');
} else {
details.thoughts = processThoughtMarkdown(markdownEl);
}
}
}
} else {
await waitForElement('context-sidebar:not([style*="opacity: 0"]) [data-test-id="model-line"]', 2000);
await sleep(200);
const activeSidebar = document.querySelector('context-sidebar:not([style*="opacity: 0"])');
if (activeSidebar) {
const modelSpan = activeSidebar.querySelector('[data-test-id="model-line"] span.ng-star-inserted');
if (modelSpan && modelSpan.textContent) {
details.model = modelSpan.textContent.trim()
.replace(/^Used\s+/i, '')
.replace(/\s*model$/i, '')
.trim();
}
}
}
const closeBtn = document.querySelector(
'context-sidebar:not([style*="opacity: 0"]) button[data-test-id="close-button"], ' +
'context-sidebar:not([style*="opacity: 0"]) button[aria-label="Close sidebar"]'
);
if (closeBtn) closeBtn.click();
await sleep(400);
return details;
}
/* ---------------- Extraction ---------------- */
function processThoughtMarkdown(el) {
if (!el) return '';
const clone = el.cloneNode(true);
clone.querySelectorAll('b, strong').forEach(b => {
b.replaceWith(`**${b.textContent}**`);
});
clone.querySelectorAll('i, em').forEach(i => {
i.replaceWith(`*${i.textContent}*`);
});
clone.querySelectorAll('code:not(pre code)').forEach(code => {
code.replaceWith(`\`${code.textContent}\``);
});
clone.querySelectorAll('pre').forEach(pre => {
const code = pre.innerText;
const lang = pre.getAttribute('data-language') || '';
pre.replaceWith(`\n\`\`\`${lang}\n${code}\n\`\`\`\n`);
});
clone.querySelectorAll('p').forEach(p => {
p.after(document.createTextNode('\n\n'));
});
clone.querySelectorAll('br').forEach(br => br.replaceWith('\n'));
let text = (clone.textContent || '').replace(/\n{3,}/g, '\n\n').trim();
return cleanMarkdown(text);
}
function processElement(el) {
if (!el) return '';
const clone = el.cloneNode(true);
clone.querySelectorAll('button, mat-icon, .action-bar, .feedback_buttons, .thoughts-header, .select-button').forEach(e => e.remove());
clone.querySelectorAll('b, strong').forEach(b => b.textContent = `**${b.textContent}**`);
clone.querySelectorAll('i, em').forEach(i => i.textContent = `*${i.textContent}*`);
clone.querySelectorAll('code:not(pre code)').forEach(code => {
code.textContent = `\`${code.textContent}\``;
});
clone.querySelectorAll('a').forEach(a => {
const href = a.href;
const text = a.innerText;
if (href && text) a.textContent = `[${text}](${href})`;
});
clone.querySelectorAll('pre').forEach(pre => {
const code = pre.innerText;
const lang = pre.getAttribute('data-language') || '';
pre.textContent = `\n\`\`\`${lang}\n${code}\n\`\`\`\n`;
});
const blockTags = ['p', 'div', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'tr'];
blockTags.forEach(tag => {
clone.querySelectorAll(tag).forEach(block => block.after('\n'));
});
clone.querySelectorAll('br').forEach(br => br.replaceWith('\n'));
return cleanMarkdown(clone.textContent);
}
/* ---------------- Main Logic ---------------- */
async function exportToMarkdown() {
if (isExporting) {
console.log("Gemini2Markdown: Export already in progress.");
return;
}
isExporting = true;
let cancelState = { cancel: false };
const setStatus = (text, showStopBtn = false) => {
// Clear any pending cleanup timeouts if a new status update happens
if (toastTimeoutId) clearTimeout(toastTimeoutId);
if (toastFadeTimeoutId) clearTimeout(toastFadeTimeoutId);
let toast = document.getElementById('gemini2md-toast');
if (!toast) {
toast = document.createElement('div');
toast.id = 'gemini2md-toast';
toast.style.cssText = `
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
background-color: var(--gem-sys-color--inverse-surface, #303030);
color: var(--gem-sys-color--inverse-on-surface, #f2f2f2);
padding: 12px 24px; border-radius: 8px;
font-family: var(--gem-sys-typography-type-scale--body-m-font-name, "Google Sans", sans-serif);
font-size: 14px; z-index: 10000;
box-shadow: 0 4px 6px rgba(0,0,0,0.3);
transition: opacity 0.3s;
display: flex; align-items: center; justify-content: center; gap: 16px;
`;
const textSpan = document.createElement('span');
textSpan.id = 'gemini2md-toast-text';
toast.appendChild(textSpan);
const stopBtn = document.createElement('button');
stopBtn.id = 'gemini2md-toast-stop';
stopBtn.textContent = 'Stop Scroll';
stopBtn.style.cssText = `
background-color: rgba(255, 255, 255, 0.2);
color: #fff;
border: none;
border-radius: 4px;
padding: 4px 8px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
`;
stopBtn.onmouseover = () => stopBtn.style.backgroundColor = 'rgba(255, 255, 255, 0.3)';
stopBtn.onmouseout = () => stopBtn.style.backgroundColor = 'rgba(255, 255, 255, 0.2)';
stopBtn.addEventListener('click', () => {
cancelState.cancel = true;
stopBtn.style.display = 'none';
});
toast.appendChild(stopBtn);
document.body.appendChild(toast);
}
const textSpan = document.getElementById('gemini2md-toast-text');
if (textSpan) textSpan.textContent = text;
const stopBtn = document.getElementById('gemini2md-toast-stop');
if (stopBtn) {
stopBtn.style.display = (showStopBtn && !cancelState.cancel) ? 'block' : 'none';
}
toast.style.opacity = '1';
console.log(`Gemini2Markdown: ${text}`);
};
try {
await scrollChatToTop((text) => setStatus(text, true), cancelState);
setStatus('🔍 Extracting chat data...');
const containers = document.querySelectorAll('.conversation-container');
if (containers.length === 0) throw new Error("No chat found. Please ensure the page is fully loaded.");
const conversationId = location.pathname.match(/\/app\/([a-zA-Z0-9]+)/)?.[1];
const titleEl =
document.querySelector('.conversation-title-container .gds-title-m') ||
(conversationId ? document.querySelector(`a.conversation[href*="/app/${conversationId}"] .conversation-title`) : null) ||
document.querySelector('a.conversation.selected .conversation-title');
let cleanTitle = '';
if (titleEl && titleEl.innerText && titleEl.innerText.trim().length > 0) {
cleanTitle = trim(titleEl.innerText);
} else {
cleanTitle = trim(document.title)
.replace(/ - Google Gemini$/i, '')
.replace(/ - Gemini$/i, '')
.replace(/ [-–|].*$/i, '');
}
if (/^(google\s*)?gemini$/i.test(cleanTitle) || cleanTitle === '' || cleanTitle.toLowerCase() === 'chats') {
const firstQuery = document.querySelector('user-query p.query-text-line, user-query .query-text, user-query .query-content, .user-query');
if (firstQuery && firstQuery.textContent) {
let fallbackText = trim(firstQuery.textContent).replace(/^You said\s*/i, '');
cleanTitle = fallbackText.split(/[\r\n]+/)[0].substring(0, 64).trim();
} else {
cleanTitle = conversationId ? `chat-${conversationId}` : 'Gemini_Conversation';
}
}
cleanTitle = cleanTitle.replace(/[<>:"/\\|?*]/g, '').replace(/\s+/g, ' ');
let displayTitle = cleanTitle.substring(0, 64).trim();
const timestamp = getFormattedTimestamp();
const toc = [];
const turnBuffer = [];
let globalModel = 'Gemini';
let chatIndex = 1;
for (let i = 0; i < containers.length; i++) {
const container = containers[i];
const userQuery = container.querySelector('user-query .query-content, .user-query');
const hasDual = !!container.querySelector('dual-model-response');
const modelResponses = hasDual
? [...container.querySelectorAll('response-selection-panel')]
: [...container.querySelectorAll('model-response')];
const isDual = hasDual && modelResponses.length > 1;
if (!userQuery && modelResponses.length === 0) continue;
setStatus(`🔍 ${i+1}/${containers.length}`);
let turnText = `### chat-${chatIndex}\n\n`;
if (userQuery) {
const text = processElement(userQuery);
toc.push(`- [${chatIndex}: ${text.substring(0, 50).replace(/\n/g, ' ')}...](#chat-${chatIndex})`);
turnText += `####### User writes:\n\n${text}\n\n`;
}
if (modelResponses.length > 0) {
const details = await extractDetailsFromSidebar(container);
if (i === 0 && details.model !== 'Gemini') {
globalModel = details.model;
}
for (let rIndex = 0; rIndex < modelResponses.length; rIndex++) {
const responseNode = modelResponses[rIndex];
const draftLabel = isDual ? (rIndex === 0 ? ' (Choice A)' : ' (Choice B)') : '';
turnText += `####### Gemini (${details.model})${draftLabel} writes:\n\n`;
let legacyThoughtsText = '';
if (!details.thoughts) {
const legacyThoughtNode = responseNode.querySelector('model-thoughts');
if (legacyThoughtNode) {
const expandBtn = legacyThoughtNode.querySelector('.thoughts-header-button');
if (expandBtn && expandBtn.textContent && expandBtn.textContent.includes('Show thinking')) {
expandBtn.click();
await sleep(100);
}
legacyThoughtsText = processElement(legacyThoughtNode.querySelector('.thoughts-content'));
}
}
const finalThoughts = (rIndex === 0) ? (details.thoughts || legacyThoughtsText) : '';
if (finalThoughts) {
turnText += `**Thinking steps:**\n---\n\n${finalThoughts}\n\n`;
}
const responseClone = responseNode.cloneNode(true);
responseClone.querySelectorAll('model-thoughts, .thoughts-container').forEach(e => e.remove());
if (finalThoughts) turnText += `**Response (Gemini):**\n---\n\n`;
const contentNode = responseClone.querySelector('message-content') ||
responseClone.querySelector('structured-content-container') ||
responseClone;
turnText += `${processElement(contentNode)}\n\n`;
if (isDual && rIndex < modelResponses.length - 1) {
turnText += `---\n\n`;
}
}
}
turnText += `___\n###### [top](#table-of-contents)\n\n`;
turnBuffer.push(turnText);
chatIndex++;
}
const header = `---\ntitle: ${cleanTitle}\ndate: ${timestamp}\nurl: ${location.href}\nmodel: ${globalModel}\n---\n\n# ${cleanTitle}\n\n`;
const finalContent = [header, `## Table of Contents\n${toc.join('\n')}\n\n---\n\n`, ...turnBuffer].join('');
const blob = new Blob([finalContent], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `GEMINI_${displayTitle}_${timestamp}.md`;
a.click();
URL.revokeObjectURL(url);
setStatus(`✅ Done!`);
} catch (e) {
console.error(e);
setStatus(`❌ Error: ${e.message}`);
alert("Export failed: " + e.message);
} finally {
// Unset the lock and reset cancel state
isExporting = false;
cancelState.cancel = false;
// Immediately hide stop button
const stopBtn = document.getElementById('gemini2md-toast-stop');
if (stopBtn) stopBtn.style.display = 'none';
// Cleanly fade and entirely remove the toast element from the DOM
toastTimeoutId = setTimeout(() => {
const toast = document.getElementById('gemini2md-toast');
if (toast) {
toast.style.opacity = '0';
toastFadeTimeoutId = setTimeout(() => {
if (toast.parentNode) toast.remove();
}, 300); // 300ms accounts for the CSS opacity transition
}
}, 2500);
}
}
/* ---------------- UI Integration ---------------- */
function addExportMenuOption() {
const overlays = document.querySelectorAll('.cdk-overlay-pane gem-menu');
for (const menu of overlays) {
if (menu.querySelector('gem-menu-item[data-test-id="delete-button"]') && !menu.querySelector('#gemini-export-md-menu-item')) {
const templateItem = menu.querySelector('gem-menu-item');
if (!templateItem) continue;
const mdItem = templateItem.cloneNode(true);
mdItem.id = 'gemini-export-md-menu-item';
mdItem.setAttribute('data-test-id', 'gemini-export-md-menu-item');
mdItem.setAttribute('aria-label', 'Export to Markdown');
mdItem.setAttribute('value', 'export-markdown');
mdItem.classList.remove('active');
const content = mdItem.querySelector('gem-menu-item-content');
if (content) content.classList.remove('active');
const labelSpan = mdItem.querySelector('.label span') || mdItem.querySelector('.label');
if (labelSpan) {
labelSpan.textContent = 'Export to Markdown';
}
const iconContainer = mdItem.querySelector('.leading-container mat-icon');
if (iconContainer) {
const svgIcon = createSvgIcon('20', '13');
svgIcon.style.display = 'block';
svgIcon.style.fill = 'currentColor';
iconContainer.parentNode.replaceChild(svgIcon, iconContainer);
}
mdItem.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
document.body.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Escape', code: 'Escape', keyCode: 27, bubbles: true, cancelable: true
}));
exportToMarkdown();
});
mdItem.addEventListener('mouseenter', () => {
mdItem.style.backgroundColor = 'var(--mat-menu-item-hover-state-layer-color, rgba(0,0,0,0.04))';
});
mdItem.addEventListener('mouseleave', () => {
mdItem.style.backgroundColor = 'transparent';
});
const deleteBtn = menu.querySelector('gem-menu-item[data-test-id="delete-button"]');
if (deleteBtn) {
menu.insertBefore(mdItem, deleteBtn);
} else {
menu.appendChild(mdItem);
}
}
}
}
if (typeof GM_registerMenuCommand !== 'undefined') {
GM_registerMenuCommand('Export to Markdown', exportToMarkdown);
}
new MutationObserver(addExportMenuOption).observe(document.body, { childList: true, subtree: true });
})();