// ==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);
})();