522 lines
27 KiB
JavaScript
Executable File
522 lines
27 KiB
JavaScript
Executable File
#!/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); });
|