#!/usr/bin/env node /** * 每日筛选守护运行器 v1.0(2026-08-26) * 用法:ACCOUNT=hr1 CDP_PORT=9223 node daily-runner.js * * 逻辑:从启动(北京时间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); });