Last active
September 12, 2025 21:19
-
-
Save ergolyam/f537fe07f87b9ae37f6bd80a77bb369f to your computer and use it in GitHub Desktop.
Save current ChatGPT dialog (user + assistant) as a .md file via the Tampermonkey menu.
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
| // ==UserScript== | |
| // @name ChatGPT → Markdown saver | |
| // @namespace tgpt.chat.markdown.saver | |
| // @version 1.3 | |
| // @description Save current ChatGPT dialog (user + assistant) as a .md file via the Tampermonkey menu. | |
| // @match https://chatgpt.com/* | |
| // @match https://chat.openai.com/* | |
| // @match http*://*/* // lets you run this on mirrors/embeds too (menu-only, harmless) | |
| // @match file:///* // saved HTML files | |
| // @grant GM_registerMenuCommand | |
| // @grant GM_download | |
| // @grant GM_notification | |
| // @run-at document-idle | |
| // ==/UserScript== | |
| (function () { | |
| 'use strict'; | |
| // ---- logging helpers ----------------------------------------------------- | |
| const TAG = '🪵[GPT→MD]'; | |
| const log = (...a) => console.log(TAG, ...a); | |
| const warn = (...a) => console.warn(TAG, ...a); | |
| const err = (...a) => console.error(TAG, ...a); | |
| // Note: keep notify available for Debug only; not used by save flow anymore. | |
| function notify(text, silent = false) { | |
| try { | |
| if (typeof GM_notification === 'function') { | |
| GM_notification({ title: 'ChatGPT → Markdown', text, timeout: 4000, silent }); | |
| } | |
| } catch (_) { /* noop */ } | |
| } | |
| window.addEventListener('error', e => { | |
| err('Uncaught error:', e.error || e.message || e); | |
| }); | |
| // ---- utils --------------------------------------------------------------- | |
| function safeText(s) { return (s || '').replace(/\s+\n/g, '\n').replace(/[ \t]+\n/g, '\n'); } | |
| function elToMd(el, indent = '') { | |
| if (!el) return ''; | |
| if (el.closest?.('[data-testid^="copy-turn-action-button"],[data-testid^="copy-code-block"],[data-qa="copy-code"]')) return ''; | |
| if (el.tagName === 'BUTTON' || el.getAttribute?.('aria-label') === 'Copy') return ''; | |
| const T = el.tagName?.toUpperCase?.() || ''; | |
| const kids = [...el.childNodes]; | |
| if (el.nodeType === Node.TEXT_NODE) return (el.nodeValue || '').replace(/\s+/g, ' '); | |
| if (T === 'PRE') { | |
| const codeEl = el.querySelector('code') || el; | |
| const cls = codeEl.className || ''; | |
| const lang = (cls.match(/language-([a-z0-9+-]+)/i) || [, ''])[1]; | |
| const code = (codeEl.textContent || '').replace(/\n+$/, ''); | |
| return `\n\`\`\`${lang}\n${code}\n\`\`\`\n`; | |
| } | |
| if (T === 'CODE' && !el.closest('pre')) { | |
| const txt = (el.textContent || '').trim().replace(/`/g, '\\`'); | |
| return '`' + txt + '`'; | |
| } | |
| if (/^H[1-6]$/.test(T)) { | |
| const level = Number(T.slice(1)); | |
| return `\n${'#'.repeat(level)} ${safeText(el.textContent).trim()}\n\n`; | |
| } | |
| if (T === 'P') return `\n${kids.map(n => elToMd(n, indent)).join('').trim()}\n\n`; | |
| if (T === 'BR') return ' \n'; | |
| if (T === 'HR') return `\n---\n\n`; | |
| if (T === 'BLOCKQUOTE') { | |
| const inner = kids.map(n => elToMd(n, indent)).join('').trim(); | |
| return '\n' + inner.split('\n').map(l => (l ? `> ${l}` : '>')).join('\n') + '\n\n'; | |
| } | |
| if (T === 'UL' || T === 'OL') { | |
| const isOL = T === 'OL'; | |
| let idx = Number(el.getAttribute('start') || 1); | |
| const items = [...el.children].filter(c => c.tagName?.toUpperCase() === 'LI'); | |
| const lines = items.map(li => { | |
| const sublists = [...li.children].filter(c => /^(UL|OL)$/i.test(c.tagName)); | |
| const contentNodes = [...li.childNodes].filter(n => !sublists.includes(n)); | |
| const content = contentNodes.map(n => elToMd(n, indent + ' ')).join('').trim(); | |
| const bullet = isOL ? `${idx++}. ` : '- '; | |
| const main = `${indent}${bullet}${content.split('\n').shift() || ''}`; | |
| const rest = content.split('\n').slice(1).map(l => (l ? `${indent} ${l}` : '')).join('\n'); | |
| const nested = sublists.map(c => elToMd(c, indent + ' ')).join(''); | |
| return [main, rest, nested ? nested.replace(/\n$/, '') : ''].filter(Boolean).join('\n'); | |
| }); | |
| return '\n' + lines.join('\n') + '\n\n'; | |
| } | |
| if (T === 'A') { | |
| const href = (el.getAttribute('href') || '').trim(); | |
| const label = (el.textContent || '').trim() || href; | |
| return href ? `[${label}](${href})` : label; | |
| } | |
| if (T === 'STRONG' || T === 'B') return `**${kids.map(n => elToMd(n, indent)).join('')}**`; | |
| if (T === 'EM' || T === 'I') return `*${kids.map(n => elToMd(n, indent)).join('')}*`; | |
| if (T === 'IMG') { | |
| const alt = el.getAttribute('alt') || ''; | |
| const src = el.getAttribute('src') || ''; | |
| return src ? `` : alt; | |
| } | |
| return kids.map(n => elToMd(n, indent)).join(''); | |
| } | |
| function extractTurnMarkdown(article) { | |
| const role = | |
| article.getAttribute('data-turn') || | |
| article.querySelector('[data-message-author-role]')?.getAttribute('data-message-author-role') || | |
| (/assistant/i.test(article.textContent) ? 'assistant' : 'user'); | |
| let blocks = []; | |
| if (role === 'assistant') { | |
| blocks = [...article.querySelectorAll('.markdown')]; | |
| if (!blocks.length) blocks = [article]; | |
| } else { | |
| const userRoot = | |
| article.querySelector('.user-message-bubble-color') || | |
| article.querySelector('[data-message-author-role="user"]') || | |
| article; | |
| blocks = [userRoot]; | |
| } | |
| const parts = blocks.map(node => { | |
| const clone = node.cloneNode(true); | |
| clone.querySelectorAll('button[aria-label="Copy"],[data-testid^="copy-turn-action-button"],[data-testid^="copy-code-block"],[data-qa="copy-code"]').forEach(n => n.remove()); | |
| return elToMd(clone).trim(); | |
| }).filter(Boolean); | |
| const heading = role === 'user' ? '### You' : '### ChatGPT'; | |
| const content = parts.join('\n\n').trim(); | |
| return { role, md: content ? `${heading}\n\n${content}\n` : '' }; | |
| } | |
| function collectAllTurns() { | |
| const a1 = [...document.querySelectorAll('article[data-turn]')]; // saved HTML uses this | |
| const a2 = [...document.querySelectorAll('article[id^="conversation-turn-"]')]; | |
| const a3 = [...document.querySelectorAll('article[data-testid^="conversation-turn"]')]; | |
| const base = a1.length ? a1 : (a2.length ? a2 : a3); | |
| const nodes = [...new Set(base)]; | |
| log('Collecting turns… candidates:', { a1: a1.length, a2: a2.length, a3: a3.length, picked: nodes.length }); | |
| const results = nodes.map(extractTurnMarkdown).filter(t => t.md); | |
| const byRole = results.reduce((acc, t) => (acc[t.role] = (acc[t.role] || 0) + 1, acc), {}); | |
| log('Collected turns:', { total: results.length, byRole }); | |
| return results.map(t => t.md); | |
| } | |
| function makeFrontMatter() { | |
| const url = location.href; | |
| const title = (document.title || 'ChatGPT conversation').trim(); | |
| const ts = new Date().toISOString(); | |
| return [ | |
| `# ${title}`, | |
| ``, | |
| `**URL:** ${url}`, | |
| `**Exported:** ${ts}`, | |
| ``, | |
| `---`, | |
| `` | |
| ].join('\n'); | |
| } | |
| function buildFileName() { | |
| const iso = new Date().toISOString().replace(/[:]/g, '-').replace(/\..+$/, ''); | |
| const idMatch = location.pathname.match(/\/c\/([a-f0-9-]{8,})/i); | |
| const id = idMatch ? idMatch[1] : 'dialog'; | |
| return `ChatGPT_${iso}_${id}.md`; | |
| } | |
| // Three-step save with clear logs | |
| function saveTextAsFile(name, text) { | |
| const blob = new Blob([text], { type: 'text/markdown;charset=utf-8' }); | |
| try { | |
| const url = URL.createObjectURL(blob); | |
| log('Saving via Blob+anchor…', { name, size: text.length }); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = name; | |
| document.body.appendChild(a); | |
| a.click(); | |
| a.remove(); | |
| setTimeout(() => URL.revokeObjectURL(url), 0); | |
| return true; | |
| } catch (e1) { | |
| warn('Blob+anchor failed:', e1); | |
| try { | |
| const dataUrl = 'data:text/markdown;charset=utf-8,' + encodeURIComponent(text); | |
| if (typeof GM_download === 'function') { | |
| log('Saving via GM_download + data: URL…', { length: text.length }); | |
| GM_download({ url: dataUrl, name, saveAs: true }); | |
| return true; | |
| } | |
| } catch (e2) { | |
| warn('GM_download failed:', e2); | |
| } | |
| try { | |
| const dataUrl = 'data:text/markdown;charset=utf-8,' + encodeURIComponent(text); | |
| log('Saving via anchor + data: URL…'); | |
| const a = document.createElement('a'); | |
| a.href = dataUrl; | |
| a.download = name; | |
| document.body.appendChild(a); | |
| a.click(); | |
| a.remove(); | |
| return true; | |
| } catch (e3) { | |
| err('All save strategies failed:', e3); | |
| throw e3; | |
| } | |
| } | |
| } | |
| function exportNow() { | |
| console.group(TAG, 'Export run'); | |
| try { | |
| // No notifications during save | |
| log('Environment:', { | |
| href: location.href, | |
| origin: location.origin, | |
| tampermonkey: typeof GM_registerMenuCommand === 'function' ? 'ok' : 'missing', | |
| gm_download: typeof GM_download === 'function' ? 'ok' : 'missing', | |
| gm_notification: typeof GM_notification === 'function' ? 'ok' : 'missing', | |
| }); | |
| const turns = collectAllTurns(); | |
| if (!turns.length) { | |
| warn('No turns found (see console).'); | |
| throw new Error('No ChatGPT turns found on this page. Check selectors & whether the page has loaded.'); | |
| } | |
| const md = makeFrontMatter() + turns.join('\n---\n\n'); | |
| const name = buildFileName(); | |
| log('Built markdown:', { chars: md.length, name }); | |
| const ok = saveTextAsFile(name, md); | |
| if (ok) { | |
| log('✅ Export complete:', name); | |
| } else { | |
| throw new Error('Save routine returned false.'); | |
| } | |
| } catch (e) { | |
| err('Export failed:', e); | |
| alert('ChatGPT → MD export failed:\n' + (e && e.message ? e.message : String(e))); | |
| } finally { | |
| console.groupEnd(TAG); | |
| } | |
| } | |
| function debugInspect() { | |
| console.group(TAG, 'Debug inspect'); | |
| try { | |
| log('Page title:', document.title); | |
| log('Location:', location.href); | |
| const a1 = document.querySelectorAll('article[data-turn]').length; | |
| const a2 = document.querySelectorAll('article[id^="conversation-turn-"]').length; | |
| const a3 = document.querySelectorAll('article[data-testid^="conversation-turn"]').length; | |
| log('Turn article counts:', { 'article[data-turn]': a1, 'article[id^="conversation-turn-"]': a2, 'article[data-testid^="conversation-turn"]': a3 }); | |
| const sample = document.querySelector('article[data-turn]') || document.querySelector('article[id^="conversation-turn-"]') || document.querySelector('article[data-testid^="conversation-turn"]'); | |
| if (sample) { | |
| const role = sample.getAttribute('data-turn') || | |
| sample.querySelector('[data-message-author-role]')?.getAttribute('data-message-author-role') || '(unknown)'; | |
| log('Sample article role:', role); | |
| log('Has .markdown inside sample?', !!sample.querySelector('.markdown')); | |
| log('Has .user-message-bubble-color inside sample?', !!sample.querySelector('.user-message-bubble-color')); | |
| } else { | |
| warn('No sample <article> found. The page may not be a ChatGPT transcript.'); | |
| } | |
| // Debug is allowed to notify | |
| notify('Debug info printed to console.', true); | |
| } catch (e) { | |
| err('Debug inspect failed:', e); | |
| alert('ChatGPT → MD debug failed:\n' + (e && e.message ? e.message : String(e))); | |
| } finally { | |
| console.groupEnd(TAG); | |
| } | |
| } | |
| // ---- menu registration ---------------------------------------------------- | |
| (function registerMenus() { | |
| try { | |
| if (typeof GM_registerMenuCommand === 'function') { | |
| GM_registerMenuCommand('Save ChatGPT dialog as .md', exportNow); | |
| GM_registerMenuCommand('Debug (print findings to console)', debugInspect); | |
| log('Menu registered. Click Tampermonkey icon → choose an action.'); | |
| } else { | |
| warn('GM_registerMenuCommand not available; cannot register menu.'); | |
| } | |
| } catch (e) { | |
| err('Menu registration failed:', e); | |
| } | |
| })(); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment