Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save mmatiaschek/b8e93170d533250d886c609a9991b00d to your computer and use it in GitHub Desktop.

Select an option

Save mmatiaschek/b8e93170d533250d886c609a9991b00d to your computer and use it in GitHub Desktop.
ChatGPT-Exporter-Resilient
// ==UserScript==
// @name ChatGPT-Exporter-Resilient
// @namespace qiusheng-resilient
// @version 5.1.0
// @description Export all ChatGPT conversations with adaptive throttling, IndexedDB persistence, and resume support
// @match https://chatgpt.com/*
// @match https://chat.openai.com/*
// @run-at document-start
// @grant none
// @require https://cdn.jsdelivr.net/npm/jszip@3.10.1/dist/jszip.min.js
// @require https://cdn.jsdelivr.net/npm/file-saver@2.0.5/dist/FileSaver.min.js
// @icon https://chatgpt.com/favicon.ico
// @author qiusheng (modified for resilience)
// @license MIT
// ==/UserScript==
(() => {
// ── CONFIG ────────────────────────────────────────────────────────────
const CFG = {
// Adaptive throttle: starts here, adjusts automatically
INITIAL_DELAY_MS: 3500, // starting delay between conversation fetches
MIN_DELAY_MS: 1500, // fastest we'll ever go
MAX_DELAY_MS: 120_000, // absolute ceiling for inter-request delay (2 min)
// After this many consecutive successes, reduce delay by SPEEDUP_FACTOR
SPEEDUP_AFTER: 20,
SPEEDUP_FACTOR: 0.85, // multiply delay by this on speedup (gentle)
// On 429: multiply delay by this immediately
SLOWDOWN_FACTOR: 2.0,
// After a 429, wait this long before resuming (then continue at new slower rate)
RATE_LIMIT_PAUSE_MS: 90_000, // 1.5 minutes pause on 429 (if no Retry-After header)
// List pagination
DELAY_BETWEEN_LIST_PAGES_MS: 1000,
// Retries
MAX_RETRIES_PER_CONV: 4,
// IndexedDB
DB_NAME: 'cgptx_conversations',
DB_VERSION: 1,
STORE_NAME: 'conversations',
META_STORE: 'meta',
};
// ── UTILS ─────────────────────────────────────────────────────────────
const U = {
qs: (s, r = document) => r.querySelector(s),
ce: (t, props = {}, attrs = {}) => { const el = document.createElement(t); Object.assign(el, props); for (const k in attrs) el.setAttribute(k, attrs[k]); return el; },
sleep: (ms) => new Promise(r => setTimeout(r, ms)),
nowStr: () => { const d = new Date(); const p = n => String(n).padStart(2, '0'); return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`; },
sanitize: s => (s || 'untitled').replace(/[\\/:*?"<>|]+/g, '_').slice(0, 80),
isConvPage: () => /^\/c\/[0-9a-f-]+$/i.test(location.pathname) || /^\/g\/[^/]+\/c\/[0-9a-f-]+$/i.test(location.pathname),
convId: () => {
const m1 = location.pathname.match(/^\/c\/([0-9a-f-]+)$/i);
if (m1) return m1[1] || '';
const m2 = location.pathname.match(/^\/g\/[^/]+\/c\/([0-9a-f-]+)$/i);
return (m2 && m2[1]) || '';
},
projectId: () => {
const m = location.pathname.match(/^\/g\/([^/]+)\/c\/[0-9a-f-]+$/i);
return (m && m[1]) || '';
},
isHostOK: () => location.host.endsWith('chatgpt.com') || location.host.endsWith('chat.openai.com'),
on: (t, fn) => window.addEventListener(t, fn),
emit: (t, d) => window.dispatchEvent(new CustomEvent(t, { detail: d })),
ts: s => { if (!s && s !== 0) return ''; const n = typeof s === 'number' ? s : Number(s); const ms = n > 1e12 ? n : (n * 1000); const d = new Date(ms); return isFinite(d) ? d.toISOString().replace('T', ' ').replace('Z', ' UTC') : ''; },
isoToStamp: (s) => { if (!s) return ''; const d = new Date(s); if (!isFinite(d)) return ''; const p = n => String(n).padStart(2, '0'); return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`; }
};
// ── INDEXED DB STORAGE ────────────────────────────────────────────────
// Stores full conversation JSON. Survives tab close, browser crash, etc.
const DB = (() => {
let _db = null;
const open = () => new Promise((resolve, reject) => {
if (_db) return resolve(_db);
const req = indexedDB.open(CFG.DB_NAME, CFG.DB_VERSION);
req.onupgradeneeded = (e) => {
const db = e.target.result;
if (!db.objectStoreNames.contains(CFG.STORE_NAME)) {
db.createObjectStore(CFG.STORE_NAME, { keyPath: 'id' });
}
if (!db.objectStoreNames.contains(CFG.META_STORE)) {
db.createObjectStore(CFG.META_STORE, { keyPath: 'key' });
}
};
req.onsuccess = (e) => { _db = e.target.result; resolve(_db); };
req.onerror = (e) => reject(e.target.error);
});
const put = async (id, convData, taskMeta) => {
const db = await open();
return new Promise((resolve, reject) => {
const tx = db.transaction(CFG.STORE_NAME, 'readwrite');
tx.objectStore(CFG.STORE_NAME).put({ id, data: convData, meta: taskMeta, savedAt: Date.now() });
tx.oncomplete = () => resolve();
tx.onerror = (e) => reject(e.target.error);
});
};
const get = async (id) => {
const db = await open();
return new Promise((resolve, reject) => {
const tx = db.transaction(CFG.STORE_NAME, 'readonly');
const req = tx.objectStore(CFG.STORE_NAME).get(id);
req.onsuccess = () => resolve(req.result || null);
req.onerror = (e) => reject(e.target.error);
});
};
const getAllKeys = async () => {
const db = await open();
return new Promise((resolve, reject) => {
const tx = db.transaction(CFG.STORE_NAME, 'readonly');
const req = tx.objectStore(CFG.STORE_NAME).getAllKeys();
req.onsuccess = () => resolve(new Set(req.result || []));
req.onerror = (e) => reject(e.target.error);
});
};
const getAll = async () => {
const db = await open();
return new Promise((resolve, reject) => {
const tx = db.transaction(CFG.STORE_NAME, 'readonly');
const req = tx.objectStore(CFG.STORE_NAME).getAll();
req.onsuccess = () => resolve(req.result || []);
req.onerror = (e) => reject(e.target.error);
});
};
const count = async () => {
const db = await open();
return new Promise((resolve, reject) => {
const tx = db.transaction(CFG.STORE_NAME, 'readonly');
const req = tx.objectStore(CFG.STORE_NAME).count();
req.onsuccess = () => resolve(req.result);
req.onerror = (e) => reject(e.target.error);
});
};
const clear = async () => {
const db = await open();
return new Promise((resolve, reject) => {
const tx = db.transaction(CFG.STORE_NAME, 'readwrite');
tx.objectStore(CFG.STORE_NAME).clear();
tx.oncomplete = () => resolve();
tx.onerror = (e) => reject(e.target.error);
});
};
// Save/load task list metadata so we know what's left
const saveMeta = async (key, value) => {
const db = await open();
return new Promise((resolve, reject) => {
const tx = db.transaction(CFG.META_STORE, 'readwrite');
tx.objectStore(CFG.META_STORE).put({ key, value, savedAt: Date.now() });
tx.oncomplete = () => resolve();
tx.onerror = (e) => reject(e.target.error);
});
};
const loadMeta = async (key) => {
const db = await open();
return new Promise((resolve, reject) => {
const tx = db.transaction(CFG.META_STORE, 'readonly');
const req = tx.objectStore(CFG.META_STORE).get(key);
req.onsuccess = () => resolve(req.result ? req.result.value : null);
req.onerror = (e) => reject(e.target.error);
});
};
return { open, put, get, getAllKeys, getAll, count, clear, saveMeta, loadMeta };
})();
// ── ADAPTIVE THROTTLE ─────────────────────────────────────────────────
const Throttle = (() => {
let currentDelay = CFG.INITIAL_DELAY_MS;
let consecutiveOK = 0;
let totalRateLimits = 0;
const onSuccess = () => {
consecutiveOK++;
if (consecutiveOK >= CFG.SPEEDUP_AFTER) {
const newDelay = Math.max(CFG.MIN_DELAY_MS, Math.round(currentDelay * CFG.SPEEDUP_FACTOR));
if (newDelay < currentDelay) {
console.log(`[Throttle] Speeding up: ${currentDelay}ms -> ${newDelay}ms`);
currentDelay = newDelay;
}
consecutiveOK = 0;
}
};
const onRateLimit = () => {
consecutiveOK = 0;
totalRateLimits++;
const newDelay = Math.min(CFG.MAX_DELAY_MS, Math.round(currentDelay * CFG.SLOWDOWN_FACTOR));
console.log(`[Throttle] Rate limited (#${totalRateLimits})! Slowing: ${currentDelay}ms -> ${newDelay}ms`);
currentDelay = newDelay;
};
const wait = () => U.sleep(currentDelay);
const getDelay = () => currentDelay;
const getStats = () => ({ delay: currentDelay, consecutiveOK, totalRateLimits });
const reset = () => { currentDelay = CFG.INITIAL_DELAY_MS; consecutiveOK = 0; totalRateLimits = 0; };
return { onSuccess, onRateLimit, wait, getDelay, getStats, reset };
})();
// ── CREDENTIALS ───────────────────────────────────────────────────────
const Cred = (() => {
let token = null, accountId = null, lastTs = 0, lastErr = '';
const mask = (s, keepL = 8, keepR = 6) => {
if (!s) return '';
if (s.length <= keepL + keepR) return s;
return `${s.slice(0, keepL)}…${s.slice(-keepR)}`;
};
const ensureViaSession = async (tries = 4) => {
for (let i = 0; i < tries; i++) {
try {
const r = await fetch('/api/auth/session', { credentials: 'include' });
if (r.ok) {
const j = await r.json().catch(() => ({}));
if (j && j.accessToken) { token = j.accessToken; lastErr = ''; lastTs = Date.now(); }
if (!accountId) {
const m = document.cookie.match(/(?:^|;\s*)_account=([^;]+)/);
if (m) accountId = decodeURIComponent(m[1]);
}
U.emit('cgptx-cred-update', { token, accountId });
if (token) return true;
} else {
lastErr = `session ${r.status}`;
}
} catch (e) { lastErr = e && e.message ? e.message : 'session_error'; }
await U.sleep(300 * (i + 1));
}
U.emit('cgptx-cred-update', { token, accountId });
return !!token;
};
const getAuthHeaders = () => {
const h = new Headers();
if (token) h.set('authorization', `Bearer ${token}`);
if (accountId) h.set('chatgpt-account-id', accountId);
return h;
};
const debugText = () => {
const ts = lastTs ? new Date(lastTs).toLocaleString() : '—';
const tok = token ? mask(token) : 'none';
const acc = accountId || 'none';
const err = lastErr ? `\nError: ${lastErr}` : '';
return `Token: ${tok}\nAccount: ${acc}\nUpdated: ${ts}${err}`;
};
return {
get token() { return token; },
get accountId() { return accountId; },
get debug() { return debugText(); },
ensureViaSession, getAuthHeaders,
};
})();
// ── NETWORK ───────────────────────────────────────────────────────────
const Net = (() => {
const base = () => location.origin;
const mergeHeaders = (a, b) => { const h = new Headers(a || {}); (b || new Headers()).forEach((v, k) => h.set(k, v)); return h; };
// Returns {data, rateLimited}
const req = async (url, opt = {}, expectJson = true) => {
if (!Cred.token) await Cred.ensureViaSession(6);
const h = mergeHeaders(opt.headers, Cred.getAuthHeaders());
const init = Object.assign({ credentials: 'include' }, opt, { headers: h });
const resp = await fetch(url, init).catch(() => null);
if (!resp) throw new Error('network_failed');
if (resp.status === 401) {
await Cred.ensureViaSession(6);
const h2 = mergeHeaders(opt.headers, Cred.getAuthHeaders());
const init2 = Object.assign({ credentials: 'include' }, opt, { headers: h2 });
const resp2 = await fetch(url, init2).catch(() => null);
if (!resp2 || !resp2.ok) throw new Error(`http_${resp2 ? resp2.status : 'network'}`);
return { data: expectJson ? await resp2.json() : await resp2.blob(), rateLimited: false };
}
if (resp.status === 429) {
// Parse Retry-After header (seconds) if present
const ra = resp.headers.get('retry-after');
const retryAfterSec = ra ? (Number(ra) || 0) : 0;
return { data: null, rateLimited: true, retryAfterMs: retryAfterSec > 0 ? retryAfterSec * 1000 : 0 };
}
if (resp.status >= 500) throw new Error(`server_${resp.status}`);
if (!resp.ok) {
const t = await resp.text().catch(() => '');
throw new Error(`http_${resp.status}:${t.slice(0, 160)}`);
}
return { data: expectJson ? await resp.json() : await resp.blob(), rateLimited: false };
};
const list = (p = {}) => {
const { is_archived, is_starred, offset = 0, limit = 50, order = 'updated' } = p;
const q = new URLSearchParams({
offset: String(offset), limit: String(limit), order: String(order),
...(typeof is_archived === 'boolean' ? { is_archived: String(is_archived) } : {}),
...(typeof is_starred === 'boolean' ? { is_starred: String(is_starred) } : {}),
});
return req(`${base()}/backend-api/conversations?${q.toString()}`, { method: 'GET' });
};
const getConv = (id, projectId) => {
const headers = projectId ? { 'chatgpt-project-id': projectId } : undefined;
return req(`${base()}/backend-api/conversation/${id}`, { method: 'GET', headers });
};
const listGizmosSidebar = (p = {}) => {
const { conversations_per_gizmo = 20, owned_only = true, cursor = null } = p;
const n = Math.min(typeof conversations_per_gizmo === 'number' ? conversations_per_gizmo : 20, 20);
const q = new URLSearchParams({ conversations_per_gizmo: String(n), owned_only: String(owned_only) });
if (cursor) q.set('cursor', cursor);
return req(`${base()}/backend-api/gizmos/snorlax/sidebar?${q.toString()}`, { method: 'GET' });
};
return { list, getConv, listGizmosSidebar };
})();
// ── MARKDOWN CONVERTER ────────────────────────────────────────────────
const MD = (() => {
const roleLabel = r => ({ user: 'User', assistant: 'Assistant', system: 'System', tool: 'Tool' })[r] || r || 'Unknown';
const joinParts = parts => Array.isArray(parts) ? parts.map(x => String(x || '')).join('\n\n').trim() : String(parts || '').trim();
const modelFrom = (msg, conv) => (msg?.metadata?.model_slug || msg?.metadata?.default_model_slug || conv?.default_model_slug || '').trim();
const nodesToArray = (mapping) => {
const arr = [];
if (!mapping || typeof mapping !== 'object') return arr;
for (const k of Object.keys(mapping)) {
const n = mapping[k];
if (!n || !n.message) continue;
const m = n.message;
arr.push({ id: n.id || m.id || k, role: m.author?.role || '', create_time: m.create_time ?? null, content: m.content || {}, metadata: m.metadata || {}, channel: m.channel || null });
}
return arr;
};
const shouldSkip = (msg) => {
const ct = msg.content?.content_type;
if (msg.role === 'system') {
if (ct === 'text' && joinParts(msg.content?.parts) === '') return true;
if (ct === 'model_editable_context' && !msg.content?.model_set_context) return true;
}
if (ct === 'text' && joinParts(msg.content?.parts) === '') return true;
return false;
};
const fmtThoughts = (content) => {
const t = content?.thoughts;
if (!Array.isArray(t) || t.length === 0) return '';
const lines = ['> **Thinking**'];
t.forEach((it, idx) => {
const head = it?.summary ? `**${idx + 1}. ${it.summary}**` : `**${idx + 1}.**`;
const body = (it?.content || (Array.isArray(it?.chunks) ? it.chunks.join('\n') : '') || '').trim();
lines.push(body ? `${head}\n\n${body}` : head);
});
return lines.join('\n\n');
};
const renderMsg = (m, conv) => {
if (shouldSkip(m)) return '';
const role = roleLabel(m.role);
const model = modelFrom(m, conv);
const tstr = U.ts(m.create_time);
const head = `**${role}${model ? ` (${model})` : ''}${tstr ? ` — ${tstr}` : ''}**`;
const ct = m.content?.content_type;
let body = '';
if (ct === 'text') body = joinParts(m.content?.parts);
else if (ct === 'thoughts') body = fmtThoughts(m.content);
else if (ct === 'reasoning_recap') { const s = (typeof m.content?.content === 'string' ? m.content.content : '').trim(); body = s ? `*Thinking recap: ${s}*` : ''; }
else if (ct === 'model_editable_context') { const s = (m.content?.model_set_context || '').trim(); body = s ? `> Context\n\n${s}` : ''; }
else body = '```json\n' + JSON.stringify(m.content, null, 2) + '\n```';
if (!body) return '';
return `${head}\n\n${body}`;
};
const conversationToMD = (conv) => {
const title = conv?.title || 'untitled';
const id = conv?.conversation_id || conv?.id || '';
const projId = conv?.gizmo_id || conv?.conversation_template_id || conv?.project_id || '';
let linkLine = null;
if (id) {
linkLine = projId ? `- Link: https://chatgpt.com/g/${projId}/c/${id}` : `- Link: https://chatgpt.com/c/${id}`;
}
const meta = [
`- ID: ${id}`,
conv?.workspace_id ? `- Workspace: ${conv.workspace_id}` : null,
conv?.create_time ? `- Created: ${U.ts(conv.create_time)}` : null,
conv?.update_time ? `- Updated: ${U.ts(conv.update_time)}` : null,
conv?.default_model_slug ? `- Model: ${conv.default_model_slug}` : null,
linkLine
].filter(Boolean).join('\n');
const nodes = nodesToArray(conv?.mapping);
nodes.sort((a, b) => { const ta = a.create_time ?? 0, tb = b.create_time ?? 0; return ta !== tb ? ta - tb : String(a.id).localeCompare(String(b.id)); });
const lines = [`# ${title}\n`];
if (meta) lines.push(meta, '\n---\n');
for (const m of nodes) { const s = renderMsg(m, conv); if (s) lines.push(s, '\n'); }
if (Array.isArray(conv?.safe_urls) && conv.safe_urls.length) {
lines.push('---\n**Links**\n');
conv.safe_urls.forEach(u => lines.push(`- ${u}`));
lines.push('');
}
return lines.join('\n').trim() + '\n';
};
return { conversationToMD };
})();
// ── UI ────────────────────────────────────────────────────────────────
const UI = (() => {
let root, panel, btn;
let btnCur, btnAll, btnDownloadNow, btnReindex, btnReset;
let barWrap, bar, info, badge, stopBtn, statusDiv, throttleDiv;
let exporting = false, cancel = false, opening = false, isOpen = false, autoHideTimer = null;
const DIST = 100, DELAY = 2000;
const css = `
.cgptx-fab{position:fixed;right:18px;bottom:18px;z-index:2147483647;}
.cgptx-btn{width:48px;height:48px;border:none;border-radius:14px;cursor:pointer;background:#111827;color:#fff;
box-shadow:0 8px 22px rgba(0,0,0,.22);display:flex;align-items:center;justify-content:center;transition:.2s;opacity:.95}
.cgptx-btn:hover{transform:translateY(-1px);opacity:1}
.cgptx-panel{position:fixed;right:18px;bottom:74px;width:clamp(280px,32vw,360px);
background:#fffffff5;border:1px solid #e6e6e7;border-radius:18px;box-shadow:0 16px 36px rgba(17,24,39,.18);
backdrop-filter:saturate(1.1) blur(6px);padding:14px 14px 12px;z-index:2147483647;
opacity:0;transform:translateY(10px) scale(.98);transition:opacity .4s ease, transform .4s ease;pointer-events:none;
max-height:80vh;overflow-y:auto}
.cgptx-panel.cgptx-open{opacity:1;transform:translateY(0) scale(1);pointer-events:auto}
.cgptx-ttl{font-size:15px;color:#0f172a;margin-bottom:10px;font-weight:700;letter-spacing:.2px;text-align:center}
.cgptx-col{display:flex;flex-direction:column;gap:8px;margin-top:4px}
.cgptx-a{width:100%;height:38px;border-radius:12px;border:1px solid #e6e6e7;background:#f8fafc;color:#0f172a;cursor:pointer;
font-size:13px;font-weight:600;letter-spacing:.2px;display:flex;align-items:center;justify-content:center;line-height:1.15;padding:6px 10px;transition:.15s}
.cgptx-a:hover{background:#f3f5f7;border-color:#dfe3e9;transform:translateY(-0.5px)}
.cgptx-a:active{transform:translateY(0)}
.cgptx-a.green{background:#e8f7ee;border-color:#b7e3c9;color:#0a6c32}
.cgptx-a.danger{background:#fff5f5;border-color:#ffd9d9;color:#b60000;font-size:11px;height:32px}
.cgptx-progress{margin-top:12px;background:#f2f3f5;border-radius:10px;height:10px;overflow:hidden;display:none}
.cgptx-bar{height:100%;width:0%;background:#4b8df8;transition:width .25s}
.cgptx-progtext{margin-top:6px;font-size:12px;color:#4b5563;display:none}
.cgptx-status{margin-top:6px;font-size:11px;color:#6b7280;min-height:14px;word-break:break-all}
.cgptx-throttle{margin-top:4px;font-size:10px;color:#9ca3af;font-family:monospace}
.cgptx-badges{margin-top:10px}
.cgptx-badge{width:100%;min-height:36px;border-radius:12px;border:1px solid #e6e6e7;background:#f8fafc;color:#374151;
display:flex;align-items:center;justify-content:center;gap:8px;font-size:12px;font-weight:600;letter-spacing:.2px;text-align:center;padding:6px 10px;
user-select:text;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.cgptx-chip{font-weight:800}
.cgptx-chip.ok{color:#0a7d39}
.cgptx-chip.bad{color:#c02626}
.cgptx-stop{margin-top:10px;width:100%;height:36px;border-radius:10px;border:1px solid #ffd9d9;background:#fff5f5;color:#b60000;display:none;cursor:pointer;font-weight:600}
`;
const mount = () => {
if (!U.qs('#cgptx-style')) {
document.head.appendChild(U.ce('style', { id: 'cgptx-style', textContent: css }));
}
if (!root) {
root = U.ce('div', { className: 'cgptx-fab' });
btn = U.ce('button', { className: 'cgptx-btn', innerHTML: '&#x2B07;' });
panel = U.ce('div', { className: 'cgptx-panel' });
const ttl = U.ce('div', { className: 'cgptx-ttl', textContent: 'ChatGPT Export v5' });
btnCur = U.ce('button', { className: 'cgptx-a', textContent: 'Export current (JSON + MD)' });
btnAll = U.ce('button', { className: 'cgptx-a', textContent: 'Export ALL — fetch + resume' });
btnDownloadNow = U.ce('button', { className: 'cgptx-a green', textContent: 'Download saved (JSON + MD ZIPs)' });
btnReindex = U.ce('button', { className: 'cgptx-a', textContent: 'Re-index (rescan conversation list)' });
btnReset = U.ce('button', { className: 'cgptx-a danger', textContent: 'Clear saved data (start over)' });
const col = U.ce('div', { className: 'cgptx-col' });
col.append(btnCur, btnAll, btnDownloadNow, btnReindex, btnReset);
barWrap = U.ce('div', { className: 'cgptx-progress' });
bar = U.ce('div', { className: 'cgptx-bar' }); barWrap.append(bar);
info = U.ce('div', { className: 'cgptx-progtext' });
statusDiv = U.ce('div', { className: 'cgptx-status' });
throttleDiv = U.ce('div', { className: 'cgptx-throttle' });
const badges = U.ce('div', { className: 'cgptx-badges' });
badge = U.ce('div', { className: 'cgptx-badge', textContent: 'Credentials: initializing...' });
badges.append(badge);
stopBtn = U.ce('button', { className: 'cgptx-stop', textContent: 'Stop export (progress is saved)' });
panel.append(ttl, col, barWrap, info, statusDiv, throttleDiv, badges, stopBtn);
root.append(btn);
btn.onclick = onFabClick;
btnCur.onclick = () => exportSingle();
btnAll.onclick = () => exportAll();
btnDownloadNow.onclick = () => downloadFromDB();
btnReindex.onclick = async () => {
if (exporting) return;
await DB.saveMeta('taskIndex', null);
setStatus('Index cleared. Next "Export ALL" will rescan.');
updateIndexInfo();
};
btnReset.onclick = async () => {
if (confirm('Delete all saved conversation data from browser? Downloaded ZIP files are NOT affected.')) {
await DB.clear();
await DB.saveMeta('taskIndex', null);
await DB.saveMeta('projectMap', null);
updateDownloadBtn();
setStatus('All saved data and index cleared.');
}
};
U.on('cgptx-cred-update', updateBadge);
tickBadge();
document.addEventListener('mousemove', onDocMouseMove, { passive: true });
}
if (!document.body.contains(root)) document.body.append(root);
if (!document.body.contains(panel)) document.body.append(panel);
if (getComputedStyle(root).display === 'none') root.style.display = 'block';
refreshButtons();
updateDownloadBtn();
};
const onFabClick = async () => {
if (opening) return;
opening = true; btn.disabled = true;
try {
await Cred.ensureViaSession(6); updateBadge();
if (isOpen) closePanel(); else openPanel();
} finally { btn.disabled = false; opening = false; }
};
const openPanel = () => { if (isOpen) return; clearAutoHide(); panel.classList.add('cgptx-open'); isOpen = true; refreshButtons(); updateDownloadBtn(); };
const closePanel = () => { if (!isOpen) return; clearAutoHide(); panel.classList.remove('cgptx-open'); isOpen = false; };
const onDocMouseMove = (e) => {
if (!isOpen || exporting) return;
const r = panel.getBoundingClientRect();
const inside = e.clientX >= r.left - DIST && e.clientX <= r.right + DIST && e.clientY >= r.top - DIST && e.clientY <= r.bottom + DIST;
if (!inside) { if (!autoHideTimer) autoHideTimer = setTimeout(closePanel, DELAY); }
else clearAutoHide();
};
const clearAutoHide = () => { if (autoHideTimer) { clearTimeout(autoHideTimer); autoHideTimer = null; } };
const tickBadge = () => { const t = setInterval(() => { updateBadge(); if (Cred.token && Cred.accountId) clearInterval(t); }, 800); };
const updateBadge = () => {
const okT = !!Cred.token, okA = !!Cred.accountId;
badge.innerHTML = `Creds: <span class="cgptx-chip ${okT ? 'ok' : 'bad'}">${okT ? 'Token OK' : 'Token X'}</span> / <span class="cgptx-chip ${okA ? 'ok' : 'bad'}">${okA ? 'Account OK' : 'Account X'}</span>`;
badge.title = Cred.debug;
badge.style.background = (okT && okA) ? '#e8f7ee' : '#fff5f5';
badge.style.borderColor = (okT && okA) ? '#b7e3c9' : '#ffd9d9';
};
const updateDownloadBtn = async () => {
if (!btnDownloadNow) return;
try {
const c = await DB.count();
btnDownloadNow.textContent = c > 0 ? `Download ${c} saved conversations (JSON + MD ZIPs)` : 'No saved data yet';
btnDownloadNow.disabled = c === 0;
} catch { btnDownloadNow.textContent = 'Download saved (ZIP)'; }
updateIndexInfo();
};
const updateIndexInfo = async () => {
if (!btnReindex) return;
try {
const idx = await DB.loadMeta('taskIndex');
if (idx && Array.isArray(idx.tasks)) {
const when = idx.savedAt ? new Date(idx.savedAt).toLocaleString() : '?';
btnReindex.textContent = `Re-index (${idx.tasks.length} chats indexed ${when})`;
} else {
btnReindex.textContent = 'Re-index (no index yet — will scan on first export)';
}
} catch { /* ignore */ }
};
const refreshButtons = () => {
const showCur = U.isConvPage();
if (btnCur) btnCur.style.display = showCur ? 'block' : 'none';
};
// ── Elapsed / ETA helpers ───────────────────────────────────────────
let exportStartTime = 0;
const fmtDuration = (ms) => {
const s = Math.floor(ms / 1000);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
if (h > 0) return `${h}h ${m}m ${sec}s`;
if (m > 0) return `${m}m ${sec}s`;
return `${sec}s`;
};
const elapsedStr = () => exportStartTime ? fmtDuration(Date.now() - exportStartTime) : '';
const etaStr = (done, total) => {
if (!exportStartTime || done <= 0 || total <= 0) return '';
const elapsed = Date.now() - exportStartTime;
const perItem = elapsed / done;
const remaining = perItem * (total - done);
return fmtDuration(remaining);
};
const setProg = (p, text) => { barWrap.style.display = 'block'; info.style.display = 'block'; bar.style.width = Math.max(0, Math.min(100, p)) + '%'; info.textContent = text || ''; };
const resetProg = () => { barWrap.style.display = 'none'; info.style.display = 'none'; bar.style.width = '0%'; info.textContent = ''; };
const setStatus = (text) => { if (statusDiv) statusDiv.textContent = text; };
const setThrottle = (text) => { if (throttleDiv) throttleDiv.textContent = text; };
const saveBlob = (blob, name) => {
if (typeof saveAs === 'function') saveAs(blob, name);
else { const a = U.ce('a', { href: URL.createObjectURL(blob), download: name }); document.body.appendChild(a); a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 3000); a.remove(); }
};
// ── Countdown display (for rate-limit pauses) ───────────────────────
const countdownWait = async (totalMs, label) => {
const end = Date.now() + totalMs;
while (Date.now() < end) {
if (cancel) throw new Error('Stopped by user');
const rem = end - Date.now();
const min = Math.floor(rem / 60000);
const sec = Math.floor((rem % 60000) / 1000);
setStatus(`${label || 'Paused'}: ${min}m ${sec}s remaining...`);
await U.sleep(1000);
}
};
// ── Compute pause duration: use server's Retry-After if available, else fallback
const rateLimitPause = (retryAfterMs) => {
if (retryAfterMs && retryAfterMs > 0) return Math.min(retryAfterMs + 2000, CFG.MAX_DELAY_MS * 10); // server says + 2s buffer
return CFG.RATE_LIMIT_PAUSE_MS; // fallback: 90s
};
// ── Rate-limit-aware fetch for a single conversation ────────────────
const fetchConvAdaptive = async (id, projectId) => {
for (let attempt = 0; attempt <= CFG.MAX_RETRIES_PER_CONV; attempt++) {
if (cancel) throw new Error('Stopped by user');
try {
const result = await Net.getConv(id, projectId);
if (result.rateLimited) {
Throttle.onRateLimit();
const pauseMs = rateLimitPause(result.retryAfterMs);
const pauseSec = Math.round(pauseMs / 1000);
const source = result.retryAfterMs ? `server says ${Math.round(result.retryAfterMs / 1000)}s` : 'no Retry-After header';
console.log(`[Export] 429 on ${id} (${source}), pausing ${pauseSec}s, new rate: ${Throttle.getDelay()}ms/req`);
await countdownWait(pauseMs, `Rate limited (${source}) — pausing ${pauseSec}s, will slow to ${(Throttle.getDelay() / 1000).toFixed(1)}s/req`);
await Cred.ensureViaSession(6);
continue;
}
Throttle.onSuccess();
return result.data;
} catch (e) {
if (e.message === 'Stopped by user') throw e;
if (e.message?.startsWith('server_') && attempt < CFG.MAX_RETRIES_PER_CONV) {
Throttle.onRateLimit();
const pauseMs = CFG.RATE_LIMIT_PAUSE_MS;
await countdownWait(pauseMs, `Server error (${e.message}), pausing`);
await Cred.ensureViaSession(6);
continue;
}
throw e;
}
}
throw new Error(`Max retries for ${id}`);
};
// ── Rate-limit-aware list fetch ─────────────────────────────────────
const listAdaptive = async (params) => {
for (let attempt = 0; attempt <= CFG.MAX_RETRIES_PER_CONV; attempt++) {
if (cancel) throw new Error('Stopped by user');
try {
const result = await Net.list(params);
if (result.rateLimited) {
Throttle.onRateLimit();
const pauseMs = rateLimitPause(result.retryAfterMs);
await countdownWait(pauseMs, `Rate limited on list (pausing ${Math.round(pauseMs / 1000)}s)`);
await Cred.ensureViaSession(6);
continue;
}
return result.data;
} catch (e) {
if (e.message === 'Stopped by user') throw e;
if (attempt < CFG.MAX_RETRIES_PER_CONV) { await countdownWait(30000, 'List error, retrying'); continue; }
throw e;
}
}
};
const gizmoAdaptive = async (params) => {
for (let attempt = 0; attempt <= CFG.MAX_RETRIES_PER_CONV; attempt++) {
if (cancel) throw new Error('Stopped by user');
try {
const result = await Net.listGizmosSidebar(params);
if (result.rateLimited) {
Throttle.onRateLimit();
const pauseMs = rateLimitPause(result.retryAfterMs);
await countdownWait(pauseMs, `Rate limited on gizmo list (pausing ${Math.round(pauseMs / 1000)}s)`);
await Cred.ensureViaSession(6);
continue;
}
return result.data;
} catch (e) {
if (e.message === 'Stopped by user') throw e;
if (attempt < CFG.MAX_RETRIES_PER_CONV) { await countdownWait(30000, 'Gizmo list error'); continue; }
throw e;
}
}
};
// ── Collect all conversation IDs (newest first) ─────────────────────
const collectAllIds = async () => {
const combos = [
{ is_archived: false, is_starred: false },
{ is_archived: true, is_starred: false },
{ is_archived: false, is_starred: true },
{ is_archived: true, is_starred: true },
];
const seen = new Set();
const tasks = []; // {id, projectId, title, update_time}
const projectMap = new Map();
for (const c of combos) {
let offset = 0;
while (true) {
if (cancel) throw new Error('Stopped by user');
const page = await listAdaptive({ ...c, offset, limit: 50, order: 'updated' });
const arr = Array.isArray(page?.items) ? page.items : [];
for (const it of arr) {
if (!it?.id || seen.has(it.id)) continue;
seen.add(it.id);
const projId = it.conversation_template_id || it.gizmo_id || null;
tasks.push({ id: it.id, projectId: projId, title: it.title || '', update_time: it.update_time || 0 });
if (projId && !projectMap.has(projId)) {
projectMap.set(projId, { projectId: projId, projectName: '', createdAt: '' });
}
}
const total = Number(page?.total || 0);
const got = offset + arr.length;
setStatus(`Scanning: ${got}/${total} (archived:${c.is_archived ? 'Y' : 'N'}/starred:${c.is_starred ? 'Y' : 'N'})`);
if (!arr.length || got >= total) break;
offset += 50;
await U.sleep(CFG.DELAY_BETWEEN_LIST_PAGES_MS);
}
}
// Gizmo sidebar for project names + extra conversations
try {
let cursor = null;
do {
if (cancel) throw new Error('Stopped by user');
const sidebar = await gizmoAdaptive({ cursor });
const items = Array.isArray(sidebar?.items) ? sidebar.items : [];
for (const it of items) {
const g = it?.gizmo?.gizmo;
if (!g?.id) continue;
const pid = g.id;
if (!projectMap.has(pid)) projectMap.set(pid, { projectId: pid, projectName: '', createdAt: '' });
const rec = projectMap.get(pid);
if (g.display?.name) rec.projectName = g.display.name;
if (g.created_at) rec.createdAt = g.created_at;
const convItems = Array.isArray(it?.conversations?.items) ? it.conversations.items : [];
for (const cv of convItems) {
if (!cv?.id || seen.has(cv.id)) continue;
seen.add(cv.id);
tasks.push({ id: cv.id, projectId: pid, title: cv.title || '', update_time: cv.update_time || 0 });
}
}
cursor = sidebar?.cursor || null;
if (cursor) await U.sleep(CFG.DELAY_BETWEEN_LIST_PAGES_MS);
} while (cursor);
} catch (e) {
if (e.message === 'Stopped by user') throw e;
console.warn('[Export] Gizmo sidebar error (continuing):', e);
}
// Sort newest first
tasks.sort((a, b) => (b.update_time || 0) - (a.update_time || 0));
return { tasks, projectMap };
};
// ── Build ZIP from IndexedDB ────────────────────────────────────────
const buildZipFromDB = async (format, projectMap) => {
const allRecords = await DB.getAll();
if (!allRecords.length) return null;
const zip = new JSZip();
const folderNames = new Map();
if (projectMap) {
const counts = {};
for (const [pid, p] of projectMap) {
const base = U.sanitize(p.projectName || pid || 'project');
counts[base] = (counts[base] || 0) + 1;
}
for (const [pid, p] of projectMap) {
let name = U.sanitize(p.projectName || pid || 'project');
if (counts[name] > 1 && p.createdAt) name = U.sanitize(`${p.projectName || name}_${U.isoToStamp(p.createdAt)}`);
folderNames.set(pid, name);
}
}
for (const rec of allRecords) {
const data = rec.data;
const meta = rec.meta || {};
const title = U.sanitize(data?.title || meta.title || 'untitled');
const id = rec.id;
let content;
if (format === 'md') {
content = MD.conversationToMD({ ...data, conversation_id: id, gizmo_id: meta.projectId || data?.gizmo_id });
} else {
content = JSON.stringify(data, null, 2);
}
const ext = format === 'md' ? 'md' : 'json';
const fileName = `${title}_${id}.${ext}`;
if (meta.projectId && folderNames.has(meta.projectId)) {
const folder = zip.folder(folderNames.get(meta.projectId));
folder.file(fileName, content);
} else {
zip.file(fileName, content);
}
}
zip.file('_export_info.json', JSON.stringify({
exportedAt: new Date().toISOString(),
format,
totalConversations: allRecords.length,
}, null, 2));
return zip.generateAsync({ type: 'blob', compression: 'DEFLATE', compressionOptions: { level: 9 } });
};
// ── Download whatever is in IndexedDB right now ─────────────────────
const downloadFromDB = async () => {
setStatus('Building ZIPs from saved data...');
setProg(30, 'Compressing JSON...');
try {
const projectMap = await DB.loadMeta('projectMap');
const pm = projectMap ? new Map(Object.entries(projectMap)) : null;
const jsonBlob = await buildZipFromDB('json', pm);
if (!jsonBlob) { setStatus('No saved data to download.'); resetProg(); return; }
saveBlob(jsonBlob, `chatgpt-export-json-${U.nowStr()}.zip`);
setProg(70, 'Compressing MD...');
const mdBlob = await buildZipFromDB('md', pm);
if (mdBlob) saveBlob(mdBlob, `chatgpt-export-md-${U.nowStr()}.zip`);
setProg(100, 'Downloaded!');
setStatus('Both JSON + MD ZIPs saved to your downloads folder.');
} catch (e) {
setStatus(`Download failed: ${e.message}`);
}
setTimeout(resetProg, 2000);
};
// ── Export single conversation ───────────────────────────────────────
const exportSingle = async () => {
if (exporting) return; exporting = true; cancel = false; setProg(3, 'Preparing...');
try {
await Cred.ensureViaSession(6); updateBadge();
const id = U.convId(); if (!id) throw new Error('Not on a conversation page');
const pid = U.projectId();
const data = await fetchConvAdaptive(id, pid || undefined);
const title = U.sanitize(data?.title || '');
// Save both JSON and MD
saveBlob(new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }), `${title}_${id}.json`);
const md = MD.conversationToMD({ ...data, conversation_id: id, gizmo_id: pid || data?.gizmo_id });
saveBlob(new Blob([md], { type: 'text/markdown;charset=utf-8' }), `${title}_${id}.md`);
setProg(100, 'Done! Both JSON + MD saved.');
} catch (e) { setProg(0, `Failed: ${e.message || e}`); }
finally { setTimeout(resetProg, 1200); exporting = false; }
};
// ── Export all with adaptive throttling + IndexedDB persistence ─────
const exportAll = async () => {
if (exporting) return;
exporting = true; cancel = false;
stopBtn.style.display = 'block';
Throttle.reset();
exportStartTime = Date.now();
setProg(2, 'Scanning conversation list...');
setStatus('');
setThrottle('');
try {
await Cred.ensureViaSession(6); updateBadge();
// Phase 1: Discover all conversations (use cached index if available)
let tasks, projectMap;
const cachedIndex = await DB.loadMeta('taskIndex');
if (cachedIndex && Array.isArray(cachedIndex.tasks) && cachedIndex.tasks.length > 0) {
tasks = cachedIndex.tasks;
projectMap = new Map(Object.entries(cachedIndex.projectMap || {}));
setStatus(`Using cached index: ${tasks.length} conversations (indexed ${new Date(cachedIndex.savedAt).toLocaleString()}). Hit Re-index to rescan.`);
console.log(`[Export] Using cached index with ${tasks.length} conversations`);
} else {
const result = await collectAllIds();
tasks = result.tasks;
projectMap = result.projectMap;
// Save index for next run
const pmObj = {};
for (const [k, v] of projectMap) pmObj[k] = v;
await DB.saveMeta('taskIndex', { tasks, projectMap: pmObj, savedAt: Date.now() });
updateIndexInfo();
console.log(`[Export] Built and cached index with ${tasks.length} conversations`);
}
// Save project map to DB for later ZIP building
const pmObj = {};
for (const [k, v] of projectMap) pmObj[k] = v;
await DB.saveMeta('projectMap', pmObj);
// Check what's already in IndexedDB
const alreadySaved = await DB.getAllKeys();
const pending = tasks.filter(t => !alreadySaved.has(t.id));
const totalAll = tasks.length;
const totalPending = pending.length;
const skipped = totalAll - totalPending;
if (!totalPending) {
setProg(100, `All ${totalAll} conversations already saved!`);
setStatus('Hit "Download saved" to get your ZIPs. Or "Clear saved data" to re-fetch.');
updateDownloadBtn();
return;
}
setStatus(`Found ${totalAll} total, ${skipped} already saved, ${totalPending} to fetch`);
exportStartTime = Date.now(); // reset after scan phase
// Phase 2: Fetch one at a time with adaptive delays
let done = 0, failed = 0;
for (const task of pending) {
if (cancel) {
setStatus(`Stopped. ${done} new + ${skipped} previous = ${done + skipped} saved. Resume anytime.`);
break;
}
const stats = Throttle.getStats();
const elapsed = elapsedStr();
const eta = etaStr(done, totalPending);
setThrottle(`delay: ${(stats.delay / 1000).toFixed(1)}s | streak: ${stats.consecutiveOK}/${CFG.SPEEDUP_AFTER} | 429s: ${stats.totalRateLimits} | elapsed: ${elapsed}${eta ? ` | ETA: ${eta}` : ''}`);
try {
setStatus(`Fetching: ${task.title || task.id} (${done + 1}/${totalPending})`);
const data = await fetchConvAdaptive(task.id, task.projectId);
// Save to IndexedDB immediately
await DB.put(task.id, data, { projectId: task.projectId, title: task.title });
done++;
const totalDone = done + skipped;
const pct = Math.round((totalDone / totalAll) * 100);
setProg(pct, `Saved: ${totalDone}/${totalAll} (${done} new, ${skipped} resumed${failed ? `, ${failed} failed` : ''})`);
updateDownloadBtn();
// Adaptive delay
await Throttle.wait();
} catch (e) {
if (e.message === 'Stopped by user') {
setStatus(`Stopped. ${done} new + ${skipped} previous saved. Resume anytime. Elapsed: ${elapsedStr()}`);
break;
}
console.error(`[Export] Failed: ${task.id}`, e);
failed++;
setStatus(`Failed: ${task.id} (${e.message}). Skipping, continuing...`);
}
}
// Phase 3: Auto-download both JSON + MD ZIPs
const totalSaved = done + skipped;
if (totalSaved > 0) {
setStatus('Building JSON ZIP...');
setProg(95, 'Compressing JSON...');
const jsonBlob = await buildZipFromDB('json', projectMap);
if (jsonBlob) saveBlob(jsonBlob, `chatgpt-export-json-${U.nowStr()}.zip`);
setStatus('Building Markdown ZIP...');
setProg(98, 'Compressing MD...');
const mdBlob = await buildZipFromDB('md', projectMap);
if (mdBlob) saveBlob(mdBlob, `chatgpt-export-md-${U.nowStr()}.zip`);
}
const totalElapsed = elapsedStr();
setProg(100, `Done! ${totalSaved} saved, ${failed} failed. Total: ${totalElapsed}`);
setStatus(failed
? `${failed} failed. ${totalSaved} saved in browser. Run again to retry. Elapsed: ${totalElapsed}`
: `All ${totalSaved} conversations exported as JSON + MD! Elapsed: ${totalElapsed}`
);
} catch (e) {
setProg(0, `Error: ${e.message || e}`);
setStatus(`Progress saved in browser. Resume anytime. Elapsed: ${elapsedStr()}`);
} finally {
setTimeout(() => { resetProg(); stopBtn.style.display = 'none'; setThrottle(''); }, 5000);
exporting = false; cancel = false;
updateDownloadBtn();
}
};
const hookHistory = () => {
const ps = history.pushState, rs = history.replaceState;
history.pushState = function () { const r = ps.apply(this, arguments); U.emit('cgptx-url'); return r; };
history.replaceState = function () { const r = rs.apply(this, arguments); U.emit('cgptx-url'); return r; };
window.addEventListener('popstate', () => U.emit('cgptx-url'));
window.addEventListener('cgptx-url', () => refreshButtons());
};
return { mount, hookHistory };
})();
// ── BOOT ──────────────────────────────────────────────────────────────
const boot = async () => {
if (!U.isHostOK()) return;
await DB.open();
UI.hookHistory();
const init = () => { UI.mount(); setInterval(UI.mount, 1500); };
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
};
boot();
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment