/* 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';
}
})();