Files
zhaopin/scripts/daily-runner.js
T

410 lines
21 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 用户定稿):登录失效+每日日报 → 群(数字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)(附二维码)`);
return true;
} catch (e) { log(`图片消息发送失败: ${String(e.message).slice(0, 100)}`); return false; }
}
// 二维码当日只发一次(2026-08-28 用户定稿:仅当天首次登录检查时发,后续异常不再发群)
let qrSentToday = false;
function qrAlreadySent() {
if (qrSentToday) return true;
const f = path.join(STATE_DIR, `qr-sent-${ACCT}-${today()}`);
if (fs.existsSync(f)) { qrSentToday = true; return true; }
return false;
}
function markQrSent() {
qrSentToday = true;
try { fs.writeFileSync(path.join(STATE_DIR, `qr-sent-${ACCT}-${today()}`), new Date().toISOString()); } catch { }
}
// 登录失效时:刷新页面抓登录二维码,发群(文字+二维码+补充说明)
// 当日已发过二维码 → 不再发群(私聊简告),二维码消息每天最多一条(2026-08-28 用户定稿)
function notifyLoginExpired() {
const label = `${ACCT}(${ACCT === 'hr1' ? 'Chrome·合塑账号' : 'Edge·派卡账号'})`;
const text = `🚨 当前${label}账号登录已失效,今日筛选中止。扫码可重新登录:\n如今天不需要协助筛选简历,可忽略该二维码`;
if (qrAlreadySent()) {
log('今日二维码已发过,本次异常不再发群(私聊简告)');
send(DM, `🚨 ${label}再次登录失效(今日二维码已发过群,不再重复发)。账号进入扫码监听`);
return;
}
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) {
if (sendGroupImage(text, qrPath)) markQrSent();
return;
}
log('二维码截图异常(文件过小或不存在),降级为纯文字');
} catch (e) { log(`二维码获取失败: ${String(e.message).slice(0, 100)},降级为纯文字`); }
send(GROUP, `🚨 当前${label}账号登录已失效,今日筛选中止,请在任务机重新登录智联`);
markQrSent();
}
// 被顶号告警:先关闭被顶弹窗/刷新页面 → 导航到登录页抓二维码 → 发群(文字+二维码+说明)
async function notifyKickedOut(chatLeft) {
const label = `${ACCT}(${ACCT === 'hr1' ? 'Chrome·合塑账号' : 'Edge·派卡账号'})`;
const text = `🚨 ${label}被其他人登录了(账号在其他设备/浏览器登录,当前已退出),任务停止。如非本人操作请注意账号安全。扫码可重新登录:\n如今天不需要协助筛选简历,可忽略该二维码`;
// 当日已发过二维码 → 不再发群(私聊简告),2026-08-28 用户定稿
if (qrAlreadySent()) {
log('今日二维码已发过,被顶号不再发群(私聊简告)');
send(DM, `🚨 ${label}被顶号(今日二维码已发过群,不再重复发)。账号进入扫码监听`);
writeState('kicked_out', chatLeft);
return;
}
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) {
if (sendGroupImage(text, qrPath)) markQrSent();
writeState('kicked_out', chatLeft);
return;
}
log('二维码截图异常,降级为纯文字');
} catch (e) { log(`二维码获取失败: ${String(e).slice(0, 100)},降级为纯文字`); }
send(GROUP, `🚨 ${label}被其他人登录了(账号在其他设备/浏览器登录,当前已退出),任务停止。如非本人操作请注意账号安全`);
markQrSent();
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 === 'platform_blocked') return '⛔ 平台阻碍(限额等)';
if (s === 'qr_expired') return '🚨 等扫码但二维码过期';
if (s === 'waiting_relogin') return '⏳ 等待扫码登录';
if (s === 'stopped_by_user') return '⏹️ 手动停止';
if (s === 'tunnel_failed') 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, qrExpired = 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) {
const r = await p.evaluate(() => {
const body = document.body?.innerText || '';
return {
logged: !location.href.includes('passport') &&
!/账号已在其他设备或浏览器登录/.test(body) &&
document.querySelectorAll('.recommend-item').length > 0,
// 二维码过期/失效提示(智联文案:"二维码已过期" / "已失效,请刷新"等)
qrExpired: /二维码[^\n]{0,10}(过期|失效)|已过期.{0,10}刷新/.test(body),
};
}).catch(() => ({ logged: false, qrExpired: false }));
logged = r.logged; qrExpired = r.qrExpired;
}
try { await b.close(); } catch (_) {}
} catch (_) { /* CDP暂时不通就下轮再查 */ }
if (logged) {
log('🎉 检测到已扫码登录,自动复工');
// 回应扫码人:让ta知道扫码生效、任务已自动恢复(v1.3)
send(GROUP, `✅ ${ACCT}(${ACCT === 'hr1' ? 'Chrome·合塑账号' : 'Edge·派卡账号'})扫码登录成功,筛选任务已自动恢复`);
return true;
}
if (qrExpired) { log('⏹ 登录二维码已过期,停止监听(今日不再等扫码)'); writeState('qr_expired', null); return false; }
}
log('登录监听结束(到时或被叫停),今日不再复工');
return false;
}
// 阻碍二次核查(v10.6):重新打开一个候选人弹窗,看是否仍是无效内容页
// 返回:确认原因字符串(确认阻碍) / null(未复现,可继续)
async function verifyBlockedPage() {
const pw = require('playwright-core');
try {
const b = await pw.chromium.connectOverCDP(`http://127.0.0.1:${PORT}`);
const p = b.contexts()[0]?.pages().find(x => /rd\d?\.zhaopin\.com/.test(x.url()));
if (!p) return '页面不可达';
const r = await p.evaluate(() => {
const modal = document.querySelector('.new-shortcut-resume__modal');
if (!modal) return null; // 弹窗已关(子进程退出时关了)→ 打开一个再验
const text = modal.innerText || '';
if (/安全限额|查看.*上限|今日.*上限|明天再/.test(text)) return '日浏览限额';
return null;
}).catch(() => null);
// 弹窗关了:主动点开第一个卡片再验一次
if (r === null) {
await p.evaluate(() => document.querySelector('.recommend-item .resume-item__content')?.click()).catch(() => {});
await new Promise(rr => setTimeout(rr, 3000));
const r2 = await p.evaluate(() => {
const modal = document.querySelector('.new-shortcut-resume__modal');
const text = modal?.innerText || '';
if (!modal) return '弹窗无法打开';
// 同样的有效性判定:缺结构信号 = 无效页
const hasNameAge = /\d{2}岁/.test(text) || /(本科|大专|硕士|博士|高中|中专)/.test(text);
if (!hasNameAge) {
if (/安全限额|查看.*上限|明日再|明天再/.test(text)) return '日浏览限额';
return `无效弹窗: ${text.split('\n').filter(Boolean).slice(0, 2).join('/').slice(0, 60)}`;
}
return null; // 弹窗正常 → 未复现
}).catch(() => null);
try { await p.evaluate(() => document.querySelector('.new-shortcut-resume__close')?.click()).catch(() => {}); } catch (_) {}
return r2;
}
try { await b.close(); } catch (_) {}
return r;
} catch (e) { return `核查异常: ${String(e).slice(0, 60)}`; }
}
// ---------- 主流程 ----------
(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('隧道已恢复');
}
// v1.4:启动时主动登录态检查——跑第一批之前先看页面,失效/被顶立即发二维码+进监听
// (否则第一批要空跑1-2分钟在页面上撞见登录页才告警)
let abortDay = false; // 启动阶段没等来扫码 → 今日收工
{
const pw = require('playwright-core');
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) {
const st = await p.evaluate(() => ({
login: location.href.includes('passport'),
kicked: !!document.querySelector('.km-modal.km-modal--message-box') &&
/账号已在其他设备或浏览器登录/.test(document.body?.innerText || ''),
ok: !location.href.includes('passport') &&
document.querySelectorAll('.recommend-item').length > 0,
})).catch(() => null);
if (st?.login || st?.kicked) {
log(`启动登录态检查:${st.kicked ? '被顶号' : '登录失效'},直接走告警+扫码监听`);
if (st.kicked) await notifyKickedOut(null);
else { notifyLoginExpired(); writeState('login_expired', null); }
const relogged = await waitForRelogin();
if (relogged) { writeState('running', null); log('扫码登录成功,正式开始筛选'); }
else { log('启动阶段未等来扫码,今日收工'); abortDay = true; }
} else if (st?.ok) {
log('启动登录态检查:✅ 正常');
} else {
log('启动登录态检查:页面状态未知(可能加载中),继续按正常流程跑');
}
}
try { await b.close(); } catch (_) {}
} catch (e) { log(`启动登录态检查异常(忽略,按正常流程跑): ${String(e).slice(0, 80)}`); }
}
if (abortDay) { log('runner 结束'); process.exit(0); }
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;
}
// 阻碍即停(v10.6 通用,2026-08-27 收紧:碰到即终态停止,当日不再恢复):
// 弹窗无有效简历(日浏览限额/提示页等)→ 私聊告警(附弹窗原文)→ 立即停止
// 二次核查仅用于给告警补充确认信息,不影响"停止"这个结果
else if (/弹窗无有效简历数据/.test(out)) {
const detail = await verifyBlockedPage();
send(DM, `🚨 ${ACCT} 碰到平台阻碍${detail ? `(${detail})` : ''},任务已停止,当日不再重新启动。请人工查看任务机浏览器确认\n日志: ${out.match(/弹窗内容: [^\n]*/)?.[0] || ''}`);
maybeSendReport('platform_blocked', lastChatLeft);
break;
}
// 风控/验证码 → 私聊告警,结束(不监听,风控需人工评估)
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;
// v10.7:批内软截止输出识别(scrape 在18点后优雅退出,日志含"软截止")→ 正常收工
if (/软截止/.test(out)) { log('批内已到18点软截止,收工'); maybeSendReport('time_cutoff', lastChatLeft); break; }
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); });