|
#!/usr/bin/env node |
|
/** |
|
* ChatGPT Conversation Exporter — Node.js with local web UI. |
|
* Starts a local server, opens a browser with a nice UI, |
|
* user pastes their session JSON, and conversations are exported. |
|
*/ |
|
|
|
import { createServer } from "node:http"; |
|
import { writeFileSync, mkdirSync } from "node:fs"; |
|
import { join } from "node:path"; |
|
import { homedir } from "node:os"; |
|
import { randomUUID } from "node:crypto"; |
|
import { Buffer } from "node:buffer"; |
|
import { execSync } from "node:child_process"; |
|
|
|
const API_BASE = "https://chatgpt.com/backend-api"; |
|
const PAGE_SIZE = 100; |
|
const DELAY = 500; |
|
const OUTPUT_DIR = join(homedir(), "Desktop", "chatgpt-export"); |
|
const ZIP_PATH = join(homedir(), "Desktop", "chatgpt-export.zip"); |
|
const HOST = "127.0.0.1"; |
|
const PORT = 8423; |
|
const DEVICE_ID = randomUUID(); |
|
|
|
const HEADERS = { |
|
"Content-Type": "application/json", |
|
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", |
|
Accept: "application/json", |
|
"Accept-Language": "en-US,en;q=0.9", |
|
Referer: "https://chatgpt.com/", |
|
Origin: "https://chatgpt.com", |
|
"Oai-Device-Id": DEVICE_ID, |
|
"Oai-Language": "en-US", |
|
"Sec-Ch-Ua": '"Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24"', |
|
"Sec-Ch-Ua-Mobile": "?0", |
|
"Sec-Ch-Ua-Platform": '"macOS"', |
|
"Sec-Fetch-Dest": "empty", |
|
"Sec-Fetch-Mode": "cors", |
|
"Sec-Fetch-Site": "same-origin", |
|
}; |
|
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); |
|
|
|
// ── API helpers ───────────────────────────────────────────────────── |
|
|
|
async function apiGet(path, token) { |
|
const resp = await fetch(`${API_BASE}/${path}`, { |
|
headers: { ...HEADERS, Authorization: `Bearer ${token}` }, |
|
}); |
|
if (!resp.ok) { |
|
const body = await resp.text().catch(() => ""); |
|
throw new Error(`HTTP ${resp.status}: ${body.slice(0, 300)}`); |
|
} |
|
return resp.json(); |
|
} |
|
|
|
async function apiFetchBinary(url, token) { |
|
const h = { ...HEADERS, Accept: "*/*" }; |
|
if (token) h.Authorization = `Bearer ${token}`; |
|
const resp = await fetch(url, { headers: h }); |
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`); |
|
const buffer = Buffer.from(await resp.arrayBuffer()); |
|
const contentType = resp.headers.get("content-type") || ""; |
|
return { buffer, contentType }; |
|
} |
|
|
|
const MIME_TO_EXT = { |
|
"image/png": ".png", "image/jpeg": ".jpg", "image/gif": ".gif", |
|
"image/webp": ".webp", "image/svg+xml": ".svg", "application/pdf": ".pdf", |
|
"text/plain": ".txt", "text/html": ".html", "text/csv": ".csv", |
|
"application/json": ".json", "application/zip": ".zip", |
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx", |
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx", |
|
}; |
|
|
|
// ── File references ───────────────────────────────────────────────── |
|
|
|
function extractFileReferences(convo) { |
|
const refs = []; |
|
const seen = new Set(); |
|
const mapping = convo.mapping || {}; |
|
|
|
for (const node of Object.values(mapping)) { |
|
const msg = node.message; |
|
if (!msg) continue; |
|
|
|
// image_asset_pointer in content parts |
|
if (msg.content?.parts) { |
|
for (const part of msg.content.parts) { |
|
if (part?.content_type === "image_asset_pointer" && part.asset_pointer) { |
|
const match = part.asset_pointer.match(/^(?:file-service|sediment):\/\/(.+)$/); |
|
if (match && !seen.has(match[1])) { |
|
seen.add(match[1]); |
|
refs.push({ |
|
fileId: match[1], |
|
filename: part.metadata?.dalle?.prompt ? "dalle_image.png" : "image.png", |
|
type: "image", |
|
nodeId: node.id, |
|
}); |
|
} |
|
} |
|
} |
|
} |
|
|
|
// metadata.attachments |
|
if (msg.metadata?.attachments) { |
|
for (const att of msg.metadata.attachments) { |
|
if (att.id && !seen.has(att.id)) { |
|
seen.add(att.id); |
|
refs.push({ |
|
fileId: att.id, |
|
filename: att.name || "attachment", |
|
type: "attachment", |
|
nodeId: node.id, |
|
}); |
|
} |
|
} |
|
} |
|
|
|
// metadata.citations |
|
if (msg.metadata?.citations) { |
|
for (const cit of msg.metadata.citations) { |
|
const fileId = cit.metadata?.file_id || cit.file_id; |
|
const title = cit.metadata?.title || cit.title || "citation"; |
|
if (fileId && !seen.has(fileId)) { |
|
seen.add(fileId); |
|
refs.push({ |
|
fileId, |
|
filename: title, |
|
type: "citation", |
|
nodeId: node.id, |
|
}); |
|
} |
|
} |
|
} |
|
} |
|
|
|
return refs; |
|
} |
|
|
|
async function downloadFile(fileId, token, fallbackName) { |
|
const meta = await apiGet(`files/download/${fileId}`, token); |
|
console.log(` download_url: ${meta.download_url?.slice(0, 120)}...`); |
|
console.log(` file_name: ${meta.file_name}, status: ${meta.status}`); |
|
const url = meta.download_url; |
|
if (!url) throw new Error("No download_url returned"); |
|
const { buffer, contentType } = await apiFetchBinary(url, token); |
|
let filename = meta.file_name || fallbackName || fileId; |
|
// Add extension from content-type if missing |
|
if (!filename.includes(".") && contentType) { |
|
const mime = contentType.split(";")[0].trim(); |
|
const ext = MIME_TO_EXT[mime]; |
|
if (ext) filename += ext; |
|
} |
|
return { filename, buffer }; |
|
} |
|
|
|
function deduplicateFilename(name, usedNames) { |
|
if (!usedNames.has(name)) { |
|
usedNames.add(name); |
|
return name; |
|
} |
|
const dot = name.lastIndexOf("."); |
|
const base = dot > 0 ? name.slice(0, dot) : name; |
|
const ext = dot > 0 ? name.slice(dot) : ""; |
|
let i = 1; |
|
while (usedNames.has(`${base}_${i}${ext}`)) i++; |
|
const deduped = `${base}_${i}${ext}`; |
|
usedNames.add(deduped); |
|
return deduped; |
|
} |
|
|
|
// ── Markdown converter ────────────────────────────────────────────── |
|
|
|
function conversationToMarkdown(convo, fileMap = {}) { |
|
const title = convo.title || "Untitled"; |
|
const ct = convo.create_time; |
|
let dateStr = ""; |
|
if (ct) dateStr = new Date(ct * 1000).toISOString().replace("T", " ").slice(0, 16) + " UTC"; |
|
|
|
const lines = [`# ${title}`, ""]; |
|
if (dateStr) lines.push(`*${dateStr}*\n`); |
|
|
|
const mapping = convo.mapping || {}; |
|
const rootId = Object.keys(mapping).find((k) => mapping[k].parent == null); |
|
|
|
if (rootId) { |
|
const queue = [rootId]; |
|
while (queue.length) { |
|
const nid = queue.shift(); |
|
const node = mapping[nid] || {}; |
|
const msg = node.message; |
|
if (msg?.content?.parts) { |
|
const role = msg.author?.role || "unknown"; |
|
// Skip system, tool, and non-text assistant messages |
|
if (role === "system" || role === "tool") { |
|
queue.push(...(node.children || [])); |
|
continue; |
|
} |
|
const contentType = msg.content?.content_type || "text"; |
|
if (role === "assistant" && contentType !== "text") { |
|
queue.push(...(node.children || [])); |
|
continue; |
|
} |
|
const textParts = []; |
|
|
|
for (const part of msg.content.parts) { |
|
if (typeof part === "string") { |
|
textParts.push(part); |
|
} else if (part?.content_type === "image_asset_pointer" && part.asset_pointer) { |
|
const match = part.asset_pointer.match(/^(?:file-service|sediment):\/\/(.+)$/); |
|
if (match && fileMap[match[1]]) { |
|
textParts.push(``); |
|
} else { |
|
textParts.push("[image]"); |
|
} |
|
} else { |
|
textParts.push(JSON.stringify(part)); |
|
} |
|
} |
|
|
|
// Add attachment links |
|
if (msg.metadata?.attachments) { |
|
for (const att of msg.metadata.attachments) { |
|
if (att.id && fileMap[att.id]) { |
|
textParts.push(`\n📎 [${att.name || "attachment"}](${fileMap[att.id]})`); |
|
} |
|
} |
|
} |
|
|
|
const text = stripCitations(textParts.join("\n")).trim(); |
|
if (text) { |
|
lines.push(`## ${role.charAt(0).toUpperCase() + role.slice(1)}\n\n${text}\n`); |
|
} |
|
} |
|
queue.push(...(node.children || [])); |
|
} |
|
} |
|
return lines.join("\n"); |
|
} |
|
|
|
// ── HTML converter ────────────────────────────────────────────────── |
|
|
|
function stripCitations(str) { |
|
return str.replace(/\u3010[^】]*\u3011/g, ""); |
|
} |
|
|
|
function escapeHtml(str) { |
|
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); |
|
} |
|
|
|
function conversationToHtml(convo, fileMap = {}, allConversations = [], currentFname = "") { |
|
const title = escapeHtml(convo.title || "Untitled"); |
|
const ct = convo.create_time; |
|
let dateStr = ""; |
|
if (ct) dateStr = new Date(ct * 1000).toISOString().replace("T", " ").slice(0, 16) + " UTC"; |
|
|
|
const messages = []; |
|
const mapping = convo.mapping || {}; |
|
const rootId = Object.keys(mapping).find((k) => mapping[k].parent == null); |
|
|
|
if (rootId) { |
|
const queue = [rootId]; |
|
while (queue.length) { |
|
const nid = queue.shift(); |
|
const node = mapping[nid] || {}; |
|
const msg = node.message; |
|
if (msg?.content?.parts) { |
|
const role = msg.author?.role || "unknown"; |
|
const contentType = msg.content?.content_type || "text"; |
|
if (role === "system") { |
|
queue.push(...(node.children || [])); |
|
continue; |
|
} |
|
|
|
// Determine if this is internal/thinking content |
|
const isInternal = role === "tool" || |
|
(role === "assistant" && contentType !== "text") || |
|
(role === "user" && contentType === "user_editable_context"); |
|
|
|
const textParts = []; |
|
const imageParts = []; |
|
|
|
for (const part of msg.content.parts) { |
|
if (typeof part === "string") { |
|
textParts.push(part); |
|
} else if (part?.content_type === "image_asset_pointer" && part.asset_pointer) { |
|
const match = part.asset_pointer.match(/^(?:file-service|sediment):\/\/(.+)$/); |
|
if (match && fileMap[match[1]]) { |
|
imageParts.push(fileMap[match[1]]); |
|
} |
|
} |
|
} |
|
|
|
const attachments = []; |
|
if (msg.metadata?.attachments) { |
|
for (const att of msg.metadata.attachments) { |
|
if (att.id && fileMap[att.id]) { |
|
attachments.push({ name: att.name || "attachment", path: fileMap[att.id] }); |
|
} |
|
} |
|
} |
|
|
|
const text = stripCitations(textParts.join("\n")).trim(); |
|
if (text || imageParts.length || attachments.length) { |
|
messages.push({ role, text, images: imageParts, attachments, isInternal, contentType }); |
|
} |
|
} |
|
queue.push(...(node.children || [])); |
|
} |
|
} |
|
|
|
const INTERNAL_LABELS = { |
|
multimodal_text: "File context", code: "Code", execution_output: "Output", |
|
computer_output: "Output", tether_browsing_display: "Web browsing", |
|
system_error: "Error", text: "Tool output", |
|
}; |
|
|
|
// Build message HTML |
|
const messagesHtml = messages.map((m) => { |
|
if (m.isInternal) { |
|
const label = INTERNAL_LABELS[m.contentType] || "Internal context"; |
|
const b64 = Buffer.from(m.text, "utf8").toString("base64"); |
|
return `<details class="thinking"><summary>${label}</summary><div class="thinking-content md-content" dir="auto" data-md="${b64}"></div></details>`; |
|
} |
|
|
|
const roleClass = m.role === "user" ? "user" : "assistant"; |
|
let content = ""; |
|
|
|
if (m.role === "user") { |
|
// User messages: render as escaped text (users don't write markdown) |
|
const escapedText = escapeHtml(m.text); |
|
content = `<div class="bubble" dir="auto">${escapedText.replace(/\n/g, "<br>")}</div>`; |
|
} else { |
|
// Assistant messages: store markdown for client-side rendering |
|
const avatar = `<div class="avatar"><svg viewBox="0 0 41 41" fill="none" xmlns="http://www.w3.org/2000/svg" width="24" height="24"><path d="M37.532 16.87a9.963 9.963 0 0 0-.856-8.184 10.078 10.078 0 0 0-10.855-4.835A9.964 9.964 0 0 0 18.306.5a10.079 10.079 0 0 0-9.614 6.977 9.967 9.967 0 0 0-6.664 4.834 10.08 10.08 0 0 0 1.24 11.817 9.965 9.965 0 0 0 .856 8.185 10.079 10.079 0 0 0 10.855 4.835 9.965 9.965 0 0 0 7.516 3.35 10.078 10.078 0 0 0 9.617-6.981 9.967 9.967 0 0 0 6.663-4.834 10.079 10.079 0 0 0-1.243-11.813ZM22.498 37.886a7.474 7.474 0 0 1-4.799-1.735c.061-.033.168-.091.237-.134l7.964-4.6a1.294 1.294 0 0 0 .655-1.134V19.054l3.366 1.944a.12.12 0 0 1 .066.092v9.299a7.505 7.505 0 0 1-7.49 7.496ZM6.392 31.006a7.471 7.471 0 0 1-.894-5.023c.06.036.162.099.237.141l7.964 4.6a1.297 1.297 0 0 0 1.308 0l9.724-5.614v3.888a.12.12 0 0 1-.048.103l-8.051 4.649a7.504 7.504 0 0 1-10.24-2.744ZM4.297 13.62A7.469 7.469 0 0 1 8.2 10.333c0 .068-.004.19-.004.274v9.201a1.294 1.294 0 0 0 .654 1.132l9.723 5.614-3.366 1.944a.12.12 0 0 1-.114.012L7.044 23.86a7.504 7.504 0 0 1-2.747-10.24Zm27.658 6.437-9.724-5.615 3.367-1.943a.121.121 0 0 1 .114-.012l8.048 4.648a7.498 7.498 0 0 1-1.158 13.528V21.36a1.293 1.293 0 0 0-.647-1.132v-.17Zm3.35-5.043c-.059-.037-.162-.099-.236-.141l-7.965-4.6a1.298 1.298 0 0 0-1.308 0l-9.723 5.614v-3.888a.12.12 0 0 1 .048-.103l8.05-4.645a7.497 7.497 0 0 1 11.135 7.763Zm-21.063 6.929-3.367-1.944a.12.12 0 0 1-.065-.092v-9.299a7.497 7.497 0 0 1 12.293-5.756 6.94 6.94 0 0 0-.236.134l-7.965 4.6a1.294 1.294 0 0 0-.654 1.132l-.006 11.225Zm1.829-3.943 4.33-2.501 4.332 2.5v5l-4.331 2.5-4.331-2.5V18Z" fill="currentColor"/></svg></div>`; |
|
// Encode text as base64 to avoid HTML escaping issues with markdown |
|
const b64 = Buffer.from(m.text, "utf8").toString("base64"); |
|
content = `${avatar}<div class="content"><div class="md-content" dir="auto" data-md="${b64}"></div></div>`; |
|
} |
|
|
|
// Add images |
|
if (m.images.length) { |
|
const imgs = m.images.map((src) => `<a href="${escapeHtml(src)}" target="_blank"><img src="${escapeHtml(src)}" alt="image" loading="lazy"></a>`).join(""); |
|
content += `<div class="images">${imgs}</div>`; |
|
} |
|
|
|
// Add attachment cards |
|
if (m.attachments.length) { |
|
const atts = m.attachments |
|
.map((a) => `<a class="attachment" href="${escapeHtml(a.path)}" target="_blank"><span class="att-icon">📎</span><span class="att-name">${escapeHtml(a.name)}</span></a>`) |
|
.join(""); |
|
content += `<div class="attachments">${atts}</div>`; |
|
} |
|
|
|
return `<div class="message ${roleClass}">${content}</div>`; |
|
}).join("\n"); |
|
|
|
// Build sidebar HTML |
|
const sidebarItems = allConversations.map((c) => { |
|
const isActive = c.fname === currentFname; |
|
const cls = isActive ? "sidebar-item active" : "sidebar-item"; |
|
return `<a class="${cls}" href="${c.fname}.html" title="${escapeHtml(c.title)}">${escapeHtml(c.title)}</a>`; |
|
}).join("\n"); |
|
|
|
return `<!DOCTYPE html> |
|
<html lang="en"> |
|
<head> |
|
<meta charset="utf-8"> |
|
<meta name="viewport" content="width=device-width, initial-scale=1"> |
|
<title>${title}</title> |
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release/build/styles/github-dark.min.css"> |
|
<style> |
|
* { box-sizing: border-box; margin: 0; padding: 0; } |
|
body { |
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif; |
|
background: #ffffff; color: #0d0d0d; |
|
line-height: 1.65; font-size: 16px; |
|
display: flex; height: 100vh; |
|
} |
|
|
|
/* ── Sidebar ── */ |
|
.sidebar { |
|
width: 260px; min-width: 260px; height: 100vh; |
|
background: #f9f9f9; border-right: 1px solid #e5e5e5; |
|
overflow-y: auto; padding: 16px 0; |
|
flex-shrink: 0; position: sticky; top: 0; |
|
} |
|
.sidebar-header { |
|
padding: 8px 16px 16px; font-size: 14px; font-weight: 600; |
|
color: #6b6b6b; border-bottom: 1px solid #e5e5e5; margin-bottom: 8px; |
|
} |
|
.sidebar-item { |
|
display: block; padding: 8px 16px; font-size: 13px; |
|
color: #0d0d0d; text-decoration: none; |
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; |
|
border-radius: 8px; margin: 2px 8px; |
|
} |
|
.sidebar-item:hover { background: #ececec; } |
|
.sidebar-item.active { background: #e5e5e5; font-weight: 600; } |
|
|
|
/* ── Hamburger toggle (mobile) ── */ |
|
.sidebar-toggle { |
|
display: none; position: fixed; top: 12px; left: 12px; z-index: 100; |
|
background: #f4f4f4; border: 1px solid #e5e5e5; border-radius: 8px; |
|
width: 36px; height: 36px; cursor: pointer; |
|
align-items: center; justify-content: center; font-size: 20px; |
|
} |
|
@media (max-width: 768px) { |
|
.sidebar { |
|
position: fixed; left: -280px; z-index: 99; |
|
transition: left 0.2s; box-shadow: 2px 0 8px rgba(0,0,0,0.1); |
|
} |
|
.sidebar.open { left: 0; } |
|
.sidebar-toggle { display: flex; } |
|
.main { margin-left: 0 !important; } |
|
} |
|
|
|
/* ── Main area ── */ |
|
.main { flex: 1; overflow-y: auto; } |
|
.header { |
|
max-width: 768px; margin: 0 auto; padding: 32px 24px 16px; |
|
border-bottom: 1px solid #e5e5e5; |
|
} |
|
.header h1 { font-size: 22px; font-weight: 600; } |
|
.header .date { font-size: 13px; color: #6b6b6b; margin-top: 4px; } |
|
.chat { max-width: 768px; margin: 0 auto; padding: 24px; } |
|
|
|
/* ── Messages ── */ |
|
.message { margin-bottom: 24px; } |
|
.message.user { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; } |
|
.message.user .bubble { |
|
background: #f4f4f4; border-radius: 18px; padding: 10px 16px; |
|
max-width: 85%; white-space: pre-wrap; word-break: break-word; |
|
} |
|
.message.user .images { width: 100%; display: flex; justify-content: flex-end; } |
|
.message.assistant { |
|
display: flex; gap: 12px; align-items: flex-start; |
|
} |
|
.message.assistant .avatar { |
|
width: 28px; height: 28px; border-radius: 50%; |
|
background: #00a67e; color: #fff; |
|
display: flex; align-items: center; justify-content: center; |
|
flex-shrink: 0; margin-top: 2px; |
|
} |
|
.message.assistant .content { flex: 1; min-width: 0; } |
|
.message.assistant .content h1, |
|
.message.assistant .content h2, |
|
.message.assistant .content h3 { margin: 16px 0 8px; font-weight: 600; } |
|
.message.assistant .content h1 { font-size: 20px; } |
|
.message.assistant .content h2 { font-size: 18px; } |
|
.message.assistant .content h3 { font-size: 16px; } |
|
.message.assistant .content p { margin: 8px 0; } |
|
.message.assistant .content ul, |
|
.message.assistant .content ol { margin: 8px 0; padding-left: 24px; } |
|
.message.assistant .content li { margin: 4px 0; } |
|
.message.assistant .content a { color: #1a7f64; } |
|
.message.assistant .content code { |
|
background: #f0f0f0; border-radius: 4px; padding: 2px 5px; |
|
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; |
|
font-size: 14px; |
|
} |
|
.message.assistant .content pre { margin: 12px 0; border-radius: 8px; overflow: hidden; } |
|
.message.assistant .content pre code { |
|
display: block; background: #0d0d0d; color: #f8f8f2; |
|
padding: 16px; overflow-x: auto; border-radius: 0; |
|
font-size: 13px; line-height: 1.5; |
|
} |
|
.code-block { position: relative; } |
|
.code-block .copy-btn { |
|
position: absolute; top: 8px; right: 8px; |
|
background: #333; border: none; color: #999; cursor: pointer; |
|
font-size: 12px; padding: 4px 10px; border-radius: 4px; |
|
opacity: 0; transition: opacity 0.2s; |
|
} |
|
.code-block:hover .copy-btn { opacity: 1; } |
|
.code-block .copy-btn:hover { color: #fff; background: #555; } |
|
.images img { max-width: 100%; border-radius: 8px; margin: 4px 0; display: block; cursor: pointer; } |
|
.images img:hover { opacity: 0.9; } |
|
.message.user .images img { max-width: 300px; } |
|
.attachments { margin-top: 8px; display: flex; flex-wrap: wrap; gap: 8px; } |
|
.attachment { |
|
display: inline-flex; align-items: center; gap: 8px; |
|
background: #f4f4f4; border: 1px solid #e5e5e5; border-radius: 8px; |
|
padding: 8px 12px; text-decoration: none; color: #0d0d0d; font-size: 14px; |
|
} |
|
.attachment:hover { background: #ececec; } |
|
.att-icon { font-size: 16px; } |
|
.att-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 200px; } |
|
|
|
/* ── Collapsed internal/thinking blocks ── */ |
|
.thinking { |
|
margin-bottom: 24px; border-left: 3px solid #d4d4d4; |
|
padding-left: 16px; font-size: 14px; |
|
} |
|
.thinking summary { |
|
color: #8e8e8e; font-style: italic; cursor: pointer; |
|
padding: 4px 0; user-select: none; |
|
} |
|
.thinking summary:hover { color: #555; } |
|
.thinking-content { |
|
color: #6b6b6b; padding: 8px 0; font-style: italic; |
|
} |
|
.thinking-content p, .thinking-content li { color: #6b6b6b; } |
|
.thinking-content pre code { opacity: 0.7; } |
|
.thinking-content h1, .thinking-content h2, .thinking-content h3 { |
|
color: #6b6b6b; font-size: 15px; |
|
} |
|
</style> |
|
</head> |
|
<body> |
|
<button class="sidebar-toggle" onclick="document.querySelector('.sidebar').classList.toggle('open')">☰</button> |
|
<nav class="sidebar"> |
|
<div class="sidebar-header">Conversations</div> |
|
${sidebarItems} |
|
</nav> |
|
<div class="main"> |
|
<div class="header"> |
|
<h1>${title}</h1> |
|
${dateStr ? `<div class="date">${dateStr}</div>` : ""} |
|
</div> |
|
<div class="chat"> |
|
${messagesHtml} |
|
</div> |
|
</div> |
|
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"><\/script> |
|
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release/build/highlight.min.js"><\/script> |
|
<script> |
|
document.addEventListener('DOMContentLoaded', () => { |
|
marked.setOptions({ |
|
highlight: (code, lang) => { |
|
if (lang && hljs.getLanguage(lang)) { |
|
return hljs.highlight(code, { language: lang }).value; |
|
} |
|
return hljs.highlightAuto(code).value; |
|
}, |
|
breaks: true, |
|
}); |
|
|
|
const renderer = new marked.Renderer(); |
|
renderer.code = function({ text, lang }) { |
|
const highlighted = lang && hljs.getLanguage(lang) |
|
? hljs.highlight(text, { language: lang }).value |
|
: hljs.highlightAuto(text).value; |
|
return '<div class="code-block"><button class="copy-btn" onclick="navigator.clipboard.writeText(this.nextElementSibling.querySelector(\\'code\\').textContent);this.textContent=\\'Copied!\\';setTimeout(()=>this.textContent=\\'Copy\\',1500)">Copy</button>' |
|
+ '<pre><code class="hljs">' + highlighted + '</code></pre></div>'; |
|
}; |
|
marked.use({ renderer }); |
|
|
|
document.querySelectorAll('.md-content').forEach(el => { |
|
const md = decodeURIComponent(escape(atob(el.dataset.md))); |
|
el.innerHTML = marked.parse(md); |
|
}); |
|
|
|
// Scroll active sidebar item into view |
|
const active = document.querySelector('.sidebar-item.active'); |
|
if (active) active.scrollIntoView({ block: 'center', behavior: 'instant' }); |
|
}); |
|
<\/script> |
|
</body> |
|
</html>`; |
|
} |
|
|
|
function sanitizeFilename(name, maxLen = 80) { |
|
return name.replace(/[<>:"/\\|?*]/g, "_").replace(/^[. ]+|[. ]+$/g, "").slice(0, maxLen) || "untitled"; |
|
} |
|
|
|
// ── ZIP builder ───────────────────────────────────────────────────── |
|
|
|
function crc32(buf) { |
|
const table = new Uint32Array(256); |
|
for (let i = 0; i < 256; i++) { |
|
let c = i; |
|
for (let j = 0; j < 8; j++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; |
|
table[i] = c; |
|
} |
|
let crc = 0xffffffff; |
|
for (let i = 0; i < buf.length; i++) crc = table[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8); |
|
return (crc ^ 0xffffffff) >>> 0; |
|
} |
|
|
|
function buildZip(files) { |
|
const entries = []; |
|
let offset = 0; |
|
|
|
for (const file of files) { |
|
const pathBuf = Buffer.from(file.path, "utf8"); |
|
const data = Buffer.isBuffer(file.data) ? file.data : Buffer.from(file.data, "utf8"); |
|
const crc = crc32(data); |
|
|
|
const header = Buffer.alloc(30); |
|
header.writeUInt32LE(0x04034b50, 0); |
|
header.writeUInt16LE(20, 4); |
|
header.writeUInt32LE(crc, 14); |
|
header.writeUInt32LE(data.length, 18); |
|
header.writeUInt32LE(data.length, 22); |
|
header.writeUInt16LE(pathBuf.length, 26); |
|
|
|
entries.push({ header, pathBuf, data, crc, offset }); |
|
offset += 30 + pathBuf.length + data.length; |
|
} |
|
|
|
const cdParts = []; |
|
for (const e of entries) { |
|
const cd = Buffer.alloc(46); |
|
cd.writeUInt32LE(0x02014b50, 0); |
|
cd.writeUInt16LE(20, 4); |
|
cd.writeUInt16LE(20, 6); |
|
cd.writeUInt32LE(e.crc, 16); |
|
cd.writeUInt32LE(e.data.length, 20); |
|
cd.writeUInt32LE(e.data.length, 24); |
|
cd.writeUInt16LE(e.pathBuf.length, 28); |
|
cd.writeUInt32LE(e.offset, 42); |
|
cdParts.push(cd, e.pathBuf); |
|
} |
|
const cdBuf = Buffer.concat(cdParts); |
|
|
|
const eocd = Buffer.alloc(22); |
|
eocd.writeUInt32LE(0x06054b50, 0); |
|
eocd.writeUInt16LE(entries.length, 8); |
|
eocd.writeUInt16LE(entries.length, 10); |
|
eocd.writeUInt32LE(cdBuf.length, 12); |
|
eocd.writeUInt32LE(offset, 16); |
|
|
|
const parts = []; |
|
for (const e of entries) parts.push(e.header, e.pathBuf, e.data); |
|
parts.push(cdBuf, eocd); |
|
return Buffer.concat(parts); |
|
} |
|
|
|
// ── Export logic ──────────────────────────────────────────────────── |
|
|
|
async function runExport(token, sendEvent) { |
|
try { |
|
sendEvent("status", "Fetching conversation list..."); |
|
const conversations = []; |
|
let offset = 0; |
|
while (true) { |
|
const data = await apiGet(`conversations?offset=${offset}&limit=${PAGE_SIZE}`, token); |
|
const items = data.items || []; |
|
if (!items.length) break; |
|
conversations.push(...items); |
|
const total = data.total || 0; |
|
sendEvent("status", `Fetching conversation list... ${conversations.length}/${total}`); |
|
offset += PAGE_SIZE; |
|
if (offset >= total) break; |
|
await sleep(DELAY); |
|
} |
|
|
|
const total = conversations.length; |
|
if (total === 0) { |
|
sendEvent("done", JSON.stringify({ total: 0, succeeded: 0, failed: 0, output: "" })); |
|
return; |
|
} |
|
|
|
sendEvent("status", `Found ${total} conversations. Starting download...`); |
|
|
|
const jsonDir = join(OUTPUT_DIR, "json"); |
|
const mdDir = join(OUTPUT_DIR, "markdown"); |
|
const htmlDir = join(OUTPUT_DIR, "html"); |
|
const filesDir = join(OUTPUT_DIR, "files"); |
|
mkdirSync(jsonDir, { recursive: true }); |
|
mkdirSync(mdDir, { recursive: true }); |
|
mkdirSync(htmlDir, { recursive: true }); |
|
|
|
const zipFiles = []; |
|
const failed = []; |
|
let totalFiles = 0; |
|
let failedFiles = 0; |
|
const failedFileDetails = []; |
|
|
|
// Pass 1: Download all conversations and files |
|
const downloaded = []; // { fname, title, convo, jsonStr, fileMap } |
|
|
|
for (let i = 0; i < total; i++) { |
|
const { id: cid, title: rawTitle } = conversations[i]; |
|
const title = rawTitle || "Untitled"; |
|
const safe = sanitizeFilename(title); |
|
const fname = `${safe}_${cid.slice(0, 8)}`; |
|
|
|
sendEvent("progress", JSON.stringify({ current: i + 1, total, title })); |
|
|
|
try { |
|
const convo = await apiGet(`conversation/${cid}`, token); |
|
const jsonStr = JSON.stringify(convo, null, 2); |
|
|
|
// Extract and download file references |
|
const fileRefs = extractFileReferences(convo); |
|
const fileMap = {}; |
|
const usedNames = new Set(); |
|
|
|
console.log(`[${fname}] Found ${fileRefs.length} file ref(s)`); |
|
if (fileRefs.length) { |
|
for (const ref of fileRefs) console.log(` -> ${ref.type}: ${ref.filename} (${ref.fileId})`); |
|
const convFilesDir = join(filesDir, fname); |
|
mkdirSync(convFilesDir, { recursive: true }); |
|
|
|
for (const ref of fileRefs) { |
|
totalFiles++; |
|
try { |
|
console.log(` Downloading ${ref.fileId}...`); |
|
sendEvent("status", `Downloading file ${totalFiles} for "${title}"...`); |
|
const { filename: dlName, buffer } = await downloadFile(ref.fileId, token, ref.filename); |
|
const actualName = deduplicateFilename(dlName || ref.filename, usedNames); |
|
writeFileSync(join(convFilesDir, actualName), buffer); |
|
zipFiles.push({ path: `files/${fname}/${actualName}`, data: buffer }); |
|
fileMap[ref.fileId] = `../files/${fname}/${actualName}`; |
|
await sleep(DELAY); |
|
} catch (e) { |
|
failedFiles++; |
|
console.error(` FAILED: ${ref.filename} (${ref.fileId}): ${e.message}`); |
|
} |
|
} |
|
} |
|
|
|
// Write JSON and Markdown immediately |
|
const mdStr = conversationToMarkdown(convo, fileMap); |
|
writeFileSync(join(jsonDir, `${fname}.json`), jsonStr, "utf8"); |
|
writeFileSync(join(mdDir, `${fname}.md`), mdStr, "utf8"); |
|
zipFiles.push({ path: `json/${fname}.json`, data: jsonStr }); |
|
zipFiles.push({ path: `markdown/${fname}.md`, data: mdStr }); |
|
|
|
// Store for HTML pass 2 (needs full conversation list for sidebar) |
|
downloaded.push({ fname, title, convo, fileMap }); |
|
} catch (e) { |
|
console.error(`[conversation error] "${title}": ${e.message}`); |
|
failed.push(title); |
|
} |
|
|
|
await sleep(DELAY); |
|
} |
|
|
|
// Pass 2: Generate HTML with sidebar navigation |
|
sendEvent("status", "Generating HTML pages..."); |
|
const allConvos = downloaded.map((d) => ({ fname: d.fname, title: d.title })); |
|
|
|
for (const d of downloaded) { |
|
const htmlStr = conversationToHtml(d.convo, d.fileMap, allConvos, d.fname); |
|
writeFileSync(join(htmlDir, `${d.fname}.html`), htmlStr, "utf8"); |
|
zipFiles.push({ path: `html/${d.fname}.html`, data: htmlStr }); |
|
} |
|
|
|
sendEvent("status", "Creating ZIP archive..."); |
|
const zipBuf = buildZip(zipFiles); |
|
writeFileSync(ZIP_PATH, zipBuf); |
|
|
|
if (failedFileDetails.length) { |
|
console.error(`\n${failedFiles} file download(s) failed:`); |
|
for (const d of failedFileDetails) console.error(` - ${d}`); |
|
console.error(""); |
|
} |
|
|
|
let doneMsg = { |
|
total, |
|
succeeded: total - failed.length, |
|
failed: failed.length, |
|
failedTitles: failed, |
|
output: OUTPUT_DIR, |
|
zip: ZIP_PATH, |
|
totalFiles, |
|
failedFiles, |
|
failedFileDetails, |
|
}; |
|
sendEvent("done", JSON.stringify(doneMsg)); |
|
} catch (e) { |
|
sendEvent("error_msg", `Export failed: ${e.message}`); |
|
} |
|
} |
|
|
|
// ── HTML page ─────────────────────────────────────────────────────── |
|
|
|
const HTML_PAGE = `<!DOCTYPE html> |
|
<html lang="en"> |
|
<head> |
|
<meta charset="utf-8"> |
|
<meta name="viewport" content="width=device-width, initial-scale=1"> |
|
<title>ChatGPT Exporter</title> |
|
<style> |
|
* { box-sizing: border-box; margin: 0; padding: 0; } |
|
body { |
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; |
|
background: #0f172a; color: #e2e8f0; |
|
min-height: 100vh; display: flex; align-items: center; justify-content: center; |
|
} |
|
.card { |
|
background: #1e293b; border-radius: 16px; padding: 40px; |
|
max-width: 580px; width: 100%; box-shadow: 0 25px 50px rgba(0,0,0,0.4); |
|
} |
|
h1 { font-size: 24px; margin-bottom: 8px; color: #f8fafc; } |
|
.subtitle { color: #94a3b8; margin-bottom: 32px; font-size: 14px; } |
|
.step { display: flex; gap: 12px; margin-bottom: 20px; } |
|
.step-num { |
|
flex-shrink: 0; width: 28px; height: 28px; border-radius: 50%; |
|
background: #3b82f6; color: #fff; display: flex; align-items: center; |
|
justify-content: center; font-size: 13px; font-weight: 600; |
|
} |
|
.step-num.done { background: #22c55e; } |
|
.step-text { font-size: 14px; line-height: 1.6; padding-top: 3px; } |
|
.step-text a { color: #60a5fa; text-decoration: none; } |
|
.step-text a:hover { text-decoration: underline; } |
|
kbd { |
|
background: #334155; border-radius: 4px; padding: 2px 6px; |
|
font-family: inherit; font-size: 12px; border: 1px solid #475569; |
|
} |
|
textarea { |
|
width: 100%; height: 100px; background: #0f172a; border: 2px solid #334155; |
|
border-radius: 8px; color: #e2e8f0; padding: 12px; font-family: monospace; |
|
font-size: 13px; resize: vertical; margin: 16px 0; |
|
} |
|
textarea:focus { outline: none; border-color: #3b82f6; } |
|
textarea::placeholder { color: #64748b; } |
|
button { |
|
width: 100%; padding: 12px; border: none; border-radius: 8px; |
|
background: #3b82f6; color: #fff; font-size: 15px; font-weight: 600; |
|
cursor: pointer; transition: background 0.2s; |
|
} |
|
button:hover { background: #2563eb; } |
|
button:disabled { background: #475569; cursor: not-allowed; } |
|
.progress-section { margin-top: 24px; } |
|
.progress-bar-bg { |
|
width: 100%; height: 8px; background: #334155; border-radius: 4px; |
|
overflow: hidden; margin: 12px 0; |
|
} |
|
.progress-bar { |
|
height: 100%; background: #3b82f6; border-radius: 4px; |
|
transition: width 0.3s ease; width: 0%; |
|
} |
|
.progress-text { font-size: 13px; color: #94a3b8; } |
|
.current-title { font-size: 13px; color: #64748b; margin-top: 4px; |
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } |
|
.result { margin-top: 20px; padding: 16px; border-radius: 8px; font-size: 14px; } |
|
.result.success { background: #14532d; color: #bbf7d0; } |
|
.result.error { background: #7f1d1d; color: #fecaca; } |
|
.hidden { display: none; } |
|
</style> |
|
</head> |
|
<body> |
|
<div class="card"> |
|
<h1>ChatGPT Exporter</h1> |
|
<p class="subtitle">Export all your conversations as JSON, Markdown & HTML</p> |
|
|
|
<div id="steps"> |
|
<div class="step"> |
|
<div class="step-num" id="step1-num">1</div> |
|
<div class="step-text"> |
|
Open <a href="https://chatgpt.com/api/auth/session" target="_blank"> |
|
chatgpt.com/api/auth/session</a> in a new tab.<br> |
|
<span style="color:#94a3b8">(If you see a login page, log in first, then open the link again.)</span> |
|
</div> |
|
</div> |
|
<div class="step"> |
|
<div class="step-num" id="step2-num">2</div> |
|
<div class="step-text"> |
|
Select all the text on that page (<kbd>Cmd+A</kbd>), |
|
copy it (<kbd>Cmd+C</kbd>), and paste it below. |
|
</div> |
|
</div> |
|
</div> |
|
|
|
<textarea id="token-input" placeholder='Paste the session JSON here — it looks like {"user":{...},"accessToken":"eyJhbG..."}'></textarea> |
|
<button id="export-btn" onclick="startExport()">Export conversations</button> |
|
|
|
<div id="progress-section" class="progress-section hidden"> |
|
<div class="progress-bar-bg"><div class="progress-bar" id="progress-bar"></div></div> |
|
<div class="progress-text" id="progress-text">Starting...</div> |
|
<div class="current-title" id="current-title"></div> |
|
</div> |
|
|
|
<div id="result" class="result hidden"></div> |
|
</div> |
|
|
|
<script> |
|
const isMac = navigator.platform.toUpperCase().includes('MAC'); |
|
if (!isMac) { |
|
document.querySelectorAll('kbd').forEach(el => { |
|
el.textContent = el.textContent.replace('Cmd', 'Ctrl'); |
|
}); |
|
} |
|
|
|
function startExport() { |
|
const raw = document.getElementById('token-input').value.trim(); |
|
if (!raw) return alert('Please paste the session JSON first.'); |
|
|
|
let parsed; |
|
try { parsed = JSON.parse(raw); } catch { |
|
return alert('Invalid JSON. Make sure you copied the entire page content.'); |
|
} |
|
const token = parsed.accessToken; |
|
if (!token) return alert('No "accessToken" found. Make sure you are logged in and copied the full page.'); |
|
|
|
document.getElementById('export-btn').disabled = true; |
|
document.getElementById('token-input').disabled = true; |
|
document.getElementById('progress-section').classList.remove('hidden'); |
|
document.getElementById('result').classList.add('hidden'); |
|
document.getElementById('step1-num').classList.add('done'); |
|
document.getElementById('step1-num').textContent = '\\u2713'; |
|
document.getElementById('step2-num').classList.add('done'); |
|
document.getElementById('step2-num').textContent = '\\u2713'; |
|
|
|
fetch('/start-export', { |
|
method: 'POST', |
|
headers: { 'Content-Type': 'application/json' }, |
|
body: JSON.stringify({ token }), |
|
}) |
|
.then(r => r.json()) |
|
.then(data => { |
|
const evtSource = new EventSource('/progress/' + data.exportId); |
|
|
|
evtSource.addEventListener('status', (e) => { |
|
document.getElementById('progress-text').textContent = e.data; |
|
}); |
|
|
|
evtSource.addEventListener('progress', (e) => { |
|
const d = JSON.parse(e.data); |
|
const pct = Math.round((d.current / d.total) * 100); |
|
document.getElementById('progress-bar').style.width = pct + '%'; |
|
document.getElementById('progress-text').textContent = |
|
'Downloading ' + d.current + ' of ' + d.total + ' (' + pct + '%)'; |
|
document.getElementById('current-title').textContent = d.title; |
|
}); |
|
|
|
evtSource.addEventListener('error_msg', (e) => { |
|
evtSource.close(); |
|
showResult('error', e.data); |
|
resetUI(); |
|
}); |
|
|
|
evtSource.addEventListener('done', (e) => { |
|
evtSource.close(); |
|
const d = JSON.parse(e.data); |
|
if (d.total === 0) { |
|
showResult('success', 'No conversations found in your account.'); |
|
} else { |
|
let msg = 'Done! Exported ' + d.succeeded + ' of ' + d.total + ' conversations.'; |
|
if (d.totalFiles > 0) msg += '<br>' + (d.totalFiles - d.failedFiles) + ' of ' + d.totalFiles + ' files downloaded.'; |
|
msg += '<br><br>Saved to:<br><code>' + d.output + '</code>'; |
|
msg += '<br><code>' + d.zip + '</code>'; |
|
if (d.failed > 0) msg += '<br><br>' + d.failed + ' conversations failed: ' + d.failedTitles.join(', '); |
|
if (d.failedFileDetails && d.failedFileDetails.length) msg += '<br><br>File download errors:<br>' + d.failedFileDetails.map(s => '• ' + s).join('<br>'); |
|
showResult('success', msg); |
|
} |
|
resetUI(); |
|
}); |
|
|
|
evtSource.onerror = () => { |
|
evtSource.close(); |
|
showResult('error', 'Connection lost.'); |
|
resetUI(); |
|
}; |
|
}) |
|
.catch(err => { |
|
showResult('error', 'Failed to start: ' + err.message); |
|
resetUI(); |
|
}); |
|
} |
|
|
|
function resetUI() { |
|
document.getElementById('export-btn').disabled = false; |
|
document.getElementById('token-input').disabled = false; |
|
} |
|
|
|
function showResult(type, message) { |
|
const el = document.getElementById('result'); |
|
el.className = 'result ' + type; |
|
el.innerHTML = message; |
|
el.classList.remove('hidden'); |
|
} |
|
</script> |
|
</body> |
|
</html>`; |
|
|
|
// ── HTTP server ───────────────────────────────────────────────────── |
|
|
|
const exports = new Map(); // exportId -> { events: [], done: false } |
|
|
|
const server = createServer((req, res) => { |
|
if (req.method === "GET" && (req.url === "/" || req.url === "")) { |
|
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); |
|
res.end(HTML_PAGE); |
|
} else if (req.method === "POST" && req.url === "/start-export") { |
|
let body = ""; |
|
req.on("data", (c) => (body += c)); |
|
req.on("end", () => { |
|
let token; |
|
try { |
|
token = JSON.parse(body).token; |
|
} catch { |
|
res.writeHead(400); |
|
res.end(); |
|
return; |
|
} |
|
|
|
const exportId = randomUUID().slice(0, 8); |
|
exports.set(exportId, { events: [], done: false }); |
|
|
|
const sendEvent = (type, data) => { |
|
const entry = exports.get(exportId); |
|
if (entry) entry.events.push({ type: type, data: data.replace(/\n/g, "\\n") }); |
|
}; |
|
|
|
runExport(token, sendEvent).finally(() => { |
|
const entry = exports.get(exportId); |
|
if (entry) entry.done = true; |
|
}); |
|
|
|
res.writeHead(200, { "Content-Type": "application/json" }); |
|
res.end(JSON.stringify({ exportId })); |
|
}); |
|
} else if (req.method === "GET" && req.url.startsWith("/progress/")) { |
|
const exportId = req.url.split("/progress/")[1]; |
|
const entry = exports.get(exportId); |
|
if (!entry) { |
|
res.writeHead(404); |
|
res.end(); |
|
return; |
|
} |
|
|
|
res.writeHead(200, { |
|
"Content-Type": "text/event-stream", |
|
"Cache-Control": "no-cache", |
|
Connection: "keep-alive", |
|
}); |
|
|
|
let sent = 0; |
|
const interval = setInterval(() => { |
|
while (sent < entry.events.length) { |
|
const evt = entry.events[sent++]; |
|
res.write(`event: ${evt.type}\ndata: ${evt.data}\n\n`); |
|
} |
|
if (entry.done && sent >= entry.events.length) { |
|
clearInterval(interval); |
|
exports.delete(exportId); |
|
res.end(); |
|
} |
|
}, 200); |
|
|
|
req.on("close", () => clearInterval(interval)); |
|
} else { |
|
res.writeHead(404); |
|
res.end(); |
|
} |
|
}); |
|
|
|
// ── Main ──────────────────────────────────────────────────────────── |
|
|
|
const url = `http://${HOST}:${PORT}`; |
|
server.listen(PORT, HOST, () => { |
|
console.log(`ChatGPT Exporter running at ${url}`); |
|
console.log("Press Ctrl+C to stop.\n"); |
|
|
|
// Open browser |
|
try { |
|
if (process.platform === "darwin") execSync(`open "${url}"`); |
|
else if (process.platform === "linux") execSync(`xdg-open "${url}"`); |
|
} catch {} |
|
}); |
Hi! Thank you for the amazing script. I've been using it to export a massive chat history (800+ conversations), but I kept running into severe HTTP 429 (Too Many Requests) errors and script crashes due to expired old assets.
I've forked your Gist and heavily modified the script to make it completely bulletproof for users with large accounts. You can check out my updated version here: (https://gist.github.com/Mohammad-Sherif/d761a638accb6f7b3cc91fdbacb631bf)
Key improvements in my version:
Feel free to merge these concepts into your main script or link to the fork. Thanks again for the great foundation!