聊天 cleaner.user · JS // ==UserScript== // @name 抖音私信清理助手 · 关键词批量删除会话 // @namespace http://tampermonkey.net/ // @version 2026.07.25 // @description 按预览文字关键词或聊天记录关键词匹配,批量删除 douyin.com/chat 中的会话。支持前N条/全部/日期区间范围控制,默认预览(不删除)模式,需手动切换为执行模式。 // @author assistant // @match https://www.douyin.com/chat* // @grant GM_setValue // @grant GM_getValue // @license MIT // ==/UserScript== (function () { 'use strict'; // ==================== 工具函数 ==================== function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function simulateMouseClick(el) { if (!el) return false; const rect = el.getBoundingClientRect(); const x = rect.left + rect.width / 2; const y = rect.top + rect.height / 2; el.dispatchEvent(new MouseEvent('click', { bubbles: true, clientX: x, clientY: y })); return true; } function simulateContextMenu(el) { if (!el) return false; const rect = el.getBoundingClientRect(); const x = rect.left + rect.width / 2; const y = rect.top + rect.height / 2; // 部分菜单组件监听的是 mousedown/mouseup 组合而非 contextmenu,这里都触发一遍 el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: x, clientY: y, button: 2 })); el.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: x, clientY: y, button: 2 })); el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: x, clientY: y, button: 2 })); return true; } // 把 "2025/11/27" 这种字符串转成可比较的数字 20251127;解析失败返回 null function parseDateStr(str) { if (!str) return null; const m = str.trim().match(/^(\d{4})\/(\d{1,2})\/(\d{1,2})$/); if (!m) return null; return parseInt(m[1], 10) * 10000 + parseInt(m[2], 10) * 100 + parseInt(m[3], 10); } function todayNum() { const d = new Date(); return d.getFullYear() * 10000 + (d.getMonth() + 1) * 100 + d.getDate(); } // ==================== 配置 ==================== const DEFAULT_CONFIG = { previewKeywords: '对方回复或关注你之前,只能发送一条文字消息。请礼貌发言,自觉遵守《抖音自律公约》', deepKeywords: 'hi老师,接🈴佐吗', enableDeepScan: true, maxScrollInChat: 15, // 深度扫描单个会话时最多向上滚动几次 scopeMode: 'top', // top | all | date topN: 50, dateStart: '', // YYYY/MM/DD,date 模式下生效 dateEnd: '', actionDelay: 1200, // 每处理完一个会话后的延迟(ms),防风控 dryRun: true, // 仅预览,不真正删除 maxLogs: 300 }; let cfg = { ...DEFAULT_CONFIG, ...(GM_getValue('cleanerConfig', {})) }; let isRunning = false; let isPaused = false; let stopRequested = false; const processedFingerprints = new Set(); // 已处理过的会话指纹,避免重复处理 function saveConfig() { GM_setValue('cleanerConfig', cfg); } function keywordList(text) { return text.split('\n').map((s) => s.trim()).filter((s) => s.length > 0); } // ==================== 日志 ==================== function addLog(message, type = 'info') { const logs = GM_getValue('cleanerLogs', []); logs.unshift({ timestamp: new Date().toISOString(), message, type }); if (logs.length > cfg.maxLogs) logs.splice(cfg.maxLogs); GM_setValue('cleanerLogs', logs); renderLogLine(message, type); } function renderLogLine(message, type) { const container = document.getElementById('dy-clean-log'); if (!container) return; const line = document.createElement('div'); line.style.cssText = `padding:4px 0;border-bottom:1px solid rgba(255,255,255,0.05);color:${ type === 'success' ? '#00d8b8' : type === 'error' ? '#ff2c54' : type === 'warn' ? '#ffc107' : '#ccc' };`; const t = new Date().toLocaleTimeString(); line.textContent = `${t} - ${message}`; container.prepend(line); while (container.children.length > 200) container.removeChild(container.lastChild); } // ==================== 会话列表相关 ==================== function getConversationItems() { return Array.from(document.querySelectorAll('[data-e2e="conversation-item"]')); } function findScrollableListContainer() { const selectors = ['.conversationConversationListwrapper']; for (const sel of selectors) { const el = document.querySelector(sel); if (el) return el; } const sample = document.querySelector('[data-e2e="conversation-item"]'); if (sample) { let el = sample.parentElement; while (el && el !== document.body) { const s = window.getComputedStyle(el); if ((s.overflowY === 'auto' || s.overflowY === 'scroll') && el.scrollHeight > el.clientHeight + 10) { return el; } el = el.parentElement; } } return null; } function isListExhausted() { // 抖音底部"没有更多了~"的提示,class 名可能变化,做一个宽松匹配 return !!document.querySelector('[class*="no-more-tip-"]'); } function getItemFingerprint(item) { const title = item.querySelector('.conversationConversationItemtitle')?.textContent.trim() || ''; const date = item.querySelector('.ConversationItemTagNextToTitletimeStr')?.textContent.trim() || ''; const hint = item.querySelector('.ConversationItemHinttextBox')?.textContent.trim() || ''; return `${title}__${date}__${hint.slice(0, 20)}`; } function getItemDateNum(item) { const date = item.querySelector('.ConversationItemTagNextToTitletimeStr')?.textContent.trim() || ''; return parseDateStr(date); } function inDateScope(item) { const dNum = getItemDateNum(item); if (dNum === null) return true; // 解析不出日期就不因为这个条件排除 const startNum = parseDateStr(cfg.dateStart) ?? -Infinity; const endNum = parseDateStr(cfg.dateEnd) ?? Infinity; return dNum >= startNum && dNum <= endNum; } // ==================== 匹配判断 ==================== function matchPreview(item) { const hint = item.querySelector('.ConversationItemHinttextBox')?.textContent.trim() || ''; const kws = keywordList(cfg.previewKeywords); return kws.some((kw) => kw && hint.includes(kw)); } // 深度扫描:点开该会话,滚动加载历史消息,检查文字内容 async function matchDeepScan(item, title) { const kws = keywordList(cfg.deepKeywords); if (kws.length === 0) return false; simulateMouseClick(item); await sleep(1200); // 等待右侧消息面板加载 const messageListSel = '.messageMessageListlist'; let scrollCount = 0; while (scrollCount <= cfg.maxScrollInChat) { const texts = Array.from(document.querySelectorAll('.TextMessageTextpureText')) .map((el) => el.textContent || ''); for (const t of texts) { if (kws.some((kw) => kw && t.includes(kw))) { addLog(`[${title}] 聊天记录命中关键词`, 'info'); return true; } } const listEl = document.querySelector(messageListSel); if (!listEl) break; // 消息列表是 rotate(180deg) 布局,向上滚历史消息实际是让 scrollTop 增大 // 若已经到顶(没有更多历史),停止 const beforeHeight = listEl.scrollHeight; listEl.scrollTop += 600; await sleep(500); scrollCount++; // 高度没变化说明加载不到更多了 if (listEl.scrollHeight === beforeHeight && scrollCount > 2) break; } return false; } // ==================== 删除操作 ==================== async function deleteConversation(item, title) { simulateContextMenu(item); await sleep(500); const menuItems = Array.from(document.querySelectorAll('.ConversationItemMenumenuItem')); let deleteBtn = null; for (const mi of menuItems) { const text = mi.querySelector('.ConversationItemMenutext')?.textContent.trim() || mi.textContent.trim(); if (text.includes('删除') && mi.classList.contains('ConversationItemMenuisDanger')) { deleteBtn = mi; break; } } // 找不到带danger类的,退而求其次找文字含"删除"的 if (!deleteBtn) { for (const mi of menuItems) { const text = mi.querySelector('.ConversationItemMenutext')?.textContent.trim() || mi.textContent.trim(); if (text.includes('删除')) { deleteBtn = mi; break; } } } if (!deleteBtn) { addLog(`[${title}] 右键菜单未找到"删除"选项,跳过`, 'error'); // 尝试关闭可能已弹出的菜单 document.body.click(); return false; } deleteBtn.click(); await sleep(500); // 处理可能出现的二次确认弹窗 await handleConfirmDialog(title); return true; } async function handleConfirmDialog(title) { await sleep(300); const scopeEl = document.getElementById('pc-im-popup-container') || document; const candidates = Array.from( scopeEl.querySelectorAll('button, [role="button"], .semi-button') ); for (const btn of candidates) { const text = (btn.textContent || '').trim(); if (/^(删除|确定|确认)$/.test(text)) { btn.click(); addLog(`[${title}] 已点击确认弹窗按钮"${text}"`, 'info'); await sleep(500); return true; } } return false; } // ==================== 主流程 ==================== async function runCleaner() { if (isRunning) return; isRunning = true; stopRequested = false; isPaused = false; updateRunButtons(); addLog(`开始扫描 (模式: ${cfg.dryRun ? '仅预览' : '执行删除'}, 范围: ${scopeDesc()})`, 'info'); const container = findScrollableListContainer(); let processedCount = 0; // 计入"前N条"范围 let stuckRounds = 0; let lastScrollTop = -1; while (!stopRequested) { while (isPaused && !stopRequested) await sleep(300); if (stopRequested) break; const items = getConversationItems(); let progressedThisRound = false; for (const item of items) { if (stopRequested) break; while (isPaused && !stopRequested) await sleep(300); if (stopRequested) break; const fp = getItemFingerprint(item); if (processedFingerprints.has(fp)) continue; if (cfg.scopeMode === 'date' && !inDateScope(item)) { processedFingerprints.add(fp); continue; } processedFingerprints.add(fp); progressedThisRound = true; processedCount++; const title = item.querySelector('.conversationConversationItemtitle')?.textContent.trim() || '(未知)'; let matched = matchPreview(item); if (!matched && cfg.enableDeepScan) { matched = await matchDeepScan(item, title); } if (matched) { if (cfg.dryRun) { addLog(`[预览] 匹配到会话「${title}」,命中即会删除(当前为预览模式,未实际删除)`, 'warn'); } else { addLog(`匹配到会话「${title}」,正在删除...`, 'warn'); // 删除后 DOM 会移除该元素,重新获取一次最新的引用更安全 const freshItem = getConversationItems().find((it) => getItemFingerprint(it) === fp) || item; const ok = await deleteConversation(freshItem, title); if (ok) addLog(`「${title}」删除操作已执行`, 'success'); } } else { addLog(`「${title}」未命中,跳过`, 'info'); } await sleep(cfg.actionDelay); if (cfg.scopeMode === 'top' && processedCount >= cfg.topN) { addLog(`已达到设定的前 ${cfg.topN} 条范围,停止`, 'success'); stopRequested = true; break; } } if (stopRequested) break; if (cfg.scopeMode === 'top' && processedCount >= cfg.topN) break; if (isListExhausted()) { addLog('已到达会话列表底部,扫描结束', 'success'); break; } if (!container) { addLog('未找到可滚动的会话列表容器,无法继续加载更多会话', 'error'); break; } container.scrollTop += 700; await sleep(600); if (container.scrollTop === lastScrollTop) { stuckRounds++; if (stuckRounds >= 4) { addLog('滚动多次后列表无变化,判定已到底部,扫描结束', 'warn'); break; } } else { stuckRounds = 0; } lastScrollTop = container.scrollTop; if (!progressedThisRound && stuckRounds === 0) { // 这一轮没有新条目但滚动仍在变化,继续下一轮 } } isRunning = false; updateRunButtons(); addLog(`扫描结束,共处理 ${processedCount} 条会话`, 'success'); } function scopeDesc() { if (cfg.scopeMode === 'top') return `前 ${cfg.topN} 条`; if (cfg.scopeMode === 'date') return `日期 ${cfg.dateStart || '不限'} ~ ${cfg.dateEnd || '不限'}`; return '全部'; } function stopCleaner() { stopRequested = true; isPaused = false; addLog('已请求停止', 'warn'); updateRunButtons(); } function togglePause() { if (!isRunning) return; isPaused = !isPaused; addLog(isPaused ? '已暂停' : '已继续', 'info'); updateRunButtons(); } function resetProgress() { processedFingerprints.clear(); addLog('已重置扫描进度(下次运行将从头开始判断,但不会撤销已删除的会话)', 'info'); } // ==================== UI ==================== function updateRunButtons() { const startBtn = document.getElementById('dy-clean-start'); const pauseBtn = document.getElementById('dy-clean-pause'); const stopBtn = document.getElementById('dy-clean-stop'); if (startBtn) startBtn.disabled = isRunning; if (pauseBtn) pauseBtn.textContent = isPaused ? '▶️ 继续' : '⏸️ 暂停'; if (pauseBtn) pauseBtn.disabled = !isRunning; if (stopBtn) stopBtn.disabled = !isRunning; } function el(tag, style, html) { const e = document.createElement(tag); if (style) e.style.cssText = style; if (html !== undefined) e.innerHTML = html; return e; } function createPanel() { const existing = document.getElementById('dy-clean-helper'); if (existing) existing.remove(); const panel = el('div', `position:fixed;top:20px;left:20px;width:460px;max-height:88vh;overflow:hidden; background:linear-gradient(135deg,#1a1a1a 0%,#2d2d2d 100%);border-radius:16px; box-shadow:0 8px 32px rgba(0,0,0,0.4);z-index:9999;color:#fff; font-family:'PingFang SC','Microsoft YaHei',sans-serif;display:flex;flex-direction:column;` ); panel.id = 'dy-clean-helper'; panel.innerHTML = `
🧹 私信批量清理助手
单会话最大向上滚动次数:
N =
~
⚠️ 关闭此选项后将真正执行删除,操作不可撤销,请先用预览模式确认匹配结果无误。
运行日志
`; document.body.appendChild(panel); bindPanelEvents(); addDragFunctionality(panel, 'dy-clean-header'); renderExistingLogs(); } function renderExistingLogs() { const logs = GM_getValue('cleanerLogs', []); // 只回显最近的一部分,避免面板一开始过长 logs.slice(0, 50).reverse().forEach((l) => renderLogLine(l.message, l.type)); } function bindPanelEvents() { document.getElementById('dy-clean-close').addEventListener('click', () => { document.getElementById('dy-clean-helper').style.display = 'none'; }); document.getElementById('dy-clean-preview-kw').addEventListener('change', (e) => { cfg.previewKeywords = e.target.value; saveConfig(); }); document.getElementById('dy-clean-deep-kw').addEventListener('change', (e) => { cfg.deepKeywords = e.target.value; saveConfig(); }); document.getElementById('dy-clean-enable-deep').addEventListener('change', (e) => { cfg.enableDeepScan = e.target.checked; saveConfig(); }); document.getElementById('dy-clean-max-scroll').addEventListener('change', (e) => { cfg.maxScrollInChat = parseInt(e.target.value, 10) || 0; saveConfig(); }); document.getElementById('dy-clean-topn').addEventListener('change', (e) => { cfg.topN = parseInt(e.target.value, 10) || 1; saveConfig(); }); document.getElementById('dy-clean-date-start').addEventListener('change', (e) => { cfg.dateStart = e.target.value.trim(); saveConfig(); }); document.getElementById('dy-clean-date-end').addEventListener('change', (e) => { cfg.dateEnd = e.target.value.trim(); saveConfig(); }); document.getElementById('dy-clean-delay').addEventListener('change', (e) => { cfg.actionDelay = parseInt(e.target.value, 10) || 1000; saveConfig(); }); document.getElementById('dy-clean-dryrun').addEventListener('change', (e) => { cfg.dryRun = e.target.checked; saveConfig(); }); document.querySelectorAll('input[name="dy-clean-scope"]').forEach((radio) => { radio.addEventListener('change', (e) => { cfg.scopeMode = e.target.value; saveConfig(); document.getElementById('dy-clean-scope-top').style.display = cfg.scopeMode === 'top' ? 'block' : 'none'; document.getElementById('dy-clean-scope-date').style.display = cfg.scopeMode === 'date' ? 'block' : 'none'; }); }); document.getElementById('dy-clean-start').addEventListener('click', () => { if (!cfg.dryRun) { const ok = confirm('即将以【执行删除】模式运行,命中关键词的会话会被真实删除且不可撤销,确认继续吗?'); if (!ok) return; } runCleaner(); }); document.getElementById('dy-clean-pause').addEventListener('click', togglePause); document.getElementById('dy-clean-stop').addEventListener('click', stopCleaner); document.getElementById('dy-clean-reset').addEventListener('click', resetProgress); } // 拖动功能(简化版,独立实现,避免与其他脚本冲突) function addDragFunctionality(panel, headerId) { const header = document.getElementById(headerId); if (!header) return; let dragging = false, offsetX = 0, offsetY = 0; header.addEventListener('mousedown', (e) => { if (e.target.tagName === 'BUTTON') return; dragging = true; const rect = panel.getBoundingClientRect(); offsetX = e.clientX - rect.left; offsetY = e.clientY - rect.top; panel.style.left = rect.left + 'px'; panel.style.top = rect.top + 'px'; panel.style.right = 'auto'; e.preventDefault(); }); document.addEventListener('mousemove', (e) => { if (!dragging) return; const x = e.clientX - offsetX; const y = e.clientY - offsetY; panel.style.left = Math.max(0, Math.min(x, window.innerWidth - panel.offsetWidth)) + 'px'; panel.style.top = Math.max(0, Math.min(y, window.innerHeight - panel.offsetHeight)) + 'px'; }); document.addEventListener('mouseup', () => { dragging = false; }); } function createReopenButton() { const btn = el('div', `position:fixed;top:20px;left:20px;width:44px;height:44px;border-radius:50%; background:linear-gradient(135deg,#ff2c54 0%,#ff6b8b 100%);color:#fff;display:none; align-items:center;justify-content:center;cursor:pointer;z-index:9998;font-size:18px;`, '🧹' ); btn.id = 'dy-clean-reopen'; btn.title = '打开私信清理助手'; btn.addEventListener('click', () => { const panel = document.getElementById('dy-clean-helper'); if (panel) { panel.style.display = 'flex'; btn.style.display = 'none'; } else { createPanel(); btn.style.display = 'none'; } }); document.body.appendChild(btn); } function init() { createPanel(); createReopenButton(); addLog('私信清理助手已加载,默认【仅预览模式】,请先运行确认匹配结果', 'info'); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();