Skip to content

Instantly share code, notes, and snippets.

@ergolyam
Last active September 12, 2025 21:19
Show Gist options
  • Select an option

  • Save ergolyam/f537fe07f87b9ae37f6bd80a77bb369f to your computer and use it in GitHub Desktop.

Select an option

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.
// ==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}](${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