Files
zhaopin/scripts/daily-runner.js
T

190 lines
9.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 修正:用户明确反馈"串台",一切通知(日报/登录失效/隧道/风控)只发私聊,禁止发群
const GROUP = 'oc_d09c77b62ca5f90f6699830fbc695c44'; // (历史变量名,实际=私聊目标)
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 = () => bjHm() >= 18 * 60; // 北京 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(`已发送消息 → 私聊: ${text.slice(0, 60)}`);
} catch (e) { log(`发送失败: ${String(e.message).slice(0, 100)}`); }
}
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) }));
});
}
// ---------- 日报 ----------
function maybeSendReport(myStatus, chatLeft) {
writeState(myStatus, chatLeft);
if (reportAlreadySent()) { log('日报今日已发过,跳过'); return; }
const other = readOtherState();
// 对方已结束(done/login_expired/alert/无状态)→ 我来发;对方还在跑 → 对方结束时发
const otherDone = !other || other.status !== 'running';
if (!otherDone) { log(`对方(${OTHER})仍在运行,日报由对方结束时发送`); return; }
try {
const md = execSync(`node ${SCRIPTS}/daily-report.js`, {
cwd: SCRIPTS, encoding: 'utf8', timeout: 60000,
env: { ...process.env, CHAT_LEFT_HR1: ACCT === 'hr1' ? chatLeft : other?.chatLeft, CHAT_LEFT_HR2: ACCT === 'hr2' ? chatLeft : other?.chatLeft,
HR1_STATUS: ACCT === 'hr1' ? statusText(myStatus) : statusText(other?.status),
HR2_STATUS: ACCT === 'hr2' ? statusText(myStatus) : statusText(other?.status) },
});
fs.writeFileSync(`/tmp/daily-report-${today()}.md`, md);
execSync(`${HERMES} send -t "feishu:${GROUP}" -f /tmp/daily-report-${today()}.md`,
{ stdio: ['pipe', 'pipe', 'pipe'], timeout: 60000 });
markReportSent();
log('日报已发送到群');
} catch (e) { log(`日报生成/发送失败: ${String(e.message).slice(0, 150)}`); }
}
function statusText(s) {
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 '✅ 在线';
}
// ---------- 主流程 ----------
(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;
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();
// 登录失效 → 群告警,结束
if (/登录已失效|passport\.zhaopin\.com/.test(out)) {
send(GROUP, `🚨 ${ACCT}(${ACCT === 'hr1' ? 'Chrome·合塑账号' : 'Edge·派卡账号'})智联登录态已失效,今日筛选中止,请在任务机重新登录智联`);
maybeSendReport('login_expired', lastChatLeft);
break;
}
// 风控/验证码 → 私聊告警,结束
if (/ALERT.*(风控|验证|频繁|受限)/.test(out)) {
send(DM, `🚨 ${ACCT} 触发风控/验证码(${(out.match(/ALERT[::][^\n]*/) || ['未知原因'])[0]}),今日筛选中止`);
maybeSendReport('alert', lastChatLeft);
break;
}
// 聊天耗尽 → 结束
if (/聊天次数耗尽/.test(out)) { log('聊天次数耗尽,收工'); maybeSendReport('chat_exhausted', 0); break; }
// 解析剩余次数
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); });