295 lines
16 KiB
JavaScript
295 lines
16 KiB
JavaScript
// 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}`);
|
||
}
|
||
}
|