Created
May 22, 2026 13:08
-
-
Save mmatiaschek/b8e93170d533250d886c609a9991b00d to your computer and use it in GitHub Desktop.
ChatGPT-Exporter-Resilient
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-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: '⬇' }); | |
| 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