侧栏一键删除;补全 Account/Device 头 + 401/403 重试 + API失败降级DOM
// ==UserScript==
// @name ChatGPT quick delete
// @namespace http://tampermonkey.net/
// @version 1.0
// @description 侧栏一键删除;补全 Account/Device 头 + 401/403 重试 + API失败降级DOM
// @match https://chatgpt.com/*
// @grant none
// @license MIT
// ==/UserScript==
(function () {
'use strict';
const STYLE_ID = 'tm-chatgpt-qdel-style';
const BTN_CLS = 'tm-qdel-btn';
const MARK = 'tmQdel';
const deletedIds = new Set();
let token = null;
let accountId = null;
let tokenPromise = null;
function ensureStyle() {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement('style');
style.id = STYLE_ID;
style.textContent = `
a[href*="/c/"].${BTN_CLS}-host {
position: relative !important;
padding-right: 28px !important;
}
.${BTN_CLS} {
position: absolute; right: 6px; top: 50%;
transform: translateY(-50%); z-index: 5;
width: 22px; height: 22px; line-height: 20px;
text-align: center; border-radius: 6px;
color: #f66; cursor: pointer; font-size: 16px;
opacity: 0; user-select: none;
}
a[href*="/c/"].${BTN_CLS}-host:hover .${BTN_CLS},
.${BTN_CLS}:hover {
opacity: 1; background: rgba(255,80,80,.12);
}
`;
document.documentElement.appendChild(style);
}
function deviceId() {
try { return localStorage.getItem('oai-did') || ''; } catch { return ''; }
}
function apiHeaders(t) {
const h = {
Authorization: `Bearer ${t}`,
'Content-Type': 'application/json',
Accept: 'application/json',
'OAI-Language': (navigator.language || 'en-US'),
};
const did = deviceId();
if (did) h['OAI-Device-Id'] = did;
if (accountId) h['ChatGPT-Account-ID'] = accountId;
return h;
}
// 从官网真实请求里偷 token / accountId(比 session 更贴近页面)
const _fetch = window.fetch;
window.fetch = function (...args) {
try {
const init = args[1] || {};
const raw = init.headers;
const get = (k) => {
if (!raw) return '';
if (typeof raw.get === 'function') return raw.get(k) || raw.get(k.toLowerCase()) || '';
return raw[k] || raw[k.toLowerCase()] || '';
};
const auth = get('Authorization') || get('authorization');
if (auth.startsWith('Bearer ')) token = auth.slice(7);
const acc = get('ChatGPT-Account-ID') || get('chatgpt-account-id');
if (acc) accountId = acc;
} catch {}
return _fetch.apply(this, args);
};
async function getAccessToken(force = false) {
if (token && !force) return token;
if (!tokenPromise || force) {
tokenPromise = _fetch('/api/auth/session', { credentials: 'include' })
.then(r => r.json())
.then(j => {
if (!j?.accessToken) throw new Error('no accessToken');
token = j.accessToken;
accountId = j.account?.id || j.user?.id || accountId || null;
return token;
})
.finally(() => { tokenPromise = null; });
}
return tokenPromise;
}
getAccessToken().catch(() => {});
function getConvId(a) {
const m = (a.getAttribute('href') || '').match(/\/c\/([^/?#]+)/);
return m ? m[1] : null;
}
function isSidebarChatLink(a) {
if (!(a instanceof HTMLAnchorElement)) return false;
if (!getConvId(a)) return false;
return !!(a.closest('nav') || a.closest('aside') || a.closest('[class*="sidebar"]'));
}
async function deleteChatApi(id) {
const once = async (force) => {
const t = await getAccessToken(force);
const r = await _fetch(`/backend-api/conversation/${id}`, {
method: 'PATCH',
credentials: 'include',
headers: apiHeaders(t),
body: JSON.stringify({ is_visible: false }),
});
const text = await r.text().catch(() => '');
let body = {};
try { body = text ? JSON.parse(text) : {}; } catch { body = { raw: text }; }
return { r, body };
};
let { r, body } = await once(false);
if (r.status === 401 || r.status === 403) {
({ r, body } = await once(true));
}
if (!r.ok) {
const msg = body?.detail || body?.error || body?.message || JSON.stringify(body).slice(0, 200);
throw new Error(`delete ${r.status}: ${msg}`);
}
return body;
}
/** API 失败时走原生菜单删除(慢但稳) */
async function deleteChatDom(a) {
const row = a.closest('div') || a.parentElement;
if (!row) throw new Error('no row');
row.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
row.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
const more =
row.querySelector('button[aria-haspopup="menu"]') ||
row.querySelector('button[id*="radix"]') ||
[...row.querySelectorAll('button')].find(b =>
/more|选项|選項|オプション/i.test(b.getAttribute('aria-label') || '')
);
if (!more) throw new Error('no more button');
more.click();
await new Promise(r => setTimeout(r, 120));
const item = [...document.querySelectorAll('[role="menuitem"], button, div[role="option"]')]
.find(el => /delete|删除|刪除|削除/i.test((el.textContent || '').trim()));
if (!item) throw new Error('no delete item');
item.click();
await new Promise(r => setTimeout(r, 120));
const confirm = [...document.querySelectorAll('button')]
.find(b => /delete|删除|刪除|削除/i.test((b.textContent || '').trim())
&& b.closest('[role="dialog"], [data-state="open"], form'));
if (confirm) confirm.click();
}
async function deleteChat(id, a) {
try {
return await deleteChatApi(id);
} catch (e) {
console.warn('[qdel] api fail, fallback dom', e);
await deleteChatDom(a);
}
}
function findChatRow(a) {
let el = a;
while (el.parentElement) {
const p = el.parentElement;
if (p === document.body || p === document.documentElement ||
p.matches('nav, aside, main, [role="navigation"]')) break;
const links = [...p.querySelectorAll('a[href*="/c/"]')].filter(getConvId);
if (links.some(x => x !== a)) return el;
el = p;
}
return el;
}
function hideChatRow(a) {
const row = findChatRow(a);
row.style.setProperty('display', 'none', 'important');
row.setAttribute('data-tm-qdel-hidden', '1');
return row;
}
function showChatRow(row) {
if (!row) return;
row.style.removeProperty('display');
row.removeAttribute('data-tm-qdel-hidden');
}
function goHomeSoft() {
const candidates = [
...document.querySelectorAll('nav a[href="/"], aside a[href="/"], a[href="/"]'),
...document.querySelectorAll('nav button, aside button, button'),
];
const btn = candidates.find(el => {
const tx = (el.getAttribute('aria-label') || el.textContent || '').trim();
return el.matches('a[href="/"]') || /new chat|新聊天|新對話|新しいチャット/i.test(tx);
});
if (btn) { btn.click(); return; }
location.assign('https://chatgpt.com/');
}
function attach(a) {
const id = getConvId(a);
if (!id) return;
if (deletedIds.has(id)) { hideChatRow(a); return; }
if (a.dataset[MARK]) return;
a.dataset[MARK] = '1';
a.classList.add(`${BTN_CLS}-host`);
const btn = document.createElement('span');
btn.className = BTN_CLS;
btn.textContent = '×';
btn.title = 'Delete chat';
btn.addEventListener('click', e => {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
if (btn.dataset.busy) return;
btn.dataset.busy = '1';
const viewing = location.pathname.includes(`/c/${id}`);
deletedIds.add(id);
const row = hideChatRow(a);
if (viewing) goHomeSoft();
deleteChat(id, a)
.catch(err => {
console.error('[qdel]', err);
deletedIds.delete(id);
showChatRow(row);
a.dataset[MARK] = '';
alert('删除失败: ' + (err?.message || err));
})
.finally(() => { delete btn.dataset.busy; });
});
a.appendChild(btn);
}
function scan() {
ensureStyle();
document.querySelectorAll('a[href*="/c/"]').forEach(a => {
if (isSidebarChatLink(a)) attach(a);
});
}
let scanTimer = 0;
new MutationObserver(() => {
if (scanTimer) return;
scanTimer = setTimeout(() => { scanTimer = 0; scan(); }, 50);
}).observe(document.documentElement, { childList: true, subtree: true });
scan();
})();