Created
August 29, 2026 00:06
-
-
Save ergolyam/5208551e0e0c0161fbc8f7eef9193a51 to your computer and use it in GitHub Desktop.
Export current ChatGPT conversation to Markdown
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 | |
| // @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