布局扁平化: 仓库根与服务器运行目录(/opt/data/skills/recruitment-resume-screener)一一对应,便于双端git直接管理
This commit is contained in:
@@ -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`);
|
||||
}
|
||||
Executable
+124
@@ -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
|
||||
@@ -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();
|
||||
@@ -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); });
|
||||
@@ -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);
|
||||
Generated
+50
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
Executable
+141
@@ -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
|
||||
@@ -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(' | ')}`);
|
||||
Executable
+521
@@ -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); });
|
||||
Reference in New Issue
Block a user