#!/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); });