Exports Google Gemini chats to Markdown. Captures "Show Thinking", auto-detects model names, loads full history, and supports dual/parallel responses.
// ==UserScript==
// @name Gemini2Markdown
// @namespace https://greatest.deepsurf.us/en/users/1552401-chipfin
// @version 1.8.2
// @description Exports Google Gemini chats to Markdown. Captures "Show Thinking", 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';
/* ---------------- 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 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) {
const scroller = getChatScroller();
if (!scroller) return;
let stableCount = 0;
for (let i = 0; i < 55; i++) {
scroller.scrollTop = 0;
if (statusCallback) statusCallback(`⬆️ ${i}`);
await sleep(1300);
if (scroller.scrollTop !== 0) {
stableCount = 0;
} else {
stableCount++;
}
if (stableCount >= 4) break;
}
}
async function expandAllThoughts(statusCallback) {
if (statusCallback) statusCallback("🧠");
const buttons = document.querySelectorAll('model-thoughts .thoughts-header-button');
for (const btn of buttons) {
const container = btn.closest('model-thoughts');
if (!container?.querySelector('.thoughts-content') || (btn.textContent && btn.textContent.includes('Show thinking'))) {
btn.click();
await sleep(100);
}
}
await sleep(1000);
}
async function detectModelForContainer(container) {
const menuBtn = container.querySelector('.more-menu-button, button[data-test-id="more-actions-button"]');
if (!menuBtn) return 'Gemini';
menuBtn.click();
await sleep(300);
const overlayItems = [...document.querySelectorAll('.cdk-overlay-pane .mat-mdc-menu-item-text')];
const modelItem = overlayItems.find(el => el.textContent && el.textContent.trim().startsWith('Model:'));
let model = 'Gemini';
if (modelItem) {
model = modelItem.textContent.trim();
}
document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', keyCode: 27, bubbles: true, cancelable: true }));
await sleep(100);
return model;
}
/* ---------------- Extraction ---------------- */
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('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() {
const btn = document.querySelector('#gemini-export-md-icon');
const setStatus = (text) => {
if (btn) {
const labelSpan = btn.querySelector('.dynamic-upsell-label');
if (labelSpan) {
labelSpan.textContent = text;
} else {
btn.textContent = '';
const span = document.createElement('span');
span.style.fontSize = '11px';
span.style.fontWeight = 'bold';
span.style.color = 'currentColor';
span.textContent = text;
btn.appendChild(span);
}
} else {
console.log(`Gemini2Markdown: ${text}`);
}
};
try {
await scrollChatToTop(setStatus);
await expandAllThoughts(setStatus);
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 currentModel = 'User';
if (modelResponses.length > 0) {
currentModel = hasDual ? 'Gemini' : await detectModelForContainer(container);
}
if (i === 0 && currentModel !== 'User') {
globalModel = currentModel;
}
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) {
modelResponses.forEach((responseNode, rIndex) => {
const draftLabel = isDual ? (rIndex === 0 ? ' (Choice A)' : ' (Choice B)') : '';
turnText += `####### Gemini (${currentModel})${draftLabel} writes:\n\n`;
let hasThoughts = false;
const thoughtNode = responseNode.querySelector('model-thoughts');
if (thoughtNode) {
const thoughtText = processElement(thoughtNode.querySelector('.thoughts-content'));
if (thoughtText) {
hasThoughts = true;
turnText += `**Shown Thinking (Gemini):**\n---\n\n${thoughtText}\n\n`;
}
}
const responseClone = responseNode.cloneNode(true);
responseClone.querySelectorAll('model-thoughts, .thoughts-container').forEach(e => e.remove());
if (hasThoughts) 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);
} catch (e) {
console.error(e);
alert("Export failed: " + e.message);
} finally {
if (btn) {
const labelSpan = btn.querySelector('.dynamic-upsell-label');
if (labelSpan) {
labelSpan.textContent = 'Markdown';
} else {
btn.textContent = '';
btn.appendChild(createSvgIcon('24', '15'));
}
}
}
}
/* ---------------- UI Integration ---------------- */
function addExportButton() {
if (document.querySelector('#gemini-export-md-icon')) return;
// Try to hook into the Upsell button (Language Agnostic)
const upsellBtn = document.querySelector('g1-dynamic-upsell-button button');
if (upsellBtn) {
upsellBtn.id = 'gemini-export-md-icon';
upsellBtn.setAttribute('title', 'Export chat as Markdown');
const upsellLabel = upsellBtn.querySelector('.dynamic-upsell-label');
if (upsellLabel) {
upsellLabel.textContent = 'Markdown';
}
const icon = upsellBtn.querySelector('mat-icon');
if (icon) {
const svgIcon = createSvgIcon('20', '13');
svgIcon.style.marginRight = '6px';
svgIcon.style.verticalAlign = 'middle';
icon.parentNode.replaceChild(svgIcon, icon);
}
upsellBtn.setAttribute('role', 'button');
upsellBtn.removeAttribute('aria-describedby');
upsellBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopImmediatePropagation();
exportToMarkdown();
}, true);
return;
}
// Fallback for users without an Upsell button
const anchor = document.querySelector(
'studio-sidebar-button, [data-test-id="studio-sidebar-button"], ' +
'new-chat-button, [data-test-id="new-chat-button-container"], ' +
'conversation-actions-icon'
);
if (!anchor) return;
try {
const exportBtn = document.createElement('button');
exportBtn.id = 'gemini-export-md-icon';
exportBtn.setAttribute('title', 'Export chat as Markdown');
exportBtn.style.cssText = `
display: inline-flex; align-items: center; justify-content: center;
align-self: center; width: 40px; height: 40px; background: transparent;
border: none; border-radius: 50%; cursor: pointer; color: inherit;
transition: background 0.2s; margin: 3px 4px 0 0;
padding: 0; vertical-align: middle; z-index: 1000;
`;
exportBtn.appendChild(createSvgIcon('24', '15'));
exportBtn.addEventListener('mouseenter', () => exportBtn.style.background = 'rgba(154, 160, 166, 0.1)');
exportBtn.addEventListener('mouseleave', () => exportBtn.style.background = 'transparent');
exportBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); exportToMarkdown(); });
anchor.parentNode.insertBefore(exportBtn, anchor);
} catch (err) {
console.warn("Gemini2Markdown: Could not inject button.", err);
}
}
if (typeof GM_registerMenuCommand !== 'undefined') {
GM_registerMenuCommand('Export to Markdown', exportToMarkdown);
}
setTimeout(addExportButton, 2000);
new MutationObserver(addExportButton).observe(document.body, { childList: true, subtree: true });
})();