Files
zhaopin/skill/scripts/scoring.js
T

295 lines
16 KiB
JavaScript
Raw Normal View History

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