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.
This commit is contained in:
+14
@@ -0,0 +1,14 @@
|
||||
# macOS
|
||||
.DS_Store
|
||||
._*
|
||||
|
||||
# Local-only Claude config
|
||||
.claude/settings.local.json
|
||||
|
||||
# Dependencies — referenced by their manifests, never committed
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
*.zip
|
||||
*.log
|
||||
@@ -0,0 +1,74 @@
|
||||
# ChatGPT export extension (Amgreat)
|
||||
|
||||
A small Chrome extension that exports a user's **full** ChatGPT history to JSON
|
||||
files — no paywall, no limits, nothing leaves the browser. Its output has the
|
||||
same shape as ChatGPT's official `conversations.json`, so it drops straight into
|
||||
the migration tool.
|
||||
|
||||
Current version: **1.4**, shown in the footer of the extension's own window, so
|
||||
you can tell which build a machine actually has loaded.
|
||||
|
||||
## How it works
|
||||
|
||||
Runs as a content script on `chatgpt.com`, in the page's own origin, so it uses
|
||||
the user's existing logged-in session. It calls ChatGPT's own internal API
|
||||
(`/backend-api/conversations` to list, `/backend-api/conversation/{id}` to fetch
|
||||
each in full), assembles them into a JSON array, and offers it as a download.
|
||||
That is exactly what the paid extensions do — the paywall is artificial.
|
||||
|
||||
A green **"Export for Gemini"** button appears bottom-right on ChatGPT. Click it
|
||||
and a window opens showing each step and a progress bar. Conversations download
|
||||
in **batches of 25**, one file per batch, named
|
||||
`chatgpt-export-<date>-partN.json`. Feed all of them to the migration tool at
|
||||
once.
|
||||
|
||||
Two details worth knowing:
|
||||
|
||||
- **Downloads go through the service worker.** Chrome blocks a page from
|
||||
starting more than one download, so `background.js` performs them via
|
||||
`chrome.downloads`. The blob URL has to be created in the content script,
|
||||
because service workers have no `URL.createObjectURL`.
|
||||
- **The pace adapts.** It starts at 1.5s between conversations, speeds up while
|
||||
requests succeed, and backs off 1.3x on every HTTP 429, honouring
|
||||
`Retry-After` when ChatGPT sends one. After sustained throttling it takes a
|
||||
five-minute rest rather than hammering. Progress is saved to `localStorage`,
|
||||
so an interrupted run resumes at the right batch instead of starting over.
|
||||
|
||||
Free accounts throttle hard at roughly 33 conversations. Paid accounts run at
|
||||
about 21 conversations a minute.
|
||||
|
||||
## Try it yourself (unpacked, 1 minute)
|
||||
|
||||
1. Chrome → `chrome://extensions` → toggle **Developer mode** (top right) on.
|
||||
2. **Load unpacked** → pick this folder.
|
||||
3. Open/refresh **chatgpt.com** → the green button is bottom-right → click it.
|
||||
4. Leave the window open. Files land in Downloads as each batch completes.
|
||||
|
||||
## Rolling it out to 30 people
|
||||
|
||||
Don't ask non-savvy staff to enable developer mode. Two clean options, both
|
||||
using tools you already have:
|
||||
|
||||
- **Force-install via Google Admin Console (recommended).** You manage Chrome
|
||||
through Workspace already. Zip this folder, host it (or publish it *unlisted*
|
||||
to the Chrome Web Store), then Admin Console → **Devices → Chrome → Apps &
|
||||
extensions** → add it as **Force install** for the org. It appears for
|
||||
everyone automatically, no clicks, no developer mode.
|
||||
- **Unlisted Chrome Web Store item.** Publish once (one-time $5 dev fee), share
|
||||
the private link; users click "Add to Chrome". Simpler than self-hosting, and
|
||||
it auto-updates.
|
||||
|
||||
Either way the extension stays yours and free of limits.
|
||||
|
||||
## Notes / limits
|
||||
|
||||
- **Text only.** Conversations come across in full; image/file attachments do
|
||||
not (same as the migration tool).
|
||||
- **The user must be logged in to ChatGPT** in that browser; the window says so
|
||||
if they are not. The session token refreshes automatically if it expires
|
||||
part-way through a long run.
|
||||
- A large history takes a while. It fetches one chat at a time on purpose, to
|
||||
stay under ChatGPT's rate limits; half an hour is normal for a few hundred
|
||||
conversations.
|
||||
- If ChatGPT changes its internal API, the two endpoint paths in `content.js`
|
||||
may need updating. They are the only ChatGPT-specific parts.
|
||||
@@ -0,0 +1,26 @@
|
||||
/* Service worker: performs downloads via the extension's own downloads API.
|
||||
*
|
||||
* A content script (page context) may auto-download only ONE file before Chrome
|
||||
* blocks the rest behind a "multiple downloads" guard. Routing each batch
|
||||
* through chrome.downloads here sidesteps that: every part downloads
|
||||
* immediately and appears in Chrome's download tray.
|
||||
*
|
||||
* Service workers cannot call URL.createObjectURL, so the content script makes
|
||||
* the blob URL and sends it here. A data: URL is accepted as a fallback for the
|
||||
* same reason, though large ones are less reliable — hence blob first.
|
||||
*/
|
||||
chrome.runtime.onMessage.addListener(function (msg, sender, sendResponse) {
|
||||
if (!msg || msg.type !== 'download' || !msg.url) return;
|
||||
try {
|
||||
chrome.downloads.download(
|
||||
{ url: msg.url, filename: msg.filename, saveAs: false, conflictAction: 'uniquify' },
|
||||
function (id) {
|
||||
var err = chrome.runtime.lastError;
|
||||
sendResponse({ ok: !err && id != null, error: err && err.message });
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
sendResponse({ ok: false, error: String(e) });
|
||||
}
|
||||
return true; // keep the message channel open for the async sendResponse
|
||||
});
|
||||
+471
@@ -0,0 +1,471 @@
|
||||
/* 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';
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "ChatGPT export for Gemini (Amgreat)",
|
||||
"version": "1.4",
|
||||
"description": "Exports your full ChatGPT history to a JSON file for the Amgreat Gemini migration tool. Runs entirely in your own browser; nothing is sent anywhere.",
|
||||
"permissions": ["downloads"],
|
||||
"background": { "service_worker": "background.js" },
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["https://chatgpt.com/*", "https://chat.openai.com/*"],
|
||||
"js": ["content.js"],
|
||||
"run_at": "document_idle"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user