// ==UserScript== // @name BOSS直聘 · 自动换微信助手 // @name:en BOSS Zhipin Auto WeChat Exchanger // @namespace https://greasyfork.org/zh-CN/scripts/boss-wechat-exchange // @version 1.4.0 // @description 在 BOSS直聘 沟通页逐个自动发起「换微信」:先回复招呼语,再按按钮状态机点击、处理确认弹窗、抓取微信号;带拟人节奏、每日/每小时上限、人机验证自动暂停、已读与已交换跳过、CSV 导出 // @description:en Auto exchange WeChat on BOSS Zhipin chat page: greeting first, button state machine, confirm dialog, WeChat ID capture, human-like pacing, rate limits, captcha auto-pause, CSV export. // @author bwe-tools // @license MIT // @match https://www.zhipin.com/web/chat/* // @match https://www.zhipin.com/web/boss/* // @run-at document-idle // @grant none // @noframes // @tag BOSS直聘 // @tag 招聘 // @tag 自动化 // @tag 换微信 // @tag 效率工具 // ==/UserScript== (function () { 'use strict'; if (window.top !== window.self) return; if (window.__BWE__ && window.__BWE__.booted) return; const VERSION = '1.4.0'; const K_CFG = 'bwe_cfg_v2'; const K_STAT = 'bwe_stat_v1'; /* ============================== 工具 ============================== */ const $ = (s, r) => (r || document).querySelector(s); const $$ = (s, r) => Array.from((r || document).querySelectorAll(s)); const sleep = (ms) => new Promise((res) => setTimeout(res, ms)); const rand = (a, b) => a + Math.random() * (b - a); const irand = (a, b) => Math.floor(a + Math.random() * (b - a + 1)); const pad2 = (n) => String(n).padStart(2, '0'); const fmtT = (d) => { d = d || new Date(); return pad2(d.getHours()) + ':' + pad2(d.getMinutes()) + ':' + pad2(d.getSeconds()); }; const todayKey = () => { const d = new Date(); return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate()); }; const norm = (s) => String(s || '').replace(/\s+/g, ''); const store = { get(k, dft) { try { if (typeof GM_getValue === 'function') { const v = GM_getValue(k); return v === undefined ? dft : v; } const raw = localStorage.getItem(k); return raw == null ? dft : JSON.parse(raw); } catch (e) { return dft; } }, set(k, v) { try { if (typeof GM_setValue === 'function') { GM_setValue(k, v); return; } localStorage.setItem(k, JSON.stringify(v)); } catch (e) {} }, }; function isVisible(el) { if (!el || !el.isConnected) return false; const r = el.getBoundingClientRect(); if (r.width < 2 || r.height < 2) return false; if (r.bottom < -200 || r.top > innerHeight + 2000) return false; const cs = getComputedStyle(el); if (cs.display === 'none' || cs.visibility === 'hidden') return false; if (Number(cs.opacity) < 0.05) return false; return true; } async function waitFor(fn, timeout, step) { const t0 = Date.now(); while (Date.now() - t0 < timeout) { let v; try { v = fn(); } catch (e) { v = null; } if (v) return v; await sleep(step || 120); } return null; } function humanClick(el) { if (!el) return false; try { el.scrollIntoView({ block: 'center', behavior: 'instant' }); } catch (e) {} const r = el.getBoundingClientRect(); const opt = { bubbles: true, cancelable: true, view: window, clientX: r.left + r.width * rand(0.3, 0.7), clientY: r.top + r.height * rand(0.3, 0.7), button: 0, }; try { el.dispatchEvent(new PointerEvent('pointerover', opt)); el.dispatchEvent(new PointerEvent('pointerdown', opt)); } catch (e) {} el.dispatchEvent(new MouseEvent('mousedown', opt)); try { el.dispatchEvent(new PointerEvent('pointerup', opt)); } catch (e) {} el.dispatchEvent(new MouseEvent('mouseup', opt)); const native = /^(BUTTON|A|INPUT)$/i.test(el.tagName) || el.getAttribute('role') === 'button'; if (native && typeof el.click === 'function') el.click(); else el.dispatchEvent(new MouseEvent('click', opt)); return true; } function clickableAncestor(el, maxUp) { let cur = el; let i = 0; const okTag = /^(BUTTON|A)$/i; while (cur && i < (maxUp || 4)) { if (okTag.test(cur.tagName)) return cur; const c = (cur.className && typeof cur.className === 'string') ? cur.className : ''; if (/btn|button|item|tool|toolbar|op|link|action/i.test(c)) return cur; if (cur.getAttribute && cur.getAttribute('role') === 'button') return cur; cur = cur.parentElement; i++; } return el; } /* ============================== 配置 ============================== */ const DEFAULTS = { enabled: false, dryRun: false, intervalMin: 18, intervalMax: 42, clickDelayMin: 900, clickDelayMax: 2600, dailyCap: 30, hourlyCap: 10, skipIfExchanged: true, oncePerDay: true, onlyUnread: true, activeWithinDays: 0, pauseWhenHidden: true, stopOnVerify: true, maxFailStreak: 3, btnWaitMs: 9000, nameInclude: '', nameExclude: '', jobInclude: '', greeting: '你好', greetingAlways: false, msgAfter: '', useListFilterTabs: false, selectors: { listItem: '', chatBox: '', inputBox: '', sendBtn: '', exchangeBtn: '' } }; function loadCfg() { const saved = store.get(K_CFG, null); const cfg = JSON.parse(JSON.stringify(DEFAULTS)); if (saved && typeof saved === 'object') { Object.keys(DEFAULTS).forEach((k) => { if (k === 'selectors') { Object.assign(cfg.selectors, saved.selectors || {}); } else if (saved[k] !== undefined) { cfg[k] = saved[k]; } }); } return cfg; } const cfg = loadCfg(); const saveCfg = () => store.set(K_CFG, cfg); /* ============================== 统计 ============================== */ function loadStat() { const s = store.get(K_STAT, null); if (!s || s.date !== todayKey()) { return { date: todayKey(), ok: 0, skip: 0, fail: 0, sent: 0, hour: {}, ids: {}, records: [] }; } s.hour = s.hour || {}; s.ids = s.ids || {}; s.records = s.records || []; return s; } const stat = loadStat(); const saveStat = () => store.set(K_STAT, stat); function bumpHour() { const h = String(new Date().getHours()); stat.hour[h] = (stat.hour[h] || 0) + 1; } function hourUsed() { return stat.hour[String(new Date().getHours())] || 0; } /* ============================== 日志 ============================== */ const logs = []; let logEl = null; function log(msg, kind) { const line = '[' + fmtT() + '] ' + msg; logs.push({ t: Date.now(), kind: kind || 'info', msg }); if (logs.length > 800) logs.shift(); if (logEl) { const d = document.createElement('div'); d.className = 'bwe-log-line bwe-' + (kind || 'info'); d.textContent = line; logEl.appendChild(d); while (logEl.childElementCount > 300) logEl.removeChild(logEl.firstChild); logEl.scrollTop = logEl.scrollHeight; } try { console.log('%c[BWE]', 'color:#0a84ff', msg); } catch (e) {} } /* ============================== 选择器 / 元素定位 ============================== */ const EXCHANGE_TEXTS = ['换微信', '交换微信', '互换微信', '加微信', '获取微信']; function chatRoot() { if (cfg.selectors.chatBox) { const e = $(cfg.selectors.chatBox); if (e && isVisible(e)) return e; } const cands = ['.chat-conversation', '.chat-content', '.conversation-box', '.chat-panel', '.chat-main', '.main-wrap']; for (const s of cands) { const e = $(s); if (e && isVisible(e)) return e; } return document.body; } /** 深度优先找文本完全等于某个词的叶子元素 */ function leafByText(texts, maxLen, root) { const want = new Set(texts); const host = root || chatRoot(); const out = []; let all; try { all = host.querySelectorAll('*'); } catch (e) { return out; } for (let i = 0; i < all.length; i++) { const el = all[i]; const raw = el.textContent; if (!raw || raw.length > (maxLen || 12)) continue; const t = norm(raw); if (!want.has(t)) continue; if (!isVisible(el)) continue; let deeper = false; for (let j = 0; j < el.children.length; j++) { if (norm(el.children[j].textContent) === t) { deeper = true; break; } } if (deeper) continue; out.push(el); } return out; } /** 「换微信」候选打分:越像聊天区里的操作按钮分越高 */ function scoreExchangeCandidate(el, box) { const cls = (el.className && typeof el.className === 'string') ? el.className : ''; // 悬浮提示 / 纯文案节点永远不是按钮(真机里 .chat-tooltip-custom 的文案就是「交换微信」) if (/tooltip|chat-tip|hint|popover|placeholder/i.test(cls)) return -1e6; let score = 0; if (box && box.contains(el)) score += 6; if (el.tagName === 'BUTTON' || el.tagName === 'A') score += 4; // 明确是按钮的给高分(真机按钮:span.operate-btn) if (/(^|[\s-])(btn|button)([\s-]|$)/i.test(cls) || /-btn($|\s)/i.test(cls)) score += 8; if (/(^|[\s-])(item|op|operate|action|tool|toolbar)([\s-]|$)/i.test(cls)) score += 2; // 排除明显不是按钮的区域(右侧「不合适/在消息列表」等) if (/not-?suit|report|more-menu|dropdown|menu-list|label-list/i.test(cls)) score -= 30; const t = norm(el.textContent); if (t === '换微信' || t === '交换微信') score += 4; let depth = 0; let p = el; while (p) { depth++; p = p.parentElement; } score += depth * 0.01; // 距离输入框越近越像工具条上的按钮 const ir = $('.chat-input, [contenteditable="true"]'); if (ir) { const a = el.getBoundingClientRect(); const b = ir.getBoundingClientRect(); const d = Math.abs(a.top - b.top) + Math.abs(a.bottom - b.bottom); score += Math.max(0, 40 - d / 12); } return score; } function pickExchangeCandidate(list) { const box = chatRoot(); let best = null; let bestScore = -1e9; list.forEach((el) => { const s = scoreExchangeCandidate(el, box); if (s > bestScore) { bestScore = s; best = el; } }); return best; } function findExchangeButton() { const inPanel = (el) => !!(el.closest && el.closest('#bwe-panel')); // ① 用户自定义选择器 if (cfg.selectors.exchangeBtn) { const e = $$(cfg.selectors.exchangeBtn).filter((x) => isVisible(x) && !inPanel(x))[0]; if (e) return clickableAncestor(e, 4); } // ② 精确文本(最稳) const exact = leafByText(EXCHANGE_TEXTS, 8).filter((x) => !inPanel(x)); if (exact.length) { const best = pickExchangeCandidate(exact); return best ? clickableAncestor(best, 4) : null; } // ③ 已知工具条容器内模糊匹配(真实页面的容器叫 operate-exchange-*) const CONTAINERS = [ '[class*="operate-exchange"]', '[class*="exchange-box"]', '[class*="exchange-area"]', '[class*="chat-op"]', '[class*="toolbar"]', '[class*="operate"]' ]; for (const sel of CONTAINERS) { const boxes = $$(sel).filter((b) => isVisible(b) && !inPanel(b)); for (const box of boxes) { const cands = $$('*', box).filter((el) => { if (!isVisible(el) || inPanel(el)) return false; const t = norm(el.textContent); if (!t || t.length > 12 || !t.includes('微信')) return false; // 只要最内层 for (let i = 0; i < el.children.length; i++) { if (norm(el.children[i].textContent) === t) return false; } return true; }); if (cands.length) { const best = pickExchangeCandidate(cands); return best ? clickableAncestor(best, 4) : null; } } } // ④ 全页兜底:class 带 exchange/weixin,且**自身文本必须含「微信」**(防止点到「不合适」那一组) const guess = $$('[class*="exchange"], [class*="weixin"], [class*="wechat"]').filter((el) => { if (!isVisible(el) || inPanel(el)) return false; const t = norm(el.textContent); return t.indexOf('微信') !== -1 && t.length <= 12; }); if (guess.length) { const best = pickExchangeCandidate(guess); return best ? clickableAncestor(best, 4) : null; } return null; } /** 元素是否处于禁用态(真机:span.operate-btn.disabled + 提示「双方回复后可用」) */ function isDisabledEl(el) { if (!el) return true; if (el.disabled === true) return true; if (el.getAttribute && el.getAttribute('aria-disabled') === 'true') return true; const c = (el.className && typeof el.className === 'string') ? el.className : ''; if (/(^|[\s-])(disabled|is-disabled|forbid)([\s-]|$)/i.test(c)) return true; const cs = getComputedStyle(el); if (cs.pointerEvents === 'none') return true; return false; } /** * 「换微信」按钮的完整状态 * state: missing 找不到 / disabled 存在但不可点 / pending 请求中 / exchanged 已交换 / ready 可点 */ function exchangeButtonInfo() { const el = findExchangeButton(); if (!el) return { el: null, state: 'missing', disabled: true, tip: '', tipText: '' }; const item = el.closest('.operate-icon-item, [class*="operate-icon"]'); const tipEl = item ? item.querySelector('.chat-tooltip-custom, [class*="tooltip"]') : null; const tip = norm((tipEl && tipEl.textContent) || (item && item.textContent) || el.textContent); const tipText = ((tipEl && tipEl.getAttribute && tipEl.getAttribute('data-tip')) || '').trim(); const disabled = isDisabledEl(el) || (el.parentElement ? isDisabledEl(el.parentElement) : false); let state = disabled ? 'disabled' : 'ready'; if (/请求中/.test(tip)) state = 'pending'; else if (!disabled && /已交换/.test(tip)) state = 'exchanged'; return { el, state, disabled, tip, tipText }; } function describeEl(el) { if (!el) return 'null'; const cls = (el.className && typeof el.className === 'string') ? el.className.split(/\s+/).slice(0, 3).join('.') : ''; return el.tagName.toLowerCase() + (cls ? '.' + cls : '') + ' 「' + norm(el.textContent).slice(0, 12) + '」'; } /* ============================== 会话列表 ============================== */ const LIST_SELECTORS = [ '.chat-user-list .geek-item', '.user-list .geek-item', '.friend-list .geek-item', '.geek-list li', '.chat-list li', '.user-list li', 'li[role="listitem"]' ]; function getListItems() { if (cfg.selectors.listItem) { const l = $$(cfg.selectors.listItem).filter(isVisible); if (l.length) return l; } for (const s of LIST_SELECTORS) { const l = $$(s).filter(isVisible); if (l.length >= 2) return l; } return []; } function detectUnread(el) { const badge = el.querySelector('[class*="badge"], [class*="unread"], [class*="red-dot"], [class*="notice"], [class*="count"]'); if (badge) { const t = norm(badge.textContent); if (t && /\d/.test(t)) return true; if (!t) { const cs = getComputedStyle(badge); if (cs.display !== 'none' && cs.visibility !== 'hidden' && badge.getBoundingClientRect().width > 2) return true; } } return /(^|[\s-])(unread|new-msg|has-new)([\s-]|$)/i.test(el.className || ''); } /** 从一行文本里剥掉时间、角标、系统提示,只留姓名 */ function cleanNameLine(line, job) { let s = norm(line); s = s.replace(/^(\d{1,2}:\d{2}|\d{1,2}月\d{1,2}日|昨天|前天|刚刚|\d+天前)+/, ''); s = s.replace(/[\[【][^\]】]*[\]】]/g, ''); s = s.replace(/(你)?撤回了一条消息/g, '').replace(/对方正在输入/g, ''); s = s.replace(/(送达|已读|未读|已发送)/g, ''); s = s.replace(/^(新|未读|已读|置顶)+/, ''); s = s.replace(/\d+$/, ''); s = s.trim(); if (!s || s.length > 10) return ''; if (/^\d+$/.test(s)) return ''; if (/^(\d{1,2}:\d{2}|\d{1,2}月\d{1,2}日|昨天|前天|刚刚)$/.test(s)) return ''; if (job && s === job) return ''; if (job && s.indexOf(job) === 0) return ''; return s; } /** 姓名提取:先按姓名节点解析,失败再逐行找最像姓名的那个 */ function extractName(el, lines, job) { const nameEl = el.querySelector('.geek-name, [class*="geek-name"], [class*="name-text"], [class*="nickname"], [class*="user-name"]'); if (nameEl) { let raw = norm(nameEl.textContent); // 姓名节点里常混着时间/职位/预览,从职位处截断 if (job) { const i = raw.indexOf(job); if (i > 0) raw = raw.slice(0, i); } const cleaned = cleanNameLine(raw, job); if (cleaned) return cleaned; } for (const l of lines) { const c = cleanNameLine(l, job); if (c) return c; } return ''; } function readItem(el, idx) { const lines = (el.innerText || el.textContent || '').split('\n').map((s) => s.trim()).filter(Boolean); const jobEl = el.querySelector('[class*="source-job"], [class*="job"], [class*="position"], [class*="gray"]'); const job = jobEl ? norm(jobEl.textContent) : ''; const name = extractName(el, lines, job); const timeText = (lines.find((s) => /\d{1,2}:\d{2}$/.test(s) || /月\d{1,2}日/.test(s) || /昨天|前天|\d+天前|刚刚/.test(s)) || ''); // 列表预览里,最后一条是我发的会带 [送达] / [已读] 前缀 —— 列表层就能判断「已回复」, // 不用先点开、也不会因为点开而把未读变已读。 const preview = (lines.find((s) => s !== name && s !== job && s !== timeText && s.length > 1) || ''); const fromMe = /[\[【]\s*(送达|已读|已发送|已送达)\s*[\]】]/.test(el.innerText || el.textContent || ''); const uid = el.dataset.uid || el.dataset.id || el.dataset.geekid || el.dataset.geekId || el.getAttribute('data-id') || ''; return { el, idx, name, job, timeText, preview, fromMe, unread: detectUnread(el), key: uid ? 'u:' + uid : 'k:' + name + '|' + job + '|' + timeText, }; } function collectTargets() { const items = getListItems(); const list = items.map(readItem); const inc = (s) => norm(s).split(/[,,;;\s]+/).filter(Boolean); const nameInc = inc(cfg.nameInclude); const nameExc = inc(cfg.nameExclude); const jobInc = inc(cfg.jobInclude); return list.filter((it) => { if (!it.name) return false; if (nameInc.length && !nameInc.some((k) => it.name.includes(k))) return false; if (nameExc.length && nameExc.some((k) => it.name.includes(k))) return false; if (jobInc.length && !jobInc.some((k) => it.job.includes(k))) return false; if (cfg.onlyUnread && !it.unread) return false; return true; }); } function isTouchedToday(it) { if (!cfg.oncePerDay) return false; const r = stat.ids[it.key]; return !!r; } function markDone(it, status, extra) { const rec = { t: Date.now(), time: new Date().toLocaleString('zh-CN'), name: it.name, job: it.job, uid: it.key, status: status, wechat: (extra && extra.wechat) || '', note: (extra && extra.note) || '', }; stat.ids[it.key] = { ts: Date.now(), status: status }; stat.records.push(rec); if (stat.records.length > 3000) stat.records.shift(); saveStat(); renderStats(); } /* ============================== 会话状态判断 ============================== */ const WX_MARKERS = [ /微信号?\s*[::]/, /微信[::]\s*[A-Za-z]/, /已交换微信/, /对方已同意/, /同意交换/, /微信名片/ ]; const LIMIT_MARKERS = [/今日.*上限/, /次数.*(用完|不足|已达)/, /升级.*会员/, /暂无.*权限/, /消耗.*道具/, /牛人币/]; function chatText() { const box = recordRoot() || chatRoot(); return (box.innerText || box.textContent || '').replace(/\r/g, ''); } function recordRoot() { const cands = [ '.chat-record', '.chat-message-list', '.conversation-message', '[class*="message-list"]', '[class*="chat-record"]', '[class*="message-box"]' ]; for (const s of cands) { const e = $(s); if (e && isVisible(e)) return e; } return null; } /** 工具条上的按钮状态:已交换 / 请求中(真机文案:换微信请求中) */ function toolbarExchangeState() { const t = norm(chatRoot().innerText || ''); if (/请求中|请求交换微信已发送|等待对方同意|待对方确认/.test(t)) return 'pending'; if (/已交换微信|已获取微信/.test(t)) return 'exchanged'; return ''; } const PENDING_MARKERS = [/请求交换微信已发送/, /等待对方同意/, /已发送交换微信/, /请求已发送/]; function scanChat() { const text = chatText(); const ts = toolbarExchangeState(); let exchanged = ts === 'exchanged' || WX_MARKERS.some((re) => re.test(text)); const pending = ts === 'pending' || PENDING_MARKERS.some((re) => re.test(text)); if (pending) exchanged = true; // 已发过请求,不再重复点 let wechat = ''; const m = text.match(/(?:微信号?|威信|VX|vx|V信)\s*[::]?\s*([A-Za-z][-_A-Za-z0-9]{5,19})/); if (m) wechat = m[1]; if (!wechat) { const p = text.match(/(?:\+?86[-\s]?)?(1[3-9]\d{9})/); if (p && /微信|手机|同号/.test(text)) wechat = '手机号:' + p[1]; } return { text, exchanged, pending, wechat }; } /** 当前会话里是否已经有「我发出的」消息(BOSS 要求先回复才能换微信) */ function hasMyMessage() { const box = recordRoot() || chatRoot(); // 信号①:BOSS 在自己发出的每条消息旁标「送达 / 已读 / 已发送」,这是最可靠的文字信号 const marks = box.querySelectorAll('span, em, i, b, div, p'); for (let i = 0; i < marks.length; i++) { const m = marks[i]; const t = (m.textContent || '').trim(); if (t.length > 6) continue; if (!/^(送达|已读|已发送|已送达|未读)$/.test(t)) continue; // 只看最内层,避免整段容器命中 let deeper = false; for (let j = 0; j < m.children.length; j++) { if ((m.children[j].textContent || '').trim() === t) { deeper = true; break; } } if (deeper) continue; if (isVisible(m)) return true; } // 信号②:class 直接标明是自己发的 const br = box.getBoundingClientRect(); if (!br.width) return false; const mid = br.left + br.width * 0.52; const nodes = box.querySelectorAll('[class*="item"], li, p, div'); for (let i = 0; i < nodes.length; i++) { const n = nodes[i]; const t = (n.textContent || '').trim(); if (!t) continue; let deeper = false; for (let j = 0; j < n.children.length; j++) { if ((n.children[j].textContent || '').trim() === t) { deeper = true; break; } } if (deeper) continue; const c = (n.className && typeof n.className === 'string') ? n.className : ''; if (/self|mine|from-?me|is-?self|my-?msg|msg-?right|right-?msg|to-?geek/i.test(c)) return true; // 信号③:气泡右对齐 const r = n.getBoundingClientRect(); if (r.width > 16 && r.width < br.width * 0.9 && r.left > mid) return true; } return false; } function toasts() { const sel = '.toast, .boss-toast, [class*="toast"], [class*="tip-message"], [class*="message-tip"]'; return $$(sel).filter(isVisible).map((e) => norm(e.textContent)).filter(Boolean); } /** 只在「可见的短文本节点」里匹配,避免读到隐藏模板里的文案 */ function visibleTextMatch(re, maxLen) { const tags = 'div,span,p,h1,h2,h3,h4,b,strong,em,label,li,a,button'; const nodes = document.querySelectorAll(tags); for (let i = 0; i < nodes.length; i++) { const el = nodes[i]; const t = el.textContent; if (!t || t.length > (maxLen || 60)) continue; if (!re.test(t)) continue; if (!isVisible(el)) continue; return true; } return false; } function verifyPresent() { const sels = [ '#captcha', '.geetest_panel', '.geetest_box', '[class*="captcha"] iframe', 'iframe[src*="captcha"]', 'iframe[src*="verify"]', '[class*="verify-box"]', '[class*="slider-verify"]', '[class*="slide-verify"]' ]; for (const s of sels) { const e = $(s); if (e && isVisible(e)) return true; } return visibleTextMatch(/请完成下方验证|拖动.{0,6}滑块|安全验证|请进行验证|滑动到最右/, 40); } /* ============================== 动作 ============================== */ function chatSignature() { const box = chatRoot(); const t = norm(box.innerText || box.textContent || ''); return t.length + '#' + t.slice(0, 60); } function headerName() { const sels = [ '.chat-header .name', '.chat-header [class*="name"]', '.conversation-header [class*="name"]', '.chat-name', '.message-header [class*="name"]', '[class*="chat-title"]' ]; for (const s of sels) { const e = $(s); if (e && isVisible(e)) { const t = norm(e.textContent); if (t) return t; } } return ''; } function isDoneClass(el) { return /(^|[\s-])(active|selected|checked|current|cur)([\s-]|$)/i.test(el.className || ''); } async function openConversation(it) { const el = it.el; if (!el || !el.isConnected) return false; const nameHit = () => { const hn = headerName(); return !!hn && (hn.includes(it.name) || it.name.includes(hn)); }; // 已经停在这条会话上 if (isDoneClass(el) && nameHit()) return true; const before = chatSignature(); const body = el.querySelector('[class*="item-body"], [class*="content"], [class*="info"]') || el; humanClick(body); const ok = await waitFor(() => { if (isDoneClass(el) && (nameHit() || !headerName())) return true; if (nameHit()) return true; return chatSignature() !== before && nameHit(); }, 5000, 150); await sleep(rand(400, 900)); return !!ok; } function findInput() { if (cfg.selectors.inputBox) { const e = $(cfg.selectors.inputBox); if (e && isVisible(e)) return e; } const cands = [ '.chat-input [contenteditable="true"]', '.chat-input', '[contenteditable="true"]', '.input-area textarea', 'textarea' ]; for (const s of cands) { const e = $(s); if (e && isVisible(e)) return e; } return null; } async function sendMessage(text) { if (!text) return false; const box = findInput(); if (!box) { log('未找到输入框,跳过消息发送', 'warn'); return false; } humanClick(box); box.focus(); await sleep(rand(200, 500)); const text2 = text.replace(/\s+$/g, ''); try { if (box.isContentEditable) { const sel = window.getSelection(); const range = document.createRange(); range.selectNodeContents(box); sel.removeAllRanges(); sel.addRange(range); document.execCommand('insertText', false, text2); } else { const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set; setter.call(box, text2); box.dispatchEvent(new Event('input', { bubbles: true })); } } catch (e) { try { box.textContent = text2; box.dispatchEvent(new InputEvent('input', { bubbles: true, data: text2 })); } catch (e2) { log('写入输入框失败:' + e2.message, 'err'); return false; } } await sleep(rand(250, 600)); const btn = leafByText(['发送'], 4, document.body) .map((el) => clickableAncestor(el, 3)) .find((el) => isVisible(el) && el.getBoundingClientRect().top > box.getBoundingClientRect().top - 200); if (btn) { humanClick(btn); } else { box.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true })); box.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true })); } await sleep(rand(500, 1100)); log('已发送消息:' + text2.slice(0, 24) + (text2.length > 24 ? '…' : ''), 'ok'); stat.sent++; saveStat(); return true; } function visibleDialogs() { return $$('[class*="dialog"], [class*="modal"], [class*="popup"], [role="dialog"]') .filter(isVisible) .filter((d) => (d.innerText || '').trim().length > 0) .filter((d) => !d.closest('#bwe-panel')); } /** * 不依赖 class 的弹窗识别:从「确定」这类按钮往上找,看它所在的容器里有没有「交换微信」。 * 真机的确认弹窗类名不一定含 dialog/modal/popup,按文案找才稳。 * 注意:绝不要把「发送」当成确认词 —— 聊天区的发送按钮会和它撞车。 */ const CONFIRM_LABELS = ['确定', '确认', '立即交换', '继续交换', '同意']; /** 一个容器像不像「确认弹窗」:要同时有确定/取消,且文本很短 */ function looksLikeConfirmBox(box) { if (!box) return false; const t = norm(box.innerText || box.textContent || ''); if (!t || t.length > 80) return false; if (t.indexOf('交换微信') === -1) return false; if (!/确定|确认/.test(t)) return false; if (!/取消|关闭|暂不/.test(t)) return false; if (box.querySelector('[contenteditable="true"]')) return false; return true; } function confirmButtonIn(box) { const leaves = leafByText(CONFIRM_LABELS, 6, box); const hit = leaves.map((el) => clickableAncestor(el, 3)).find((el) => isVisible(el) && !isDisabledEl(el)); if (hit) return hit; const primary = $$('button, a, [class*="btn"]', box) .filter((el) => isVisible(el) && !isDisabledEl(el)) .filter((el) => CONFIRM_LABELS.indexOf(norm(el.textContent)) !== -1) .find((el) => /primary|sure|confirm|main|ok|submit/i.test(el.className || '')); return primary || null; } /** 全页按文案找「含交换微信的确认弹窗里的确定按钮」 */ function findExchangeConfirmByText() { const nodes = document.querySelectorAll('button, span, div, a, p'); for (let i = 0; i < nodes.length; i++) { const el = nodes[i]; if (el.closest && el.closest('#bwe-panel')) continue; const t = norm(el.textContent); if (!t || t.length > 6 || CONFIRM_LABELS.indexOf(t) === -1) continue; if (!isVisible(el) || isDisabledEl(el)) continue; let box = el.parentElement; let depth = 0; while (box && depth < 6) { if (looksLikeConfirmBox(box)) { return { btn: clickableAncestor(el, 2), box }; } box = box.parentElement; depth++; } } return null; } /** 关闭残留弹窗,避免遮罩挡住后续点击 */ async function dismissDialogs() { const dlgs = visibleDialogs().filter((d) => /交换微信|微信号/.test(norm(d.innerText || ''))); for (const d of dlgs) { const cancel = leafByText(['取消', '关闭', '暂不', '知道了'], 4, d) .map((el) => clickableAncestor(el, 3)) .find((el) => isVisible(el)); if (cancel) { humanClick(cancel); await sleep(300); } } return dlgs.length; } async function handleConfirmDialog(before) { const pre = new Set(before || []); const deadline = Date.now() + 4000; const seen = []; while (Date.now() < deadline) { // ① 新出现的、class 命中 dialog/modal/popup 的容器 const fresh = visibleDialogs().filter((d) => !pre.has(d)); // ② 不依赖 class:从「确定」按钮往上找到的弹窗 const byText = findExchangeConfirmByText(); const boxes = fresh.slice(); if (byText && byText.box && boxes.indexOf(byText.box) === -1) boxes.push(byText.box); for (const box of boxes) { const txt = norm(box.innerText || box.textContent || ''); seen.push(txt.slice(0, 40)); if (/验证|滑块|拖动/.test(txt)) return { clicked: false, dialog: 'verify', text: txt }; if (txt.indexOf('微信') === -1) continue; const target = confirmButtonIn(box); if (!target) continue; humanClick(target); await sleep(rand(500, 1100)); return { clicked: true, dialog: 'confirm', text: txt.slice(0, 60), el: describeEl(target) }; } // ③ 直接命中按钮(容器没匹配上也能点) if (byText && byText.btn) { humanClick(byText.btn); await sleep(rand(500, 1100)); return { clicked: true, dialog: 'confirm-text', text: norm(byText.box.innerText || '').slice(0, 60), el: describeEl(byText.btn) }; } await sleep(150); } log('⚠ 没找到可点的「确定」按钮,弹窗文本:' + (seen.length ? seen[0] : '(未检测到弹窗)'), 'warn'); return { clicked: false, dialog: 'not-found', text: seen[0] || '' }; } async function verifyExchange(it) { const before = norm(chatText()).length; const res = await waitFor(() => { const sc = scanChat(); // 真机成功标志:聊天区插入「请求交换微信已发送」系统消息 + 按钮变「换微信请求中」 if (sc.pending) return { type: 'sent', wechat: sc.wechat, toast: '请求交换微信已发送' }; const t = toasts(); if (t.some((x) => LIMIT_MARKERS.some((re) => re.test(x)))) return { type: 'limit', toast: t.join(' / ') }; if (t.some((x) => /成功|已发送|已通知|等待对方/.test(x))) return { type: 'sent', toast: t.join(' / ') }; if (sc.exchanged) return { type: 'exchanged', wechat: sc.wechat }; if (norm(chatText()).length > before + 4) return { type: 'changed', wechat: scanChat().wechat }; return null; }, 8000, 300); return res || { type: 'unknown' }; } /* ============================== 单次处理 ============================== */ let failStreak = 0; async function processOne(it, token) { // 上一轮如果留下没关掉的弹窗,会挡住左侧列表的点击 if (visibleDialogs().length) await dismissDialogs(); log('▶ 打开会话:' + it.name + (it.job ? ' · ' + it.job : '')); const opened = await openConversation(it); if (!opened) { log('✗ 会话未成功切换:' + it.name, 'err'); failStreak++; markDone(it, 'open-fail'); return; } await sleep(rand(500, 1200)); const sc = scanChat(); if (cfg.skipIfExchanged && (sc.exchanged || sc.pending)) { log('↷ ' + it.name + (sc.pending ? ' 已申请交换、等待对方同意,跳过' : ' 已有微信/已交换,跳过'), 'skip'); stat.skip++; saveStat(); bumpHour(); markDone(it, 'already', { wechat: sc.wechat }); renderStats(); return; } // ⚠ 业务规则:必须先回复过对方,BOSS 才允许换微信。 // needGreet 只看「我到底发过没」:列表预览带 [送达]/[已读] 或聊天区有己方气泡,都算已回复。 const needGreet = !!cfg.greeting && (cfg.greetingAlways || (!it.fromMe && !hasMyMessage())); if (needGreet) { if (cfg.dryRun) { log('[试运行] 将先发送招呼语「' + cfg.greeting + '」,再点换微信(未执行)', 'warn'); } else { log('✎ 先回复对方:' + cfg.greeting); const lenBefore = chatText().length; const sentOk = await sendMessage(cfg.greeting); if (!sentOk) { log('✗ 招呼语没发出去,跳过(BOSS 要求先回复才能换微信)', 'err'); failStreak++; stat.fail++; saveStat(); markDone(it, 'greet-fail'); renderStats(); return; } await sleep(rand(900, 1800)); // 双重确认:聊天区出现己方气泡,或记录长度确实变长了 const grew = chatText().length > lenBefore + 0; if (!hasMyMessage() && !grew) { log('✗ 招呼语发出后仍未检测到己方消息,跳过(避免点空)', 'err'); stat.fail++; saveStat(); markDone(it, 'greet-unconfirmed'); renderStats(); return; } } } const before = visibleDialogs(); // 真机:按钮一直都在,只是未满足条件时带 .disabled(提示「交换微信:双方回复后可用」)。 // 所以不是「等按钮出现」,而是「等按钮变成可点」。 const wanted = (s) => s === 'ready' || s === 'pending' || s === 'exchanged'; const info = (await waitFor(() => { const i = exchangeButtonInfo(); return wanted(i.state) ? i : null; }, cfg.btnWaitMs || 9000, 300)) || exchangeButtonInfo(); if (info.state === 'pending' || info.state === 'exchanged') { log('↷ ' + it.name + ' 已是「' + (info.tip || info.state) + '」状态,跳过', 'skip'); stat.skip++; saveStat(); markDone(it, 'already', { note: info.tip || info.state }); renderStats(); return; } if (cfg.dryRun) { log('[试运行] 「换微信」状态:' + info.state + (info.tip ? '(' + info.tip + ')' : '') + ',未点击', 'warn'); markDone(it, 'dry-run'); return; } if (info.state !== 'ready') { const wxCount = $$('div,span,a,button,li').filter((x) => { const t = norm(x.textContent); return t.length <= 20 && t.indexOf('微信') !== -1 && isVisible(x) && !x.closest('#bwe-panel'); }).length; log( '✗ ' + it.name + ' 「换微信」不可点(状态 ' + info.state + (info.tip ? ',提示:' + info.tip : '') + ';页面含「微信」元素 ' + wxCount + ' 个)。需双方都回复过才能交换,已跳过', 'err' ); failStreak++; stat.fail++; saveStat(); markDone(it, 'btn-disabled', { note: info.tip }); renderStats(); return; } const btn = info.el; humanClick(btn); await sleep(rand(cfg.clickDelayMin, cfg.clickDelayMax)); const dlg = await handleConfirmDialog(before); if (dlg.dialog === 'verify') { log('⛔ 弹出人机验证,自动暂停。请手动完成验证后点「开始」继续', 'err'); stop('verify'); return; } if (dlg.clicked) { log('已点击确认:' + dlg.el, 'info'); } else { const n = await dismissDialogs(); log('⚠ 确认按钮没点上(弹窗文本:' + (dlg.text || '无') + ')' + (n ? ',已关闭弹窗避免阻挡后续' : ''), 'warn'); } const v = await verifyExchange(it); if (v.type === 'limit') { log('⛔ 触发平台限额/权限提示:' + v.toast, 'err'); stat.fail++; markDone(it, 'limit', { note: v.toast }); renderStats(); stop('limit'); return; } if (v.type === 'exchanged' || v.type === 'changed' || v.type === 'sent') { stat.ok++; bumpHour(); failStreak = 0; saveStat(); const wx = v.wechat || scanChat().wechat; log('✓ ' + it.name + ' 已发起交换' + (wx ? ',微信号:' + wx : '(等待对方同意后回填)'), 'ok'); markDone(it, 'sent', { wechat: wx, note: v.toast || '' }); if (cfg.msgAfter) { await sleep(rand(800, 1500)); await sendMessage(cfg.msgAfter); } renderStats(); return; } stat.fail++; failStreak++; saveStat(); log('? ' + it.name + ' 结果无法确认(可能需对方同意,或无权限)', 'warn'); markDone(it, 'unverified'); renderStats(); } /* ============================== 主循环 ============================== */ const state = { running: false, token: 0 }; function checkGuards() { if (cfg.dailyCap > 0 && stat.ok + stat.sent > cfg.dailyCap) return '已达每日上限 ' + cfg.dailyCap; if (cfg.hourlyCap > 0 && hourUsed() >= cfg.hourlyCap) return '已达每小时上限 ' + cfg.hourlyCap + ',稍后再跑'; if (failStreak >= cfg.maxFailStreak) return '连续失败 ' + failStreak + ' 次,已自动停止(检查页面是否改版/掉登录)'; return ''; } async function sleepAbort(ms, token) { const end = Date.now() + ms; while (Date.now() < end) { if (!state.running || token !== state.token) return false; await sleep(Math.min(500, end - Date.now())); } return true; } async function loop(token) { log('———————— 开始运行 ————————', 'ok'); let idleRounds = 0; while (state.running && token === state.token) { const guard = checkGuards(); if (guard) { log('⛔ ' + guard, 'err'); stop('guard'); return; } if (cfg.pauseWhenHidden && document.hidden) { log('页面切到后台,暂停 20s…', 'warn'); if (!(await sleepAbort(20000, token))) return; continue; } if (cfg.stopOnVerify && verifyPresent()) { log('⛔ 检测到验证拦截,自动暂停', 'err'); stop('verify'); return; } const all = collectTargets(); const todo = all.filter((it) => !isTouchedToday(it)); if (!todo.length) { idleRounds++; log( all.length ? '本轮 ' + all.length + ' 个会话都已处理过(每人每天一次),60s 后重扫' : '左侧列表没读到会话,请确认在「沟通」页并已加载列表。60s 后重试', 'warn' ); if (idleRounds >= 5) { log('连续 5 轮无目标,自动停止。干完活记得回来点开始。', 'warn'); stop('idle'); return; } if (!(await sleepAbort(60000, token))) return; continue; } idleRounds = 0; const it = todo[0]; try { await processOne(it, token); } catch (e) { log('处理异常:' + (e && e.message), 'err'); failStreak++; } if (!state.running || token !== state.token) return; const wait = rand(Math.max(6, cfg.intervalMin), Math.max(cfg.intervalMin + 1, cfg.intervalMax)); log('⏳ 下一位 ' + wait.toFixed(1) + 's 后处理(剩 ' + Math.max(0, todo.length - 1) + ' 个待办)'); if (!(await sleepAbort(wait * 1000, token))) return; } } function start() { if (state.running) return; if (cfg.stopOnVerify && verifyPresent()) { log('页面存在验证组件,先手动完成验证再开始', 'err'); return; } state.running = true; state.token++; failStreak = 0; cfg.enabled = true; saveCfg(); renderStatus(); loop(state.token); } function stop(reason) { state.running = false; state.token++; cfg.enabled = false; saveCfg(); renderStatus(); log('■ 已停止' + (reason ? '(' + reason + ')' : '')); } /* ============================== UI ============================== */ const CSS = ` #bwe-panel{position:fixed;right:16px;bottom:16px;width:326px;z-index:2147483000;font:12px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;color:#1f2329;background:#fff;border:1px solid #e3e6eb;border-radius:12px;box-shadow:0 10px 30px rgba(20,30,60,.16);user-select:none;overflow:hidden} #bwe-panel *{box-sizing:border-box} #bwe-panel .bwe-hd{display:flex;align-items:center;gap:8px;padding:9px 10px;background:linear-gradient(180deg,#f8fafc,#f1f4f8);border-bottom:1px solid #eceff3;cursor:move} #bwe-panel .bwe-dot{width:8px;height:8px;border-radius:50%;background:#b8bfc9;flex:0 0 auto} #bwe-panel .bwe-dot.on{background:#12b76a;box-shadow:0 0 0 3px rgba(18,183,106,.16)} #bwe-panel .bwe-dot.busy{background:#f79009;box-shadow:0 0 0 3px rgba(247,144,9,.16)} #bwe-panel .bwe-ttl{font-weight:600;font-size:12.5px;letter-spacing:.2px} #bwe-panel .bwe-ver{color:#98a2b3;font-size:11px} #bwe-panel .bwe-sp{flex:1} #bwe-panel .bwe-ic{width:20px;height:20px;line-height:18px;text-align:center;border-radius:6px;border:1px solid #e3e6eb;background:#fff;color:#667085;cursor:pointer;font-size:13px} #bwe-panel .bwe-ic:hover{background:#f2f5f9;color:#1f2329} #bwe-panel .bwe-bd{padding:10px;max-height:min(70vh,620px);overflow:auto} #bwe-panel .bwe-row{display:flex;gap:6px;align-items:center;margin-bottom:8px} #bwe-panel .bwe-btn{flex:1;height:30px;border-radius:8px;border:1px solid #d0d5dd;background:#fff;color:#344054;font-size:12px;cursor:pointer;font-weight:500} #bwe-panel .bwe-btn:hover{background:#f5f7fa} #bwe-panel .bwe-btn.pri{background:#0a7cff;border-color:#0a7cff;color:#fff} #bwe-panel .bwe-btn.pri:hover{background:#0968d6} #bwe-panel .bwe-btn.dan{color:#b42318;border-color:#f3c6c0} #bwe-panel .bwe-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px 8px;margin-bottom:8px} #bwe-panel .bwe-fld{display:flex;flex-direction:column;gap:2px} #bwe-panel .bwe-fld span{color:#667085;font-size:11px} #bwe-panel input[type=number],#bwe-panel input[type=text],#bwe-panel textarea{width:100%;border:1px solid #d8dde5;border-radius:7px;padding:4px 6px;font-size:12px;color:#1f2329;background:#fff;outline:none;font-family:inherit} #bwe-panel input:focus,#bwe-panel textarea:focus{border-color:#84b6ff;box-shadow:0 0 0 2px rgba(10,124,255,.12)} #bwe-panel textarea{resize:vertical;min-height:44px;line-height:1.5} #bwe-panel .bwe-chk{display:flex;flex-wrap:wrap;gap:4px 10px;margin-bottom:8px} #bwe-panel .bwe-chk label{display:flex;align-items:center;gap:4px;color:#344054;font-size:11.5px;cursor:pointer} #bwe-panel .bwe-chk input{accent-color:#0a7cff;margin:0} #bwe-panel .bwe-sec{border-top:1px dashed #e6e9ef;margin:8px 0;padding-top:8px} #bwe-panel .bwe-sec>b{display:block;font-size:11px;color:#98a2b3;font-weight:600;letter-spacing:.4px;margin-bottom:6px} #bwe-panel .bwe-stat{display:flex;gap:6px;margin-bottom:8px} #bwe-panel .bwe-kpi{flex:1;background:#f7f9fc;border:1px solid #eef1f6;border-radius:8px;padding:5px 6px;text-align:center} #bwe-panel .bwe-kpi i{display:block;font-style:normal;font-size:15px;font-weight:700;color:#101828} #bwe-panel .bwe-kpi em{font-style:normal;font-size:10.5px;color:#98a2b3} #bwe-panel .bwe-log{height:132px;overflow:auto;background:#fbfcfe;border:1px solid #eef1f6;border-radius:8px;padding:6px;font:11px/1.55 ui-monospace,Menlo,Consolas,monospace;color:#475467;white-space:pre-wrap;word-break:break-all;user-select:text} #bwe-panel .bwe-log-line{padding:1px 0} #bwe-panel .bwe-ok{color:#067647} #bwe-panel .bwe-err{color:#b42318} #bwe-panel .bwe-warn{color:#b54708} #bwe-panel .bwe-skip{color:#7a5af8} #bwe-panel .bwe-tip{color:#98a2b3;font-size:10.5px;line-height:1.5;margin-top:4px} #bwe-ball{position:fixed;right:16px;bottom:16px;width:46px;height:46px;border-radius:50%;background:#0a7cff;color:#fff;display:none;align-items:center;justify-content:center;font-size:11px;font-weight:700;cursor:pointer;z-index:2147483000;box-shadow:0 8px 22px rgba(10,124,255,.38);line-height:1.1;text-align:center} #bwe-ball.on{background:#12b76a;box-shadow:0 8px 22px rgba(18,183,106,.38)} #bwe-panel.min .bwe-bd{display:none} `; let panel, ball, ui = {}; function el(tag, cls, txt) { const e = document.createElement(tag); if (cls) e.className = cls; if (txt != null) e.textContent = txt; return e; } function buildUI() { if (document.getElementById('bwe-panel')) { panel = document.getElementById('bwe-panel'); return; } const st = document.createElement('style'); st.textContent = CSS; document.head.appendChild(st); panel = el('div'); panel.id = 'bwe-panel'; const hd = el('div', 'bwe-hd'); hd.innerHTML = '' + '自动换微信' + 'v' + VERSION + '' + '' + '—' + '×'; panel.appendChild(hd); const bd = el('div', 'bwe-bd'); panel.appendChild(bd); // 操作行 const row = el('div', 'bwe-row'); ui.btnStart = el('button', 'bwe-btn pri', '开始'); ui.btnOnce = el('button', 'bwe-btn', '试运行一次'); ui.btnProbe = el('button', 'bwe-btn', '探针'); row.append(ui.btnStart, ui.btnOnce, ui.btnProbe); bd.appendChild(row); // 统计 const statWrap = el('div', 'bwe-stat'); const mk = (label) => { const k = el('div', 'bwe-kpi'); const i = el('i', '', '0'); const e = el('em', '', label); k.append(i, e); statWrap.appendChild(k); return i; }; ui.kOk = mk('已发起'); ui.kSkip = mk('已跳过'); ui.kFail = mk('失败'); ui.kQuota = mk('今日配额'); bd.appendChild(statWrap); // 参数 const grid = el('div', 'bwe-grid'); function field(label, key, attrs) { const f = el('div', 'bwe-fld'); f.appendChild(el('span', '', label)); const inp = document.createElement('input'); inp.type = 'number'; inp.value = cfg[key]; Object.assign(inp, attrs || {}); inp.addEventListener('change', () => { const v = Number(inp.value); cfg[key] = isNaN(v) ? DEFAULTS[key] : v; if (cfg.intervalMax <= cfg.intervalMin) cfg.intervalMax = cfg.intervalMin + 5; saveCfg(); syncInputs(); }); f.appendChild(inp); grid.appendChild(f); return inp; } ui.inMin = field('间隔最小(秒)', 'intervalMin', { min: 6, max: 3600 }); ui.inMax = field('间隔最大(秒)', 'intervalMax', { min: 7, max: 3600 }); ui.inDay = field('每日上限', 'dailyCap', { min: 0, max: 200 }); ui.inHour = field('每小时上限', 'hourlyCap', { min: 0, max: 100 }); bd.appendChild(grid); // 开关 const chk = el('div', 'bwe-chk'); const toggles = [ ['skipIfExchanged', '跳过已交换'], ['oncePerDay', '每人每天一次'], ['onlyUnread', '只看未读'], ['dryRun', '试运行(不点)'], ['pauseWhenHidden', '后台暂停'], ['stopOnVerify', '遇验证即停'] ]; ui.toggles = {}; toggles.forEach(([k, label]) => { const l = document.createElement('label'); const i = document.createElement('input'); i.type = 'checkbox'; i.checked = !!cfg[k]; i.addEventListener('change', () => { cfg[k] = i.checked; saveCfg(); }); l.append(i, document.createTextNode(label)); chk.appendChild(l); ui.toggles[k] = i; }); bd.appendChild(chk); // 文本过滤 const sec1 = el('div', 'bwe-sec'); sec1.appendChild(el('b', '', '筛选(留空=不限)')); ui.inNameInc = textInput('姓名/昵称 包含', 'nameInclude', '多个用逗号,如 张,李'); ui.inNameExc = textInput('排除', 'nameExclude', '如 测试,销售'); ui.inJobInc = textInput('沟通职位 包含', 'jobInclude', '如 运营,新媒体'); sec1.append(ui.inNameInc.wrap, ui.inNameExc.wrap, ui.inJobInc.wrap); bd.appendChild(sec1); // 话术 const sec2 = el('div', 'bwe-sec'); sec2.appendChild(el('b', '', '话术(BOSS 需先回复才能换微信)')); ui.inGreeting = textArea('首句招呼语(必发)', 'greeting', '默认:你好'); const greetChk = document.createElement('label'); greetChk.style.cssText = 'display:flex;align-items:center;gap:4px;font-size:11.5px;color:#344054;margin:-2px 0 6px;cursor:pointer'; const greetBox = document.createElement('input'); greetBox.type = 'checkbox'; greetBox.checked = !!cfg.greetingAlways; greetBox.style.accentColor = '#0a7cff'; greetBox.addEventListener('change', () => { cfg.greetingAlways = greetBox.checked; saveCfg(); }); greetChk.append(greetBox, document.createTextNode('每次对话都发(不勾选则仅在尚未回复过时发)')); ui.inMsgAfter = textArea('交换后再补一句', 'msgAfter', '例:微信同号,麻烦通过一下,备注:姓名+岗位'); sec2.append(ui.inGreeting.wrap, greetChk, ui.inMsgAfter.wrap); bd.appendChild(sec2); // 高级 const sec3 = el('div', 'bwe-sec'); const advHead = el('b', '', '高级(点击展开)'); advHead.style.cursor = 'pointer'; sec3.appendChild(advHead); const advBox = el('div'); advBox.style.display = 'none'; advHead.addEventListener('click', () => { advBox.style.display = advBox.style.display === 'none' ? 'block' : 'none'; }); const selTa = document.createElement('textarea'); selTa.style.minHeight = '74px'; selTa.style.fontFamily = 'ui-monospace,Menlo,Consolas,monospace'; selTa.style.fontSize = '11px'; selTa.value = JSON.stringify(cfg.selectors, null, 2); const selSave = el('button', 'bwe-btn', '保存选择器'); selSave.style.marginTop = '4px'; selSave.addEventListener('click', () => { try { const o = JSON.parse(selTa.value); cfg.selectors = Object.assign({}, DEFAULTS.selectors, o); saveCfg(); log('选择器已保存', 'ok'); } catch (e) { log('选择器 JSON 解析失败:' + e.message, 'err'); } }); advBox.append( el('div', 'bwe-tip', '页面改版时,先点「探针」把候选元素打到日志,再把 CSS 选择器填进来。exchangeBtn 为空则走文本匹配(推荐)。'), selTa, selSave ); sec3.appendChild(advBox); bd.appendChild(sec3); // 日志 const sec4 = el('div', 'bwe-sec'); const logHead = el('b', '', '运行日志'); sec4.appendChild(logHead); logEl = el('div', 'bwe-log'); sec4.appendChild(logEl); const row2 = el('div', 'bwe-row'); row2.style.marginTop = '6px'; const bCsv = el('button', 'bwe-btn', '导出 CSV'); const bReset = el('button', 'bwe-btn dan', '重置今日'); row2.append(bCsv, bReset); sec4.appendChild(row2); bd.appendChild(sec4); bCsv.addEventListener('click', exportCsv); bReset.addEventListener('click', () => { if (!confirm('清空今日统计与「已处理」记录?')) return; const fresh = { date: todayKey(), ok: 0, skip: 0, fail: 0, sent: 0, hour: {}, ids: {}, records: [] }; Object.assign(stat, fresh); saveStat(); renderStats(); log('今日记录已重置', 'warn'); }); document.body.appendChild(panel); ball = el('div'); ball.id = 'bwe-ball'; ball.innerHTML = '换
微信'; ball.addEventListener('click', () => { ball.style.display = 'none'; panel.style.display = ''; }); document.body.appendChild(ball); // 拖拽 let drag = null; hd.addEventListener('mousedown', (e) => { if (e.target.classList.contains('bwe-ic')) return; const r = panel.getBoundingClientRect(); drag = { dx: e.clientX - r.left, dy: e.clientY - r.top }; e.preventDefault(); }); window.addEventListener('mousemove', (e) => { if (!drag) return; const x = Math.max(0, Math.min(innerWidth - 60, e.clientX - drag.dx)); const y = Math.max(0, Math.min(innerHeight - 40, e.clientY - drag.dy)); panel.style.left = x + 'px'; panel.style.top = y + 'px'; panel.style.right = 'auto'; panel.style.bottom = 'auto'; }); window.addEventListener('mouseup', () => (drag = null)); $('#bwe-min').addEventListener('click', () => panel.classList.toggle('min')); $('#bwe-hide').addEventListener('click', () => { panel.style.display = 'none'; ball.style.display = 'flex'; }); panel.addEventListener('mousedown', (e) => e.stopPropagation()); panel.addEventListener('click', (e) => e.stopPropagation()); panel.addEventListener('keydown', (e) => e.stopPropagation()); ui.btnStart.addEventListener('click', () => { if (state.running) stop('手动'); else start(); }); ui.btnOnce.addEventListener('click', onceRun); ui.btnProbe.addEventListener('click', probe); renderStatus(); renderStats(); log('面板已就绪 · v' + VERSION + ' · 在「沟通」页点开始', 'ok'); } function textInput(label, key, ph) { const wrap = el('div', 'bwe-fld'); wrap.style.marginBottom = '6px'; wrap.appendChild(el('span', '', label)); const inp = document.createElement('input'); inp.type = 'text'; inp.placeholder = ph || ''; inp.value = cfg[key] || ''; inp.addEventListener('change', () => { cfg[key] = inp.value.trim(); saveCfg(); }); wrap.appendChild(inp); return { wrap, inp }; } function textArea(label, key, ph) { const wrap = el('div', 'bwe-fld'); wrap.style.marginBottom = '6px'; wrap.appendChild(el('span', '', label)); const ta = document.createElement('textarea'); ta.placeholder = ph || ''; ta.value = cfg[key] || ''; ta.addEventListener('change', () => { cfg[key] = ta.value.trim(); saveCfg(); }); wrap.appendChild(ta); return { wrap, ta }; } function syncInputs() { if (!ui.inMin) return; ui.inMin.value = cfg.intervalMin; ui.inMax.value = cfg.intervalMax; ui.inDay.value = cfg.dailyCap; ui.inHour.value = cfg.hourlyCap; } function renderStatus() { const dot = document.getElementById('bwe-dot'); if (dot) { dot.className = 'bwe-dot' + (state.running ? ' on' : ''); } if (ui.btnStart) { ui.btnStart.textContent = state.running ? '暂停' : '开始'; ui.btnStart.classList.toggle('dan', state.running); ui.btnStart.classList.toggle('pri', !state.running); } if (ball) ball.classList.toggle('on', state.running); } function renderStats() { if (!ui.kOk) return; ui.kOk.textContent = stat.ok; ui.kSkip.textContent = stat.skip; ui.kFail.textContent = stat.fail; ui.kQuota.textContent = (cfg.dailyCap ? stat.ok + '/' + cfg.dailyCap : stat.ok); } /* ============================== 探针 / 试运行 / 导出 ============================== */ function probe() { log('——— 探针开始 ———', 'warn'); const clip = (s, n) => String(s || '').replace(/\s+/g, ' ').slice(0, n || 200); // ① 精确文本命中 const leaves = leafByText(EXCHANGE_TEXTS.concat(['发送', '换电话', '约面试', '求简历']), 8); log('精确文本命中 ' + leaves.length + ' 个:', 'skip'); leaves.slice(0, 8).forEach((el2) => { const r = el2.getBoundingClientRect(); log(' 「' + norm(el2.textContent) + '」 ' + describeEl(el2) + ' @(' + Math.round(r.left) + ',' + Math.round(r.top) + ') → 祖先 ' + describeEl(clickableAncestor(el2, 4)), 'skip'); }); // ② 所有含「微信」的可见元素(模糊,最能说明按钮到底长什么样) const fuzzy = $$('div,span,a,button,li,i,em,p').filter((el2) => { if (el2.closest('#bwe-panel')) return false; const t = norm(el2.textContent); return t.length > 0 && t.length <= 20 && t.indexOf('微信') !== -1 && isVisible(el2); }); log('含「微信」的可见元素 ' + fuzzy.length + ' 个:', 'skip'); fuzzy.slice(0, 10).forEach((el2) => { const r = el2.getBoundingClientRect(); log(' 「' + norm(el2.textContent) + '」 ' + describeEl(el2) + ' @(' + Math.round(r.left) + ',' + Math.round(r.top) + ') w=' + Math.round(r.width), 'skip'); }); // ③ 工具条容器真实结构(operate-exchange / operate / toolbar) const boxes = $$('[class*="operate-exchange"], [class*="chat-op"], [class*="toolbar"], [class*="operate"]').filter((b) => isVisible(b) && !b.closest('#bwe-panel')); log('工具条容器 ' + boxes.length + ' 个:', 'skip'); boxes.slice(0, 4).forEach((b) => { log(' ' + describeEl(b) + ' HTML: ' + clip(b.outerHTML, 240), 'skip'); }); // ④ 当前找到的按钮 const btn = findExchangeButton(); log('findExchangeButton 结果:' + (btn ? describeEl(btn) : '未找到'), btn ? 'ok' : 'err'); // ⑤ 列表 const items = getListItems(); log('左侧列表项 ' + items.length + ' 个;首项 HTML: ' + (items[0] ? clip(items[0].outerHTML, 200) : '-'), 'skip'); const it0 = items[0] ? readItem(items[0], 0) : null; if (it0) { log('首项解析:name=「' + it0.name + '」 job=「' + it0.job + '」 time=「' + it0.timeText + '」 未读=' + it0.unread + ' 我发过=' + it0.fromMe, 'skip'); } const tg = collectTargets(); log('当前过滤后目标 ' + tg.length + ' 个,前 3 个:' + tg.slice(0, 3).map((x) => x.name + (x.unread ? '(未读)' : '') + (x.fromMe ? '(已回)' : '')).join(' / '), 'skip'); log('当前可见弹窗数:' + visibleDialogs().length, 'skip'); log('——— 探针结束,把上面信息发我即可适配 ———', 'warn'); } async function onceRun() { if (state.running) { log('正在运行中,先暂停再试运行', 'warn'); return; } const all = collectTargets(); const it = all.find((x) => !isTouchedToday(x)); if (!it) { log('没有可试运行的目标', 'warn'); return; } const bak = cfg.dryRun; cfg.dryRun = true; log('——— 试运行:' + it.name + ' ———', 'warn'); await processOne(it, -1); cfg.dryRun = bak; } function exportCsv() { if (!stat.records.length) { log('还没有记录可导出', 'warn'); return; } const head = ['时间', '姓名', '沟通职位', '状态', '微信号', '备注']; const esc = (s) => '"' + String(s == null ? '' : s).replace(/"/g, '""') + '"'; const rows = stat.records.map((r) => [r.time, r.name, r.job, r.status, r.wechat, r.note].map(esc).join(',') ); const csv = '\ufeff' + [head.map(esc).join(','), ...rows].join('\r\n'); const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'boss-wechat-' + todayKey() + '.csv'; document.body.appendChild(a); a.click(); setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1500); log('已导出 ' + stat.records.length + ' 条记录', 'ok'); } /* ============================== 启动 ============================== */ function boot() { if (!/\/web\/(chat|boss)/.test(location.pathname)) return; buildUI(); window.__BWE__ = { booted: true, version: VERSION, start, stop, cfg, stat, probe, exportCsv, // 供自动化测试 / 二次开发调用 __test: { collectTargets, readItem, getListItems, findExchangeButton, exchangeButtonInfo, isDisabledEl, scanChat, hasMyMessage, processOne, openConversation, sendMessage, handleConfirmDialog, findExchangeConfirmByText, dismissDialogs, visibleDialogs, describeEl, leafByText, saveCfg, saveStat, state, }, togglePanel() { if (!panel) return; const hidden = panel.style.display === 'none'; panel.style.display = hidden ? '' : 'none'; ball.style.display = hidden ? 'none' : 'flex'; }, }; // 扩展 popup 通过 postMessage 控制 window.addEventListener('message', (ev) => { if (ev.source !== window) return; const d = ev.data; if (!d || d.__bwe !== 1) return; if (d.action === 'toggle-panel') window.__BWE__.togglePanel(); if (d.action === 'start') start(); if (d.action === 'stop') stop('外部'); if (d.action === 'status') { window.postMessage({ __bwe: 1, action: 'status-reply', running: state.running, ok: stat.ok, skip: stat.skip, fail: stat.fail }, '*'); } }); log('页面已注入 · 路径 ' + location.pathname, 'ok'); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => setTimeout(boot, 800)); } else { setTimeout(boot, 800); } // SPA 路由切换后重挂(面板可能被 React 清掉时兜底) let lastPath = location.pathname; setInterval(() => { if (location.pathname !== lastPath) { lastPath = location.pathname; if (/\/web\/(chat|boss)/.test(lastPath) && !document.getElementById('bwe-panel')) { setTimeout(buildUI, 900); } } }, 1500); })();