Files
zhaopin/scripts/zhaopin-scrape.js
T

522 lines
27 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// 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); });