Skip to content

Instantly share code, notes, and snippets.

@maciejkos
Created June 18, 2025 22:08
Show Gist options
  • Save maciejkos/98afba3ff3443f2066e67f47bbe2ad0e to your computer and use it in GitHub Desktop.
Save maciejkos/98afba3ff3443f2066e67f47bbe2ad0e to your computer and use it in GitHub Desktop.
Export complete Google Gemini chat to markdown using DevTools console
// WORKS AS OF 6/18/2025; Maciej Kos
// == Enhanced Chat Exporter ==
//
// Purpose:
// This script is designed to be run in the browser's Developer Tools console on a chat page.
// Its primary goal is to export the entire chat history, including both user messages and
// bot responses, into a formatted text block that can be easily copied to the clipboard.
//
// Features:
// 1. Auto-Scroll: Automatically scrolls the chat window to the top to load all messages
// in an infinite-scroll chat interface.
// 2. User & Bot Differentiation: Clearly labels messages as "USER:" or "BOT:".
// 3. Markdown for Bot Responses: Attempts to convert the HTML formatting of bot responses
// (including headings, lists, code blocks, bold, italics) into Markdown.
// - User messages are copied as plain text as their formatting is typically simpler.
// 4. Non-Breaking Space Cleanup: Replaces non-breaking spaces (often appearing as <0xa0>
// or similar in text editors) with regular spaces for cleaner output.
// 5. User Feedback: Provides visual feedback on the button (e.g., "Scrolling...", "Copied!").
//
// How to Use:
// 1. Open the chat page in your browser.
// 2. Open the Developer Tools (usually by pressing F12 or right-clicking on the page
// and selecting "Inspect" or "Inspect Element").
// 3. Navigate to the "Console" tab in the Developer Tools.
// 4. Paste this entire script into the console and press Enter.
// 5. A button labeled "Copy Formatted Chat" will appear in the bottom-right corner
// of the page (if the main chat container is found).
// 6. Click the "Copy Formatted Chat" button.
// - The script will first auto-scroll to the top of the chat to load all messages.
// The button will show "Scrolling..." during this phase.
// - Once scrolling is complete, it will process the messages.
// - Finally, the button will briefly show "Copied!" and the formatted chat history
// will be on your clipboard.
// 7. Paste the content into your desired text editor or document.
//
// Notes:
// - The script relies on specific HTML structures and CSS class names of the chat page.
// If the website's design changes, this script may need to be updated.
// - The Markdown conversion for bot messages is a "best-effort" attempt and might not
// be perfect for all complex HTML structures.
// - The auto-scroll feature has a maximum number of attempts to prevent infinite loops.
// For extremely long chats, this limit or the delay between scrolls might need adjustment.
// - Debugging information is logged to the console, which can be helpful if issues arise.
//
// Target Chat Container Selector:
// The script automatically tries to find the main chat history container using the
// CSS selector: '#chat-history > infinite-scroller'. If this selector is incorrect
// for your specific chat page, it will need to be updated in the script.
//
// --- Script Start ---
// I. PREPARATION AND BUTTON SETUP
// Try to remove any button created by a previous run of this script to avoid duplicates.
const existingCopyButton = document.getElementById('my-unique-copy-helper-button');
if (existingCopyButton) {
existingCopyButton.remove();
}
// Define the CSS selector for the main scrollable chat history container.
const chatHistorySelector = '#chat-history > infinite-scroller';
// Attempt to find the chat history container element on the page.
const chatHistoryContainer = document.querySelector(chatHistorySelector);
// Check if the chat history container was found and log the result.
if (chatHistoryContainer) {
console.log("Chat history container found:", chatHistoryContainer);
} else {
// If not found, warn the user as the script cannot proceed without it.
console.warn(`Could not find element with selector "${chatHistorySelector}". Please ensure this element exists on the page.`);
alert(`Error: Chat history container "${chatHistorySelector}" not found. The script cannot proceed.`);
// Optionally, one could throw an error here to halt script execution if the container is critical.
// throw new Error(`Chat history container "${chatHistorySelector}" not found.`);
}
// Create the "Copy Formatted Chat" button element.
const copyButton = document.createElement('button');
copyButton.textContent = 'Copy Formatted Chat';
copyButton.id = 'my-unique-copy-helper-button'; // Assign an ID for potential removal/styling.
// Style the button for visibility and usability.
copyButton.style.position = 'fixed'; // Keep it in view during page scroll.
copyButton.style.bottom = '10px'; // Position from the bottom.
copyButton.style.right = '10px'; // Position from the right.
copyButton.style.zIndex = '1000'; // Ensure it's on top of most page content.
copyButton.style.padding = '10px'; // Make it easy to click.
copyButton.style.backgroundColor = '#4CAF50'; // Green, a common color for positive actions.
copyButton.style.color = 'white'; // White text for contrast.
copyButton.style.border = 'none'; // Modern flat look.
copyButton.style.borderRadius = '5px'; // Rounded corners.
copyButton.style.cursor = 'pointer'; // Indicate it's clickable.
copyButton.style.fontSize = '16px'; // Legible font size.
// II. HELPER FUNCTIONS
/**
* Replaces all occurrences of the non-breaking space character (U+00A0)
* with a regular space character (U+0020).
* @param {string} text The input string.
* @returns {string} The text with non-breaking spaces replaced.
*/
function cleanNBSP(text) {
if (typeof text !== 'string') return text; // Guard against non-string input
return text.replace(/\u00A0/g, ' ');
}
/**
* Recursively converts an HTML DOM Node and its children into a Markdown string.
* Handles basic inline formatting, links, and is specialized for code blocks.
* @param {Node} node The HTML DOM Node to convert.
* @returns {string} The Markdown representation of the node.
*/
function convertNodeToMarkdown(node) {
let markdownText = '';
if (!node) return markdownText;
if (node.nodeType === Node.TEXT_NODE) {
// For text nodes, clean non-breaking spaces.
let processedText = cleanNBSP(node.textContent);
// Preserve all whitespace if inside a <pre> tag (for code).
if (node.parentNode && node.parentNode.tagName && node.parentNode.tagName.toLowerCase() === 'pre') {
markdownText += processedText;
} else {
// For other text, normalize multiple spaces to one.
// Preserve meaningful single newlines if they are the only content of the text node.
if (processedText.trim() === '' && processedText.includes('\n')) {
markdownText += '\n';
} else if (processedText.trim() !== '') {
// Replace multiple whitespace characters (including newlines within a paragraph) with a single space.
markdownText += processedText.replace(/\s+/g, ' ');
}
}
} else if (node.nodeType === Node.ELEMENT_NODE) {
const tagName = node.tagName.toLowerCase();
let childrenContent = '';
// Handle specific block elements directly for correct Markdown structure.
if (tagName === 'code-block') {
// Custom handling for <code-block> elements.
const langElement = node.querySelector('div.code-block-decoration > span');
const lang = langElement ? cleanNBSP(langElement.innerText).trim().toLowerCase() : '';
const codeElement = node.querySelector('pre code, pre'); // Look for <code> inside <pre>, or just <pre>
// Use innerText of the code element to preserve internal newlines and spacing.
const codeText = codeElement ? cleanNBSP(codeElement.innerText).replace(/(\r\n|\r|\n)$/, '') : ''; // Remove one trailing newline
return `\`\`\`${lang}\n${codeText}\n\`\`\`\n`; // Return directly, ensure trailing newline for block spacing
}
if (tagName === 'pre') { // For raw <pre> tags not already handled by <code-block>
return `\`\`\`\n${cleanNBSP(node.innerText).replace(/(\r\n|\r|\n)$/, '')}\n\`\`\`\n`;
}
// Recursively process child nodes for other element types.
for (const childNode of node.childNodes) {
childrenContent += convertNodeToMarkdown(childNode);
}
// Apply Markdown formatting based on the HTML tag.
switch (tagName) {
case 'strong': case 'b':
markdownText += `**${childrenContent.trim()}**`;
break;
case 'em': case 'i':
markdownText += `*${childrenContent.trim()}*`;
break;
case 'code': // Inline code
// Ensure not part of a <pre> or <code-block> that's already handled
if (!node.closest('pre') && !node.closest('code-block')) {
markdownText += `\`${childrenContent.trim()}\``;
} else {
markdownText += childrenContent; // Pass through if inside an already handled block
}
break;
case 'a':
markdownText += `[${childrenContent.trim()}](${node.getAttribute('href') || ''})`;
break;
case 'br':
markdownText += '\n';
break;
case 'p':
// Paragraphs are typically separated by a blank line in Markdown.
// `extractBotContentAsMarkdown` will add the second newline.
markdownText += childrenContent.trim() + '\n';
break;
// Cases for ul, ol, hr, h1-h6 are primarily handled by extractBotContentAsMarkdown
// for correct block-level structure. If they are nested within other elements
// (e.g., a <strong> inside an <li>), this default will pass their converted content through.
default:
markdownText += childrenContent;
break;
}
}
return markdownText;
}
/**
* Extracts content from the main bot message panel and converts it to Markdown.
* This function iterates over direct children of the bot message panel (e.g., p, h1, ul, code-block).
* @param {HTMLElement} botContentElement The HTML element containing the bot's message content.
* @returns {string} The Markdown representation of the bot's message.
*/
function extractBotContentAsMarkdown(botContentElement) {
if (!botContentElement) return "";
const parts = []; // Array to hold Markdown strings for each block element.
// Iterate over direct children of the bot message panel.
for (const child of botContentElement.children) {
const tagName = child.tagName.toLowerCase();
let currentBlockContent = '';
// Handle specific block-level elements.
if (tagName === 'code-block' || tagName === 'pre') {
currentBlockContent = convertNodeToMarkdown(child).trim(); // Use the refined converter
} else if (tagName.match(/^h[1-6]$/)) { // Headings H1-H6
const level = parseInt(tagName[1]);
currentBlockContent = `${'#'.repeat(level)} ${cleanNBSP(child.innerText).trim()}`; // Use innerText for headings
} else if (tagName === 'ul') { // Unordered lists
const listItems = [];
// Query for direct children 'li' elements to avoid nested list items being processed incorrectly.
child.querySelectorAll(':scope > li').forEach(li => {
let liContent = convertNodeToMarkdown(li).trim();
// Indent subsequent lines of a multi-line list item for better Markdown.
liContent = liContent.replace(/\n(?!$)/g, '\n '); // Indent lines, but not a final empty line.
listItems.push(`* ${liContent}`);
});
currentBlockContent = listItems.join('\n');
} else if (tagName === 'ol') { // Ordered lists
const listItems = [];
child.querySelectorAll(':scope > li').forEach((li, index) => {
let liContent = convertNodeToMarkdown(li).trim();
liContent = liContent.replace(/\n(?!$)/g, '\n '); // Indent for ordered lists (4 spaces common)
listItems.push(`${index + 1}. ${liContent}`);
});
currentBlockContent = listItems.join('\n');
} else if (tagName === 'hr') { // Horizontal rule
currentBlockContent = '---';
} else if (tagName === 'p') { // Paragraphs
currentBlockContent = convertNodeToMarkdown(child).trim();
} else { // Fallback for other direct children (e.g., divs used for layout)
console.log("DEBUG-MARKDOWN: Unhandled direct child in extractBotContentAsMarkdown, using convertNodeToMarkdown:", child);
currentBlockContent = convertNodeToMarkdown(child).trim();
}
// Add the processed block content to our parts array if it's not empty.
if (currentBlockContent) {
parts.push(currentBlockContent);
}
}
// Join block parts with double newlines for Markdown paragraph separation.
// Clean up excessive newlines that might result from combined processing.
return parts.join('\n\n').replace(/\n{3,}/g, '\n\n').trim();
}
/**
* Asynchronously scrolls the given element to its top repeatedly to load
* all content in an infinite-scroll interface.
* @param {HTMLElement} scrollableElement The element to scroll.
* @param {function(string)} statusCallback A callback function to update status text (e.g., on the button).
*/
async function scrollToTopToLoadAll(scrollableElement, statusCallback) {
console.log("Initiating auto-scroll to load all messages...");
statusCallback("Scrolling to top (0%)..."); // Initial status update.
scrollableElement.style.scrollBehavior = 'auto'; // Use instant scrolling for script efficiency.
let previousScrollHeight = 0;
let currentScrollHeight = scrollableElement.scrollHeight;
let attempts = 0;
const MAX_SCROLL_ATTEMPTS = 100; // Safety break for very long chats or if detection fails.
const DELAY_MS_CONTENT_LOAD = 2500; // Time (ms) to wait after a scroll for new content to load. Adjust if needed.
// Perform an initial scroll to the top.
scrollableElement.scrollTop = 0;
// Wait a shorter period initially, as the first load might be quick or already partially done.
await new Promise(resolve => setTimeout(resolve, DELAY_MS_CONTENT_LOAD / 2));
// Loop until all content is loaded or max attempts are reached.
while (attempts < MAX_SCROLL_ATTEMPTS) {
previousScrollHeight = scrollableElement.scrollHeight;
scrollableElement.scrollTop = 0; // Scroll to the very top.
// Provide a rough progress update on the button.
let progress = Math.min(99, Math.round((attempts / (MAX_SCROLL_ATTEMPTS / 2)) * 100)); // Cap progress display at 99% during scroll.
statusCallback(`Scrolling (${progress}%)...`);
console.log(`Auto-scroll attempt #${attempts + 1}: Scrolled to top. Waiting ${DELAY_MS_CONTENT_LOAD}ms for new messages.`);
// Wait for the website to load more messages.
await new Promise(resolve => setTimeout(resolve, DELAY_MS_CONTENT_LOAD));
currentScrollHeight = scrollableElement.scrollHeight;
// Check if we're at the top and the scroll height hasn't changed.
// This indicates that no new content was loaded by the scroll.
if (scrollableElement.scrollTop === 0 && currentScrollHeight === previousScrollHeight) {
console.log("Auto-scroll: Reached top and scroll height did not change. Assuming all messages are loaded.");
break; // Exit loop.
}
// Log current state for debugging.
if (scrollableElement.scrollTop > 0) {
console.log(`Auto-scroll: Still more to scroll (scrollTop: ${scrollableElement.scrollTop}). This shouldn't happen if scrollTop = 0 was just set unless page re-flowed quickly.`);
} else { // scrollTop is 0, but scrollHeight must have changed if we didn't break.
console.log(`Auto-scroll: At top, but new content loaded (scrollHeight changed from ${previousScrollHeight} to ${currentScrollHeight}). Continuing.`);
}
attempts++;
}
// Final status update based on whether max attempts were reached.
if (attempts >= MAX_SCROLL_ATTEMPTS) {
console.warn("Auto-scroll: Reached maximum scroll attempts. Proceeding with currently loaded messages.");
statusCallback("Max scrolls reached. Processing...");
} else {
statusCallback("Scrolling complete. Processing...");
}
console.log("Auto-scroll finished.");
scrollableElement.style.scrollBehavior = ''; // Reset scroll behavior to default.
}
// III. MAIN BUTTON EVENT LISTENER - CORE LOGIC
copyButton.addEventListener('click', async () => {
// Ensure the chat container is available.
if (!chatHistoryContainer) {
console.error(`Chat history container "${chatHistorySelector}" was not found when the button was clicked.`);
alert(`Error: Chat history container "${chatHistorySelector}" not found. Please ensure it exists on the page.`);
return;
}
// Store original button state to revert after operation.
const originalButtonText = copyButton.textContent;
const originalButtonColor = copyButton.style.backgroundColor;
try {
// 1. Auto-scroll to load all messages.
// The button text and color will be updated by the statusCallback.
await scrollToTopToLoadAll(chatHistoryContainer, (statusText) => {
copyButton.textContent = statusText;
copyButton.style.backgroundColor = '#FF9800'; // Orange color during scrolling/processing.
});
// Brief pause after scrolling before processing, just in case UI needs a moment to settle.
await new Promise(resolve => setTimeout(resolve, 500));
// 2. Process and format chat messages.
let formattedMessages = []; // Array to store processed "USER:" and "BOT:" message strings.
// Get all direct children of the chat history container. These are the individual "turn" containers.
const conversationTurns = chatHistoryContainer.children;
console.log("Total conversation turns (e.g., div.conversation-container) found after scrolling:", conversationTurns.length);
for (const turnContainer of conversationTurns) {
console.log("--- Processing Turn Container:", turnContainer);
// A. Extract USER message (if present in this turn).
// User messages are typically simpler and copied as plain text.
const userQueryElement = turnContainer.querySelector('user-query div.query-text');
if (userQueryElement) {
const userText = cleanNBSP(userQueryElement.innerText); // Clean non-breaking spaces.
if (userText && userText.trim() !== "") {
formattedMessages.push("USER:\n" + userText.trim() + "\n");
console.log("DEBUG-USER: Added USER message (plain text).");
}
}
// B. Extract BOT message (if present in this turn).
// Bot messages are processed for Markdown conversion.
const modelResponseElement = turnContainer.querySelector('model-response');
if (modelResponseElement) {
const botResponseContentWrapper = modelResponseElement.querySelector('div.response-content');
if (botResponseContentWrapper) {
// Attempt to find the specific panel containing Markdown-like HTML.
const markdownPanel = botResponseContentWrapper.querySelector('message-content.model-response-text div.markdown.markdown-main-panel');
let botText = "";
if (markdownPanel) {
console.log("DEBUG-BOT: Found markdownPanel. Converting to Markdown using extractBotContentAsMarkdown.");
botText = extractBotContentAsMarkdown(markdownPanel); // NBSP cleaning is handled within this function.
} else {
// Fallback strategy if the specific markdownPanel isn't found.
console.log("DEBUG-BOT: markdownPanel not found. Attempting fallback text extraction.");
const messageContentElement = botResponseContentWrapper.querySelector('message-content.model-response-text');
if (messageContentElement) {
console.log("DEBUG-BOT: Falling back to messageContentElement.innerText.");
botText = cleanNBSP(messageContentElement.innerText); // Clean NBSP for this fallback.
} else {
console.log("DEBUG-BOT: messageContentElement also not found, falling back to botResponseContentWrapper.innerText.");
botText = cleanNBSP(botResponseContentWrapper.innerText); // Clean NBSP for the broadest fallback.
}
}
if (botText && botText.trim() !== "") {
formattedMessages.push("BOT:\n" + botText.trim() + "\n"); // botText is now (attempted) Markdown.
console.log("DEBUG-BOT: Added BOT message.");
} else {
console.log("DEBUG-BOT: Bot text extraction (or Markdown conversion) yielded empty content.");
}
}
}
} // End of loop through conversationTurns.
// 3. Prepare final text for clipboard.
let finalTextToCopy;
if (formattedMessages.length > 0) {
finalTextToCopy = formattedMessages.join("\n"); // Add an extra newline between distinct USER/BOT blocks.
console.log('Formatted chat content prepared (' + formattedMessages.length + ' messages).');
} else {
// Fallback if no messages were parsed (e.g., structure mismatch).
console.warn(`No messages identified. Falling back to raw innerText of chat container. Check logs and page structure.`);
finalTextToCopy = cleanNBSP(chatHistoryContainer.innerText); // Clean NBSP for this final fallback.
if (!finalTextToCopy || finalTextToCopy.trim() === "") {
alert("No text content found to copy. The selected element might be empty or structured differently than expected. Check console for warnings.");
copyButton.textContent = originalButtonText; // Revert button on this specific failure.
copyButton.style.backgroundColor = originalButtonColor;
return;
}
}
// 4. Copy to clipboard.
await navigator.clipboard.writeText(finalTextToCopy);
// 5. Provide success feedback on the button.
copyButton.textContent = 'Copied!';
copyButton.style.backgroundColor = '#2196F3'; // Blue for success.
setTimeout(() => { // Revert button text and color after a short delay.
copyButton.textContent = originalButtonText;
copyButton.style.backgroundColor = originalButtonColor;
}, 2000);
} catch (err) {
// Handle any errors during the process.
console.error('Error during copy operation: ', err);
alert('An error occurred during copying. Check console for details.');
copyButton.textContent = originalButtonText; // Revert button text.
copyButton.style.backgroundColor = '#F44336'; // Red for error.
setTimeout(() => { // Revert color after showing error state.
copyButton.textContent = originalButtonText;
copyButton.style.backgroundColor = originalButtonColor;
}, 3000);
}
});
// IV. APPEND BUTTON TO PAGE
// Only add the button to the page if the main chat container was successfully found.
if (chatHistoryContainer) {
document.body.appendChild(copyButton);
console.log("Copy button added to the page.");
} else {
// Log if the button wasn't added due to the container not being found.
console.log(`Button not added as the chat history container ("${chatHistorySelector}") was not found.`);
}
// --- Script End ---
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment