布局扁平化: 仓库根与服务器运行目录(/opt/data/skills/recruitment-resume-screener)一一对应,便于双端git直接管理

This commit is contained in:
mzl92
2026-08-26 22:56:14 +08:00
parent e0d9c4e434
commit 1af2bac8a2
24 changed files with 3 additions and 3 deletions
+37
View File
@@ -0,0 +1,37 @@
// analyze-abc.js — 验证"A为主、B/C为辅"设计的实际效果
// 对历史已打招呼者跑新门槛+评分,对比 [A+B/C] 与 [仅A] 两组的评级分布
'use strict';
const path = require('path');
function tryRequire(names) { for (const n of names) { try { return require(n); } catch (_) {} } throw new Error('需要 better-sqlite3'); }
const Database = tryRequire([process.env.SQLITE3_PATH, path.join(__dirname, 'node_modules', 'better-sqlite3'), 'better-sqlite3']);
const { CONFIG, evaluateCandidate } = require('./scoring');
const dbPath = process.argv[2] || path.join(__dirname, '..', '..', 'data', 'recruitment.db');
const db = new Database(dbPath, { readonly: true });
const rows = db.prepare(`
SELECT name, age, education, experience, raw_text, education_detail, work_detail, cert_detail
FROM candidates WHERE greeted = 1`).all();
const groups = { 'A+语言信号': { 推: 0, 待: 0, 不: 0 }, '仅A(无B/C)': { 推: 0, 待: 0, 不: 0 } };
let pass = 0;
for (const r of rows) {
const res = evaluateCandidate({
rawText: r.raw_text, workDetail: r.work_detail, educationDetail: r.education_detail,
certDetail: r.cert_detail, age: r.age, education: r.education, experience: r.experience,
});
if (!res.gate.pass) continue;
pass++;
const edu = r.education_detail || r.raw_text || '';
const cert = (r.cert_detail || '') + '\n' + (r.raw_text || '');
const hasBC = CONFIG.majorKeywords.some(k => edu.includes(k)) || CONFIG.certKeywords.some(k => cert.includes(k));
const g = hasBC ? groups['A+语言信号'] : groups['仅A(无B/C)'];
if (res.level === '推荐') g.推++; else if (res.level === '待定') g.待++; else g.不++;
}
console.log(`历史打招呼 ${rows.length} 人 → 过新门槛 ${pass} 人,分组评级分布:\n`);
for (const [name, g] of Object.entries(groups)) {
const t = g.推 + g.待 + g.不;
if (!t) { console.log(`${name}: 0人`); continue; }
console.log(`${name}: ${t}人 | 推荐 ${g.推} (${(g.推 / t * 100).toFixed(0)}%) | 待定 ${g.待} (${(g.待 / t * 100).toFixed(0)}%) | 不推荐 ${g.不} (${(g.不 / t * 100).toFixed(0)}%)`);
console.log(` → 会打招呼的(推荐+待定)占 ${(g.推 + g.待) / t * 100}%\n`);
}
+124
View File
@@ -0,0 +1,124 @@
#!/bin/bash
# 简历筛选系统连通性检查
# 用法: bash check-connectivity.sh
# 检查:SSH连接(Tailscale)、WiFi信号、两条CDP隧道、Watchdog、batch-runner
set -euo pipefail
SSH_HOST="100.64.0.7"
SSH_PORT="22"
SSH_USER="sshadmin"
SSH_CMD="ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no -p $SSH_PORT $SSH_USER@$SSH_HOST"
PASS=0
FAIL=0
WARN=0
ok() { echo " ✅ $1"; ((PASS++)); }
fail() { echo " ❌ $1"; ((FAIL++)); }
warn() { echo " ⚠️ $1"; ((WARN++)); }
echo "═══ 简历筛选系统连通性检查 ═══"
echo ""
# 1. SSH 连接
echo "【1】SSH → Windows ($SSH_HOST:$SSH_PORT)"
if $SSH_CMD "echo OK" >/dev/null 2>&1; then
ok "SSH 连接正常"
else
fail "SSH 连接失败(Tailscale不通或远端关机)"
echo " → 后续检查跳过"
exit 1
fi
# 2. WiFi 信号
echo ""
echo "【2】WiFi 信号"
WIFI_INFO=$($SSH_CMD "netsh wlan show interfaces" 2>/dev/null | grep -E "SSID|Signal|State" || true)
if echo "$WIFI_INFO" | grep -qi "connected"; then
SIGNAL=$(echo "$WIFI_INFO" | grep -i "signal" | sed 's/.*:\s*//' | tr -d ' %')
SSID=$(echo "$WIFI_INFO" | grep -i "SSID" | tail -1 | sed 's/.*:\s*//')
echo " SSID: $SSID | 信号: ${SIGNAL:-?}%"
if [ "${SIGNAL:-0}" -lt 30 ]; then
warn "WiFi 信号极弱(${SIGNAL}%),可能频繁断连"
else
ok "WiFi 已连接"
fi
else
fail "WiFi 未连接"
fi
# 3. CDP 隧道
echo ""
echo "【3】CDP 隧道"
# hr1 Chrome (9223)
echo " hr1 Chrome — 隧道端口 9223"
if curl -s --connect-timeout 5 http://127.0.0.1:9223/json/version >/dev/null 2>&1; then
BROWSER=$(curl -s --connect-timeout 5 http://127.0.0.1:9223/json/version | python3 -c "import sys,json;print(json.load(sys.stdin).get('Browser','?'))" 2>/dev/null || echo "?")
TABS=$(curl -s http://127.0.0.1:9223/json/list 2>/dev/null | python3 -c "
import sys,json
tabs=json.load(sys.stdin)
pages=[t for t in tabs if t.get('type')=='page']
for t in pages: print(f' {t[\"title\"]} → {t[\"url\"][:80]}')
print(f' 共 {len(pages)} 个页面')
" 2>/dev/null || echo " (无法读取)")
ok "CDP 正常 ($BROWSER)"
echo "$TABS"
else
fail "CDP 不通(隧道断开或Chrome未运行)"
fi
# hr2 Edge (9224)
echo " hr2 Edge — 隧道端口 9224"
if curl -s --connect-timeout 5 http://127.0.0.1:9224/json/version >/dev/null 2>&1; then
BROWSER=$(curl -s --connect-timeout 5 http://127.0.0.1:9224/json/version | python3 -c "import sys,json;print(json.load(sys.stdin).get('Browser','?'))" 2>/dev/null || echo "?")
TABS=$(curl -s http://127.0.0.1:9224/json/list 2>/dev/null | python3 -c "
import sys,json
tabs=json.load(sys.stdin)
pages=[t for t in tabs if t.get('type')=='page']
for t in pages: print(f' {t[\"title\"]} → {t[\"url\"][:80]}')
print(f' 共 {len(pages)} 个页面')
" 2>/dev/null || echo " (无法读取)")
ok "CDP 正常 ($BROWSER)"
echo "$TABS"
else
fail "CDP 不通(隧道断开或Edge未运行)"
fi
# 4. Watchdog 状态
echo ""
echo "【4】隧道守护 Watchdog"
if ps aux | grep ssh-tunnel-watchdog | grep -v grep >/dev/null 2>&1; then
WPID=$(ps aux | grep ssh-tunnel-watchdog | grep -v grep | awk '{print $2}')
ok "运行中 (PID $WPID)"
if [ -f /tmp/ssh-tunnel-watchdog.pid ]; then
echo " PID文件: $(cat /tmp/ssh-tunnel-watchdog.pid)"
else
warn "无 PID 锁文件(可能是旧版 v1,需重启才能用 v2)"
fi
else
warn "未运行"
fi
# 5. batch-runner 实例
echo ""
echo "【5】batch-runner 实例"
RUNNING=$(ps aux | grep "batch-runner" | grep -v grep || true)
if [ -n "$RUNNING" ]; then
echo "$RUNNING" | while read -r line; do
ok "运行中: $(echo "$line" | grep -oP 'ACCOUNT=\S+' || echo '账号未标注')"
done
else
echo " (无 batch-runner 运行)"
fi
# 汇总
echo ""
echo "═══ 检查完成 ═══"
echo "✅ 通过: $PASS ❌ 失败: $FAIL ⚠️ 警告: $WARN"
if [ "$FAIL" -gt 0 ]; then
echo "有失败项,修复后再启动筛选。"
else
echo "连通性正常,可以启动筛选。"
fi
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env node
/**
* 每日筛选汇总生成器 v1.0(2026-08-26)
* 从 recruitment.db 查询当日数据,生成 Markdown 日报。
* 用法:node daily-report.js [YYYY-MM-DD] [--db /path/to.db]
* 输出:Markdown 文本到 stdout(cron 任务用 hermes send -f 发送)
*/
const Database = require('better-sqlite3');
const path = require('path');
const args = process.argv.slice(2);
const dateArg = args.find(a => /^\d{4}-\d{2}-\d{2}$/.test(a));
const dbArgIdx = args.indexOf('--db');
const DB_PATH = dbArgIdx >= 0 ? args[dbArgIdx + 1] : '/opt/data/recruitment-data/recruitment-live.db';
const day = dateArg || new Date().toISOString().slice(0, 10);
const dayStart = `${day}T00:00`;
const dayEnd = `${day}T23:59`;
const db = new Database(DB_PATH, { readonly: true });
// ---------- 数据查询 ----------
const rows = db.prepare(
`SELECT account, name, age, education, experience, school, score, score_level, role_tag,
greeted, greeted_at, notes
FROM candidates WHERE scraped_at >= ? AND scraped_at <= ?`
).all(dayStart, dayEnd);
// 账号聚合
const byAcct = {};
for (const r of rows) {
const a = byAcct[r.account || '未知'] || (byAcct[r.account || '未知'] = { n: 0, rec: 0, wait: 0, irr: 0, greeted: 0 });
a.n++;
if (r.score_level === '推荐') a.rec++;
else if (r.score_level === '待定') a.wait++;
else a.irr++;
if (r.greeted) a.greeted++;
}
const total = { n: rows.length, rec: 0, wait: 0, irr: 0, greeted: 0 };
for (const a of Object.values(byAcct)) { total.rec += a.rec; total.wait += a.wait; total.irr += a.irr; total.greeted += a.greeted; }
// 打招呼明细(当日实际打过招呼的人)
const greetedRows = rows.filter(r => r.greeted && r.greeted_at && r.greeted_at.startsWith(day))
.sort((x, y) => (y.score || 0) - (x.score || 0));
// 历史累计
const histTotal = db.prepare('SELECT COUNT(*) c FROM candidates').get().c;
// 最近一次聊天剩余(从最近 scrape_log 无记录,需运行时传入;此处用环境变量或显示待补)
const chatLeft = process.env.CHAT_LEFT_HR1 ? `hr1 剩 ${process.env.CHAT_LEFT_HR1}` : 'hr1 剩 ?';
const chatLeft2 = process.env.CHAT_LEFT_HR2 ? `hr2 剩 ${process.env.CHAT_LEFT_HR2}` : 'hr2 剩 ?';
// ---------- Markdown 渲染 ----------
const WEEK = ['日', '一', '二', '三', '四', '五', '六'];
const wk = WEEK[new Date(day + 'T12:00:00Z').getUTCDay()];
const ACCT_META = {
hr1: { label: 'hr1(Chrome)', chatLeft: () => process.env.CHAT_LEFT_HR1 ? `剩 ${process.env.CHAT_LEFT_HR1} 次` : '—', status: () => process.env.HR1_STATUS || '✅ 在线' },
hr2: { label: 'hr2(Edge)', chatLeft: () => process.env.CHAT_LEFT_HR2 ? `剩 ${process.env.CHAT_LEFT_HR2} 次` : '—', status: () => process.env.HR2_STATUS || '✅ 在线' },
};
const md = [];
md.push(`# 📊 智联简历筛选日报`);
md.push('');
md.push(`**${day} 周${wk}** · 岗位:二手车出口外贸业务员`);
md.push('---');
md.push('');
// 总览
md.push(`## ■ 今日总览`);
md.push('');
md.push(`| 指标 | hr1 Chrome | hr2 Edge | 合计 |`);
md.push(`|:---|---:|---:|---:|`);
md.push(`| 新增筛选 | ${byAcct.hr1?.n || 0} | ${byAcct.hr2?.n || 0} | **${total.n}** |`);
md.push(`| 打招呼 | ${byAcct.hr1?.greeted || 0} | ${byAcct.hr2?.greeted || 0} | **${total.greeted}** |`);
md.push('');
// 分账号小节
for (const acct of ['hr1', 'hr2']) {
const meta = ACCT_META[acct];
const v = byAcct[acct];
md.push(`## ■ ${meta.label}`);
md.push('');
if (!v || v.n === 0) {
md.push(`> 今日无筛选数据 · ${meta.status()}`);
md.push('');
continue;
}
const p = (n) => ((n / v.n) * 100).toFixed(1) + '%';
md.push(`| 结果 | 人数 | 占比 |`);
md.push(`|:---|---:|---:|`);
md.push(`| ✅ 推荐 | ${v.rec} | ${p(v.rec)} |`);
md.push(`| ⏳ 待定 | ${v.wait} | ${p(v.wait)} |`);
md.push(`| ⛔ 拦截 | ${v.irr} | ${p(v.irr)} |`);
md.push('');
const gr = greetedRows.filter(g => g.account === acct);
if (gr.length) {
md.push(`**打招呼画像(${gr.length} 人)**`);
md.push('');
md.push(`| 姓名 | 年龄 | 学历 | 经验 | 评分 | 级别 |`);
md.push(`|:---|---:|:---:|---:|---:|:---:|`);
const top = gr.slice(0, 5);
for (const g of top) {
const expRaw = (g.experience || '').toString();
const exp = /应届生|在校生/.test(expRaw) ? '应届' : (expRaw.match(/\d+年/) || [])[0] || ((expRaw.match(/^\d+$/)?.[0] || '') + '年' || '—');
md.push(`| ${g.name} | ${g.age || '—'} | ${g.education || '—'} | ${exp} | ${g.score || '—'} | ${g.score_level} |`);
}
if (gr.length > 5) md.push(`| …共 ${gr.length} 人 | | | | | |`);
md.push('');
}
}
md.push(`## ■ 资源与状态`);
md.push('');
md.push(`| 账号 | 聊天剩余 | 状态 |`);
md.push(`|:---|---:|:---|`);
md.push(`| hr1 Chrome | ${ACCT_META.hr1.chatLeft()} | ${ACCT_META.hr1.status()} |`);
md.push(`| hr2 Edge | ${ACCT_META.hr2.chatLeft()} | ${ACCT_META.hr2.status()} |`);
md.push('');
md.push(`- 历史累计:${histTotal.toLocaleString()} 人`);
console.log(md.join('\n'));
db.close();
+189
View File
@@ -0,0 +1,189 @@
#!/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); });
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env node
/**
* 18:00 强制收队 + 异常检测 v1.0(2026-08-26)
* 由 cron 在北京时间 18:05 触发(UTC 10:05)。
*
* 职责:
* 1. 强杀所有仍在运行的 daily-runner / zhaopin-scrape 进程(正常应已自行结束)
* 2. 异常检测:
* a. 18点仍在跑的进程 → 判定"未按时收队"(含失联/卡死)
* b. 卡死检测:进程 cputime 为 0 或远小于 etime(pitfalls #81)
* c. 状态文件检查:state-hrX.json 缺失/非当日 → 该账号今日未正常运行
* 3. 若日报今日未发(runner 异常没发成)→ 补发生成日报到群
* 4. 输出检测结果(cron agent 转发给用户私聊)
*/
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const SCRIPTS = '/opt/data/skills/recruitment-resume-screener/scripts';
const STATE_DIR = '/opt/data/logs/recruitment/state';
fs.mkdirSync(STATE_DIR, { recursive: true });
const GROUP = 'oc_d09c77b62ca5f90f6699830fbc695c44'; // 2026-08-26 修正:只发私聊
const HERMES = '/opt/hermes/.venv/bin/hermes';
const bjNow = () => new Date(Date.now() + 8 * 3600 * 1000);
const today = () => bjNow().toISOString().slice(0, 10);
const log = (m) => console.log(`[enforce] ${m}`);
const findings = []; // 异常发现
let reportSent = false;
// ---------- 1. 进程检查与强杀 ----------
let psOut = '';
try { psOut = execSync("ps aux | grep -E 'daily-runner|zhaopin-scrape' | grep -v grep || true", { encoding: 'utf8' }); } catch { }
const lines = psOut.split('\n').filter(l => l.trim());
if (lines.length === 0) {
log('无残留进程 ✅');
} else {
for (const line of lines) {
const pid = line.trim().split(/\s+/)[1];
// 卡死检测:cputime vs etime
let hung = false, detail = '';
try {
const pi = execSync(`ps -p ${pid} -o pid,stat,etime,cputime,rss,cmd --no-headers`, { encoding: 'utf8' }).trim();
detail = pi;
const parts = pi.split(/\s+/);
const etime = parts[2] || '', cputime = parts[3] || '';
const toSec = (t) => { // etime: [[dd-]hh:]mm:ss
let s = 0;
const m = t.match(/(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)/);
if (m) s = (+m[1] || 0) * 86400 + (+m[2] || 0) * 3600 + (+m[3]) * 60 + (+m[4]);
return s;
};
const cSec = toSec(cputime), eSec = toSec(etime);
if (cSec === 0 || (eSec > 600 && cSec / eSec < 0.01)) hung = true;
} catch { }
findings.push(`18点后仍在运行${hung ? '(疑似卡死)' : ''}: ${line.trim().slice(0, 140)}`);
try { process.kill(parseInt(pid, 10), 'SIGKILL'); log(`已强杀 PID ${pid}`); }
catch (e) { log(`杀 ${pid} 失败: ${e.message}`); findings.push(`强杀失败 PID ${pid}: ${e.message}`); }
}
// 复查
try {
const left = execSync("ps aux | grep -E 'daily-runner|zhaopin-scrape' | grep -v grep || true", { encoding: 'utf8' }).trim();
if (left) findings.push(`强杀后仍有残留:\n${left.slice(0, 300)}`);
} catch { }
}
// ---------- 2. 状态文件检查 ----------
for (const acct of ['hr1', 'hr2']) {
const f = path.join(STATE_DIR, `state-${acct}.json`);
try {
const s = JSON.parse(fs.readFileSync(f, 'utf8'));
if (s.date !== today()) findings.push(`${acct}: 今日无状态记录(runner 未启动或启动即失败)`);
else log(`${acct}: 今日状态=${s.status} endedAt=${s.endedAt}`);
} catch {
findings.push(`${acct}: 状态文件缺失(runner 未启动)`);
}
}
// ---------- 3. 日报补发 ----------
const sentFlag = path.join(STATE_DIR, `report-sent-${today()}`);
if (fs.existsSync(sentFlag)) {
reportSent = true;
log('日报今日已由 runner 发送 ✅');
} else {
log('日报未发送,补发中...');
try {
const env = { ...process.env };
// 从状态文件取聊天剩余
for (const [acct, key] of [['hr1', 'CHAT_LEFT_HR1'], ['hr2', 'CHAT_LEFT_HR2']]) {
try { const s = JSON.parse(fs.readFileSync(path.join(STATE_DIR, `state-${acct}.json`), 'utf8')); if (s.chatLeft != null) env[key] = String(s.chatLeft); } catch { }
}
const md = execSync(`node ${SCRIPTS}/daily-report.js`, { cwd: SCRIPTS, encoding: 'utf8', timeout: 60000, env });
fs.writeFileSync(`/tmp/daily-report-enforce-${today()}.md`, md);
execSync(`${HERMES} send -t "feishu:${GROUP}" -f /tmp/daily-report-enforce-${today()}.md`, { stdio: ['pipe', 'pipe', 'pipe'], timeout: 60000 });
fs.writeFileSync(sentFlag, new Date().toISOString());
reportSent = true;
log('日报已补发到群 ✅');
} catch (e) { findings.push(`日报补发失败: ${String(e.message).slice(0, 150)}`); }
}
// ---------- 4. 汇总输出 ----------
console.log('\n========== 18点收队结果 ==========');
console.log(`日报: ${reportSent ? '✅ 已发送' : '❌ 未发送'}`);
console.log(`异常: ${findings.length === 0 ? '无 ✅' : findings.length + ' 项 ⚠️'}`);
for (const f of findings) console.log(` ⚠️ ${f}`);
console.log('==================================');
process.exit(0);
+50
View File
@@ -0,0 +1,50 @@
{
"name": "scripts",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "scripts",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"better-sqlite3": "^13.0.3",
"playwright-core": "^1.62.1"
}
},
"node_modules/better-sqlite3": {
"version": "13.0.3",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz",
"integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==",
"license": "MIT",
"dependencies": {
"node-addon-api": "^8.0.0"
},
"engines": {
"node": ">=22"
}
},
"node_modules/node-addon-api": {
"version": "8.9.2",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz",
"integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==",
"license": "MIT",
"engines": {
"node": "^18 || ^20 || >= 21"
}
},
"node_modules/playwright-core": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
}
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"name": "scripts",
"version": "1.0.0",
"description": "",
"main": "scoring.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"better-sqlite3": "^13.0.3",
"playwright-core": "^1.62.1"
}
}
+294
View File
@@ -0,0 +1,294 @@
// scoring.js v10 — 简历筛选评分与硬性预筛门槛(纯逻辑,无浏览器依赖,可离线测试)
//
// v10 变更(2026-08-26,需求方反馈"打招呼范围过宽"):
// 1. 新增硬性预筛门槛 hardGate():不相关候选人直接标记"不相关",永不进入打招呼流程
// - 门槛A(岗位/经历):全文须含外贸类关键词(外贸/外贸销售/外贸业务/外贸汽车/外贸助理/
// 外贸主管/外贸经理/外销/国际贸易/国贸/海外销售/出口/跨境电商/二手车出口/汽车出口/整车出口)
// - 门槛B(毕业专业):教育经历须含 英语/商务英语/应用英语/俄语/西班牙语/西语/法语/
// 国际经济与贸易/贸易经济
// - 门槛C(技能证书):须含 英语四级/六级/八级(CET-4/6, 专四/专八)、俄语四/六/八级、
// 法语四级/四八级、西班牙语四八级 等指定证书
// - A/B/C 满足任一即算"方向相关";都不满足 → 不相关,不评分不打招呼
// 2. 新增毕业时间窗口:2020-2026 届(教育经历中解析到的最晚年份;
// 解析不到时不硬拒,gateReason 标记 year-unknown,可配置 GRAD_YEAR.strict=true 改为硬拒)
// 3. 保留 v9 七维评分 + 动态阈值 + 年龄>35 一票否决 + 岗位推荐标签
//
// 用法(脚本内):const { hardGate, scoreCandidate } = require('./scoring');
// 离线测试:node scoring.js # 跑内置样例
// node scoring.js --db <path> # 用真实数据库抽样验证
'use strict';
// ========== 可配置筛选条件(需求方 2026-08-26 更新) ==========
const CONFIG = {
// 门槛A:岗位/经历关键词(任一命中即通过,全文匹配 rawText+workDetail)
tradeKeywords: [
'外贸', '外销', '国际贸易', '国贸', '海外销售', '出口',
'跨境电商', '跨境', '二手车出口', '汽车出口', '整车出口', '海外市场',
],
// 门槛B:毕业专业(教育经历 educationDetail 匹配,rawText 兜底)
majorKeywords: [
'英语', '商务英语', '应用英语', '俄语', '西班牙语', '西语', '法语',
'国际经济与贸易', '贸易经济',
],
// 门槛C:技能证书(certDetail + rawText 匹配)
certKeywords: [
// 英语
'CET-4', 'CET-6', 'CET4', 'CET6',
'大学英语四级', '大学英语六级', '英语四级', '英语六级', '英语八级',
'英语专业四级', '英语专业八级', '专四', '专八', 'TEM-4', 'TEM-8',
// 俄语
'俄语四级', '俄语六级', '俄语八级', '俄语专业四级', '俄语专业八级',
// 法语
'法语四级', '法语六级', '法语八级', '法语四八级', '法语专业四级', '法语专业八级',
// 西班牙语
'西班牙语四级', '西班牙语六级', '西班牙语八级', '西班牙语四八级',
'西语四级', '西语六级', '西语八级', '西语四八级',
],
// 门槛D:毕业时间窗口 [min, max](含边界;教育经历解析不到年份时见 strict)
gradYear: { min: 2020, max: 2026, strict: false },
// 年龄硬否决(v9 沿用)
maxAge: 35,
// 动态阈值(v9 沿用):行业匹配≥4 时推荐线降 2 分
thresholds: { recommend: 26, recommendIndustry: 24, maybe: 18 },
};
// ========== v9 七维评分关键词(与 jd-requirements.md 映射一致) ==========
const INDUSTRY_HIGH = ['二手车', '整车出口', '汽车出口', '车辆出口', '海外汽车', 'VIN', '车架号', '滚装船', '车型配置', '多VIN'];
const INDUSTRY_MID = ['汽配', '工程机械', '大宗', '汽车', '车辆'];
const TRADE_HIGH = ['询盘', '报价', '谈判', '成交', 'PI', '报关', '清关', '收款', '复购', '客户开发', '订单', 'T/T', 'LC']; // 仅 workDetail
const TRADE_MID = ['外贸', '出口', '跨境电商', '海外客户', '国际贸易', '跟单', '外销'];
const LANG_TOP = ['雅思', 'IELTS', '托福', 'TOEFL', 'BEC', '俄语', '法语', '西班牙语', '西语', '葡萄牙语', '阿拉伯语'];
const LANG_MID = ['CET-6', 'CET6', '大学英语六级', '英语六级', '专八', '英语专业八级', 'TEM-8'];
const LANG_LOW = ['CET-4', 'CET4', '大学英语四级', '英语四级', '专四', '英语专业四级', 'TEM-4', '商务英语', '英语口语'];
const CHANNEL_HIGH = ['阿里巴巴国际站', '阿里国际站', '独立站', 'LinkedIn', 'Facebook', 'WhatsApp', 'Google', '展会', '中国制造网', 'Instagram']; // 仅 workDetail
const CHANNEL_LOW = ['客户开发', '邮件开发', '获客', '社媒推广', '社媒'];
const MAJOR_HIGH = ['商务英语', '国际贸易', '英语', '国际经济与贸易'];
const MAJOR_LANG = ['俄语', '西班牙语', '西语', '法语', '德语', '日语', '朝鲜语', '翻译', '应用英语'];
const MAJOR_OTHER = ['市场营销', '跨境电商', '电子商务', '贸易经济'];
function countHits(text, list) {
return list.reduce((n, k) => (text.includes(k) ? n + 1 : n), 0);
}
// ========== 硬性预筛门槛 ==========
// 从教育经历解析毕业年份(取出现的最晚年份作为毕业时间)
function parseGradYear(educationDetail, rawText) {
const src = educationDetail || rawText || '';
const years = (src.match(/(?:19|20)\d{2}/g) || []).map(Number).filter(y => y >= 1990 && y <= 2030);
if (!years.length) return null;
return Math.max(...years);
}
/**
* 硬性预筛:通过返回 { pass: true, reasons: [...] };
* 不通过返回 { pass: false, reason: '...' }(reason 用于日志与数据库 notes)。
* 数据形态与 extractModalData 输出一致:{ rawText, workDetail, educationDetail, certDetail, age }
*/
function hardGate(data) {
const rawText = data.rawText || '';
const workDetail = data.workDetail || '';
const eduDetail = data.educationDetail || '';
const certDetail = data.certDetail || '';
const reasons = [];
// 年龄一票否决(先于其他门槛,v9 沿用)
if (data.age && data.age > CONFIG.maxAge) {
return { pass: false, reason: `age>${CONFIG.maxAge}`, level: '不推荐' };
}
// A:外贸相关【经验】必须命中(2026-08-26 需求方收紧:只要相关经验,不再考虑培养类)
// 只匹配 workDetail(工作经历)——求职意向/教育背景里提到外贸不算经验
const tradeHit = CONFIG.tradeKeywords.filter(k => workDetail.includes(k));
const majorSrc = eduDetail || rawText;
const majorHit = CONFIG.majorKeywords.filter(k => majorSrc.includes(k));
const certSrc = certDetail + '\n' + rawText;
const certHit = CONFIG.certKeywords.filter(k => certSrc.includes(k));
if (!tradeHit.length) {
// B/C 只中专业或证书 = 培养类,明确拦截并注明原因便于统计
const traineeSignal = [];
if (majorHit.length) traineeSignal.push(`major:${majorHit[0]}`);
if (certHit.length) traineeSignal.push(`cert:${certHit[0]}`);
return {
pass: false,
reason: traineeSignal.length ? `trainee-only(${traineeSignal.join('+')})` : 'no-trade-experience',
level: '不相关',
};
}
reasons.push(`经验:${tradeHit.slice(0, 3).join('/')}`);
if (majorHit.length) reasons.push(`专业:${majorHit.slice(0, 3).join('/')}`);
if (certHit.length) reasons.push(`证书:${certHit.slice(0, 3).join('/')}`);
// D:毕业时间窗口
const gradYear = parseGradYear(eduDetail, rawText);
if (gradYear !== null && (gradYear < CONFIG.gradYear.min || gradYear > CONFIG.gradYear.max)) {
return { pass: false, reason: `grad-year-${gradYear}-out-of-${CONFIG.gradYear.min}-${CONFIG.gradYear.max}`, level: '不相关' };
}
if (gradYear === null) reasons.push('year-unknown');
return { pass: true, reasons, gradYear };
}
// ========== 活跃度解析(v9 沿用 v5 逻辑) ==========
function parseActivity(activity) {
if (!activity) return 2;
const text = activity.toLowerCase();
if (/^\d+分钟前/.test(text) && /回复|浏览/.test(text)) return 5;
if (/半小时前|1小时前/.test(text)) return 4;
if (/^\d+小时前/.test(text)) return 4;
if (/48小时前在线/.test(text)) return 3;
if (/^\d天前(有回复|在线|浏览)/.test(text) || /^[123]天前/.test(text)) return 3;
if (/新注册/.test(text)) return 2;
if (/^\d+天前/.test(text)) return 1;
return 2;
}
// ========== 经验年数解析(注意"27年应届生"陷阱,先判应届) ==========
function parseExperienceYears(exp) {
if (!exp) return null;
if (/应届生|在校生/.test(exp)) return 0;
const m = exp.match(/(\d+)年(\d*个月?)?/);
if (m) return parseInt(m[1], 10) + (parseInt((m[2] || '').replace(/[^0-9]/g, ''), 10) || 0) / 12;
return null;
}
// ========== 七维评分(v9 重建版,维度名与数据库 score_details 一致) ==========
function scoreCandidate(data) {
const rawText = data.rawText || '';
const workDetail = data.workDetail || '';
const eduDetail = data.educationDetail || '';
const fullText = rawText + '\n' + workDetail;
const expYears = parseExperienceYears(data.experience || data.positionExp || '') || 0;
// 1. 行业匹配(全文):二手车/汽车出口类高分,汽配/工程机械中分
const indH = countHits(fullText, INDUSTRY_HIGH);
const indM = countHits(fullText, INDUSTRY_MID);
let industryScore = 1;
if (indH >= 2) industryScore = 5;
else if (indH === 1) industryScore = 4;
else if (indM >= 2) industryScore = 3;
else if (indM === 1) industryScore = 2;
// 2. 外贸核心能力:highTrade 仅从 workDetail 匹配(求职意向不算数),midTrade 全文
const highTrade = countHits(workDetail, TRADE_HIGH);
const midTrade = countHits(fullText, TRADE_MID);
let tradeScore = 1;
if (highTrade >= 2) tradeScore = 5;
else if (highTrade === 1) tradeScore = 4;
else if (midTrade >= 2) tradeScore = 3;
else if (midTrade === 1) tradeScore = 2;
// 3. 语言能力:小语种/雅思托福最高;外贸经验强时保底 2 分(语言不一票否决实战派)
const langTop = countHits(fullText, LANG_TOP);
const langMid = countHits(fullText, LANG_MID);
const langLow = countHits(fullText, LANG_LOW);
let langScore = 1;
if (langTop >= 1) langScore = 5;
else if (langMid >= 1) langScore = 4;
else if (langLow >= 1) langScore = 3;
if (tradeScore <= 2 && highTrade >= 2) langScore = Math.max(langScore, 2);
// 4. 客户开发与获客(仅 workDetail,避免实习/意向虚高;区分高/低含金量)
const chH = countHits(workDetail, CHANNEL_HIGH);
const chL = countHits(workDetail, CHANNEL_LOW);
let channelScore = 1;
if (chH >= 2) channelScore = 5;
else if (chH === 1 && chL >= 1) channelScore = 4;
else if (chH === 1) channelScore = 3;
else if (chL >= 2) channelScore = 3;
else if (chL === 1) channelScore = 2;
// 5. 专业背景:高匹配专业+学历综合
const edu = data.education || '';
let eduBase = 1;
if (edu === '硕士' || edu === '博士') eduBase = 5;
else if (edu === '本科') eduBase = 4;
else if (edu === '大专') eduBase = 3;
const majorSrc = eduDetail || rawText;
let majorScore = 1;
if (countHits(majorSrc, MAJOR_HIGH) >= 1 && eduBase >= 3) majorScore = 5;
else if (countHits(majorSrc, MAJOR_LANG) >= 1) majorScore = 4;
else if (countHits(majorSrc, MAJOR_OTHER) >= 1) majorScore = 3;
else if (eduBase >= 4) majorScore = 2;
// 6. 稳定性:最长在职时长 + 工作段数
let stabilityScore = 3;
if (workDetail) {
const dms = workDetail.match(/\((\d+年\s*\d*个月?)\)/g) || workDetail.match(/(\d+年\d*个月?)(?=[\s\n])/g);
if (dms && dms.length) {
const durations = dms.map(d => {
const m = d.match(/(\d+)年\s*(\d*个月?)?/);
return (parseInt(m[1], 10) || 0) + (parseInt((m[2] || '').replace(/[^0-9]/g, ''), 10) || 0) / 12;
});
const mx = Math.max(...durations);
const jc = durations.length;
if (mx >= 2 && jc <= 3) stabilityScore = 5;
else if (mx >= 1.5 && jc <= 4) stabilityScore = 4;
else if (mx >= 1) stabilityScore = 3;
else if (durations[0] < 0.5 && jc > 3) stabilityScore = 1;
else stabilityScore = 2;
}
}
// 7. 活跃度
const activityScore = parseActivity(data.activity || '');
const total = industryScore + tradeScore + langScore + channelScore + majorScore + stabilityScore + activityScore;
// 动态阈值(v9):行业强 → 推荐线降 2 分
const recommendLine = industryScore >= 4 ? CONFIG.thresholds.recommendIndustry : CONFIG.thresholds.recommend;
let level = total >= recommendLine ? '推荐' : (total >= CONFIG.thresholds.maybe ? '待定' : '不推荐');
// 岗位推荐标签(v9)
let roleTag = '不限';
if (expYears >= 1 && (tradeScore >= 3 || channelScore >= 3 || industryScore >= 3)) roleTag = '有经验岗';
else if (expYears < 1 && (langScore >= 3 || majorScore >= 3)) roleTag = '应届生岗';
return {
total, level, roleTag,
details: { industryScore, tradeScore, langScore, channelScore, majorScore, stabilityScore, activityScore },
};
}
// ========== 总入口:门槛 + 评分 ==========
/**
* evaluateCandidate(data) → { gate, score, total, level, details }
* 门槛不通过时 level 为 '不相关'(或年龄否决的 '不推荐'),score 为 null,绝不打招呼。
*/
function evaluateCandidate(data) {
const gate = hardGate(data);
if (!gate.pass) {
return { gate, score: null, total: 0, level: gate.level || '不相关', roleTag: '不限', details: { gateReason: gate.reason } };
}
const sc = scoreCandidate(data);
return { gate, score: sc.total, total: sc.total, level: sc.level, roleTag: sc.roleTag, details: sc.details };
}
module.exports = { CONFIG, hardGate, scoreCandidate, evaluateCandidate, parseGradYear, parseExperienceYears, parseActivity };
// ========== 离线自测(node scoring.js 直接运行时) ==========
if (require.main === module) {
const samples = [
{ name: '外贸业务员(应通过)', data: { age: 27, education: '本科', experience: '3年', rawText: '三年外贸业务员经验,负责海外客户开发、询盘报价,参加展会,阿里巴巴国际站运营', workDetail: 'XX外贸公司 外贸业务员 (2年8个月) 客户开发 询盘 报价', educationDetail: '2018-2022 本科 商务英语', certDetail: '大学英语六级 CET-6' } },
{ name: '证券分析(应拦截)', data: { age: 28, education: '本科', experience: '4年', rawText: '证券公司研究员,负责行业研究与投资分析报告', workDetail: 'XX证券 证券分析师 (3年2个月) 行业研究 金融建模', educationDetail: '2014-2018 本科 金融学', certDetail: '证券从业资格' } },
{ name: '保险顾问(应拦截)', data: { age: 30, education: '大专', experience: '5年', rawText: '保险代理人,客户资源维护与保单销售', workDetail: 'XX人寿 保险顾问 (4年1个月) 保单销售 客户维护', educationDetail: '2013-2016 大专 市场营销', certDetail: '' } },
{ name: '英语应届生(应拦截-培养类)', data: { age: 22, education: '本科', experience: '应届生', rawText: '英语专业应届毕业生,求职外贸方向', workDetail: '', educationDetail: '2022-2026 本科 英语', certDetail: '英语专业四级 专四' } },
{ name: '俄语翻译(应拦截-培养类)', data: { age: 24, education: '本科', experience: '1年', rawText: '俄语翻译,协助外贸邮件沟通', workDetail: 'XX贸易 翻译 (1年0个月)', educationDetail: '2018-2022 本科 俄语', certDetail: '俄语专业四级' } },
{ name: '2019届外贸(应拦-毕业年份)', data: { age: 29, education: '本科', experience: '6年', rawText: '外贸经理,团队管理,海外市场', workDetail: 'XX进出口 外贸经理 (5年2个月) 外贸 出口', educationDetail: '2015-2019 本科 国际经济与贸易', certDetail: '' } },
{ name: '36岁外贸老手(应拦-年龄)', data: { age: 36, education: '本科', experience: '12年', rawText: '外贸总监', workDetail: '外贸 (10年0个月)', educationDetail: '2008-2012 本科 英语', certDetail: '专八' } },
];
for (const s of samples) {
const r = evaluateCandidate(s.data);
const verdict = r.gate.pass ? `${r.level} ${r.total}分 [${r.roleTag}] (${r.gate.reasons.join('; ')})` : `⛔ ${r.level} (${r.gate.reason})`;
console.log(`${s.name} → ${verdict}`);
}
}
+141
View File
@@ -0,0 +1,141 @@
#!/bin/bash
# stop-tasks.sh — 随时叫停/重启统一入口 v1.0(2026-08-26)
#
# 用法:
# bash stop-tasks.sh stop # 停止全部(hr1+hr2)
# bash stop-tasks.sh stop hr1 # 只停 hr1
# bash stop-tasks.sh restart hr2 # 重启 hr2(先干净停,再启动)
# bash stop-tasks.sh status # 查询状态(含卡死检测)
#
# 三层停止机制:
# 1. 优雅:创建 STOP.flag 旗标 → runner/scrape 每轮检查自行退出(最多等45秒)
# 2. 强杀:SIGTERM → 3秒 → SIGKILL,父runner和子scrape都杀
# 3. 验证:杀完复查 ps,任何残留即报错;同时清理旗标防止影响下次启动
set -u
STATE_DIR=/opt/data/logs/recruitment/state
LOG_DIR=/opt/data/logs/recruitment
SCRIPTS=/opt/data/skills/recruitment-resume-screener/scripts
STOP_FLAG=$STATE_DIR/STOP.flag
mkdir -p "$STATE_DIR"
# 进程发现:宽匹配(nohup/环境变量/直接参数三种启动方式都能覆盖)
find_pids() { # $1=acct 或空=全部
local pat="daily-runner|zhaopin-scrape"
[ -n "${1:-}" ] && pat="daily-runner.*$1|ACCOUNT=$1"
ps aux | grep -E "$pat" | grep -v -E "grep|stop-tasks" | awk '{print $2}'
}
kill_wait() { # $@ = pids
[ $# -eq 0 ] && return 0
kill "$@" 2>/dev/null || true
for i in 1 2 3; do sleep 1; ps -p "$@" >/dev/null 2>&1 || return 0; done
kill -9 "$@" 2>/dev/null || true
sleep 1
}
verify_none() { # $1=acct 或空;返回0=干净
local left
left=$(find_pids "${1:-}")
if [ -n "$left" ]; then
echo "❌ 仍有残留进程: $(echo $left | tr '\n' ' ')"
echo "$left" | while read -r p; do
ps -p "$p" -o pid,stat,etime,cputime,cmd --no-headers 2>/dev/null
done
return 1
fi
return 0
}
do_stop() {
local target="${1:-}"
echo "🛑 停止筛选任务${target:+($target)}..."
touch "$STOP_FLAG" # 第1层:优雅退出信号
local pids; pids=$(find_pids "$target")
if [ -n "$pids" ]; then
echo " 等待进程优雅退出(最多45秒): $(echo $pids | tr '\n' ' ')"
for i in $(seq 1 45); do
sleep 1
pids=$(find_pids "$target")
[ -z "$pids" ] && break
done
fi
pids=$(find_pids "$target")
if [ -n "$pids" ]; then
echo " 仍有进程,强制终止: $(echo $pids | tr '\n' ' ')"
kill_wait $pids # 第2层:TERM→KILL
fi
rm -f "$STOP_FLAG" # 清旗标,不影响下次启动
if verify_none "$target"; then # 第3层:验证
echo "✅ 已全部停止,验证无残留"
return 0
else
echo "⚠️ 建议稍后重跑 status 确认;若仍残留,联系管理员检查"
return 1
fi
}
do_start() { # $1=acct
local acct="$1" port label
[ "$acct" = "hr1" ] && { port=9223; label=Chrome; } || { port=9225; label=Edge; }
# 启动前防重
local existing; existing=$(find_pids "$acct")
if [ -n "$existing" ]; then
echo "⚠️ $acct 已在运行(PID $(echo $existing | tr '\n' ' ')),先停止再重启"
do_stop "$acct"
fi
# 清掉旧状态,标记新运行
rm -f "$STATE_DIR/state-$acct.json"
cd "$SCRIPTS"
ACCOUNT=$acct CDP_PORT=$port nohup node daily-runner.js "$acct" "$port" \
> "$LOG_DIR/runner-$acct-$(date +%F-%H%M%S).log" 2>&1 &
local pid=$!
sleep 3
if ps -p "$pid" >/dev/null 2>&1; then
echo "✅ $acct($label) 已启动 PID=$pid 日志=$(ls -t $LOG_DIR/runner-$acct-*.log | head -1)"
return 0
else
echo "❌ $acct 启动失败,查看日志: $(ls -t $LOG_DIR/runner-$acct-*.log | head -1)"
tail -5 "$(ls -t $LOG_DIR/runner-$acct-*.log | head -1)"
return 1
fi
}
do_restart() {
do_stop "$1" && do_start "$1"
}
do_status() {
echo "📋 筛选任务状态($(TZ=Asia/Shanghai date '+%F %T'))"
local found=0
for acct in hr1 hr2; do
local pids; pids=$(find_pids "$acct")
if [ -n "$pids" ]; then
found=1
echo " $acct: ▶️ 运行中"
echo "$pids" | while read -r p; do
local info
info=$(ps -p "$p" -o pid,stat,etime,cputime,cmd --no-headers 2>/dev/null | cut -c1-120)
# 卡死检测:cputime 为 00:00 且 etime>10min
local et cpu
et=$(echo "$info" | awk '{print $3}'); cpu=$(echo "$info" | awk '{print $4}')
local mark=""
[ "$cpu" = "00:00:00" ] && case "$et" in *:*:*) mark=" ⚠️疑似卡死(cputime=0)";; esac
echo " $info$mark"
done
local st; st=$(cat "$STATE_DIR/state-$acct.json" 2>/dev/null || echo "无状态文件")
echo " 状态: $(echo "$st" | head -c 200)"
else
echo " $acct: ⏹️ 未运行(状态: $(cat "$STATE_DIR/state-$acct.json" 2>/dev/null | head -c 120 || echo '无'))"
fi
done
[ "$found" = "0" ] && echo " (全部停止)"
[ -f "$STOP_FLAG" ] && echo " ⚠️ 存在停止旗标 STOP.flag(新批次将被阻止启动)"
}
case "${1:-status}" in
stop) do_stop "${2:-}" ;;
start) do_start "${2:?用法: start hr1|hr2}" ;;
restart) do_restart "${2:?用法: restart hr1|hr2}" ;;
status) do_status ;;
*) echo "用法: $0 stop [hr1|hr2] | start hr1|hr2 | restart hr1|hr2 | status"; exit 1 ;;
esac
+75
View File
@@ -0,0 +1,75 @@
// test-gate.js — 用真实数据库回测 v10 硬性预筛门槛
// 用法: node test-gate.js [db路径]
// 验证点:
// 1) 历史已打招呼的人,现在有多少会被新门槛拦截(拦截率)
// 2) 被拦截者的原因分布(无外贸信号 / 毕业年份超窗 / 年龄)
// 3) 需求方点名的无关职业(售前/证券/保险/金融/售后)是否被拦
// 4) 老标准下"推荐"的人是否仍被放行(不能误杀优质候选)
'use strict';
const path = require('path');
function tryRequire(names) { for (const n of names) { try { return require(n); } catch (_) {} } throw new Error('需要 better-sqlite3'); }
const Database = tryRequire([process.env.SQLITE3_PATH, path.join(__dirname, 'node_modules', 'better-sqlite3'), 'better-sqlite3']);
const { hardGate } = require('./scoring');
const dbPath = process.argv[2] || path.join(__dirname, '..', '..', 'data', 'recruitment.db');
const db = new Database(dbPath, { readonly: true });
const rows = db.prepare(`
SELECT name, age, education, experience, raw_text, education_detail, work_detail, cert_detail, score_level
FROM candidates WHERE greeted = 1`).all();
console.log(`历史已打招呼: ${rows.length} 人,开始回测 v10 门槛...\n`);
let pass = 0;
const blocked = { 'no-experience': [], 'trainee-only': [], 'grad-year': [], 'age': [] };
for (const r of rows) {
const g = hardGate({
rawText: r.raw_text, workDetail: r.work_detail, educationDetail: r.education_detail,
certDetail: r.cert_detail, age: r.age, education: r.education, experience: r.experience,
});
if (g.pass) pass++;
else if (g.reason.startsWith('grad-year')) blocked['grad-year'].push({ r, reason: g.reason });
else if (g.reason.startsWith('age')) blocked['age'].push({ r, reason: g.reason });
else if (g.reason.startsWith('trainee-only')) blocked['trainee-only'].push({ r, reason: g.reason });
else blocked['no-experience'].push({ r, reason: g.reason });
}
const total = rows.length;
console.log(`== 总体 ==`);
console.log(`放行 ${pass} 人 (${(pass / total * 100).toFixed(1)}%) | 拦截 ${total - pass} 人 (${((total - pass) / total * 100).toFixed(1)}%)`);
console.log(`拦截原因: 无外贸经验 ${blocked['no-experience'].length} | 培养类(仅专业/证书) ${blocked['trainee-only'].length} | 毕业年份超窗 ${blocked['grad-year'].length} | 年龄>35 ${blocked['age'].length}`);
// 需求方点名的无关职业,在历史已打招呼中的分布及拦截情况
const IRRELEVANT_JOBS = ['售前', '证券', '保险', '金融', '售后', '投资顾问', '银行', '理财'];
console.log(`\n== 需求方点名的无关职业(历史已打招呼中的命中情况)==`);
for (const job of IRRELEVANT_JOBS) {
const hits = rows.filter(r => (r.work_detail || '') .includes(job));
if (!hits.length) continue;
const wouldBlock = hits.filter(h => !hardGate({
rawText: h.raw_text, workDetail: h.work_detail, educationDetail: h.education_detail,
certDetail: h.cert_detail, age: h.age, education: h.education, experience: h.experience,
}).pass);
console.log(`"${job}": 命中 ${hits.length} 人 → 新门槛拦截 ${wouldBlock.length} 人${wouldBlock.length < hits.length ? `(漏放 ${hits.length - wouldBlock.length},多因简历含外贸关键词)` : ' ✅'}`);
}
// 无信号被拦者抽样(看职业构成)
console.log(`\n== "无外贸经验"被拦者抽样 20 人 ==`);
for (const { r } of blocked['no-experience'].slice(0, 20)) {
const title = (r.work_detail || r.raw_text || '').split('\n').map(s => s.trim()).find(s => s && s.length > 3 && !/^\d/.test(s)) || '?';
console.log(` ⛔ ${r.name} ${r.age || '?'}岁 ${r.education || ''} | ${(title || '').substring(0, 40)}`);
}
// 误杀检查:老标准"推荐"的人被拦了多少
const recs = db.prepare(`SELECT name, age, education, experience, raw_text, education_detail, work_detail, cert_detail FROM candidates WHERE score_level = '推荐'`).all();
let recBlocked = 0;
const recBlockedSamples = [];
for (const r of recs) {
const g = hardGate({
rawText: r.raw_text, workDetail: r.work_detail, educationDetail: r.education_detail,
certDetail: r.cert_detail, age: r.age, education: r.education, experience: r.experience,
});
if (!g.pass) { recBlocked++; if (recBlockedSamples.length < 8) recBlockedSamples.push(`${r.name}(${g.reason})`); }
}
console.log(`\n== 误杀检查 ==`);
console.log(`老标准"推荐" ${recs.length} 人 → 被新门槛拦截 ${recBlocked} 人`);
if (recBlockedSamples.length) console.log(`被拦样本: ${recBlockedSamples.join(' | ')}`);
+521
View File
@@ -0,0 +1,521 @@
#!/usr/bin/env node
// zhaopin-scrape.js v10 — 智联招聘推荐页候选人抓取 + 硬性预筛 + 七维评分 + 弹窗内打招呼
//
// v10 变更(2026-08-26):
// 1. 集成 scoring.js 硬性预筛门槛:无外贸岗位/语言专业/指定证书、毕业年份不在
// 2020-2026、年龄>35 的候选人 → 标"不相关/不推荐",绝不打招呼(解决"打招呼范围过宽")
// 2. 依赖去硬编码:playwright-core / better-sqlite3 依次尝试
// 环境变量 PWCORE_PATH/SQLITE3_PATH → 脚本目录 node_modules → 全局 require
// 3. 数据库路径默认 ./recruitment.db(RECRUITMENT_DB 覆盖),不再写死 /home/kt
// 4. 保留 v9.2 已验证行为:resumeNumber 去重、nextElementSibling 导航、
// 弹窗内打招呼、聊天次数监控、公司黑名单、人类行为模拟、健康检测熔断
//
// 运行:ACCOUNT=hr1 CDP_PORT=9223 node zhaopin-scrape.js [数量] [--full]
// --full 全量模式(重抓已存储者);默认增量
// --score-only 只抓取评分,不打招呼(验证新门槛时用)
'use strict';
// ---------- 依赖加载(多重回退,勿写死绝对路径) ----------
function tryRequire(names) {
for (const n of names) {
try { return require(n); } catch (_) { /* 下一个 */ }
}
throw new Error(`无法加载依赖,请 npm install playwright-core better-sqlite3,或设置 PWCORE_PATH/SQLITE3_PATH 环境变量`);
}
const path = require('path');
const crypto = require('crypto');
const { chromium } = tryRequire([
process.env.PWCORE_PATH,
path.join(__dirname, 'node_modules', 'playwright-core'),
'playwright-core',
].filter(Boolean));
const Database = tryRequire([
process.env.SQLITE3_PATH,
path.join(__dirname, 'node_modules', 'better-sqlite3'),
'better-sqlite3',
].filter(Boolean));
const { evaluateCandidate, parseExperienceYears, CONFIG } = require('./scoring');
// ---------- 环境配置 ----------
const DB_PATH = process.env.RECRUITMENT_DB || path.join(__dirname, 'recruitment.db');
const CDP_HOST = process.env.CDP_HOST || 'localhost';
const CDP_PORT = process.env.CDP_PORT || '9222';
const ACCOUNT = process.env.ACCOUNT || 'hr1';
const TARGET_CITY = process.env.TARGET_CITY || '郑州';
const COMPANY_BLACKLIST = ['合塑', '派卡']; // 从 workDetail 提取公司名命中即跳过
const MAX_START_SKIP = 50;
// ---------- 工具 ----------
function randomSleep(minSec, maxSec) {
const ms = Math.floor(Math.random() * (maxSec - minSec + 1) + minSec) * 1000;
return new Promise(r => setTimeout(r, ms));
}
function fingerprint(name, age, education, experience, school) {
return crypto.createHash('md5')
.update([name, age, education, experience, school].join('|'), 'utf8')
.digest('hex').substring(0, 12);
}
// ---------- 数据库 ----------
function initDB() {
const db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
db.exec(`
CREATE TABLE IF NOT EXISTS candidates (
id TEXT PRIMARY KEY, platform TEXT NOT NULL DEFAULT 'zhaopin',
name TEXT NOT NULL, age INTEGER, education TEXT, experience TEXT,
exp_years REAL, school TEXT, expected_city TEXT, expected_salary TEXT,
score INTEGER, score_level TEXT, role_tag TEXT,
greeted INTEGER NOT NULL DEFAULT 0, greeted_at TEXT,
raw_text TEXT, education_detail TEXT, work_detail TEXT, cert_detail TEXT,
card_info TEXT, score_details TEXT,
scraped_at TEXT, updated_at TEXT, notes TEXT DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_cand_level ON candidates(score_level);
CREATE INDEX IF NOT EXISTS idx_cand_greeted ON candidates(greeted);
CREATE TABLE IF NOT EXISTS greet_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
candidate_id TEXT NOT NULL, action TEXT NOT NULL, result TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')));
CREATE TABLE IF NOT EXISTS scrape_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mode TEXT, target_count INTEGER, new_count INTEGER, skip_count INTEGER,
greet_count INTEGER, error_count INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')));
`);
// 兼容 v9 旧库:补缺失列
const cols = new Set(db.prepare("PRAGMA table_info(candidates)").all().map(c => c.name));
for (const [col, ddl] of [['role_tag', "ALTER TABLE candidates ADD COLUMN role_tag TEXT"],
['account', "ALTER TABLE candidates ADD COLUMN account TEXT DEFAULT 'hr1'"],
['activity', "ALTER TABLE candidates ADD COLUMN activity TEXT"]]) {
if (!cols.has(col)) db.exec(ddl);
}
return db;
}
function upsertCandidate(db, d) {
const now = new Date().toISOString();
const prev = db.prepare('SELECT greeted, greeted_at FROM candidates WHERE id = ?').get(d.id);
const greeted = (prev && prev.greeted) || d.greeted ? 1 : 0;
db.prepare(`
INSERT INTO candidates (id, platform, account, name, age, education, experience, exp_years,
school, expected_city, expected_salary, activity, score, score_level, role_tag,
greeted, greeted_at, raw_text, education_detail, work_detail, cert_detail,
card_info, score_details, scraped_at, updated_at, notes)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET
account=excluded.account, name=excluded.name, age=excluded.age,
education=excluded.education, experience=excluded.experience, exp_years=excluded.exp_years,
school=excluded.school, expected_city=excluded.expected_city,
expected_salary=excluded.expected_salary, activity=excluded.activity,
score=excluded.score, score_level=excluded.score_level, role_tag=excluded.role_tag,
greeted=excluded.greeted,
greeted_at=CASE WHEN excluded.greeted=1 AND candidates.greeted=0 THEN excluded.greeted_at ELSE candidates.greeted_at END,
raw_text=excluded.raw_text, education_detail=excluded.education_detail,
work_detail=excluded.work_detail, cert_detail=excluded.cert_detail,
card_info=excluded.card_info, score_details=excluded.score_details,
updated_at=excluded.updated_at, notes=excluded.notes
`).run(d.id, 'zhaopin', ACCOUNT, d.name, d.age, d.education, d.experience,
parseExperienceYears(d.experience || d.positionExp), d.school, d.expectedCity || '',
d.expectedSalary || '', d.activity || '', d.score ?? null, d.scoreLevel || '',
d.roleTag || '', greeted, d.greeted ? now : (prev ? prev.greeted_at : null),
d.rawText || '', d.educationDetail || '', d.workDetail || '', d.certDetail || '',
d.cardInfo ? JSON.stringify(d.cardInfo) : null,
d.scoreDetails ? JSON.stringify(d.scoreDetails) : null,
d.scrapedAt || now, now, d.notes || '');
}
// ---------- 页面操作 ----------
async function closeModal(page) {
await page.evaluate(() => {
document.querySelector('.new-shortcut-resume__close')?.click();
document.querySelector('.km-modal--open')?.classList.remove('km-modal--open');
});
await randomSleep(1, 2);
}
// 弹窗内打招呼(v9:天然绑定正确的人,杜绝列表 index 错位)
async function clickGreetInModal(page) {
return page.evaluate(async () => {
const btn = document.querySelector('.new-shortcut-resume__modal button.km-button--primary.km-button--filled');
if (!btn) return false;
const t = btn.innerText.trim();
if (t === '继续沟通') return true; // 已打过
if (!t.includes('打招呼')) return false;
btn.click();
await new Promise(r => setTimeout(r, 2500));
return btn.innerText.trim() === '继续沟通';
});
}
// 人类行为模拟:弹窗内容超一屏时随机滚动
async function humanBrowseModal(page) {
await page.evaluate(async () => {
const wrap = document.querySelector('.new-resume-detail .km-scrollbar__wrap');
if (!wrap || wrap.scrollHeight <= wrap.clientHeight + 50) return;
const steps = 2 + Math.floor(Math.random() * 4);
for (let i = 0; i < steps; i++) {
wrap.scrollTop += 120 + Math.random() * 180;
await new Promise(r => setTimeout(r, 800 + Math.random() * 1200));
}
if (Math.random() < 0.5) wrap.scrollTop -= 60; // 随机回看
});
}
// 聊天剩余次数(.rights-chat-count .rights-count-number)
async function getChatCount(page) {
return page.evaluate(() => {
const el = document.querySelector('.rights-chat-count .rights-count-number');
const n = el ? parseInt(el.innerText.trim(), 10) : NaN;
return Number.isFinite(n) ? n : null;
}).catch(() => null);
}
// 卡片结构化提取(v9:class 选择器,不解析 innerText;含同事聊过/已打招呼标识)
async function getCardQuickInfo(page, dataIndex) {
return page.evaluate((di) => {
const card = document.querySelector(`.recommend-item[data-index="${di}"]`);
if (!card || card.offsetParent === null) return null;
const q = (sel) => card.querySelector(sel)?.innerText?.trim() || '';
const name = q('.talent-basic-info__name--inner');
const ageRaw = q('.age-label');
const expRaw = q('.work-years-label');
const chatText = card.querySelector('.cv-test-recommend-chat')?.innerText?.trim() || '';
return {
dataIndex: di, name,
age: parseInt((ageRaw.match(/\d+/) || [0])[0], 10) || null,
experience: expRaw, education: q('.education-label'),
careerStatus: q('.career-status-label'),
coworkerChatted: !!card.querySelector('.new-communication-record'),
greeted: chatText === '继续沟通',
activity: card.querySelector('.global-active-tag')?.innerText?.trim().replace(/\n/g, ' ') || '',
cardText: card.innerText || '', // v10.2:卡片全文用于列表级黑名单企业预筛(不打开弹窗)
};
}, dataIndex);
}
// v9 导航:nextElementSibling 找下一张卡(虚拟列表 data-index 会复用,兄弟关系才可靠)
async function navigateToNextCard(page, dataIndex) {
return page.evaluate((di) => {
const cur = document.querySelector(`.recommend-item[data-index="${di}"]`);
if (!cur) return null;
const item = cur.closest('[role="listitem"]');
const nextItem = item?.nextElementSibling;
const nextCard = nextItem?.querySelector('.recommend-item');
return nextCard ? nextCard.getAttribute('data-index') : null;
}, dataIndex);
}
// 弹窗数据提取(v5 逻辑沿用)
async function extractModalData(page) {
return page.evaluate(() => {
const modal = document.querySelector('.new-shortcut-resume__modal');
if (!modal) return null;
const text = modal.innerText || '';
function section(startPat, endPat) {
const sm = text.match(new RegExp(startPat));
if (!sm) return '';
const rest = text.substring(sm.index + sm[0].length);
const em = rest.match(new RegExp(endPat));
return (em ? rest.substring(0, em.index) : rest.substring(0, 500)).trim();
}
const nameMatch = text.match(/^([^\n]+)/);
const ageMatch = text.match(/(\d+)岁/);
const yearMatch = text.match(/\((\d{4})年(\d+)月\)/);
const eduMatch = text.match(/(博士|硕士|本科|大专|中专|高中|初中)/);
const expMatch = text.match(/\d+年经验|应届生|在校生/) ||
text.split('\n').map(l => l.trim()).find(l => /^\d{1,2}年$/.test(l) && (l = l.trim()));
const salaryMatch = text.match(/([\d.]+[-~到][\d.]+千?\/?月?|面议|薪资面议)/);
const cityMatch = text.match(/\[([^\]]+)\]/);
let school = '';
const eduSection = section('教育经历', '个人优势|专业技能|语言能力|证书|打电话|打招呼');
if (eduSection) {
for (const line of eduSection.split('\n').map(l => l.trim()).filter(Boolean)) {
if (!/\d{4}/.test(line) && !/^(本科|大专|硕士|博士|统招|专升本)$/.test(line) && line.length > 2 && line.length < 25) { school = line; break; }
}
}
let positionExp = '';
const pm = text.match(/岗位经验\s*\n\S+\s*\n(\d+年\d*个月?)/);
if (pm) positionExp = pm[1];
// 人才处理记录条数(v10.1:>2条说明已被多位同事处理过,不再打招呼)
let staffLogCount = null;
const logsCountEl = modal.querySelector('.new-resume-sidebar__logs-count');
if (logsCountEl) {
const cm = (logsCountEl.innerText || '').match(/\((\d+)条\)/);
if (cm) staffLogCount = parseInt(cm[1], 10);
}
return {
staffLogCount,
name: nameMatch ? nameMatch[1].trim() : '',
age: ageMatch ? parseInt(ageMatch[1], 10) : null,
birthYear: yearMatch ? parseInt(yearMatch[1], 10) : null,
education: eduMatch ? eduMatch[1] : '',
experience: expMatch ? String(expMatch[0] || expMatch) : (positionExp || ''),
positionExp, expectedSalary: salaryMatch ? salaryMatch[1] : '',
expectedCity: cityMatch ? cityMatch[1] : '', school, rawText: text,
educationDetail: eduSection,
workDetail: section('工作经历', '教育经历|个人优势|专业技能|语言能力|证书|打电话|打招呼'),
certDetail: section('所获证书|证书|语言能力', '打电话|打招呼|求职期望|教育经历|工作经历'),
scrapedAt: new Date().toISOString(),
};
});
}
// 从弹窗 URL 提取平台原生简历编号(v9:唯一可靠的跨批次去重标识)
function extractResumeNumber(page) {
const m = (page.url().match(/resumeNumber=([^&]+)/) || [])[1];
return m ? decodeURIComponent(m) : null;
}
// 公司黑名单:从工作经历提取公司名行
function extractCompanies(workDetail) {
return (workDetail || '').split('\n').map(l => l.trim())
.filter(l => l.length > 3 && l.length < 30 && !/\d{4}|^\d|[((]|^职位|^岗位/.test(l));
}
// 停止旗标(v10.3:随时叫停机制——stop-tasks.sh 创建旗标文件,脚本每轮检查优雅退出)
const STOP_FLAG = '/opt/data/logs/recruitment/state/STOP.flag';
function stopRequested() { try { return require('fs').existsSync(STOP_FLAG); } catch { return false; } }
// 健康检测(v5 沿用)
async function checkPageHealth(page) {
const url = page.url();
if (url.includes('login') || url.includes('passport')) return '🚨 ALERT: 登录已失效,请重新登录后继续';
const info = await page.evaluate(() => {
const body = document.body?.innerText || '';
return {
captcha: !!document.querySelector('.verify-wrap,.captcha,.slider-verify,.tcaptcha-iframe') || /安全验证|请完成验证|滑块验证|图形验证码/i.test(body),
risk: /操作过于频繁|访问受限|账号异常|请求异常/i.test(body),
greetLimit: /打招呼已达上限|已达到.*上限|今日打招呼次数已用完|打招呼额度不足/i.test(body),
};
}).catch(() => ({ captcha: false, risk: false, greetLimit: false }));
if (info.captcha) return '🚨 ALERT: 触发验证码/风控,已暂停';
if (info.risk) return '🚨 ALERT: 触发风控限制,已暂停';
if (info.greetLimit) return '🚨 ALERT: 打招呼已达上限,停止打招呼';
return null;
}
// ---------- 主流程 ----------
async function scrapeZhaopin(maxCount = 20, fullMode = false, scoreOnly = false) {
console.log(`[zhaopin v10] account=${ACCOUNT} cdp=${CDP_PORT} db=${DB_PATH}`);
console.log(`门槛配置: 岗位词${CONFIG.tradeKeywords.length}个 | 专业词${CONFIG.majorKeywords.length}个 | 证书词${CONFIG.certKeywords.length}个 | 毕业${CONFIG.gradYear.min}-${CONFIG.gradYear.max} | 年龄≤${CONFIG.maxAge}`);
const res = await fetch(`http://${CDP_HOST}:${CDP_PORT}/json/version`);
const info = await res.json();
const browser = await chromium.connectOverCDP(info.webSocketDebuggerUrl);
const ctx = browser.contexts()[0];
const page = ctx.pages().find(p => p.url().includes('recommend'));
if (!page) { console.log('ERROR: 未找到推荐页面(需在浏览器中打开 recommend 页)'); await browser.close(); process.exit(1); }
const db = initDB();
const existingIds = new Set(db.prepare('SELECT id FROM candidates').all().map(r => r.id));
// 启动快速跳过用的卡片指纹(name|age|education|experience,来自历史 card_info)
const storedCards = new Set(db.prepare("SELECT card_info FROM candidates WHERE card_info IS NOT NULL").all()
.map(r => { try { const c = JSON.parse(r.card_info); return [c.name, c.age, c.education, c.experience].join('|'); } catch (_) { return null; } })
.filter(Boolean));
let greeted = 0, errors = 0, newCount = 0, skipped = 0, irrelevant = 0;
let consecutiveErrors = 0, consecutiveGreetFails = 0, greetLimitReached = scoreOnly;
let currentDataIndex = null, processed = new Set(), noCardCount = 0;
let sessionProcessed = parseInt(process.env.SESSION_OFFSET || '0', 10) || 0, lastReloadAt = sessionProcessed; // v10.4:账号累计处理数(runner跨批传入)与上次刷新点
const RELOAD_EVERY = parseInt(process.env.RELOAD_EVERY || '200', 10); // v10.4:每累计200人刷新页面
const log = (m) => console.log(` ${m}`);
const stop = async (reason, code = 1) => {
console.log(reason);
db.prepare('INSERT INTO scrape_log (mode,target_count,new_count,skip_count,greet_count,error_count) VALUES (?,?,?,?,?,?)')
.run(`${fullMode ? 'full' : 'incr'}${scoreOnly ? '-score' : ''}`, maxCount, newCount, skipped, greeted, errors);
db.close(); await browser.close(); process.exit(code);
};
console.log(`已存储 ${existingIds.size} 人,${fullMode ? '全量' : '增量'}模式${scoreOnly ? '(仅评分)' : ''}`);
let chatLeft = await getChatCount(page);
if (chatLeft !== null) console.log(`聊天剩余: ${chatLeft} 次`);
// 启动快速跳过:定位第一个待处理卡片
let firstIndex = await page.evaluate(() => document.querySelector('.recommend-item')?.getAttribute('data-index'));
let skipN = 0;
while (firstIndex !== null && skipN < MAX_START_SKIP) {
const c = await getCardQuickInfo(page, firstIndex);
if (!c || !c.name) break;
const cardFp = [c.name, c.age, c.education, c.experience].join('|');
if (c.coworkerChatted || c.greeted || (c.age && c.age > CONFIG.maxAge) || (!fullMode && storedCards.has(cardFp))) {
firstIndex = await navigateToNextCard(page, firstIndex); skipN++;
continue;
}
break;
}
currentDataIndex = firstIndex;
console.log(`启动跳过 ${skipN} 人,从 data-index=${currentDataIndex} 开始`);
while (newCount < maxCount) {
// v10.3:随时叫停——每轮循环检查停止旗标(当前候选人处理完才退,不会打断打招呼中途)
if (stopRequested()) return stop('🛑 收到停止指令,优雅退出本批', 0);
// v10.4:账号累计处理满200人 → 刷新页面(清虚拟列表DOM累积/内存,重置滚动状态)
// 累计基数由 runner 通过 SESSION_OFFSET 传入(跨批次累计),批内用 offset+newCount 判断
if (sessionProcessed - lastReloadAt >= RELOAD_EVERY) {
console.log(` ♻️ 账号累计处理 ${sessionProcessed} 人(达${RELOAD_EVERY}人阈值),刷新页面...`);
await page.reload({ waitUntil: 'domcontentloaded', timeout: 60000 }).catch(e => console.log(`刷新失败: ${e.message}`));
await randomSleep(5, 10);
lastReloadAt = sessionProcessed;
processed = new Set();
currentDataIndex = null;
}
const alert = await checkPageHealth(page);
if (alert) {
if (alert.includes('上限') && !alert.includes('失效')) { console.log(alert); greetLimitReached = true; }
else return stop(alert);
}
if (await page.evaluate(() => !!document.querySelector('.km-modal--open'))) await closeModal(page);
if (currentDataIndex === null || processed.has(currentDataIndex)) {
await page.evaluate(() => window.scrollBy(0, window.innerHeight * 0.8));
await randomSleep(3, 5);
const idx = await page.evaluate(() => document.querySelector('.recommend-item')?.getAttribute('data-index'));
if (idx === null && ++noCardCount >= 5) { console.log('连续无新卡片,结束'); break; }
currentDataIndex = idx; continue;
}
noCardCount = 0;
const card = await getCardQuickInfo(page, currentDataIndex);
if (!card || !card.name) { currentDataIndex = await navigateToNextCard(page, currentDataIndex); continue; }
processed.add(currentDataIndex);
if (card.coworkerChatted) { log(`[跳过-同事聊过] ${card.name}`); skipped++; currentDataIndex = await navigateToNextCard(page, currentDataIndex); continue; }
if (card.greeted) { log(`[跳过-已沟通] ${card.name}`); skipped++; currentDataIndex = await navigateToNextCard(page, currentDataIndex); continue; }
if (card.age && card.age > CONFIG.maxAge) { log(`[跳过-年龄>35] ${card.name} ${card.age}岁`); skipped++; currentDataIndex = await navigateToNextCard(page, currentDataIndex); continue; }
// v10.2:列表级黑名单企业预筛——卡片文本命中直接跳过,不打开弹窗
const cardBlackHit = COMPANY_BLACKLIST.find(b => card.cardText && card.cardText.includes(b));
if (cardBlackHit) { log(`🚫 [跳过-列表黑名单] ${card.name} | 卡片含"${cardBlackHit}"`); skipped++; currentDataIndex = await navigateToNextCard(page, currentDataIndex); continue; }
try {
// 打开弹窗
await page.evaluate((di) => {
const el = document.querySelector(`.recommend-item[data-index="${di}"] .resume-item__content`);
(el || document.querySelector(`.recommend-item[data-index="${di}"]`))?.click();
}, currentDataIndex);
await randomSleep(3, 5);
if (!await page.evaluate(() => !!document.querySelector('.km-modal--open') || !!document.querySelector('.km-modal__backdrop--active'))) {
log('弹窗未打开,跳过'); errors++; currentDataIndex = await navigateToNextCard(page, currentDataIndex); continue;
}
// 平台唯一标识
const resumeNumber = extractResumeNumber(page);
if (resumeNumber && !fullMode && existingIds.has(resumeNumber)) {
await closeModal(page);
log(`[跳过-已存储] ${card.name}`); skipped++;
currentDataIndex = await navigateToNextCard(page, currentDataIndex); continue;
}
// 刷新检测:弹窗人名 vs 卡片人名
const refreshed = await page.evaluate((expected) => {
const actual = document.querySelector('.new-resume-detail .resume-name')?.innerText?.trim() || '';
return expected && actual && !actual.includes(expected.replace(/(先生|女士)$/, ''));
}, card.name);
if (refreshed) { await closeModal(page); log('页面已刷新(列表变化),重扫'); currentDataIndex = null; continue; }
const data = await extractModalData(page);
if (!data?.name) { await closeModal(page); log('数据提取失败'); errors++; currentDataIndex = await navigateToNextCard(page, currentDataIndex); continue; }
await humanBrowseModal(page);
// 公司黑名单
const companies = extractCompanies(data.workDetail || '');
const blackHit = COMPANY_BLACKLIST.find(b => companies.some(c => c.includes(b)));
if (blackHit) {
await closeModal(page);
log(`🚫 黑名单命中: ${data.name} | "${blackHit}"`); skipped++;
currentDataIndex = await navigateToNextCard(page, currentDataIndex); continue;
}
// ===== 硬性预筛 + 评分(v10 核心) =====
data.id = resumeNumber || fingerprint(data.name, data.age, data.education, data.experience, data.school);
data.platform = 'zhaopin';
data.activity = card.activity || '';
data.greeted = card.greeted;
data.cardInfo = { name: card.name, age: card.age, education: card.education, experience: card.experience, activity: card.activity, dataIndex: currentDataIndex };
const result = evaluateCandidate(data);
data.score = result.score;
data.scoreLevel = result.level;
data.roleTag = result.roleTag;
data.scoreDetails = result.gate.pass ? { ...result.details, gateReasons: result.gate.reasons } : { gateReason: result.gate.reason };
data.notes = result.gate.pass ? result.gate.reasons.join(';') : result.gate.reason;
upsertCandidate(db, data);
existingIds.add(data.id);
newCount++;
sessionProcessed++; // v10.4:累计处理数(供200人刷新判断)
if (!result.gate.pass) {
irrelevant++;
log(`⛔ 不相关 ${data.name} | ${data.age}岁 | ${result.gate.reason} | 不评分不打招呼`);
} else {
const icon = result.level === '推荐' ? '✅' : result.level === '待定' ? '⏳' : '❌';
log(`${icon} ${data.name} | ${result.total}分 ${result.level} [${result.roleTag}] | ${result.gate.reasons.filter(r => r !== 'year-unknown').join(';')}`);
}
// ===== 弹窗内打招呼(仅 推荐/待定 且门槛通过 且 人才处理记录≤2条) =====
if (!greetLimitReached && result.gate.pass && (result.level === '推荐' || result.level === '待定') && !data.greeted
&& !(data.staffLogCount !== null && data.staffLogCount > 2)) {
const ok = await clickGreetInModal(page);
if (ok) {
consecutiveGreetFails = 0; greeted++;
data.greeted = true;
upsertCandidate(db, data);
db.prepare('INSERT INTO greet_log (candidate_id, action, result) VALUES (?,?,?)').run(data.id, 'greet', 'success');
log(`✋ 已打招呼: ${data.name}`);
chatLeft = await getChatCount(page);
if (chatLeft !== null && chatLeft <= 5) console.log(`⚠ 聊天剩余 ${chatLeft} 次`);
if (chatLeft !== null && chatLeft <= 0) return stop('🚨 聊天次数耗尽,结束本批', 0);
} else {
consecutiveGreetFails++;
db.prepare('INSERT INTO greet_log (candidate_id, action, result) VALUES (?,?,?)').run(data.id, 'greet', 'failed');
log(`⚠ 打招呼失败(连续${consecutiveGreetFails}次)`);
if (consecutiveGreetFails >= 3) return stop('🚨 连续3次打招呼失败,可能风控/上限');
}
} else if (result.gate.pass && (result.level === '推荐' || result.level === '待定') && !data.greeted
&& data.staffLogCount !== null && data.staffLogCount > 2) {
log(`⊘ 人才处理记录过多(${data.staffLogCount}条): ${data.name} | 通过筛选但不打招呼`);
}
await closeModal(page);
if (newCount % 10 === 0) { console.log(` --- 已处理 ${newCount} 人(不相关 ${irrelevant}),休息 ---`); await randomSleep(8, 15); }
await randomSleep(2, 4);
consecutiveErrors = 0;
currentDataIndex = await navigateToNextCard(page, currentDataIndex);
} catch (e) {
errors++; consecutiveErrors++;
log(`错误: ${String(e.message).substring(0, 150)}`);
await closeModal(page).catch(() => {});
await randomSleep(2, 4);
if (consecutiveErrors >= 5) return stop('🚨 连续5次操作错误,停止');
currentDataIndex = await navigateToNextCard(page, currentDataIndex);
}
}
console.log(`\n${'='.repeat(50)}\n完成: 新增 ${newCount}(其中不相关拦截 ${irrelevant})| 跳过 ${skipped} | 打招呼 ${greeted} | 错误 ${errors}\n数据库总计: ${db.prepare('SELECT COUNT(*) c FROM candidates').get().c} 人`);
db.prepare('INSERT INTO scrape_log (mode,target_count,new_count,skip_count,greet_count,error_count) VALUES (?,?,?,?,?,?)')
.run(`${fullMode ? 'full' : 'incr'}${scoreOnly ? '-score' : ''}`, maxCount, newCount, skipped, greeted, errors);
db.close();
await browser.close();
}
// ---------- 入口 ----------
const args = process.argv.slice(2);
scrapeZhaopin(
parseInt(args.filter(a => !a.startsWith('--'))[0], 10) || 20,
args.includes('--full'),
args.includes('--score-only'),
).catch(e => { console.error('FATAL:', e.message); process.exit(1); });