ChatGPT Bulk Deleter

Delete all chats with visible log that shows while running and hides when done. Auto remounts UI on changes.

// ==UserScript==
// @name         ChatGPT Bulk Deleter
// @namespace    http://tampermonkey.net/
// @version      0.1
// @description  Delete all chats with visible log that shows while running and hides when done. Auto remounts UI on changes.
// @author       Kes
// @match        https://chatgpt.com/*
// @match        https://chat.openai.com/*
// @grant        GM_addStyle
// @license      MIT
// @noframes
// @run-at       document-idle
// ==/UserScript==

(function () {
  'use strict';

  // single supervisor object that can remount as needed
  window.__BD_SUP__ = window.__BD_SUP__ || {
    mounted: false,
    running: false,
    armed: false,
    logStore: [],
    ensureTimer: null,
    lastUrl: location.href
  };
  const S = window.__BD_SUP__;

  // ---------- UI ----------
  GM_addStyle(`
    #bd-btn{position:fixed;top:12px;left:12px;z-index:2147483647;padding:10px 14px;border:none;border-radius:10px;background:#ef4444;color:#fff;font-size:14px;font-weight:600;cursor:pointer;box-shadow:0 8px 24px rgba(0,0,0,.35)}
    #bd-btn[disabled]{opacity:.6;cursor:not-allowed}
    #bd-log{position:fixed;bottom:12px;left:12px;width:460px;max-height:55vh;overflow:auto;border:1px solid #2e2e2e;background:#111;color:#ddd;border-radius:10px;z-index:2147483647;box-shadow:0 8px 24px rgba(0,0,0,.35);font:12px ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;display:none}
    #bd-log header{display:flex;justify-content:space-between;align-items:center;padding:8px 10px;border-bottom:1px solid #2e2e2e;background:#181818;border-top-left-radius:10px;border-top-right-radius:10px}
    #bd-log header b{font-size:12px}
    #bd-log header button{background:#333;border:1px solid #444;color:#eee;border-radius:6px;padding:4px 8px;cursor:pointer}
    #bd-log pre{white-space:pre-wrap;margin:0;padding:10px;line-height:1.35}
  `);

  const sleep = ms => new Promise(r => setTimeout(r, ms));
  const base = () => window.location.origin;
  const get = sel => document.querySelector(sel);

  function getCookie(name){
    return document.cookie.split('; ').find(r => r.startsWith(name + '='))?.split('=')[1];
  }

  function showLog(show){
    const el = get('#bd-log');
    if (el) el.style.display = show ? 'block' : 'none';
  }

  function log(...a){
    const line = a.map(x => typeof x === 'string' ? x : JSON.stringify(x)).join(' ');
    S.logStore.push(line);
    const pre = get('#bd-pre');
    if (pre) pre.textContent = S.logStore.join('\n');
    console.log('[BulkDeleter]', ...a);
  }

  async function getBearer(){
    try{
      const res = await fetch(`${base()}/api/auth/session`, { credentials:'include', cache:'no-store' });
      if (!res.ok) return null;
      const j = await res.json();
      if (j && j.accessToken){
        log('token ok');
        return j.accessToken;
      }
    }catch(e){}
    log('no bearer');
    return null;
  }

  async function http(method, url, body, bearer){
    const csrfCookie = getCookie('csrfToken');
    const csrf = csrfCookie ? decodeURIComponent(csrfCookie) : '';
    const did  = getCookie('oai-did') || '';

    const headers = {
      'Accept': 'application/json',
      'Content-Type': 'application/json',
      'X-CSRF-Token': csrf,
      'OAI-Device-Id': did
    };
    if (bearer) headers['Authorization'] = `Bearer ${bearer}`;

    const opts = { method, headers, credentials: 'include', mode: 'same-origin' };
    if (body !== undefined) opts.body = JSON.stringify(body);

    try{
      const res = await fetch(url, opts);
      const text = await res.text().catch(()=> '');
      const ok = res.ok || res.status === 204 || res.status === 404;
      return { ok, status: res.status, text, url };
    }catch(e){
      return { ok:false, status:0, text:String(e), url };
    }
  }

  async function listPage(offset, limit, bearer){
    const tries = [
      `${base()}/backend-api/conversations?offset=${offset}&limit=${limit}&order=updated`,
      `${base()}/api/conversations?offset=${offset}&limit=${limit}&order=updated`,
      `${base()}/backend-api/conversations?cursor=${offset}&limit=${limit}&order=updated`
    ];
    for (const u of tries){
      try{
        const res = await fetch(u, { credentials:'include', cache:'no-store', headers: bearer ? { Authorization: `Bearer ${bearer}` } : undefined });
        if (!res.ok) continue;
        const j = await res.json();
        const items = j.items || j.conversations || j.data || [];
        const ids = [];
        for (const it of items){
          const id = it.id || it.conversation_id || it.conversationId;
          if (id) ids.push(id);
        }
        const total = Number(j.total ?? j.total_conversations ?? j.count ?? ids.length);
        const hasMore = Boolean(j.has_more ?? (offset + ids.length < total));
        return { ids, total, hasMore };
      }catch(e){}
    }
    return { ids: [], total: 0, hasMore: false };
  }

  async function listAllIds(bearer){
    const page = 100;
    let offset = 0;
    const all = new Set();

    const first = await listPage(0, page, bearer);
    first.ids.forEach(id => all.add(id));
    log(`found ${all.size} on first page total ${first.total || all.size}`);

    while (first.hasMore && all.size < (first.total || 999999)){
      offset += page;
      const next = await listPage(offset, page, bearer);
      next.ids.forEach(id => all.add(id));
      log(`page offset ${offset} added ${next.ids.length}`);
      if (!next.hasMore || next.ids.length === 0) break;
      await sleep(120);
    }
    return Array.from(all);
  }

  async function delSoftHard(id, bearer){
    const urls = [
      `${base()}/backend-api/conversation/${id}`,
      `${base()}/backend-api/conversations/${id}`,
      `${base()}/api/conversations/${id}`,
      `${base()}/api/conversation/${id}`
    ];
    for (const u of urls){
      const r = await http('PATCH', u, { is_visible:false }, bearer);
      log(r.ok ? `soft ok ${id} ${r.status}` : `soft fail ${id} ${r.status} ${u}`);
      if (r.ok) return true;
    }
    {
      const r = await http('POST', `${base()}/backend-api/conversations/delete`, { conversation_ids:[id] }, bearer);
      log(r.ok ? `bulk soft ok ${id} ${r.status}` : `bulk soft fail ${id} ${r.status}`);
      if (r.ok) return true;
    }
    for (const u of urls){
      const r = await http('DELETE', u, undefined, bearer);
      log(r.ok ? `hard ok ${id} ${r.status}` : `hard fail ${id} ${r.status} ${u}`);
      if (r.ok) return true;
    }
    return false;
  }

  async function delGraphQL(id, bearer){
    try{
      const payloads = [
        {
          operationName: 'deleteConversation',
          variables: { conversationId: id },
          query: 'mutation deleteConversation($conversationId:ID!){deleteConversation(conversationId:$conversationId){id}}'
        },
        {
          operationName: 'DeleteConversationMutation',
          variables: { id },
          query: 'mutation DeleteConversationMutation($id:ID!){conversationDelete(id:$id){success}}'
        }
      ];
      for (const p of payloads){
        const r = await http('POST', `${base()}/backend-api/graphql`, p, bearer);
        log(r.ok ? `gql ok ${id} ${r.status}` : `gql fail ${id} ${r.status}`);
        if (r.ok) return true;
      }
    }catch(e){
      log('gql error ' + e);
    }
    return false;
  }

  async function deleteOne(id, bearer){
    if (await delSoftHard(id, bearer)) return true;
    if (await delGraphQL(id, bearer)) return true;
    return false;
  }

  // ---------- Mount and resilience ----------
  function mountUI(){
    // create or refresh button
    let btn = get('#bd-btn');
    if (!btn){
      btn = document.createElement('button');
      btn.id = 'bd-btn';
      btn.textContent = 'Delete All Chats';
      btn.addEventListener('click', onButtonClick, { passive: true });
      document.body.appendChild(btn);
    }
    // create or refresh log
    let box = get('#bd-log');
    if (!box){
      box = document.createElement('div');
      box.id = 'bd-log';
      box.innerHTML = `
        <header>
          <b>Bulk Deleter Log</b>
          <div>
            <button id="bd-copy">Copy</button>
            <button id="bd-clear">Clear</button>
          </div>
        </header>
        <pre id="bd-pre"></pre>
      `;
      document.body.appendChild(box);
    }
    // hook actions
    const clearBtn = get('#bd-clear');
    const copyBtn  = get('#bd-copy');
    if (clearBtn && !clearBtn.__bdHooked){
      clearBtn.__bdHooked = true;
      clearBtn.onclick = () => { S.logStore.length = 0; const pre = get('#bd-pre'); if (pre) pre.textContent = ''; };
    }
    if (copyBtn && !copyBtn.__bdHooked){
      copyBtn.__bdHooked = true;
      copyBtn.onclick = () => navigator.clipboard.writeText(S.logStore.join('\n')).catch(()=>{});
    }

    S.mounted = true;
  }

  function ensureUI(){
    if (!document.body) return;
    if (!get('#bd-btn') || !get('#bd-log')) mountUI();
  }

  function hookSPARouteChanges(){
    // detect URL changes and try remount
    const origPush = history.pushState;
    const origReplace = history.replaceState;
    function onChange(){
      if (S.lastUrl !== location.href){
        S.lastUrl = location.href;
        setTimeout(ensureUI, 50);
        setTimeout(ensureUI, 300);
        setTimeout(ensureUI, 1200);
      }
    }
    history.pushState = function(...args){ const r = origPush.apply(this, args); onChange(); return r; };
    history.replaceState = function(...args){ const r = origReplace.apply(this, args); onChange(); return r; };
    window.addEventListener('popstate', onChange);
    new MutationObserver(() => ensureUI()).observe(document.documentElement, { childList: true, subtree: true });
  }

  // rescue hotkey: Ctrl Alt D remounts, Shift L D toggles log
  window.addEventListener('keydown', e => {
    if (e.ctrlKey && e.altKey && e.key.toLowerCase() === 'd'){ ensureUI(); }
    if (e.shiftKey && e.key.toLowerCase() === 'd'){
      const el = get('#bd-log');
      if (el) el.style.display = el.style.display === 'none' ? 'block' : 'none';
    }
  });

  // button logic with two click arm
  async function onButtonClick(){
    if (S.running) return;
    const btn = get('#bd-btn');

    // first click scans and arms
    if (!S.armed){
      S.running = true;
      showLog(true);
      btn.disabled = true;
      log('starting');
      const bearer = await getBearer();
      btn.textContent = 'Scanning...';
      const ids = await listAllIds(bearer);
      window.__BD_IDS__ = ids;
      btn.disabled = false;
      S.running = false;
      showLog(false);

      if (!ids.length){
        btn.textContent = 'Delete All Chats';
        log('no ids found');
        return;
      }
      S.armed = true;
      btn.textContent = `Click again to delete ${ids.length} chats`;
      log(`armed with ${ids.length} ids`);
      return;
    }

    // second click executes
    if (S.armed){
      S.armed = false;
      S.running = true;
      showLog(true);
      const bearer = await getBearer();
      const ids = Array.isArray(window.__BD_IDS__) ? window.__BD_IDS__ : [];
      btn.disabled = true;

      let ok = 0, fail = 0;
      for (let i = 0; i < ids.length; i++){
        const id = ids[i];
        btn.textContent = `Deleting ${i + 1}/${ids.length}...`;
        const good = await deleteOne(id, bearer);
        if (good) ok++; else fail++;
        await sleep(120);
      }

      log(`done ok ${ok} fail ${fail}`);
      btn.textContent = 'Delete All Chats';
      btn.disabled = false;
      S.running = false;
      showLog(false);
      if (ok > 0) setTimeout(() => location.replace('https://chatgpt.com/?new'), 1200);
    }
  }

  // wait for body then mount and keep it alive
  function boot(){
    if (!document.body){ requestAnimationFrame(boot); return; }
    ensureUI();
    hookSPARouteChanges();
    if (!S.ensureTimer){
      S.ensureTimer = setInterval(ensureUI, 1500);
    }
  }

  boot();
})();