Files
chatgpt-gemini-exporter/content.js
T
Vincent Rozenberg 60da5a53e8 ChatGPT to Gemini exporter, v1.4
A Chrome MV3 extension that exports a full ChatGPT history to JSON in the
shape of ChatGPT's own conversations.json, for the Amgreat Gemini migration
tool. Everything runs in the user's browser against their own session.

Batches of 25 per file, downloaded through the service worker because Chrome
blocks multiple page-initiated downloads. Adaptive pacing with 429 backoff,
Retry-After support, token refresh and localStorage resume.
2026-09-04 00:44:35 +02:00

472 lines
20 KiB
JavaScript

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