Skip to content

Instantly share code, notes, and snippets.

@ergolyam
Created August 29, 2026 00:06
Show Gist options
  • Select an option

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

Select an option

Save ergolyam/5208551e0e0c0161fbc8f7eef9193a51 to your computer and use it in GitHub Desktop.
Export current ChatGPT conversation to Markdown
// ==UserScript==
// @name ChatGPT → Markdown
// @namespace local.chatgpt-to-markdown
// @version 0.1
// @description Export current ChatGPT conversation to Markdown
// @match *://chatgpt.com/*
// @run-at context-menu
// @noframes
// @grant none
// ==/UserScript==
(async () => {
'use strict';
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
const clean = text =>
String(text ?? '')
.replace(/\r\n?/g, '\n')
.trim();
async function getFromApi() {
const conversationId =
location.pathname.match(/\/c\/([0-9a-f-]{36})(?:\/|$)/i)?.[1];
if (!conversationId)
return null;
try {
const sessionResponse = await fetch('/api/auth/session', {
credentials: 'include'
});
if (!sessionResponse.ok)
return null;
const session = await sessionResponse.json();
const accessToken = session.accessToken;
if (!accessToken)
return null;
const response = await fetch(
`/backend-api/conversation/${conversationId}`,
{
credentials: 'include',
headers: {
Authorization: `Bearer ${accessToken}`
}
}
);
if (!response.ok)
return null;
const chat = await response.json();
if (!chat.mapping || !chat.current_node)
return null;
const path = [];
for (
let id = chat.current_node;
id;
id = chat.mapping?.[id]?.parent
) {
path.push(id);
}
path.reverse();
const messages = [];
for (const id of path) {
const message = chat.mapping?.[id]?.message;
const role = message?.author?.role;
if (!['user', 'assistant'].includes(role))
continue;
if (message?.metadata?.is_visually_hidden_from_conversation)
continue;
const text = clean(
(message.content?.parts ?? [])
.filter(part => typeof part === 'string')
.join('\n\n')
);
if (text) {
messages.push({
role,
text
});
}
}
return messages.length
? {
title: chat.title,
messages
}
: null;
} catch (error) {
console.warn('[ChatGPT → Markdown] API export failed:', error);
return null;
}
}
async function getFromDom() {
const firstMessage =
document.querySelector('[data-message-author-role]');
if (!firstMessage)
return null;
let scroller = firstMessage.parentElement;
while (
scroller &&
scroller !== document.documentElement
) {
const style = getComputedStyle(scroller);
if (
/auto|scroll/.test(style.overflowY) &&
scroller.scrollHeight > scroller.clientHeight + 10
) {
break;
}
scroller = scroller.parentElement;
}
scroller ||= document.scrollingElement;
if (!scroller)
return null;
const originalTop = scroller.scrollTop;
const messages = new Map();
function collect() {
const elements = document.querySelectorAll(
'[data-message-author-role]'
);
for (const element of elements) {
const role = element.dataset.messageAuthorRole;
if (!['user', 'assistant'].includes(role))
continue;
const body =
role === 'assistant'
? element.querySelector('.markdown') || element
: element.querySelector('.whitespace-pre-wrap') || element;
const text = clean(
body.innerText || body.textContent
);
if (!text)
continue;
const turn = element.closest(
'[data-testid^="conversation-turn-"]'
);
const key =
element.dataset.messageId ||
turn?.dataset.testid ||
`${role}:${text}`;
if (!messages.has(key)) {
messages.set(key, {
role,
text
});
}
}
}
let stable = 0;
let previousCount = -1;
for (let i = 0; i < 40 && stable < 3; i++) {
scroller.scrollTo(0, 0);
await sleep(250);
collect();
if (
scroller.scrollTop <= 5 &&
messages.size === previousCount
) {
stable++;
} else {
stable = 0;
}
previousCount = messages.size;
}
let lastTop = -1;
let stalled = 0;
for (let i = 0; i < 1500 && stalled < 5; i++) {
collect();
const step = Math.max(
300,
scroller.clientHeight * 0.7
);
scroller.scrollTo(
0,
scroller.scrollTop + step
);
await sleep(120);
if (Math.abs(scroller.scrollTop - lastTop) < 2) {
stalled++;
} else {
stalled = 0;
}
lastTop = scroller.scrollTop;
}
collect();
scroller.scrollTo(0, originalTop);
return messages.size
? {
title: null,
messages: [...messages.values()]
}
: null;
}
let chat = await getFromApi();
if (!chat)
chat = await getFromDom();
if (!chat) {
alert('No messages were found in the current chat.');
return;
}
const markdown =
chat.messages
.map(({ role, text }) =>
`# ${role === 'user' ? 'User' : 'Bot'}\n${text}`
)
.join('\n\n---\n\n') +
'\n';
const title = clean(
chat.title ||
document.title.replace(
/\s*[-]\s*ChatGPT\s*$/i,
''
) ||
'chatgpt-chat'
);
const defaultName = (
title
.replace(/[<>:"/\\|?*\x00-\x1F]/g, '_')
.replace(/[. ]+$/g, '')
.slice(0, 120) ||
'chatgpt-chat'
);
let filename = prompt(
'Имя файла:',
`${defaultName}.md`
);
if (!filename)
return;
filename = filename.trim();
if (!filename.toLowerCase().endsWith('.md'))
filename += '.md';
const blob = new Blob(
[markdown],
{
type: 'text/markdown;charset=utf-8'
}
);
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
link.remove();
setTimeout(
() => URL.revokeObjectURL(url),
1000
);
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment