Last active
January 29, 2026 13:32
-
-
Save sethwebster/1c9525a8378cbc2f7a90fd122b1f21a1 to your computer and use it in GitHub Desktop.
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
| (async () => { | |
| // ========================= | |
| // CONFIG | |
| // ========================= | |
| const CFG = { | |
| // Collection | |
| sidebarSelector: 'nav[aria-label="Chat history"]', | |
| chatLinkSelector: 'a[href^="/c/"]', | |
| scrollStepRatio: 0.85, // scroll by 85% of viewport height per step | |
| scrollDelayMs: 300, | |
| stableRoundsToStop: 8, // stop after N scrolls with no new IDs | |
| // Archiving | |
| concurrency: 3, // keep low to avoid rate limiting | |
| perRequestDelayMs: 80, // tiny pacing between launches | |
| retryCount: 3, | |
| retryBackoffMs: 400, | |
| dryRun: false, // set true to only collect IDs | |
| debug: true, | |
| }; | |
| const log = (...a) => CFG.debug && console.log("[bulk-archive]", ...a); | |
| const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); | |
| // ========================= | |
| // TOKEN (read from client-bootstrap) | |
| // ========================= | |
| const bootstrapEl = document.getElementById("client-bootstrap"); | |
| if (!bootstrapEl) throw new Error("Missing #client-bootstrap (cannot read session access token)."); | |
| let accessToken; | |
| try { | |
| const bootstrap = JSON.parse(bootstrapEl.textContent); | |
| accessToken = bootstrap?.session?.accessToken; | |
| } catch (e) { | |
| throw new Error("Failed to parse #client-bootstrap JSON."); | |
| } | |
| if (!accessToken) throw new Error("No session.accessToken found in #client-bootstrap."); | |
| // ========================= | |
| // SIDEBAR + ID COLLECTION | |
| // ========================= | |
| const sidebar = document.querySelector(CFG.sidebarSelector); | |
| if (!sidebar) throw new Error(`Sidebar not found: ${CFG.sidebarSelector}`); | |
| const idFromHref = (href) => { | |
| // href can be "/c/<id>" or full URL. | |
| const m = String(href).match(/\/c\/([0-9a-f-]{20,})/i); | |
| return m ? m[1] : null; | |
| }; | |
| const collectVisibleIds = () => { | |
| const links = Array.from(sidebar.querySelectorAll(CFG.chatLinkSelector)); | |
| const ids = []; | |
| for (const a of links) { | |
| const id = idFromHref(a.getAttribute("href") || a.href); | |
| if (id) ids.push(id); | |
| } | |
| return ids; | |
| }; | |
| const allIds = new Set(); | |
| let stableRounds = 0; | |
| log("Collecting chat IDs by scrolling… (make sure you are filtered to outside projects)"); | |
| while (stableRounds < CFG.stableRoundsToStop) { | |
| const before = allIds.size; | |
| for (const id of collectVisibleIds()) allIds.add(id); | |
| const after = allIds.size; | |
| if (after === before) stableRounds++; | |
| else stableRounds = 0; | |
| // Scroll down | |
| const prevTop = sidebar.scrollTop; | |
| sidebar.scrollTop = prevTop + Math.floor(sidebar.clientHeight * CFG.scrollStepRatio); | |
| await sleep(CFG.scrollDelayMs); | |
| // If scrolling no longer moves and we're stable, we’ll stop naturally | |
| } | |
| const ids = Array.from(allIds); | |
| log(`Collected ${ids.length} chat IDs.`); | |
| if (CFG.dryRun) { | |
| console.log(ids); | |
| log("Dry run enabled; not archiving."); | |
| return; | |
| } | |
| // ========================= | |
| // ARCHIVE FUNCTION | |
| // ========================= | |
| const archiveOne = async (id) => { | |
| const url = `https://chatgpt.com/backend-api/conversation/${id}`; | |
| const body = JSON.stringify({ is_archived: true }); | |
| for (let attempt = 0; attempt <= CFG.retryCount; attempt++) { | |
| try { | |
| const res = await fetch(url, { | |
| method: "PATCH", | |
| headers: { | |
| "Content-Type": "application/json", | |
| "Accept": "*/*", | |
| "Authorization": `Bearer ${accessToken}`, | |
| }, | |
| body, | |
| credentials: "include", | |
| }); | |
| if (res.ok) return { id, ok: true, status: res.status }; | |
| // Retry on rate limits / transient errors | |
| const retryable = res.status === 429 || (res.status >= 500 && res.status <= 599); | |
| if (!retryable || attempt === CFG.retryCount) { | |
| const text = await res.text().catch(() => ""); | |
| return { id, ok: false, status: res.status, error: text.slice(0, 200) }; | |
| } | |
| await sleep(CFG.retryBackoffMs * (attempt + 1)); | |
| } catch (e) { | |
| if (attempt === CFG.retryCount) { | |
| return { id, ok: false, status: 0, error: String(e) }; | |
| } | |
| await sleep(CFG.retryBackoffMs * (attempt + 1)); | |
| } | |
| } | |
| return { id, ok: false, status: 0, error: "unknown" }; | |
| }; | |
| // ========================= | |
| // WORK QUEUE WITH LIMITED CONCURRENCY | |
| // ========================= | |
| let idx = 0; | |
| const results = []; | |
| const workers = Array.from({ length: CFG.concurrency }, (_, w) => (async () => { | |
| while (idx < ids.length) { | |
| const myIdx = idx++; | |
| const id = ids[myIdx]; | |
| const r = await archiveOne(id); | |
| results.push(r); | |
| if (r.ok) log(`✅ ${myIdx + 1}/${ids.length} archived ${id}`); | |
| else log(`❌ ${myIdx + 1}/${ids.length} failed ${id} (status ${r.status})`, r.error || ""); | |
| await sleep(CFG.perRequestDelayMs); | |
| } | |
| })()); | |
| await Promise.all(workers); | |
| const ok = results.filter(r => r.ok).length; | |
| const bad = results.length - ok; | |
| log(`Done. Success: ${ok}, Failed: ${bad}`); | |
| // Print failures for easy re-run | |
| const failedIds = results.filter(r => !r.ok).map(r => r.id); | |
| if (failedIds.length) { | |
| console.warn("Failed IDs (copy to retry):", failedIds); | |
| } | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment