// ==UserScript== // @name 豆包批量删除聊天记录 // @namespace https://docs.scriptcat.org/ // @version 0.5.4 // @description 豆包批量删除聊天记录,支持勾选部分对话删除,自动识别对话名称 // @author You // @match https://www.doubao.com/* // @grant none // @noframes // ==/UserScript== // ---------- 类型声明(编辑器智能提示) ---------- /** * @typedef {Object} RequestData * @property {string|null} body * @property {Object} query * @property {Object} headers * @property {any} raw */ /** * @typedef {Object} ResponseData * @property {string} body * @property {Object} headers * @property {any} raw */ /** * @typedef {Object} InterceptorContext * @property {string} url * @property {string} method * @property {RequestData} request * @property {ResponseData} [response] * @property {function(function(): void): Promise} execute * @property {any} [key] 可自由添加 */ /** * @typedef {Object} InterceptorHooks * @property {function(InterceptorContext): void|Promise} [before] * @property {function(InterceptorContext): void|Promise} [after] */ /** * 全局请求拦截器 * @type {function(string|RegExp, InterceptorHooks): number} */ // eslint-disable-next-line no-unused-vars /* global requestInterceptor */ // ------------------------------------------------------ (function (global) { let __originalXHR__, __originalFetch__; const __requestInterceptors__ = new Map(); let interceptorIdCounter = 0; function matchUrl(targetUrl, matcher) { return typeof matcher === 'string' ? targetUrl.includes(matcher) : matcher instanceof RegExp ? matcher.test(targetUrl) : false; } function createExecuteWrapper(resolveHook) { let executePromise = null; return async function execute(handler) { if (executePromise) await executePromise; const origXHR = __originalXHR__, origFetch = __originalFetch__; const tempXHR = global.XMLHttpRequest, tempFetch = global.fetch; global.XMLHttpRequest = origXHR; global.fetch = origFetch; try { executePromise = new Promise((resolveInner) => { const p = handler(resolveHook); Promise.resolve(p).then(resolveInner); }); await executePromise; } finally { executePromise = null; global.XMLHttpRequest = tempXHR; global.fetch = tempFetch; } }; } async function awaitHookExecution(hook, context) { if (!hook) return; try { const ret = hook(context); if (ret && typeof ret.then === 'function') await ret; } catch (err) { console.error('[RequestInterceptor] 钩子执行异常:', err); } } function createContext(url, method, headers, raw, bodyText) { let query; try { query = Object.fromEntries(new URL(url, location.origin).searchParams); } catch { query = {}; } return { url, method, request: { body: bodyText ?? null, query, headers: { ...headers }, raw }, execute: createExecuteWrapper(() => { }) }; } function attachResponseToContext(context, resHeaders, bodyText, rawRes) { context.response = { body: bodyText, headers: { ...resHeaders }, raw: rawRes }; } function hijackXHR() { if (__originalXHR__) return; __originalXHR__ = global.XMLHttpRequest; const proto = __originalXHR__.prototype; const origOpen = proto.open, origSend = proto.send; proto.open = function (method, url, ...rest) { this.__reqMeta = { method, url, reqHeaders: new Map() }; const origSetHeader = this.setRequestHeader; this.setRequestHeader = function (k, v) { this.__reqMeta.reqHeaders.set(k, v); return origSetHeader.call(this, k, v); }; return origOpen.call(this, method, url, ...rest); }; proto.send = async function (...args) { const meta = this.__reqMeta; const matchedInterceptors = Array.from(__requestInterceptors__.values()) .filter(item => matchUrl(meta.url, item.matcher)); const headersObj = Object.fromEntries(meta.reqHeaders); const bodyText = typeof args[0] === 'string' ? args[0] : null; const contexts = matchedInterceptors.map(inter => ({ interceptor: inter, context: createContext(meta.url, meta.method, headersObj, this, bodyText) })); // 执行 before for (const { interceptor, context } of contexts) { await awaitHookExecution(interceptor.before, context); } // 保存上下文 this.__interceptorContexts = contexts; origSend.call(this, ...args); this.addEventListener('loadend', async () => { try { const saved = this.__interceptorContexts; if (!saved) return; const resHeadersStr = this.getAllResponseHeaders(); const resHeadersObj = {}; resHeadersStr.split('\r\n').forEach(line => { const p = line.split(': '); if (p.length === 2) resHeadersObj[p[0]] = p[1]; }); const resBody = this.responseText; for (const { interceptor, context } of saved) { attachResponseToContext(context, resHeadersObj, resBody, this); await awaitHookExecution(interceptor.after, context); // 处理 once 自动移除 if (interceptor.options.once) { try { requestInterceptor.remove(interceptor.id); } catch (e) { } } } } catch (err) { console.error('[RequestInterceptor] XHR after 钩子异常:', err); } }); }; } function hijackFetch() { if (__originalFetch__) return; __originalFetch__ = global.fetch; const origFetch = __originalFetch__; global.fetch = async function (input, init) { let urlStr, request; if (typeof input === 'string') { urlStr = input; request = new Request(input, init); } else if (input instanceof Request) { urlStr = input.url; request = input; } else if (input instanceof URL) { urlStr = input.toString(); request = new Request(input, init); } else { urlStr = String(input); request = new Request(urlStr, init); } const matchedInterceptors = Array.from(__requestInterceptors__.values()) .filter(item => matchUrl(urlStr, item.matcher)); const headersObj = Object.fromEntries(request.headers); const bodyText = typeof init?.body === 'string' ? init.body : null; const contexts = matchedInterceptors.map(inter => ({ interceptor: inter, context: createContext(urlStr, request.method, headersObj, request, bodyText) })); // 执行 before for (const { interceptor, context } of contexts) { await awaitHookExecution(interceptor.before, context); } let response; try { response = await origFetch.call(global, input, init); } catch (err) { // 网络错误,仍然移除 once 拦截器 for (const { interceptor } of contexts) { if (interceptor.options.once) { try { requestInterceptor.remove(interceptor.id); } catch (e) { } } } console.error(err); throw err; } // 响应成功,执行 after try { const cloneRes = response.clone(); const resBody = await cloneRes.text(); const resHeadersObj = Object.fromEntries(response.headers); for (const { interceptor, context } of contexts) { attachResponseToContext(context, resHeadersObj, resBody, response); await awaitHookExecution(interceptor.after, context); if (interceptor.options.once) { try { requestInterceptor.remove(interceptor.id); } catch (e) { } } } } catch (err) { console.error('[RequestInterceptor] fetch after 钩子异常:', err); } return response; }; } function requestInterceptor(urlMatcher, hooks, options = {}) { if (!__originalXHR__) hijackXHR(); if (!__originalFetch__) hijackFetch(); const id = interceptorIdCounter++; __requestInterceptors__.set(id, { id, matcher: urlMatcher, before: hooks.before, after: hooks.after, options }); return id; } requestInterceptor.remove = id => __requestInterceptors__.delete(id); requestInterceptor.clearAll = () => { if (__originalXHR__) { const proto = __originalXHR__.prototype; delete proto.open; delete proto.send; __originalXHR__ = null; } if (__originalFetch__) { global.fetch = __originalFetch__; __originalFetch__ = null; } __requestInterceptors__.clear(); interceptorIdCounter = 0; }; global.requestInterceptor = requestInterceptor; })(window); (function () { 'use strict'; console.log('[豆包脚本] 请求拦截器已启动'); const btn = document.createElement("button"); btn.id = "doubao-delete-btn"; btn.title = "批量删除豆包聊天记录(可选择)"; btn.innerHTML = ` `; Object.assign(btn.style, { position: "fixed", top: "86px", right: "16px", zIndex: "999999", width: "44px", height: "44px", borderRadius: "50%", border: "none", backgroundColor: "#ff4d4f", color: "white", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", boxShadow: "0 2px 8px rgba(0,0,0,0.2)", transition: "background-color 0.2s", }); btn.addEventListener("mouseenter", () => { btn.style.backgroundColor = "#ff7875"; }); btn.addEventListener("mouseleave", () => { btn.style.backgroundColor = "#ff4d4f"; }); let id = null; requestInterceptor("/im/chain/recent_conv", { after: (ctx) => { const requestBody = JSON.parse(ctx.request.body || '{}'); if (requestBody.sequence_id) { id = requestBody.sequence_id; console.log('[豆包脚本] 获取到 sequence_id:', id); } } }); // ================================================================ // ⬇️ 改进:深度搜索名称字段,并输出详细调试信息 // ================================================================ /** * 递归搜索对象中可能包含名称的字段 * @param {Object} obj - 要搜索的对象 * @param {Set} visited - 已访问过的对象(避免循环引用) * @returns {string|null} 找到的名称字符串,或 null */ function findNameInObject(obj, visited = new Set()) { if (!obj || typeof obj !== 'object') return null; if (visited.has(obj)) return null; visited.add(obj); // 优先查找这些字段名 const nameKeys = ['conversation_name', 'name', 'title', 'display_name', 'conv_name', 'conversation_title', 'summary', 'text', 'content']; for (const key of nameKeys) { const val = obj[key]; if (typeof val === 'string' && val.trim().length > 0 && !/^\d+$/.test(val.trim())) { // 不是纯数字,可能是名称 return val.trim(); } } // 遍历所有属性,查找第一个非数字字符串 for (const key of Object.keys(obj)) { const val = obj[key]; if (typeof val === 'string' && val.trim().length > 0 && !/^\d+$/.test(val.trim())) { // 避免返回过长的内容 if (val.length < 100) return val.trim(); } } // 递归检查子对象 for (const key of Object.keys(obj)) { const val = obj[key]; if (typeof val === 'object' && val !== null) { const result = findNameInObject(val, visited); if (result) return result; } } return null; } /** * 从 cell 对象中提取对话名称(增强版) * @param {Object} cell - 对话数据对象 * @param {number} index - 索引,用于后备名称 * @returns {string} 对话名称 */ function getConversationName(cell, index) { // 首先尝试使用 findNameInObject 深度搜索 const found = findNameInObject(cell); if (found) return found; // 如果没找到,尝试直接访问常用字段 const directFields = ['conversation_name', 'name', 'title', 'display_name', 'conv_name']; for (const field of directFields) { if (cell[field] && typeof cell[field] === 'string' && cell[field].trim() && !/^\d+$/.test(cell[field].trim())) { return cell[field].trim(); } } // 最后使用 id 或索引 return cell.id || `对话 ${index+1}`; } /** * 显示选择对话框 * @param {Array} cells - 对话列表 * @param {Function} onConfirm - 回调,参数为选中的 id 数组 */ function showSelectionDialog(cells, onConfirm) { if (!cells || cells.length === 0) { alert('没有找到可删除的对话'); return; } // ⬇️ 输出调试信息:显示第一个 cell 的完整结构 console.log('[豆包脚本] 对话列表数据 (cells):', cells); if (cells.length > 0) { console.log('[豆包脚本] 第一个 cell 的完整结构:', JSON.stringify(cells[0], null, 2)); // 尝试找出所有非数字的字符串字段 const allKeys = new Set(); cells.forEach(cell => { Object.keys(cell).forEach(k => allKeys.add(k)); }); console.log('[豆包脚本] 所有 cell 中的字段名:', Array.from(allKeys)); } const overlay = document.createElement('div'); overlay.style.cssText = ` position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1000000; display: flex; justify-content: center; align-items: center; backdrop-filter: blur(2px); `; const dialog = document.createElement('div'); dialog.style.cssText = ` background: #1e1e2f; color: #e0e0e0; border-radius: 12px; padding: 20px; max-width: 600px; width: 90%; max-height: 80vh; overflow-y: auto; box-shadow: 0 8px 30px rgba(0,0,0,0.6); font-family: system-ui, sans-serif; `; const title = document.createElement('h3'); title.textContent = '选择要删除的对话'; title.style.marginTop = '0'; title.style.marginBottom = '12px'; dialog.appendChild(title); const toolbar = document.createElement('div'); toolbar.style.cssText = ` display: flex; gap: 10px; margin-bottom: 12px; `; const selectAllBtn = document.createElement('button'); selectAllBtn.textContent = '全选'; selectAllBtn.style.cssText = ` background: #3a3a6a; border: none; color: white; padding: 4px 12px; border-radius: 4px; cursor: pointer; `; const deselectAllBtn = document.createElement('button'); deselectAllBtn.textContent = '取消全选'; deselectAllBtn.style.cssText = ` background: #3a3a6a; border: none; color: white; padding: 4px 12px; border-radius: 4px; cursor: pointer; `; toolbar.appendChild(selectAllBtn); toolbar.appendChild(deselectAllBtn); dialog.appendChild(toolbar); const listContainer = document.createElement('div'); listContainer.style.cssText = ` max-height: 400px; overflow-y: auto; margin-bottom: 16px; `; // 生成每个对话项 cells.forEach((cell, index) => { const name = getConversationName(cell, index); const cellId = cell.id; // 如果 name 是纯数字,添加提示 const displayName = /^\d+$/.test(name) ? `ID: ${name}` : name; const item = document.createElement('div'); item.style.cssText = ` display: flex; align-items: center; padding: 6px 0; border-bottom: 1px solid #2a2a3e; `; const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.checked = true; checkbox.dataset.id = cellId; checkbox.style.marginRight = '10px'; checkbox.style.accentColor = '#6c5ce7'; const label = document.createElement('label'); label.textContent = displayName; label.style.cursor = 'pointer'; label.style.flex = '1'; label.addEventListener('click', () => { checkbox.checked = !checkbox.checked; }); item.appendChild(checkbox); item.appendChild(label); listContainer.appendChild(item); }); dialog.appendChild(listContainer); const actions = document.createElement('div'); actions.style.cssText = ` display: flex; gap: 10px; justify-content: flex-end; border-top: 1px solid #2a2a3e; padding-top: 12px; `; const cancelBtn = document.createElement('button'); cancelBtn.textContent = '取消'; cancelBtn.style.cssText = ` background: #3a3a4a; border: none; color: #ccc; padding: 6px 16px; border-radius: 6px; cursor: pointer; `; const confirmBtn = document.createElement('button'); confirmBtn.textContent = '删除选中'; confirmBtn.style.cssText = ` background: #e74c3c; border: none; color: white; padding: 6px 16px; border-radius: 6px; cursor: pointer; font-weight: bold; `; actions.appendChild(cancelBtn); actions.appendChild(confirmBtn); dialog.appendChild(actions); overlay.appendChild(dialog); document.body.appendChild(overlay); selectAllBtn.addEventListener('click', () => { listContainer.querySelectorAll('input[type="checkbox"]').forEach(cb => cb.checked = true); }); deselectAllBtn.addEventListener('click', () => { listContainer.querySelectorAll('input[type="checkbox"]').forEach(cb => cb.checked = false); }); cancelBtn.addEventListener('click', () => overlay.remove()); overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); confirmBtn.addEventListener('click', () => { const checkedBoxes = listContainer.querySelectorAll('input[type="checkbox"]:checked'); const selectedIds = Array.from(checkedBoxes).map(cb => cb.dataset.id); if (selectedIds.length === 0) { alert('请至少选择一个对话'); return; } overlay.remove(); onConfirm(selectedIds); }); } // ---------- 点击主按钮(不变) ---------- btn.addEventListener("click", () => { if (!id) { alert('尚未获取到 sequence_id,请刷新页面重试'); return; } fetch("https://www.doubao.com/im/chain/recent_conv?version_code=20800&language=zh&device_platform=web&aid=497858&real_aid=497858&pkg_type=release_version&device_id=7651059433204729395&pc_version=3.22.5&web_id=7651059466390341129&tea_uuid=7651059466390341129®ion=CN&sys_region=CN&samantha_web=1&web_platform=browser&use-olympus-account=1&web_tab_id=13a488c4-144b-433d-a394-cbdd26f1300e", { "headers": { "accept": "application/json, text/plain, */*", "accept-language": "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6", "agw-js-conv": "str", "content-type": "application/json; encoding=utf-8", "priority": "u=1, i", "sec-ch-ua": "\"Microsoft Edge\";v=\"149\", \"Chromium\";v=\"149\", \"Not)A;Brand\";v=\"24\"", "sec-ch-ua-mobile": "?0", "sec-ch-ua-platform": "\"Windows\"", "sec-fetch-dest": "empty", "sec-fetch-mode": "cors", "sec-fetch-site": "same-origin" }, "referrer": "https://www.doubao.com/", "body": JSON.stringify({ "cmd": 3200, "uplink_body": { "pull_recent_conv_chain_uplink_body": { "limit": 20, "message_count_per_conv": 10, "api_version": 1, "conv_version": 0, "direction": 3, "option": { "not_need_message": true, "need_complete_conversation": true, "need_coco_conversation": true, "need_coco_bot": true, "need_pc_pin_chain": true, "pc_pin_query_type": 0 } } }, "sequence_id": id, "channel": 2, "version": "1" }), "method": "POST", "mode": "cors", "credentials": "include" }) .then(rs => rs.json()) .then(rs => { const cells = rs.downlink_body?.pull_recent_conv_chain_downlink_body?.cells || []; if (cells.length === 0) { alert('当前没有可删除的对话'); return; } if (rs.sequence_id) { id = rs.sequence_id; } showSelectionDialog(cells, (selectedIds) => { fetch("https://www.doubao.com/im/conversation/batch_del_user_conv?version_code=20800&language=zh&device_platform=web&aid=497858&real_aid=497858&pkg_type=release_version&device_id=7651059433204729395&pc_version=3.22.5&web_id=7651059466390341129&tea_uuid=7651059466390341129®ion=CN&sys_region=CN&samantha_web=1&web_platform=browser&use-olympus-account=1&web_tab_id=13a488c4-144b-433d-a394-cbdd26f1300e", { "headers": { "accept": "application/json, text/plain, */*", "accept-language": "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6", "agw-js-conv": "str", "content-type": "application/json; encoding=utf-8", "priority": "u=1, i", "sec-ch-ua": "\"Microsoft Edge\";v=\"149\", \"Chromium\";v=\"149\", \"Not)A;Brand\";v=\"24\"", "sec-ch-ua-mobile": "?0", "sec-ch-ua-platform": "\"Windows\"", "sec-fetch-dest": "empty", "sec-fetch-mode": "cors", "sec-fetch-site": "same-origin" }, "referrer": "https://www.doubao.com/", "body": JSON.stringify({ "cmd": 4171, "uplink_body": { "batch_delete_user_conversation_uplink_body": { "conversation_id": selectedIds, "delete_all": false, "conversation_type": 3 } }, "sequence_id": id, "channel": 2, "version": "1" }), "method": "POST", "mode": "cors", "credentials": "include" }) .then(rs => rs.json()) .then(rs => { if (rs.sequence_id) { id = rs.sequence_id; } alert(`成功删除 ${selectedIds.length} 个对话`); location.reload(); }) .catch(err => { console.error('[豆包脚本] 删除失败:', err); alert('删除失败,请查看控制台'); }); }); }) .catch(err => { console.error('[豆包脚本] 获取对话列表失败:', err); alert('获取对话列表失败,请查看控制台'); }); }); document.body.appendChild(btn); })();