Last active
June 21, 2026 11:19
-
-
Save GeroZayas/ff5bf3cbe86448c9474daeb20de35a3c to your computer and use it in GitHub Desktop.
chatgpt-download-markdown-copy-and-paste-in-console.js
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
| // Generated from src/extraction-engine.js by scripts/build-exporters.js. | |
| // Edit the source engine or this build script, then run npm run build. | |
| (() => { | |
| 'use strict'; | |
| (function initChatExporterEngine(root, factory) { | |
| const engine = factory(); | |
| if (root) { | |
| root.ChatExporterEngine = engine; | |
| } | |
| if (typeof module !== 'undefined' && module.exports) { | |
| module.exports = engine; | |
| } | |
| })(typeof globalThis !== 'undefined' ? globalThis : this, function buildChatExporterEngine() { | |
| 'use strict'; | |
| const ENGINE_VERSION = '0.7.1-security-fix'; | |
| const MARKER_PREFIX = '__CHAT_EXPORTER_BLOCK_'; | |
| const PROVIDERS = { | |
| chatgpt: { | |
| id: 'chatgpt', | |
| assistantName: 'ChatGPT', | |
| sourceLabel: 'chatgpt.com', | |
| defaultTitle: 'Conversation with ChatGPT', | |
| genericTitlePattern: /^(chatgpt|new chat|untitled|chat)$/i, | |
| messageSelectors: [ | |
| 'div[data-message-author-role]', | |
| 'article[data-testid*="conversation-turn"]', | |
| 'div[data-testid="conversation-turn"]', | |
| '.group\\/conversation-turn', | |
| '[data-testid*="message"], [data-message-id], [data-message-author]' | |
| ], | |
| contentSelectors: [ | |
| '.markdown, .prose, [class*="markdown"], [class*="prose"]', | |
| '[data-message-content], [data-testid*="content"]', | |
| '.whitespace-pre-wrap, [class*="whitespace"]' | |
| ], | |
| titleSelectors: [ | |
| 'h1:not([class*="hidden"])', | |
| '[class*="conversation-title"]', | |
| '[data-testid*="conversation-title"]' | |
| ] | |
| }, | |
| gemini: { | |
| id: 'gemini', | |
| assistantName: 'Gemini', | |
| sourceLabel: 'gemini.google.com', | |
| defaultTitle: 'Conversation with Gemini', | |
| genericTitlePattern: /^(gemini|new chat|untitled|chat|bard)$/i, | |
| messageSelectors: [ | |
| 'user-query, model-response', | |
| '[data-test-id="conversation-turn"]', | |
| '[data-testid="conversation-turn"]', | |
| '[data-message-author-role]', | |
| '[class*="conversation-turn"]', | |
| '[role="listitem"]' | |
| ], | |
| contentSelectors: [ | |
| 'message-content', | |
| '.query-text', | |
| '.response-container', | |
| '.markdown, .prose, [class*="markdown"], [class*="prose"]' | |
| ], | |
| titleSelectors: [ | |
| 'h1:not([class*="hidden"])', | |
| '[class*="conversation-title"]', | |
| '[data-testid*="conversation-title"]', | |
| '[aria-label*="conversation"]' | |
| ] | |
| } | |
| }; | |
| function resolveDocument(doc) { | |
| if (doc) return doc; | |
| if (typeof document !== 'undefined') return document; | |
| throw new Error('ChatExporterEngine requires a document.'); | |
| } | |
| function getWindow(doc) { | |
| return doc.defaultView || (typeof window !== 'undefined' ? window : null); | |
| } | |
| function formatDate(date = new Date()) { | |
| return date.toISOString().split('T')[0]; | |
| } | |
| function sanitizeHtml(value) { | |
| return String(value ?? '') | |
| .replace(/&/g, '&') | |
| .replace(/</g, '<') | |
| .replace(/>/g, '>') | |
| .replace(/"/g, '"') | |
| .replace(/'/g, '''); | |
| } | |
| function normalizeWhitespace(value) { | |
| return String(value ?? '').replace(/\u00a0/g, ' ').replace(/\s+/g, ' ').trim(); | |
| } | |
| function getClassName(element) { | |
| const className = element?.className; | |
| if (!className) return ''; | |
| if (typeof className === 'string') return className; | |
| return className.baseVal || ''; | |
| } | |
| function queryAll(root, selector) { | |
| if (!root || !selector) return []; | |
| try { | |
| return Array.from(root.querySelectorAll(selector)); | |
| } catch (error) { | |
| console.warn('[Chat Exporter] Selector failed:', selector, error); | |
| return []; | |
| } | |
| } | |
| function matches(element, selector) { | |
| if (!element || !selector || typeof element.matches !== 'function') return false; | |
| try { | |
| return element.matches(selector); | |
| } catch (error) { | |
| return false; | |
| } | |
| } | |
| function getText(element) { | |
| if (!element) return ''; | |
| const innerText = typeof element.innerText === 'string' ? element.innerText : ''; | |
| return (innerText || element.textContent || '').replace(/\u00a0/g, ' ').trim(); | |
| } | |
| function collectTextWithBreaks(node) { | |
| if (!node) return ''; | |
| if (node.nodeType === 3) { | |
| return node.nodeValue || ''; | |
| } | |
| if (node.nodeType !== 1) { | |
| return ''; | |
| } | |
| const tag = node.tagName.toLowerCase(); | |
| if (tag === 'br') return '\n'; | |
| if (['script', 'style', 'button', 'svg'].includes(tag)) return ''; | |
| const before = ['div', 'p', 'li', 'tr', 'section', 'article'].includes(tag) ? '\n' : ''; | |
| const after = ['div', 'p', 'li', 'tr', 'section', 'article'].includes(tag) ? '\n' : ''; | |
| return before + Array.from(node.childNodes).map(collectTextWithBreaks).join('') + after; | |
| } | |
| function getCodeText(element) { | |
| if (!element) return ''; | |
| if (typeof element.innerText === 'string' && element.innerText.trim()) { | |
| return element.innerText.replace(/\u00a0/g, ' ').replace(/\n{3,}/g, '\n\n').trimEnd(); | |
| } | |
| const clone = element.cloneNode(true); | |
| queryAll(clone, 'br').forEach(br => br.replaceWith(clone.ownerDocument.createTextNode('\n'))); | |
| return collectTextWithBreaks(clone).replace(/\n{3,}/g, '\n\n').trimEnd(); | |
| } | |
| function normalizeCodeText(value) { | |
| return String(value ?? '') | |
| .replace(/\r\n?/g, '\n') | |
| .replace(/^\n+/, '') | |
| .replace(/\n+$/, ''); | |
| } | |
| function markdownFenceFor(code) { | |
| const runs = String(code ?? '').match(/`{3,}/g) || []; | |
| const longest = runs.reduce((max, run) => Math.max(max, run.length), 2); | |
| return '`'.repeat(longest + 1); | |
| } | |
| function safeInfoString(lang) { | |
| return String(lang || '').replace(/[^a-zA-Z0-9_+#.-]/g, ''); | |
| } | |
| function createTextNode(reference, text) { | |
| return reference.ownerDocument.createTextNode(text); | |
| } | |
| function addReplacement(replacements, html) { | |
| const marker = `${MARKER_PREFIX}${replacements.length}__`; | |
| replacements.push({ marker, html }); | |
| return marker; | |
| } | |
| function restoreReplacements(value, replacements) { | |
| return replacements.reduce((result, replacement) => result.replaceAll(replacement.marker, replacement.html), value); | |
| } | |
| function splitMarkdownFencedBlocks(value) { | |
| const source = String(value ?? ''); | |
| const pattern = /(?:^|\n)(`{3,})[^\n]*\n[\s\S]*?\n\1(?=\n|$)/g; | |
| const segments = []; | |
| let cursor = 0; | |
| let match; | |
| while ((match = pattern.exec(source)) !== null) { | |
| if (match.index > cursor) { | |
| segments.push({ type: 'text', value: source.slice(cursor, match.index) }); | |
| } | |
| segments.push({ type: 'code', value: match[0] }); | |
| cursor = match.index + match[0].length; | |
| } | |
| if (cursor < source.length) { | |
| segments.push({ type: 'text', value: source.slice(cursor) }); | |
| } | |
| return segments; | |
| } | |
| function cleanMarkdownText(value) { | |
| return String(value ?? '') | |
| .replace(/[ \t]+\n/g, '\n') | |
| .replace(/\n[ \t]+/g, '\n') | |
| .replace(/\n{3,}/g, '\n\n') | |
| .replace(/</g, '<') | |
| .replace(/>/g, '>') | |
| .replace(/&/g, '&'); | |
| } | |
| function cleanMarkdown(markdown) { | |
| return splitMarkdownFencedBlocks(markdown) | |
| .map(segment => segment.type === 'code' ? segment.value : cleanMarkdownText(segment.value)) | |
| .join('') | |
| .trim(); | |
| } | |
| function escapeMarkdownLinkText(value) { | |
| return String(value ?? '') | |
| .replace(/\\/g, '\\\\') | |
| .replace(/([\[\]])/g, '\\$1'); | |
| } | |
| function escapeMarkdownUrl(value) { | |
| return String(value ?? '') | |
| .replace(/\\/g, '%5C') | |
| .replace(/\)/g, '%29'); | |
| } | |
| function isUnsafeHref(href) { | |
| const lower = String(href || '').trim().toLowerCase(); | |
| return !lower || | |
| lower.startsWith('javascript:') || | |
| lower.startsWith('data:') || | |
| lower.startsWith('vbscript:') || | |
| lower.startsWith('#'); | |
| } | |
| function topLevelElements(elements) { | |
| return elements.filter(element => !elements.some(other => other !== element && other.contains(element))); | |
| } | |
| function removeUiElements(clone) { | |
| const uiSelector = [ | |
| 'button', | |
| 'svg', | |
| 'style', | |
| 'script', | |
| 'textarea', | |
| 'input', | |
| '[contenteditable="true"]', | |
| '[class*="regenerate"]', | |
| '[class*="copy-button"]', | |
| '[data-testid*="copy"]', | |
| '[data-test-id*="copy"]', | |
| '[aria-label*="Copy"]', | |
| '[aria-label*="copy"]', | |
| '[aria-label*="More"]', | |
| '[aria-label*="more"]' | |
| ].join(','); | |
| queryAll(clone, uiSelector).forEach(element => element.remove()); | |
| } | |
| function detectLanguage(block) { | |
| const codeElement = matches(block, 'code') ? block : block.querySelector('code'); | |
| const sources = [ | |
| codeElement?.className, | |
| block.getAttribute?.('data-language'), | |
| block.getAttribute?.('language'), | |
| block.getAttribute?.('lang'), | |
| codeElement?.getAttribute?.('data-language'), | |
| codeElement?.getAttribute?.('language'), | |
| codeElement?.getAttribute?.('lang'), | |
| block.getAttribute?.('aria-label') | |
| ].filter(Boolean).map(String); | |
| for (const source of sources) { | |
| const languageMatch = source.match(/language-([a-zA-Z0-9_+#.-]+)/); | |
| if (languageMatch) return languageMatch[1].toLowerCase(); | |
| if (/^[a-zA-Z0-9_+#.-]{1,24}$/.test(source) && !/^(code|copy|download)$/i.test(source)) { | |
| return source.toLowerCase(); | |
| } | |
| } | |
| const header = block.querySelector('[class*="sticky"], [class*="code-header"], [data-testid*="code"], [data-test-id*="code"], .code-language, .code-lang, [slot="header"]'); | |
| const headerText = normalizeWhitespace(getText(header)).replace(/\b(copy|code|download)\b/gi, '').trim(); | |
| if (headerText && headerText.length < 32 && !headerText.includes('\n')) { | |
| return headerText.toLowerCase(); | |
| } | |
| return ''; | |
| } | |
| function extractCodeBlock(block) { | |
| const clone = block.cloneNode(true); | |
| const language = detectLanguage(clone); | |
| queryAll(clone, 'button, svg, [aria-label*="Copy"], [aria-label*="copy"], [class*="sticky"], [class*="code-header"], [data-testid*="copy"], [data-test-id*="copy"], [slot="header"]').forEach(element => element.remove()); | |
| const cmContent = clone.querySelector('.cm-content'); | |
| if (cmContent) { | |
| const cmLines = queryAll(cmContent, '.cm-line'); | |
| if (cmLines.length > 0) { | |
| return { | |
| lang: language, | |
| code: normalizeCodeText(cmLines.map(line => line.textContent || '').join('\n')) | |
| }; | |
| } | |
| return { | |
| lang: language, | |
| code: normalizeCodeText(getCodeText(cmContent)) | |
| }; | |
| } | |
| const codeElement = matches(clone, 'code') ? clone : clone.querySelector('code'); | |
| return { | |
| lang: language, | |
| code: normalizeCodeText(getCodeText(codeElement || clone)) | |
| }; | |
| } | |
| function formatCodeBlock(block, format, replacements) { | |
| const { lang, code } = extractCodeBlock(block); | |
| if (format === 'markdown') { | |
| const fence = markdownFenceFor(code); | |
| return `\n\n${fence}${safeInfoString(lang)}\n${code}\n${fence}\n\n`; | |
| } | |
| const langClass = lang ? ` class="language-${sanitizeHtml(lang)}"` : ''; | |
| if (format === 'pdf') { | |
| const label = lang ? `<div class="code-language">${sanitizeHtml(lang)}</div>` : ''; | |
| return addReplacement(replacements, `<pre class="code-block">${label}<code>${sanitizeHtml(code)}</code></pre>`); | |
| } | |
| return addReplacement(replacements, `<pre><code${langClass}>${sanitizeHtml(code)}</code></pre>`); | |
| } | |
| function processCodeBlocks(clone, format, replacements) { | |
| const blocks = topLevelElements(queryAll(clone, 'pre, code-block, [data-testid*="code-block"], [data-test-id*="code-block"]')); | |
| blocks.forEach(block => { | |
| const replacement = formatCodeBlock(block, format, replacements); | |
| block.replaceWith(createTextNode(block, replacement)); | |
| }); | |
| } | |
| function processMath(clone) { | |
| const processed = new Set(); | |
| queryAll(clone, 'annotation').forEach(annotation => { | |
| const encoding = (annotation.getAttribute('encoding') || '').toLowerCase(); | |
| if (!encoding.includes('tex') && !encoding.includes('latex')) return; | |
| const tex = annotation.textContent.trim(); | |
| if (!tex) return; | |
| const displayRoot = annotation.closest('.katex-display, mjx-container[display="true"], [display="block"]'); | |
| const mathRoot = displayRoot || annotation.closest('.katex') || annotation.closest('mjx-container') || annotation.closest('math'); | |
| if (!mathRoot || processed.has(mathRoot)) return; | |
| processed.add(mathRoot); | |
| mathRoot.replaceWith(createTextNode(mathRoot, displayRoot ? `\n\n$$${tex}$$\n\n` : `$${tex}$`)); | |
| }); | |
| queryAll(clone, 'script[type^="math/tex"]').forEach(script => { | |
| const tex = script.textContent.trim(); | |
| if (!tex) return; | |
| const isDisplay = /mode=display/.test(script.type); | |
| script.replaceWith(createTextNode(script, isDisplay ? `\n\n$$${tex}$$\n\n` : `$${tex}$`)); | |
| }); | |
| } | |
| function processMedia(clone, format, replacements) { | |
| queryAll(clone, 'img, canvas, video, audio').forEach(element => { | |
| const tag = element.tagName.toLowerCase(); | |
| const alt = normalizeWhitespace(element.getAttribute('alt') || element.getAttribute('aria-label') || element.getAttribute('title') || ''); | |
| const label = tag === 'img' && alt ? `[Image: ${alt}]` : | |
| tag === 'img' ? '[Image]' : | |
| tag === 'canvas' ? '[Canvas or chart]' : | |
| tag === 'video' ? '[Video]' : | |
| tag === 'audio' ? '[Audio]' : | |
| '[Media]'; | |
| const replacement = format === 'markdown' | |
| ? label | |
| : addReplacement(replacements, `<span class="media-placeholder">${sanitizeHtml(label)}</span>`); | |
| element.replaceWith(createTextNode(element, replacement)); | |
| }); | |
| } | |
| function processLinks(clone, format, replacements) { | |
| queryAll(clone, 'a[href]').forEach(link => { | |
| if (link.closest('pre, code, code-block')) return; | |
| const href = String(link.href || link.getAttribute('href') || '').trim(); | |
| if (isUnsafeHref(href)) return; | |
| const text = normalizeWhitespace(link.textContent) || href; | |
| const replacement = format === 'markdown' | |
| ? `[${escapeMarkdownLinkText(text)}](${escapeMarkdownUrl(href)})` | |
| : addReplacement(replacements, `<a href="${sanitizeHtml(href)}">${sanitizeHtml(text)}</a>`); | |
| link.replaceWith(createTextNode(link, replacement)); | |
| }); | |
| } | |
| function tableCellText(cell) { | |
| return normalizeWhitespace(getText(cell)) | |
| .replace(/\\/g, '\\\\') | |
| .replace(/\|/g, '\\|') || ' '; | |
| } | |
| function tableToMarkdown(table) { | |
| const rows = queryAll(table, 'tr') | |
| .map(row => Array.from(row.children) | |
| .filter(cell => ['TH', 'TD'].includes(cell.tagName)) | |
| .map(tableCellText)) | |
| .filter(row => row.length > 0); | |
| if (rows.length === 0) return normalizeWhitespace(getText(table)); | |
| const width = Math.max(...rows.map(row => row.length)); | |
| const normalizedRows = rows.map(row => row.concat(Array(Math.max(0, width - row.length)).fill(' '))); | |
| const header = normalizedRows[0]; | |
| const separator = header.map(() => '---'); | |
| const body = normalizedRows.slice(1); | |
| return [ | |
| `| ${header.join(' | ')} |`, | |
| `| ${separator.join(' | ')} |`, | |
| ...body.map(row => `| ${row.join(' | ')} |`) | |
| ].join('\n'); | |
| } | |
| function tableToHtml(table) { | |
| const rows = queryAll(table, 'tr') | |
| .map(row => Array.from(row.children) | |
| .filter(cell => ['TH', 'TD'].includes(cell.tagName)) | |
| .map(cell => ({ | |
| tag: cell.tagName.toLowerCase(), | |
| text: normalizeWhitespace(getText(cell)) | |
| }))) | |
| .filter(row => row.length > 0); | |
| if (rows.length === 0) return sanitizeHtml(getText(table)); | |
| return `<table>${rows.map(row => { | |
| const cells = row.map(cell => `<${cell.tag}>${sanitizeHtml(cell.text)}</${cell.tag}>`).join(''); | |
| return `<tr>${cells}</tr>`; | |
| }).join('')}</table>`; | |
| } | |
| function processTables(clone, format, replacements) { | |
| topLevelElements(queryAll(clone, 'table')).forEach(table => { | |
| const replacement = format === 'markdown' | |
| ? `\n\n${tableToMarkdown(table)}\n\n` | |
| : addReplacement(replacements, tableToHtml(table)); | |
| table.replaceWith(createTextNode(table, replacement)); | |
| }); | |
| } | |
| function cardSignal(element) { | |
| const pieces = [ | |
| element.tagName, | |
| getClassName(element), | |
| element.getAttribute('data-testid'), | |
| element.getAttribute('data-test-id'), | |
| element.getAttribute('aria-label'), | |
| element.getAttribute('role') | |
| ].filter(Boolean).join(' ').toLowerCase(); | |
| if (/\b(artifact|canvas-preview|generated-file|download-card|attachment|file-card)\b/.test(pieces)) { | |
| return pieces.includes('artifact') || pieces.includes('canvas-preview') ? 'Artifact' : 'File'; | |
| } | |
| if (/(^|[\s_-])(attachment|file)([\s_-]|$)/.test(pieces)) { | |
| return 'File'; | |
| } | |
| return ''; | |
| } | |
| function cardLabel(element) { | |
| const candidates = [ | |
| element.getAttribute('aria-label'), | |
| element.getAttribute('title'), | |
| element.getAttribute('download'), | |
| element.getAttribute('data-filename'), | |
| getText(element) | |
| ].filter(Boolean).map(normalizeWhitespace); | |
| const label = candidates.find(value => value && value.length <= 180) || ''; | |
| return label | |
| .replace(/\b(open|download|preview|file|attachment|artifact)\b/gi, ' ') | |
| .replace(/\s+/g, ' ') | |
| .trim(); | |
| } | |
| function processCards(clone, format, replacements) { | |
| const cards = topLevelElements(Array.from(clone.querySelectorAll('*')).filter(element => { | |
| const kind = cardSignal(element); | |
| if (!kind) return false; | |
| if (matches(element, '[data-message-author-role], user-query, model-response')) return false; | |
| if (element.closest('pre, code, code-block, table')) return false; | |
| if (element.querySelector('pre, code-block, table, user-query, model-response')) return false; | |
| const text = normalizeWhitespace(getText(element)); | |
| const label = cardLabel(element); | |
| return Boolean(label || text) && text.length <= 240; | |
| })); | |
| cards.forEach(card => { | |
| const kind = cardSignal(card); | |
| const label = cardLabel(card); | |
| const text = label ? `[${kind}: ${label}]` : `[${kind}]`; | |
| const replacement = format === 'markdown' | |
| ? text | |
| : addReplacement(replacements, `<span class="card-placeholder">${sanitizeHtml(text)}</span>`); | |
| card.replaceWith(createTextNode(card, replacement)); | |
| }); | |
| } | |
| function serializeMarkdownChildren(element, context = {}) { | |
| return Array.from(element.childNodes).map((node, index) => serializeMarkdownNode(node, { | |
| ...context, | |
| index | |
| })).join(''); | |
| } | |
| function serializeMarkdownNode(node, context = {}) { | |
| if (node.nodeType === 3) { | |
| const value = node.nodeValue || ''; | |
| if (value.includes('```') || value.includes('| ---') || value.includes(MARKER_PREFIX) || value.match(/^\s*\[[^\]]+\]/)) { | |
| return value; | |
| } | |
| return value.replace(/[ \t\r\n]+/g, ' '); | |
| } | |
| if (node.nodeType !== 1) return ''; | |
| const tag = node.tagName.toLowerCase(); | |
| if (['script', 'style', 'button', 'svg'].includes(tag)) return ''; | |
| if (tag === 'br') return '\n'; | |
| if (/^h[1-6]$/.test(tag)) { | |
| const level = Number(tag.slice(1)); | |
| const content = serializeMarkdownChildren(node, context).trim(); | |
| return content ? `\n\n${'#'.repeat(level)} ${content}\n\n` : ''; | |
| } | |
| if (tag === 'p') { | |
| const content = serializeMarkdownChildren(node, context).trim(); | |
| return content ? `\n\n${content}\n\n` : ''; | |
| } | |
| if (tag === 'blockquote') { | |
| const content = serializeMarkdownChildren(node, context).trim(); | |
| return content ? `\n\n${content.split('\n').map(line => `> ${line.trim()}`).join('\n')}\n\n` : ''; | |
| } | |
| if (tag === 'ul' || tag === 'ol') { | |
| const children = Array.from(node.children).filter(child => child.tagName && child.tagName.toLowerCase() === 'li'); | |
| return `\n${children.map((child, index) => serializeMarkdownNode(child, { | |
| ...context, | |
| listType: tag, | |
| index, | |
| depth: (context.depth || 0) | |
| })).join('')}\n`; | |
| } | |
| if (tag === 'li') { | |
| const depth = context.depth || 0; | |
| const marker = context.listType === 'ol' ? `${(context.index || 0) + 1}. ` : '- '; | |
| const indent = ' '.repeat(depth); | |
| const content = serializeMarkdownChildren(node, { | |
| ...context, | |
| depth: depth + 1 | |
| }).trim(); | |
| return content ? `${indent}${marker}${content.replace(/\n+/g, `\n${indent} `)}\n` : ''; | |
| } | |
| if (['strong', 'b'].includes(tag)) { | |
| const content = serializeMarkdownChildren(node, context).trim(); | |
| return content ? `**${content}**` : ''; | |
| } | |
| if (['em', 'i'].includes(tag)) { | |
| const content = serializeMarkdownChildren(node, context).trim(); | |
| return content ? `*${content}*` : ''; | |
| } | |
| if (tag === 'code') { | |
| const content = getCodeText(node) | |
| .replace(/\\/g, '\\\\') | |
| .replace(/`/g, '\\`') | |
| .trim(); | |
| return content ? `\`${content}\`` : ''; | |
| } | |
| const content = serializeMarkdownChildren(node, context); | |
| if (['div', 'section', 'article', 'main', 'message-content', 'model-response', 'user-query', 'response-element'].includes(tag)) { | |
| return content; | |
| } | |
| return content; | |
| } | |
| function serializeHtmlChildren(element, replacements) { | |
| return Array.from(element.childNodes).map(node => serializeHtmlNode(node, replacements)).join(''); | |
| } | |
| function serializeHtmlNode(node, replacements) { | |
| if (node.nodeType === 3) { | |
| return sanitizeHtml(node.nodeValue || ''); | |
| } | |
| if (node.nodeType !== 1) return ''; | |
| const tag = node.tagName.toLowerCase(); | |
| if (['script', 'style', 'button', 'svg'].includes(tag)) return ''; | |
| if (tag === 'br') return '<br>'; | |
| const content = serializeHtmlChildren(node, replacements); | |
| const blockTags = new Set(['p', 'ul', 'ol', 'li', 'blockquote', 'strong', 'b', 'em', 'i']); | |
| if (/^h[1-6]$/.test(tag)) { | |
| return `<${tag}>${content}</${tag}>`; | |
| } | |
| if (blockTags.has(tag)) { | |
| const safeTag = tag === 'b' ? 'strong' : tag === 'i' ? 'em' : tag; | |
| return `<${safeTag}>${content}</${safeTag}>`; | |
| } | |
| if (tag === 'code') { | |
| return `<code>${sanitizeHtml(getCodeText(node).trim())}</code>`; | |
| } | |
| if (['div', 'section', 'article', 'main', 'span', 'message-content', 'model-response', 'user-query', 'response-element'].includes(tag)) { | |
| return content; | |
| } | |
| return content; | |
| } | |
| function serializeMessageContent(element, format) { | |
| const clone = element.cloneNode(true); | |
| const replacements = []; | |
| removeUiElements(clone); | |
| processCards(clone, format, replacements); | |
| processCodeBlocks(clone, format, replacements); | |
| processMath(clone); | |
| processMedia(clone, format, replacements); | |
| processLinks(clone, format, replacements); | |
| processTables(clone, format, replacements); | |
| if (format === 'markdown') { | |
| return cleanMarkdown(serializeMarkdownChildren(clone)); | |
| } | |
| const html = serializeHtmlChildren(clone, replacements) | |
| .replace(/\n{3,}/g, '\n\n') | |
| .trim(); | |
| return restoreReplacements(html, replacements); | |
| } | |
| function detectProvider(doc) { | |
| const url = doc.defaultView?.location?.href || ''; | |
| if (/gemini\.google\.com/i.test(url) || doc.querySelector('user-query, model-response')) return 'gemini'; | |
| return 'chatgpt'; | |
| } | |
| function providerFor(providerName, doc) { | |
| const id = providerName || detectProvider(doc); | |
| return PROVIDERS[id] || PROVIDERS.chatgpt; | |
| } | |
| function meaningfulScore(element) { | |
| const richCount = queryAll(element, 'pre, code-block, table, img, canvas, video, audio, annotation, script[type^="math/tex"]').length; | |
| return normalizeWhitespace(element.textContent).length + richCount * 200; | |
| } | |
| function isValidMessage(element) { | |
| const text = normalizeWhitespace(element.textContent); | |
| const richCount = queryAll(element, 'pre, code-block, table, img, canvas, video, audio, annotation, script[type^="math/tex"]').length; | |
| if (text.length < 5 && richCount === 0) return false; | |
| if (text.length > 200000) return false; | |
| if (matches(element, 'nav, aside, header, footer, form, menu')) return false; | |
| if (element.querySelector('textarea, input[type="text"], [contenteditable="true"]') && !element.hasAttribute('data-message-author-role')) return false; | |
| if (getClassName(element).match(/\b(typing|loading|spinner)\b/i)) return false; | |
| return true; | |
| } | |
| function findMessages(doc, provider) { | |
| for (const selector of provider.messageSelectors) { | |
| const messages = topLevelElements(queryAll(doc, selector)).filter(isValidMessage); | |
| if (messages.length > 0) { | |
| console.log(`[Chat Exporter] ${provider.id}: using selector "${selector}" (${messages.length} messages)`); | |
| return messages; | |
| } | |
| } | |
| const container = doc.querySelector('[role="main"], main, [class*="conversation"], [class*="chat"]'); | |
| if (!container) return []; | |
| return topLevelElements(queryAll(container, ':scope > article, :scope > section, :scope > div')).filter(isValidMessage); | |
| } | |
| function selectContentRoot(messageElement, provider) { | |
| const candidates = [messageElement]; | |
| provider.contentSelectors.forEach(selector => { | |
| if (matches(messageElement, selector)) candidates.push(messageElement); | |
| candidates.push(...queryAll(messageElement, selector)); | |
| }); | |
| return candidates | |
| .filter(Boolean) | |
| .sort((a, b) => meaningfulScore(b) - meaningfulScore(a))[0] || messageElement; | |
| } | |
| function identifySender(element, index, provider) { | |
| const tag = element.tagName.toLowerCase(); | |
| if (tag === 'user-query') return { sender: 'You', reliable: true }; | |
| if (tag === 'model-response') return { sender: provider.assistantName, reliable: true }; | |
| const role = element.getAttribute('data-message-author-role') || element.getAttribute('data-author') || element.getAttribute('data-sender'); | |
| if (role) { | |
| const normalizedRole = role.toLowerCase(); | |
| if (normalizedRole === 'user') return { sender: 'You', reliable: true }; | |
| if (['assistant', 'model', 'bot', 'chatgpt', 'gemini'].includes(normalizedRole)) { | |
| return { sender: provider.assistantName, reliable: true }; | |
| } | |
| } | |
| const classAndAttrs = [ | |
| getClassName(element), | |
| element.getAttribute('aria-label'), | |
| element.getAttribute('data-testid'), | |
| element.getAttribute('data-test-id') | |
| ].filter(Boolean).join(' ').toLowerCase(); | |
| if (classAndAttrs.match(/\b(user|human|query)\b/)) return { sender: 'You', reliable: false }; | |
| if (classAndAttrs.match(/\b(assistant|model|response|chatgpt|gemini|bard)\b/)) return { sender: provider.assistantName, reliable: false }; | |
| const textStart = normalizeWhitespace(element.textContent).slice(0, 220).toLowerCase(); | |
| if (/^(i understand|i can help|here's|i'll|let me|i'd be happy|certainly|of course|absolutely)/.test(textStart)) { | |
| return { sender: provider.assistantName, reliable: false }; | |
| } | |
| if (/^(can you|please help|how do i|i need|i want|help me|could you|explain|what is)/.test(textStart)) { | |
| return { sender: 'You', reliable: false }; | |
| } | |
| return { sender: index % 2 === 0 ? 'You' : provider.assistantName, reliable: false }; | |
| } | |
| function contentHash(content) { | |
| return normalizeWhitespace(String(content || '').replace(/<[^>]+>/g, ' ')).slice(0, 160); | |
| } | |
| function extractConversation(options = {}) { | |
| const doc = resolveDocument(options.document); | |
| const provider = providerFor(options.provider, doc); | |
| const format = options.format || 'markdown'; | |
| const rawMessages = findMessages(doc, provider); | |
| const seen = new Set(); | |
| const messages = []; | |
| rawMessages.forEach((messageElement, index) => { | |
| const contentRoot = selectContentRoot(messageElement, provider); | |
| const content = serializeMessageContent(contentRoot, format); | |
| const minLength = queryAll(contentRoot, 'pre, code-block, table, img, canvas, video, audio').length > 0 ? 3 : 10; | |
| if (!content || normalizeWhitespace(content).length < minLength) return; | |
| const hash = contentHash(content); | |
| if (seen.has(hash)) return; | |
| seen.add(hash); | |
| const sender = identifySender(messageElement, index, provider); | |
| messages.push({ | |
| sender: sender.sender, | |
| senderType: sender.sender === 'You' ? 'user' : 'assistant', | |
| reliableSender: sender.reliable, | |
| content, | |
| index | |
| }); | |
| }); | |
| return { | |
| version: ENGINE_VERSION, | |
| provider: provider.id, | |
| providerLabel: provider.assistantName, | |
| sourceLabel: provider.sourceLabel, | |
| title: extractConversationTitle(doc, provider), | |
| sourceUrl: options.includeSourceUrl === true ? doc.defaultView?.location?.href || '' : '', | |
| date: formatDate(options.date || new Date()), | |
| messages | |
| }; | |
| } | |
| function extractConversationTitle(doc, provider) { | |
| for (const selector of provider.titleSelectors) { | |
| const element = doc.querySelector(selector); | |
| const title = normalizeWhitespace(element?.textContent); | |
| if (title && !provider.genericTitlePattern.test(title)) return title; | |
| } | |
| const docTitle = normalizeWhitespace(doc.title); | |
| if (docTitle && !provider.genericTitlePattern.test(docTitle)) return docTitle; | |
| return provider.defaultTitle; | |
| } | |
| function renderMarkdown(conversation) { | |
| const source = conversation.sourceUrl | |
| ? `**Source:** [${conversation.sourceLabel}](${conversation.sourceUrl})\n` | |
| : `**Source:** ${conversation.sourceLabel}\n`; | |
| const lines = [ | |
| `# ${conversation.title}\n`, | |
| `**Date:** ${conversation.date}`, | |
| source, | |
| '---\n' | |
| ]; | |
| conversation.messages.forEach(message => { | |
| lines.push(`### **${message.sender}**\n`, message.content, '\n---\n'); | |
| }); | |
| return `${lines.join('\n').trim()}\n`; | |
| } | |
| function renderHtmlDocument(conversation, options = {}) { | |
| const isPdf = options.format === 'pdf'; | |
| const source = sanitizeHtml(conversation.sourceUrl); | |
| const sourceMarkup = source | |
| ? `<a href="${source}">${sanitizeHtml(conversation.sourceLabel)}</a>` | |
| : sanitizeHtml(conversation.sourceLabel); | |
| const title = sanitizeHtml(conversation.title); | |
| const messages = conversation.messages.map(message => { | |
| const senderClass = message.senderType === 'user' ? 'user' : 'assistant'; | |
| return ` | |
| <div class="message ${senderClass}"> | |
| <div class="sender">${sanitizeHtml(message.sender)}</div> | |
| <div class="content">${message.content}</div> | |
| </div>`; | |
| }).join(''); | |
| const pdfInstructions = isPdf ? ` | |
| <div class="instructions no-print"> | |
| <h3>Convert to PDF</h3> | |
| <ol> | |
| <li>Press Ctrl+P on Windows/Linux or Cmd+P on Mac.</li> | |
| <li>Set Destination to Save as PDF.</li> | |
| <li>Choose your preferred page size.</li> | |
| <li>Click Save.</li> | |
| </ol> | |
| <p><em>This instruction box will not appear in the PDF.</em></p> | |
| </div>` : ''; | |
| return `<!DOCTYPE html> | |
| <html> | |
| <head> | |
| <meta charset="utf-8"> | |
| <title>${title} - ${conversation.date}</title> | |
| <style> | |
| @media print { | |
| body { margin: 0; } | |
| .no-print { display: none; } | |
| .message { page-break-inside: avoid; } | |
| } | |
| body { | |
| font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; | |
| max-width: 840px; | |
| margin: auto; | |
| padding: 2rem; | |
| background: #fff; | |
| color: #333; | |
| line-height: 1.6; | |
| } | |
| .header { | |
| text-align: center; | |
| margin-bottom: 2rem; | |
| padding-bottom: 1rem; | |
| border-bottom: 2px solid #eee; | |
| } | |
| .metadata { | |
| color: #666; | |
| font-size: 0.9rem; | |
| } | |
| .message { | |
| margin-bottom: 1.5rem; | |
| padding: 1rem; | |
| border-radius: 8px; | |
| background: #f8f9fa; | |
| } | |
| .message.user { | |
| background: #eaf4ff; | |
| } | |
| .sender { | |
| font-weight: 700; | |
| color: #2c3e50; | |
| margin-bottom: 0.5rem; | |
| } | |
| .content { | |
| white-space: pre-wrap; | |
| overflow-wrap: anywhere; | |
| } | |
| pre { | |
| background: #f4f4f4; | |
| padding: 1rem; | |
| border-radius: 4px; | |
| overflow-x: auto; | |
| border-left: 4px solid #007acc; | |
| } | |
| .code-block { | |
| background: #282c34; | |
| color: #abb2bf; | |
| border-left: 0; | |
| } | |
| .code-block code { | |
| white-space: pre; | |
| } | |
| .code-language { | |
| color: #d7dae0; | |
| font-size: 12px; | |
| margin-bottom: 8px; | |
| text-transform: uppercase; | |
| } | |
| code { | |
| font-family: Consolas, Monaco, "Courier New", monospace; | |
| font-size: 0.92rem; | |
| } | |
| table { | |
| width: 100%; | |
| border-collapse: collapse; | |
| margin: 1rem 0; | |
| } | |
| th, td { | |
| border: 1px solid #d9dde3; | |
| padding: 0.5rem; | |
| text-align: left; | |
| vertical-align: top; | |
| } | |
| th { | |
| background: #eef2f7; | |
| } | |
| .instructions { | |
| background: #fff8dc; | |
| border: 1px solid #e3c565; | |
| border-radius: 8px; | |
| padding: 1rem; | |
| margin-bottom: 1.5rem; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| ${pdfInstructions} | |
| <div class="header"> | |
| <h1>${title}</h1> | |
| <div class="metadata"> | |
| <div><strong>Date:</strong> ${sanitizeHtml(conversation.date)}</div> | |
| <div><strong>Source:</strong> ${sourceMarkup}</div> | |
| <div><strong>Messages:</strong> ${conversation.messages.length}</div> | |
| </div> | |
| </div> | |
| <div class="conversation">${messages} | |
| </div> | |
| </body> | |
| </html>`; | |
| } | |
| function render(conversation, format) { | |
| if (format === 'markdown') return renderMarkdown(conversation); | |
| if (format === 'pdf') return renderHtmlDocument(conversation, { format: 'pdf' }); | |
| if (format === 'html') return renderHtmlDocument(conversation, { format: 'html' }); | |
| throw new Error(`Unsupported export format: ${format}`); | |
| } | |
| function filenameFor(conversation, format, doc) { | |
| const safeTitle = normalizeWhitespace(doc.title || conversation.title) | |
| .replace(/[<>:"/\\|?*]/g, '') | |
| .slice(0, 120); | |
| if (format === 'markdown') { | |
| return safeTitle ? `${safeTitle} (${conversation.date}).md` : `${conversation.providerLabel}_Conversation_${conversation.date}.md`; | |
| } | |
| if (format === 'pdf') { | |
| return safeTitle ? `${safeTitle} (${conversation.date}) - PrintToPDF.html` : `${conversation.providerLabel}_Conversation_${conversation.date}_PrintToPDF.html`; | |
| } | |
| return safeTitle ? `${safeTitle} (${conversation.date}).html` : `${conversation.providerLabel}_Conversation_${conversation.date}.html`; | |
| } | |
| function mimeFor(format) { | |
| return format === 'markdown' ? 'text/markdown' : 'text/html'; | |
| } | |
| function downloadFile(doc, content, filename, mimeType) { | |
| const win = getWindow(doc); | |
| const BlobCtor = win?.Blob || Blob; | |
| const urlApi = win?.URL || URL; | |
| const blob = new BlobCtor([content], { type: mimeType }); | |
| const url = urlApi.createObjectURL(blob); | |
| const anchor = doc.createElement('a'); | |
| anchor.href = url; | |
| anchor.download = filename; | |
| doc.body.appendChild(anchor); | |
| anchor.click(); | |
| doc.body.removeChild(anchor); | |
| urlApi.revokeObjectURL(url); | |
| } | |
| function exportConversation(options = {}) { | |
| const doc = resolveDocument(options.document); | |
| const format = options.format || 'markdown'; | |
| const conversation = extractConversation({ | |
| ...options, | |
| document: doc, | |
| format | |
| }); | |
| if (conversation.messages.length === 0) { | |
| const message = 'No messages found. The page structure may have changed.'; | |
| const win = getWindow(doc); | |
| if (typeof win?.alert === 'function') win.alert(message); | |
| console.warn(`[Chat Exporter] ${message}`); | |
| return { conversation, content: '' }; | |
| } | |
| const content = render(conversation, format); | |
| const filename = options.filename || filenameFor(conversation, format, doc); | |
| if (options.download !== false) { | |
| downloadFile(doc, content, filename, mimeFor(format)); | |
| console.log(`[Chat Exporter] Exported ${conversation.messages.length} messages to ${filename}`); | |
| } | |
| return { conversation, content, filename }; | |
| } | |
| return { | |
| version: ENGINE_VERSION, | |
| providers: PROVIDERS, | |
| detectProvider, | |
| extractConversation, | |
| exportConversation, | |
| serializers: { | |
| markdown: renderMarkdown, | |
| html: conversation => renderHtmlDocument(conversation, { format: 'html' }), | |
| pdf: conversation => renderHtmlDocument(conversation, { format: 'pdf' }) | |
| }, | |
| internals: { | |
| serializeMessageContent, | |
| extractCodeBlock, | |
| tableToMarkdown, | |
| tableToHtml | |
| } | |
| }; | |
| }); | |
| globalThis.ChatExporterEngine.exportConversation({ | |
| provider: 'chatgpt', | |
| format: 'markdown' | |
| }); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment