/* ChatGPT → Gemini exporter (Amgreat) * * Runs as a content script on chatgpt.com, in the page's own origin, so every * request uses the signed-in user's existing session. Nothing leaves the * browser: it lists the user's conversations via ChatGPT's own backend API, * fetches each one in full, and hands back JSON file(s) whose shape is identical * to ChatGPT's official conversations.json — exactly what the Amgreat migration * tool accepts. Large histories are saved in numbered batch files as they go. */ (function () { if (window.__amgreatExporterLoaded) return; window.__amgreatExporterLoaded = true; var VERSION = '1.4'; // shown in the modal footer, so you can confirm the build var PAGE = 100; // conversations per list page (API max) var BATCH = 25; // conversations per downloaded file (0 = one file) var FLUSH_MS = 90000; // also save whatever we have if this long since last save var MAX_RETRY = 8; // attempts per conversation before moving on var DELAY_MIN = 1000; // fastest pace we will ever use (ms) var DELAY_MAX = 12000; // slowest normal pace var REST_AFTER = 12; // consecutive 429s => take a long rest, then continue var REST_MS = 300000; // length of that rest (5 min) - the limit does recover var MAX_RUN_MS = 7200000;// absolute safety stop (2h), always saves first var STATE_KEY = 'amgreat_export_state'; // Measured against a real account: ~1.5s spacing is comfortable on a healthy // account; going faster trips a burst limit after ~30 conversations. The // limit recovers quickly, so 429s are answered with a short pause and a // modest slowdown rather than escalating punishment. var delay = 1500; var hot = 0; // consecutive 429s var streakOk = 0; // consecutive successes var onRest = null; // set during a run, so the modal can announce a rest function slower() { delay = Math.min(Math.round(delay * 1.3), DELAY_MAX); } function faster() { delay = Math.max(Math.round(delay * 0.92), DELAY_MIN); } function sleep(ms) { return new Promise(function (r) { setTimeout(r, ms); }); } function el(tag, css, text) { var n = document.createElement(tag); if (css) n.style.cssText = css; if (text != null) n.textContent = text; return n; } /* ======================= UI: launch button ============================= */ var launch = el('button', 'position:fixed;right:20px;bottom:20px;z-index:2147483646;padding:12px 18px;' + 'border:0;border-radius:10px;cursor:pointer;font:600 14px/1 system-ui,sans-serif;' + 'color:#fff;background:#0b7;box-shadow:0 4px 14px rgba(0,0,0,.25)', 'Export for Gemini'); launch.addEventListener('click', openModal); document.body.appendChild(launch); /* ======================= UI: modal ==================================== */ var ui = {}; var busy = false; function buildModal() { var back = el('div', 'position:fixed;inset:0;z-index:2147483647;background:rgba(15,15,17,.55);' + 'display:flex;align-items:center;justify-content:center;' + 'font:400 14px/1.5 system-ui,-apple-system,sans-serif'); var card = el('div', 'width:min(560px,92vw);max-height:88vh;display:flex;flex-direction:column;' + 'background:#fff;color:#1b1818;border-radius:16px;overflow:hidden;' + 'box-shadow:0 24px 60px rgba(0,0,0,.35)'); // header var head = el('div', 'padding:22px 26px 16px;border-bottom:1px solid #ece9e9'); head.appendChild(el('div', 'font:700 20px/1.2 system-ui,sans-serif', 'Export ChatGPT for Gemini')); head.appendChild(el('div', 'margin-top:6px;color:#706767', 'This fetches your full ChatGPT history and saves it as a file for the ' + 'migration tool. Everything happens in your own browser: nothing is sent anywhere.')); card.appendChild(head); // body (scrolls) var body = el('div', 'padding:20px 26px;overflow-y:auto'); // progress bar ui.barWrap = el('div', 'height:10px;border-radius:99px;background:#efeceb;overflow:hidden;display:none'); ui.bar = el('div', 'height:100%;width:0%;background:#0b7;transition:width .25s ease'); ui.barWrap.appendChild(ui.bar); body.appendChild(ui.barWrap); ui.count = el('div', 'margin-top:8px;color:#706767;font:600 13px/1.4 ui-monospace,monospace;display:none'); body.appendChild(ui.count); // step log ui.log = el('div', 'margin-top:14px;display:flex;flex-direction:column;gap:9px'); body.appendChild(ui.log); card.appendChild(body); // footer var foot = el('div', 'padding:16px 26px;border-top:1px solid #ece9e9;display:flex;gap:10px;' + 'justify-content:flex-end;align-items:center'); ui.hint = el('div', 'margin-right:auto;color:#706767;font-size:13px', ''); ui.cancel = el('button', 'padding:10px 16px;border:1px solid #ded9d9;border-radius:9px;background:#fff;' + 'cursor:pointer;font:600 14px system-ui,sans-serif;color:#1b1818', 'Close'); ui.cancel.addEventListener('click', closeModal); ui.start = el('button', 'padding:10px 18px;border:0;border-radius:9px;background:#0b7;color:#fff;' + 'cursor:pointer;font:600 14px system-ui,sans-serif', 'Start export'); ui.start.addEventListener('click', run); foot.appendChild(ui.hint); foot.appendChild(ui.cancel); foot.appendChild(ui.start); card.appendChild(foot); // small attribution + version line (version confirms which build is loaded) card.appendChild(el('div', 'padding:0 26px 16px;text-align:center;color:#a49c9c;font-size:11px', 'Amgreat Europe BV · v' + VERSION)); back.appendChild(card); back.addEventListener('click', function (e) { if (e.target === back && !busy) closeModal(); }); ui.back = back; return back; } function openModal() { if (ui.back) { document.body.appendChild(ui.back); return; } document.body.appendChild(buildModal()); // intro steps, greyed, so the user sees what will happen before starting ['Check that you are signed in to ChatGPT', 'Fetch your conversations', 'Fetch each conversation in full and save it as a file' ].forEach(function (s) { addStep(s, 'idle'); }); } function closeModal() { if (ui.back && ui.back.parentNode) ui.back.remove(); } // step rows ------------------------------------------------------------- var ICON = { idle: '○', run: '…', ok: '✓', warn: '!', err: '✗' }; var COLOR = { idle: '#b8b2b2', run: '#0b7', ok: '#1a7f4b', warn: '#b45309', err: '#c62f31' }; function addStep(text, state) { var row = el('div', 'display:flex;gap:10px;align-items:flex-start'); var mark = el('div', 'flex:none;width:22px;height:22px;border-radius:50%;text-align:center;' + 'font:700 13px/22px system-ui;color:#fff;background:' + COLOR[state || 'idle'], ICON[state || 'idle']); var label = el('div', 'color:#2a2626;padding-top:1px', text); row.appendChild(mark); row.appendChild(label); row._mark = mark; row._label = label; ui.log.appendChild(row); return row; } function setStep(row, state, text) { if (!row) return; row._mark.textContent = ICON[state]; row._mark.style.background = COLOR[state]; if (text != null) row._label.textContent = text; } // Honest progress: exported vs skipped are counted separately, so the bar can // never imply success that did not happen. function progress(done, failed, total) { ui.barWrap.style.display = 'block'; ui.count.style.display = 'block'; ui.bar.style.width = total ? Math.round((done + failed) / total * 100) + '%' : '0%'; ui.count.textContent = done + ' / ' + total + ' exported' + (failed ? ' · ' + failed + ' skipped' : '') + ' · ' + (delay >= 3000 ? 'slow (ChatGPT is throttling)' : 'normal speed'); } /* ---- resume state: remember which conversations already downloaded ---- */ function loadState() { try { var s = JSON.parse(localStorage.getItem(STATE_KEY) || 'null'); if (s && s.date === stamp() && Array.isArray(s.ids)) return s; } catch (e) {} return { date: stamp(), ids: [], part: 0 }; } function saveState(s) { try { localStorage.setItem(STATE_KEY, JSON.stringify(s)); } catch (e) {} } function clearState() { try { localStorage.removeItem(STATE_KEY); } catch (e) {} } /* ======================= authenticated fetch ========================== */ var token = null; async function refreshToken() { try { var s = await getSession(); if (s && s.accessToken) { token = s.accessToken; return true; } } catch (e) {} return false; } async function getSession() { var r = await fetch('/api/auth/session', { credentials: 'include' }); if (!r.ok) throw new Error('not-logged-in'); var s = await r.json(); if (!s || !s.accessToken) throw new Error('not-logged-in'); return s; } // One request, with rate-limit-aware retries. On 429 we honour ChatGPT's own // Retry-After header when it sends one, otherwise back off exponentially with // jitter — and permanently slow the overall pace, because a 429 means we are // asking too fast, not that this particular conversation is broken. async function api(path) { for (var attempt = 0; ; attempt++) { var r = await fetch(path, { credentials: 'include', headers: { 'Authorization': 'Bearer ' + token, 'Accept': 'application/json' } }); if (r.ok) { hot = 0; streakOk++; if (streakOk >= 10) { faster(); streakOk = 0; } return r.json(); } // A long, throttled run can outlive the session token. Refresh it once // rather than aborting an export that is otherwise going fine. if (r.status === 401) { if (attempt < 2 && await refreshToken()) continue; throw new Error('not-logged-in'); } if ((r.status === 429 || r.status >= 500) && attempt < MAX_RETRY) { if (r.status === 429) { hot++; streakOk = 0; slower(); } var ra = parseFloat(r.headers.get('retry-after') || ''); var waitMs; if (isFinite(ra) && ra > 0) { waitMs = Math.min(ra * 1000, 120000); // trust the server } else if (hot >= REST_AFTER) { // Sustained refusal: the account is in a penalty window. Resting for // several minutes reliably clears it, so wait rather than fail. if (onRest) onRest(REST_MS); waitMs = REST_MS; hot = 0; } else { waitMs = Math.min(6000 + attempt * 4000, 45000); // steady, bounded } await sleep(waitMs + Math.random() * 500); // jitter continue; } throw new Error('api-' + r.status); } } async function listAllIds(row) { var ids = [], offset = 0, total = Infinity; while (offset < total) { var page = await api('/backend-api/conversations?offset=' + offset + '&limit=' + PAGE + '&order=updated'); total = typeof page.total === 'number' ? page.total : 0; var items = page.items || []; for (var i = 0; i < items.length; i++) ids.push(items[i].id); if (!items.length) break; offset += items.length; setStep(row, 'run', 'Fetching your conversations… ' + ids.length + ' found'); await sleep(delay); } return ids; } /* ======================= download ===================================== */ function stamp() { var d = new Date(); return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); } // Download via the extension's service worker (chrome.downloads), which is not // subject to the page's "multiple downloads" block, so every part saves and // shows in Chrome's download tray. Falls back to an click if the // extension messaging is somehow unavailable. Resolves true on success. function viaWorker(url, name) { return new Promise(function (resolve) { try { chrome.runtime.sendMessage({ type: 'download', url: url, filename: name }, function (res) { resolve(!chrome.runtime.lastError && res && res.ok); }); } catch (e) { resolve(false); } }); } // Three routes, most reliable first: // 1. blob URL made here, downloaded by the service worker (no page limits, // no data: size ceiling) // 2. data: URL through the service worker // 3. plain click (works once per page, last resort) async function download(convs, part) { var name = 'chatgpt-export-' + stamp() + (part ? '-part' + part : '') + '.json'; var json = JSON.stringify(convs); var blobUrl = null; try { blobUrl = URL.createObjectURL(new Blob([json], { type: 'application/json' })); } catch (e) {} if (blobUrl) { var ok1 = await viaWorker(blobUrl, name); // keep the blob alive long enough for the download to read it setTimeout(function () { try { URL.revokeObjectURL(blobUrl); } catch (e) {} }, 60000); if (ok1) return true; } try { var dataUrl = 'data:application/json;base64,' + b64(json); if (await viaWorker(dataUrl, name)) return true; } catch (e) {} return anchorFallback(json, name); } function b64(str) { var bytes = new TextEncoder().encode(str), bin = '', CH = 0x8000; for (var i = 0; i < bytes.length; i += CH) { bin += String.fromCharCode.apply(null, bytes.subarray(i, i + CH)); } return btoa(bin); } function anchorFallback(json, name) { try { var url = URL.createObjectURL(new Blob([json], { type: 'application/json' })); var a = el('a'); a.href = url; a.download = name; document.body.appendChild(a); a.click(); a.remove(); setTimeout(function () { URL.revokeObjectURL(url); }, 4000); return true; } catch (e) { return false; } } /* ======================= main flow ==================================== */ async function run() { if (busy) return; busy = true; ui.start.style.display = 'none'; ui.cancel.textContent = 'Close'; ui.hint.textContent = 'Working… keep this tab open.'; ui.log.innerHTML = ''; var sLogin = addStep('Check that you are signed in to ChatGPT', 'run'); var sList = addStep('Fetch your conversations', 'idle'); var sFetch = addStep('Fetch each conversation in full', 'idle'); try { var session = await getSession(); token = session.accessToken; var who = session.user && session.user.email ? session.user.email : 'your account'; setStep(sLogin, 'ok', 'Signed in as ' + who); setStep(sList, 'run', 'Fetching your conversations…'); var ids = await listAllIds(sList); if (!ids.length) { setStep(sList, 'warn', 'No conversations found on this account'); ui.hint.textContent = 'Are you on the right ChatGPT account?'; finish(); return; } setStep(sList, 'ok', ids.length + ' conversations found'); // Resume: skip conversations already downloaded in an earlier attempt today. var state = loadState(); var already = {}; state.ids.forEach(function (id) { already[id] = 1; }); var todo = ids.filter(function (id) { return !already[id]; }); if (state.ids.length) { addStep('Resuming: ' + state.ids.length + ' conversations were already exported earlier today', 'ok'); } if (!todo.length) { setStep(sFetch, 'ok', 'Everything was already exported earlier today'); ui.hint.textContent = 'Nothing left to do.'; finish(); return; } var batching = BATCH > 0 && todo.length > BATCH; setStep(sFetch, 'run', batching ? 'Fetching and saving in batches of ' + BATCH + '…' : 'Fetching each conversation in full…'); progress(0, 0, todo.length); var buf = [], bufIds = [], done = 0, failed = 0, deadStreak = 0, stopped = false; var part = state.part || 0; var startPart = part; // fixed: flush() mutates state.part, so compare to this var lastFlush = Date.now(); var runStart = Date.now(); var restRow = null; var downloadTrouble = false; // When the fetcher decides to rest, save what we have first and say so. onRest = function (ms) { if (restRow) setStep(restRow, 'ok', 'Rest finished, continuing'); restRow = addStep('ChatGPT is limiting this account. Pausing ' + Math.round(ms / 60000) + ' minutes, then continuing automatically. ' + 'Files saved so far are already in your Downloads.', 'warn'); ui.hint.textContent = 'Paused to let ChatGPT recover. Keep this tab open.'; }; // Flush whatever is buffered to a file, and remember it as done. async function flush() { if (!buf.length) return; part++; var ok = await download(buf, batching || part > 1 ? part : 0); addStep(ok ? 'Batch ' + part + ' downloaded (' + buf.length + ' conversations)' : 'Batch ' + part + ' could not be saved. Check Chrome\'s download ' + 'settings, then run again: nothing is lost.', ok ? 'ok' : 'err'); if (!ok) downloadTrouble = true; if (ok) { state.ids = state.ids.concat(bufIds); state.part = part; saveState(state); } buf = []; bufIds = []; lastFlush = Date.now(); await sleep(400); } for (var i = 0; i < todo.length; i++) { try { buf.push(await api('/backend-api/conversation/' + todo[i])); bufIds.push(todo[i]); done++; deadStreak = 0; } catch (e) { if (e && e.message === 'not-logged-in') throw e; failed++; deadStreak++; } progress(done, failed, todo.length); // The fetcher already rests through rate limits, so a dead streak here // means something else is wrong. Absolute time cap as a final backstop. if (deadStreak >= 25 || Date.now() - runStart > MAX_RUN_MS) { stopped = true; break; } // Save a file when the batch is full, and also whenever it has been a // while since the last save. The time rule matters when ChatGPT is // throttling: you still get files early instead of waiting for 25. // The very first save happens after just a few conversations, so a // broken download surfaces in seconds rather than after 25. var proofRun = batching && part === startPart && done >= 3; if (buf.length && (buf.length >= BATCH || proofRun || Date.now() - lastFlush > FLUSH_MS)) { await flush(); } await sleep(delay); } await flush(); if (stopped) { setStep(sFetch, 'warn', done + ' exported before ChatGPT started refusing requests'); addStep('ChatGPT is rate-limiting this account. What you see above is saved. ' + 'Wait about 30 minutes, then press Start export again: it continues where it stopped.', 'warn'); ui.hint.textContent = 'Paused by ChatGPT. Your files are safe.'; finish(); return; } if (!done && !state.ids.length) throw new Error('all-failed'); setStep(sFetch, 'ok', done + ' conversations fetched' + (failed ? ' (' + failed + ' skipped)' : '')); addStep(part <= 1 ? 'File downloaded to your Downloads folder' : part + ' files downloaded to your Downloads folder', 'ok'); addStep('Next step: open the migration tool and drag ' + (part <= 1 ? 'the file' : 'all ' + part + ' files together') + ' into it.', 'run'); ui.hint.textContent = 'Done. You can close this window.'; clearState(); } catch (e) { var msg = e && e.message === 'not-logged-in' ? 'You are not signed in to ChatGPT. Sign in and try again.' : 'Something went wrong. Close this window and try again.'; addStep(msg, 'err'); ui.hint.textContent = ''; } finish(); } function finish() { busy = false; launch.textContent = 'Export for Gemini'; } })();