279 lines
14 KiB
JavaScript
279 lines
14 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* 每日筛选守护运行器 v1.0(2026-08-26)
|
||
* 用法:ACCOUNT=hr1 CDP_PORT=9223 node daily-runner.js <acct> <port>
|
||
*
|
||
* 逻辑:从启动(北京时间09:30由cron触发)开始循环跑批次(每批25人,批间休息10-20分钟),
|
||
* 结束条件(二选一):
|
||
* 1. 聊天次数耗尽
|
||
* 2. 北京时间 18:00 截止
|
||
* 异常退出:登录失效(群告警)/ 风控验证码(私聊告警)/ 隧道建不起来(私聊告警)
|
||
* 结束后:写状态文件;若对方账号也已结束(或失效/无状态),生成日报并发群(每日只发一次,flag防重)。
|
||
*/
|
||
const { execSync, spawn } = require('child_process');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
|
||
const ACCT = process.argv[2] || process.env.ACCOUNT || 'hr1';
|
||
const PORT = process.argv[3] || process.env.CDP_PORT || '9222';
|
||
const DB = process.env.RECRUITMENT_DB || '/opt/data/recruitment-data/recruitment-live.db';
|
||
const SCRIPTS = '/opt/data/skills/recruitment-resume-screener/scripts';
|
||
const STATE_DIR = '/opt/data/logs/recruitment/state';
|
||
const STOP_FLAG = path.join(STATE_DIR, 'STOP.flag');
|
||
// 消息策略(2026-08-26 用户定稿):登录失效+每日日报 → 群(数字HR);隧道失败/风控 → 私聊
|
||
const GROUP = 'oc_7ef4980f25ec2a80bb013d5dd79b9864'; // 群:登录失效告警 + 每日日报
|
||
const DM = 'oc_d09c77b62ca5f90f6699830fbc695c44'; // 私聊:隧道/风控异常
|
||
const HERMES = '/opt/hermes/.venv/bin/hermes';
|
||
const BATCH_MIN = 50, BATCH_MAX = 100; // 每批人数随机 50-100
|
||
const OTHER = ACCT === 'hr1' ? 'hr2' : 'hr1';
|
||
|
||
fs.mkdirSync(STATE_DIR, { recursive: true });
|
||
const log = (m) => console.log(`[daily-runner ${ACCT}] ${new Date().toISOString()} ${m}`);
|
||
|
||
// ---------- 工具 ----------
|
||
const bjNow = () => new Date(Date.now() + 8 * 3600 * 1000); // 北京时间
|
||
const bjHm = () => { const d = bjNow(); return d.getUTCHours() * 60 + d.getUTCMinutes(); };
|
||
const pastCutoff = (atMin = 18 * 60) => bjHm() >= atMin; // 默认北京 18:00,可传其它时刻
|
||
const today = () => bjNow().toISOString().slice(0, 10);
|
||
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
||
const randInt = (a, b) => a + Math.floor(Math.random() * (b - a + 1));
|
||
|
||
function send(target, text) {
|
||
try {
|
||
fs.writeFileSync('/tmp/runner-msg-' + ACCT + '.txt', text);
|
||
execSync(`${HERMES} send -t "feishu:${target}" -f /tmp/runner-msg-${ACCT}.txt`,
|
||
{ stdio: ['pipe', 'pipe', 'pipe'], timeout: 60000 });
|
||
log(`已发送消息 → ${target === GROUP ? '群(数字HR)' : '私聊'}: ${text.slice(0, 60)}`);
|
||
} catch (e) { log(`发送失败: ${String(e.message).slice(0, 100)}`); }
|
||
}
|
||
|
||
// 发送图片+文字到群(MEDIA: 语法)
|
||
function sendGroupImage(text, imgPath) {
|
||
try {
|
||
fs.writeFileSync(`/tmp/runner-imgmsg-${ACCT}.txt`, `${text}\nMEDIA:${imgPath}`);
|
||
execSync(`${HERMES} send -t "feishu:${GROUP}" -f /tmp/runner-imgmsg-${ACCT}.txt`,
|
||
{ stdio: ['pipe', 'pipe', 'pipe'], timeout: 90000 });
|
||
log(`已发送图片消息 → 群(数字HR)(附二维码)`);
|
||
} catch (e) { log(`图片消息发送失败: ${String(e.message).slice(0, 100)}`); }
|
||
}
|
||
|
||
// 登录失效时:刷新页面抓登录二维码,发群(文字+二维码+补充说明)
|
||
function notifyLoginExpired() {
|
||
const label = `${ACCT}(${ACCT === 'hr1' ? 'Chrome·合塑账号' : 'Edge·派卡账号'})`;
|
||
const text = `🚨 当前${label}账号登录已失效,今日筛选中止。扫码可重新登录:\n如今天不需要协助筛选简历,可忽略该二维码`;
|
||
const qrPath = `/tmp/zhaopin-login-qr-${ACCT}.png`;
|
||
try {
|
||
execSync(`node ${SCRIPTS}/login-qrcode.js ${PORT} ${qrPath}`,
|
||
{ cwd: SCRIPTS, timeout: 90000, stdio: ['pipe', 'pipe', 'pipe'] });
|
||
if (fs.existsSync(qrPath) && fs.statSync(qrPath).size > 1000) {
|
||
sendGroupImage(text, qrPath);
|
||
return;
|
||
}
|
||
log('二维码截图异常(文件过小或不存在),降级为纯文字');
|
||
} catch (e) { log(`二维码获取失败: ${String(e.message).slice(0, 100)},降级为纯文字`); }
|
||
send(GROUP, `🚨 当前${label}账号登录已失效,今日筛选中止,请在任务机重新登录智联`);
|
||
}
|
||
|
||
// 被顶号告警:先关闭被顶弹窗/刷新页面 → 导航到登录页抓二维码 → 发群(文字+二维码+说明)
|
||
async function notifyKickedOut(chatLeft) {
|
||
const label = `${ACCT}(${ACCT === 'hr1' ? 'Chrome·合塑账号' : 'Edge·派卡账号'})`;
|
||
const text = `🚨 ${label}被其他人登录了(账号在其他设备/浏览器登录,当前已退出),任务停止。如非本人操作请注意账号安全。扫码可重新登录:\n如今天不需要协助筛选简历,可忽略该二维码`;
|
||
const qrPath = `/tmp/zhaopin-login-qr-${ACCT}.png`;
|
||
try {
|
||
// 前置:关闭可能还挂着的被顶弹窗并刷新,让页面回到登录态
|
||
const pw = require('playwright-core');
|
||
const b = await pw.chromium.connectOverCDP(`http://127.0.0.1:${PORT}`);
|
||
const p = b.contexts()[0]?.pages().find(x => /zhaopin\.com/.test(x.url()));
|
||
if (p) {
|
||
await p.evaluate(() => {
|
||
// 点掉被顶弹窗的"重新登录"按钮(会跳登录页);找不到就关弹窗
|
||
const btn = document.querySelector('.km-modal.km-modal--message-box .km-button--primary');
|
||
if (btn) btn.click();
|
||
else document.querySelector('.km-modal__close, .new-shortcut-resume__close')?.click();
|
||
}).catch(() => {});
|
||
await new Promise(r => setTimeout(r, 3000));
|
||
}
|
||
} catch (e) { log(`前置弹窗处理失败(忽略): ${String(e).slice(0, 80)}`); }
|
||
try {
|
||
execSync(`node ${SCRIPTS}/login-qrcode.js ${PORT} ${qrPath}`,
|
||
{ cwd: SCRIPTS, timeout: 90000, stdio: ['pipe', 'pipe', 'pipe'] });
|
||
if (fs.existsSync(qrPath) && fs.statSync(qrPath).size > 1000) {
|
||
sendGroupImage(text, qrPath);
|
||
writeState('kicked_out', chatLeft);
|
||
return;
|
||
}
|
||
log('二维码截图异常,降级为纯文字');
|
||
} catch (e) { log(`二维码获取失败: ${String(e).slice(0, 100)},降级为纯文字`); }
|
||
send(GROUP, `🚨 ${label}被其他人登录了(账号在其他设备/浏览器登录,当前已退出),任务停止。如非本人操作请注意账号安全`);
|
||
writeState('kicked_out', chatLeft);
|
||
}
|
||
|
||
function writeState(status, chatLeft) {
|
||
fs.writeFileSync(path.join(STATE_DIR, `state-${ACCT}.json`),
|
||
JSON.stringify({ date: today(), status, chatLeft: chatLeft ?? null, endedAt: new Date().toISOString() }));
|
||
}
|
||
function readOtherState() {
|
||
try {
|
||
const s = JSON.parse(fs.readFileSync(path.join(STATE_DIR, `state-${OTHER}.json`), 'utf8'));
|
||
return s.date === today() ? s : null;
|
||
} catch { return null; }
|
||
}
|
||
function reportAlreadySent() { return fs.existsSync(path.join(STATE_DIR, `report-sent-${today()}`)); }
|
||
function markReportSent() { fs.writeFileSync(path.join(STATE_DIR, `report-sent-${today()}`), new Date().toISOString()); }
|
||
|
||
// ---------- 隧道 ----------
|
||
function cdpAlive() {
|
||
try {
|
||
const out = execSync(`curl -s -m 5 http://127.0.0.1:${PORT}/json/version`, { encoding: 'utf8', timeout: 10000 });
|
||
return out.includes('Browser');
|
||
} catch { return false; }
|
||
}
|
||
// 2026-08-26 修正:隧道是 WSL 任务机通过 cdp-tunnel.sh 反向建立的(服务器端无法主动重建)。
|
||
// CDP 断开时等待 WSL 端守护自愈(它每15秒检测并重建 Chrome/隧道),最多等10分钟。
|
||
async function waitForCdp(minutes = 10) {
|
||
for (let i = 0; i < minutes * 2; i++) {
|
||
if (cdpAlive()) return true;
|
||
if (i % 4 === 0) log(`等待WSL端隧道恢复...(${i / 2}分钟)`);
|
||
await sleep(30000);
|
||
}
|
||
return cdpAlive();
|
||
}
|
||
|
||
// ---------- 跑一批 ----------
|
||
let sessionTotal = 0; // 账号当日累计筛选人数(跨批次,供200人刷新阈值)
|
||
function runBatch() {
|
||
return new Promise((resolve) => {
|
||
const p = spawn('node', ['zhaopin-scrape.js', String(randInt(BATCH_MIN, BATCH_MAX))], {
|
||
cwd: SCRIPTS,
|
||
env: { ...process.env, ACCOUNT: ACCT, CDP_PORT: PORT, RECRUITMENT_DB: DB, SESSION_OFFSET: String(sessionTotal) },
|
||
});
|
||
let out = '';
|
||
p.stdout.on('data', d => { out += d; process.stdout.write(d); });
|
||
p.stderr.on('data', d => { out += d; process.stderr.write(d); });
|
||
p.on('exit', (code) => resolve({ code, out }));
|
||
p.on('error', (e) => resolve({ code: -1, out: String(e) }));
|
||
});
|
||
}
|
||
|
||
// 发日报的唯一入口:仅 18 点收队时调用(2026-08-27 用户定稿:统计汇报只在18点发,异常中止只发告警不发日报)
|
||
function maybeSendReport(myStatus, chatLeft) {
|
||
writeState(myStatus, chatLeft);
|
||
// 仅记录状态,日报由 18:05 收队任务统一生成发送
|
||
}
|
||
|
||
function statusText(s) {
|
||
if (s === 'kicked_out') return '🚨 被顶号(他处登录)';
|
||
if (s === 'login_expired') return '🚨 登录失效';
|
||
if (s === 'alert') return '⚠️ 异常中止';
|
||
if (s === 'chat_exhausted') return '✅ 聊天耗尽收工';
|
||
if (s === 'time_cutoff') return '✅ 18点收工';
|
||
if (s === 'done') return '✅ 在线';
|
||
return '✅ 在线';
|
||
}
|
||
|
||
// 登录监听:告警发出后每30秒检查登录页是否已被扫码登录(跳回推荐页/出现卡片)
|
||
// 监听到登录成功 → 自动重启筛选批次;17:30 后不再等(留给18:05收队收尾)
|
||
async function waitForRelogin() {
|
||
const pw = require('playwright-core');
|
||
log('进入登录监听(每30秒检测扫码,最多等到北京17:30)...');
|
||
writeState('waiting_relogin', null);
|
||
while (!fs.existsSync(STOP_FLAG) && !pastCutoff(17.5 * 60)) {
|
||
await new Promise(r => setTimeout(r, 30000));
|
||
let logged = false;
|
||
try {
|
||
const b = await pw.chromium.connectOverCDP(`http://127.0.0.1:${PORT}`);
|
||
const p = b.contexts()[0]?.pages().find(x => /zhaopin\.com/.test(x.url()));
|
||
if (p) {
|
||
logged = await p.evaluate(() =>
|
||
!location.href.includes('passport') &&
|
||
!/账号已在其他设备或浏览器登录/.test(document.body?.innerText || '') &&
|
||
document.querySelectorAll('.recommend-item').length > 0
|
||
).catch(() => false);
|
||
}
|
||
try { await b.close(); } catch (_) {}
|
||
} catch (_) { /* CDP暂时不通就下轮再查 */ }
|
||
if (logged) { log('🎉 检测到已扫码登录,自动复工'); return true; }
|
||
}
|
||
log('登录监听结束(到时或被叫停),今日不再复工');
|
||
return false;
|
||
}
|
||
|
||
// ---------- 主流程 ----------
|
||
(async () => {
|
||
log(`启动 | 端口${PORT} | 库${DB} | 截止北京18:00`);
|
||
writeState('running', null);
|
||
|
||
if (!cdpAlive()) {
|
||
log('CDP不通,等待WSL端守护恢复隧道(最多10分钟)...');
|
||
if (!(await waitForCdp())) {
|
||
send(DM, `🚨 ${ACCT} CDP隧道10分钟内未恢复,今日筛选未执行。请检查WSL任务机的 cdp-tunnel.sh 守护与Chrome窗口`);
|
||
writeState('tunnel_failed', null);
|
||
process.exit(1);
|
||
}
|
||
log('隧道已恢复');
|
||
}
|
||
|
||
let lastChatLeft = null, zeroStreak = 0;
|
||
let needRelogin = false; // v1.2:登录类异常(登录失效/被顶号)→ 告警后进监听等扫码,扫完自动复工
|
||
|
||
while (true) {
|
||
// v1.1:随时叫停——每轮检查停止旗标,优雅退出(不发日报不发告警,等重启或18点兜底)
|
||
if (fs.existsSync(STOP_FLAG)) {
|
||
log('检测到停止旗标,runner 优雅退出');
|
||
writeState('stopped_by_user', lastChatLeft);
|
||
break;
|
||
}
|
||
if (pastCutoff()) { log('到达北京时间18:00,收工'); maybeSendReport('time_cutoff', lastChatLeft); break; }
|
||
|
||
const { code, out } = await runBatch();
|
||
|
||
// 被顶号(账号在其他设备登录)→ 群告警+二维码,进登录监听(2026-08-27)
|
||
if (/账号被顶/.test(out)) {
|
||
await notifyKickedOut(lastChatLeft);
|
||
needRelogin = true;
|
||
}
|
||
// 登录失效 → 群告警(带登录二维码),进登录监听
|
||
else if (/登录已失效|passport\.zhaopin\.com/.test(out)) {
|
||
notifyLoginExpired();
|
||
maybeSendReport('login_expired', lastChatLeft);
|
||
needRelogin = true;
|
||
}
|
||
// 风控/验证码 → 私聊告警,结束(不监听,风控需人工评估)
|
||
else if (/ALERT.*(风控|验证|频繁|受限)/.test(out)) {
|
||
send(DM, `🚨 ${ACCT} 触发风控/验证码(${(out.match(/ALERT[::][^\n]*/) || ['未知原因'])[0]}),今日筛选中止`);
|
||
maybeSendReport('alert', lastChatLeft);
|
||
break;
|
||
}
|
||
// 聊天耗尽 → 结束
|
||
else if (/聊天次数耗尽/.test(out)) { log('聊天次数耗尽,收工'); maybeSendReport('chat_exhausted', 0); break; }
|
||
|
||
// v1.2:登录类异常 → 监听扫码,登录成功自动复工;没登录就今日收工
|
||
if (needRelogin) {
|
||
needRelogin = false;
|
||
const relogged = await waitForRelogin();
|
||
if (!relogged) break;
|
||
writeState('running', lastChatLeft); // 复工
|
||
continue;
|
||
}
|
||
|
||
// 解析剩余次数
|
||
const m = [...out.matchAll(/聊天剩余[::]?\s*(\d+)\s*次/g)].pop();
|
||
if (m) lastChatLeft = parseInt(m[1], 10);
|
||
if (lastChatLeft !== null && lastChatLeft <= 0) { log('聊天剩余0,收工'); maybeSendReport('chat_exhausted', 0); break; }
|
||
|
||
// 产出统计(零产出时拉长休息);累计到账号当日总数(跨批次)
|
||
const nm = out.match(/新增\s+(\d+)/);
|
||
const newN = nm ? parseInt(nm[1], 10) : 0;
|
||
sessionTotal += newN;
|
||
zeroStreak = newN === 0 ? zeroStreak + 1 : 0;
|
||
|
||
if (pastCutoff()) { log('到达北京时间18:00,收工'); maybeSendReport('time_cutoff', lastChatLeft); break; }
|
||
|
||
const restMin = zeroStreak >= 2 ? randInt(30, 45) : randInt(10, 20);
|
||
log(`本批新增${newN}人,休息${restMin}分钟后继续`);
|
||
await sleep(restMin * 60 * 1000);
|
||
}
|
||
log('runner 结束');
|
||
process.exit(0);
|
||
})().catch(e => { log(`未捕获异常: ${String(e).slice(0, 200)}`); writeState('error', null); process.exit(1); });
|