Created
June 18, 2025 22:08
-
-
Save maciejkos/98afba3ff3443f2066e67f47bbe2ad0e to your computer and use it in GitHub Desktop.
Export complete Google Gemini chat to markdown using DevTools console
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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