This commit is contained in:
张成
2026-04-08 17:27:40 +08:00
parent f2a8e61016
commit 51bbdacdda
5 changed files with 201 additions and 80 deletions

View File

@@ -341,6 +341,31 @@ class JobManager {
resumeInfo: resumeData
});
// 未通过规则/评分的待投递记录标记为 filtered避免长期 pending
const passedIds = new Set(matchedJobs.map((j) => j.id).filter((id) => id != null));
const notPassedIds = searchedJobs
.map((row) => (row.toJSON ? row.toJSON() : row))
.map((j) => j.id)
.filter((id) => id != null && !passedIds.has(id));
if (notPassedIds.length > 0) {
try {
await job_postings.update(
{ applyStatus: 'filtered' },
{
where: {
id: { [Op.in]: notPassedIds },
sn_code,
platform,
applyStatus: 'pending'
}
}
);
console.log(`[工作管理] 搜索并投递:不符合条件已标记 filtered ${notPassedIds.length}`);
} catch (e) {
console.warn('[工作管理] 标记 filtered 失败:', e.message);
}
}
// 7. 限制投递数量
const jobsToDeliver = matchedJobs.slice(0, maxCount);
console.log(`[工作管理] 匹配到 ${matchedJobs.length} 个职位,将投递 ${jobsToDeliver.length}`);

View File

@@ -30,7 +30,7 @@ class DeliverHandler extends BaseHandler {
*/
async doDeliver(task) {
const { sn_code, taskParams } = task;
const { keyword, platform = 'boss', pageCount = 3, maxCount = 10, filterRules = {} } = taskParams;
const { keyword, platform = 'boss', pageCount = 3, maxCount = 10 } = taskParams;
console.log(`[自动投递] 开始 - 设备: ${sn_code}, 关键词: ${keyword}`);
@@ -103,8 +103,8 @@ class DeliverHandler extends BaseHandler {
};
}
// 8. 合并过滤配置
const filterConfig = this.mergeFilterConfig(deliverConfig, filterRules, jobTypeConfig);
// 8. 过滤配置仅来自职位类型 job_types排除词 / 标题须含词等),不与账号投递配置、任务参数混用
const filterConfig = this.mergeFilterConfig(jobTypeConfig);
// 9. 过滤已投递的公司repeat_deliver_days 由投递配置给出,缺省 30上限 365
const repeatDeliverDays = Math.min(365, Math.max(1, Number(deliverConfig.repeat_deliver_days) || 30));
@@ -120,6 +120,9 @@ class DeliverHandler extends BaseHandler {
recentCompanies
);
// 本轮未进入「可投递」列表的待投递记录,标记为已过滤,避免长期停留在 pending
await this.markFilteredJobsNotPassed(pendingJobs, filteredJobs, sn_code, platform);
const jobsToDeliver = filteredJobs.slice(0, actualMaxCount);
console.log(`[自动投递] 职位筛选完成 - 原始: ${pendingJobs.length}, 符合条件: ${filteredJobs.length}, 将投递: ${jobsToDeliver.length}`);
@@ -294,6 +297,44 @@ class DeliverHandler extends BaseHandler {
await command.executeCommands(taskId, [getJobListCommand], this.mqttClient);
}
/**
* 将本批中未通过过滤/评分的职位从 pending 更新为 filtered仍 pending 的仅为通过筛选且等待下轮投递的)
* @param {Array} pendingJobs - 本批拉取的待投递
* @param {Array} filteredJobs - filterAndScoreJobsForDeliver 通过的结果(含 matchScore
*/
async markFilteredJobsNotPassed(pendingJobs, filteredJobs, sn_code, platform) {
if (!pendingJobs || pendingJobs.length === 0) {
return;
}
const passedIds = new Set(
(filteredJobs || []).map((j) => j.id).filter((id) => id != null)
);
const notPassedIds = pendingJobs
.map((j) => (j && j.id != null ? j.id : null))
.filter((id) => id != null && !passedIds.has(id));
if (notPassedIds.length === 0) {
return;
}
const job_postings = db.getModel('job_postings');
const { op } = db.models;
try {
const [n] = await job_postings.update(
{ applyStatus: 'filtered' },
{
where: {
id: { [op.in]: notPassedIds },
sn_code,
platform,
applyStatus: 'pending'
}
}
);
console.log(`[自动投递] 不符合条件已标记 filtered: ${notPassedIds.length} 条(更新行数 ${n}`);
} catch (e) {
console.warn('[自动投递] 标记 filtered 失败:', e.message);
}
}
/**
* 获取待投递职位
*/
@@ -314,42 +355,42 @@ class DeliverHandler extends BaseHandler {
}
/**
* 合并过滤配置
* 自动投递过滤配置:仅使用 job_typesexcludeKeywords、titleIncludeKeywords
* 薪资筛选不在此合并min/max 为 0 表示不做薪资过滤);评分权重仍走 accountConfig.is_salary_priority
*/
mergeFilterConfig(deliverConfig, filterRules, jobTypeConfig) {
// 排除关键词
const rawJobTypeExclude = jobTypeConfig?.excludeKeywords
? ConfigManager.parseConfig(jobTypeConfig.excludeKeywords, [])
: [];
mergeFilterConfig(jobTypeConfig) {
const base = {
exclude_keywords: [],
filter_keywords: [],
title_include_keywords: [],
min_salary: 0,
max_salary: 0,
priority_weights: []
};
const jobTypeExclude = Array.isArray(rawJobTypeExclude) ? rawJobTypeExclude : [];
if (!jobTypeConfig) {
return base;
}
const deliverExcludeRaw = ConfigManager.getExcludeKeywords(deliverConfig);
const deliverExclude = Array.isArray(deliverExcludeRaw) ? deliverExcludeRaw : [];
const filterExcludeRaw = filterRules.excludeKeywords || [];
const filterExclude = Array.isArray(filterExcludeRaw) ? filterExcludeRaw : [];
if (jobTypeConfig.excludeKeywords) {
try {
const raw = jobTypeConfig.excludeKeywords;
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
base.exclude_keywords = Array.isArray(parsed) ? parsed.map((k) => String(k || '').trim()).filter(Boolean) : [];
} catch (e) {
base.exclude_keywords = [];
}
}
// 过滤关键词
const deliverFilterRaw = ConfigManager.getFilterKeywords(deliverConfig);
const deliverFilter = Array.isArray(deliverFilterRaw) ? deliverFilterRaw : [];
const filterKeywordsRaw = filterRules.keywords || [];
const filterKeywords = Array.isArray(filterKeywordsRaw) ? filterKeywordsRaw : [];
// 薪资范围
const salaryRange = filterRules.minSalary || filterRules.maxSalary
? { min: filterRules.minSalary || 0, max: filterRules.maxSalary || 0 }
: ConfigManager.getSalaryRange(deliverConfig);
let title_include_keywords = [];
if (jobTypeConfig && jobTypeConfig.titleIncludeKeywords != null) {
if (jobTypeConfig.titleIncludeKeywords != null) {
const v = jobTypeConfig.titleIncludeKeywords;
if (Array.isArray(v)) {
title_include_keywords = v.map((k) => String(k || '').trim()).filter(Boolean);
base.title_include_keywords = v.map((k) => String(k || '').trim()).filter(Boolean);
} else if (typeof v === 'string' && v.trim()) {
try {
const p = JSON.parse(v);
if (Array.isArray(p)) {
title_include_keywords = p.map((k) => String(k || '').trim()).filter(Boolean);
base.title_include_keywords = p.map((k) => String(k || '').trim()).filter(Boolean);
}
} catch (e) {
/* ignore */
@@ -357,14 +398,7 @@ class DeliverHandler extends BaseHandler {
}
}
return {
exclude_keywords: [...jobTypeExclude, ...deliverExclude, ...filterExclude],
filter_keywords: filterKeywords.length > 0 ? filterKeywords : deliverFilter,
title_include_keywords,
min_salary: salaryRange.min,
max_salary: salaryRange.max,
priority_weights: ConfigManager.getPriorityWeights(deliverConfig)
};
return base;
}
/**

View File

@@ -8,6 +8,19 @@ const db = require('../../dbProxy');
* 自动投递只需调 filterAndScoreJobsForDeliver 一个方法。
*/
class JobFilterEngine {
getJobKey(job) {
return String(job.id || job.jobId || `${job.companyName || ''}|${job.jobTitle || ''}`);
}
getRemovedTitles(beforeJobs, afterJobs, limit = 5) {
const keptKeySet = new Set(afterJobs.map((job) => this.getJobKey(job)));
return beforeJobs
.filter((job) => !keptKeySet.has(this.getJobKey(job)))
.map((job) => job.jobTitle || '')
.filter(Boolean)
.slice(0, limit);
}
/**
* 过滤职位列表(薪资 → 标题须含词 → 关键词 → 活跃度 → 去重)
* @param {Array} jobs - 职位列表
@@ -23,50 +36,56 @@ class JobFilterEngine {
let filtered = [...jobs];
// 1. 薪资过滤
const beforeSalary = filtered.length;
const beforeSalaryJobs = [...filtered];
filtered = this.filterBySalary(filtered, config);
const salaryRemoved = beforeSalary - filtered.length;
const salaryRemoved = beforeSalaryJobs.length - filtered.length;
if (salaryRemoved > 0) {
console.log(`[jobFilterEngine] 步骤1-薪资过滤: 输入${beforeSalary} 输出${filtered.length} 剔除${salaryRemoved} (范围: ${config.min_salary ?? 0}-${config.max_salary ?? 0}K)`);
const removedTitles = this.getRemovedTitles(beforeSalaryJobs, filtered);
console.log(`[jobFilterEngine] 步骤1-薪资过滤: 范围=${config.min_salary ?? 0}-${config.max_salary ?? 0}K 剔除标题=${removedTitles.join(' | ') || '无'}`);
}
// 2. 职位标题须包含job_types.titleIncludeKeywords仅 jobTitle/jobName/name,与 commonSkills 无关)
const beforeTitleKw = filtered.length;
// 2. 职位标题须包含job_types.titleIncludeKeywords仅 jobTitle与 commonSkills 无关)
const beforeTitleFilterJobs = [...filtered];
filtered = this.filterByTitleIncludeKeywords(filtered, config);
const titleKwRemoved = beforeTitleKw - filtered.length;
const titleKwRemoved = beforeTitleFilterJobs.length - filtered.length;
if (titleKwRemoved > 0) {
console.log(`[jobFilterEngine] 步骤2-标题须含: 输入${beforeTitleKw} 输出${filtered.length} 剔除${titleKwRemoved} (须同时含: ${(config.title_include_keywords || []).join(' · ') || '无'})`);
const removedTitles = this.getRemovedTitles(beforeTitleFilterJobs, filtered);
console.log(`[jobFilterEngine] 步骤2-标题须含: 关键词=[${(config.title_include_keywords || []).join('、') || '无'}] 剔除标题=${removedTitles.join(' | ') || '无'}`);
}
// 3. 关键词过滤(排除词 + filter_keywords匹配标题与行业等
const beforeKeywords = filtered.length;
const beforeKeywordFilterJobs = [...filtered];
filtered = this.filterByKeywords(filtered, config);
const keywordsRemoved = beforeKeywords - filtered.length;
const keywordsRemoved = beforeKeywordFilterJobs.length - filtered.length;
if (keywordsRemoved > 0) {
console.log(`[jobFilterEngine] 步骤3-关键词过滤: 输入${beforeKeywords} 输出${filtered.length} 剔除${keywordsRemoved} (排除: ${(config.exclude_keywords || []).join(',') || '无'} 包含: ${(config.filter_keywords || []).join(',') || '无'})`);
const removedTitles = this.getRemovedTitles(beforeKeywordFilterJobs, filtered);
console.log(`[jobFilterEngine] 步骤3-关键词过滤: 排除=[${(config.exclude_keywords || []).join('、') || '无'}] 包含=[${(config.filter_keywords || []).join('、') || '无'}] 剔除标题=${removedTitles.join(' | ') || '无'}`);
}
// 4. 公司活跃度过滤
if (config.filter_inactive_companies) {
const beforeActivity = filtered.length;
const beforeActivityJobs = [...filtered];
filtered = await this.filterByCompanyActivity(filtered, config.company_active_days || 7);
const activityRemoved = beforeActivity - filtered.length;
const activityRemoved = beforeActivityJobs.length - filtered.length;
if (activityRemoved > 0) {
console.log(`[jobFilterEngine] 步骤4-公司活跃度过滤: 输入${beforeActivity} 输出${filtered.length} 剔除${activityRemoved}`);
const removedTitles = this.getRemovedTitles(beforeActivityJobs, filtered);
console.log(`[jobFilterEngine] 步骤4-公司活跃度过滤: 剔除标题=${removedTitles.join(' | ') || '无'}`);
}
}
// 5. 去重(同一公司、同一职位名称)
if (config.deduplicate) {
const beforeDedup = filtered.length;
const beforeDedupJobs = [...filtered];
filtered = this.deduplicateJobs(filtered);
const dedupRemoved = beforeDedup - filtered.length;
const dedupRemoved = beforeDedupJobs.length - filtered.length;
if (dedupRemoved > 0) {
console.log(`[jobFilterEngine] 步骤5-去重: 输入${beforeDedup} 输出${filtered.length} 剔除${dedupRemoved}`);
const removedTitles = this.getRemovedTitles(beforeDedupJobs, filtered);
console.log(`[jobFilterEngine] 步骤5-去重: 剔除标题=${removedTitles.join(' | ') || '无'}`);
}
}
console.log(`[jobFilterEngine] filterJobs 结束: 原始${jobs.length} 通过${filtered.length} 总剔除${jobs.length - filtered.length}`);
const keptTitles = filtered.map((j) => j.jobTitle || '').filter(Boolean).slice(0, 5);
console.log(`[jobFilterEngine] filterJobs 结束: 通过标题=${keptTitles.join(' | ') || '无'}`);
return filtered;
}
@@ -143,7 +162,7 @@ class JobFilterEngine {
}
/**
* 职位标题须包含配置中的每个子串AND 关系),不扫描描述/公司名/commonSkills
* 职位标题须包含配置中的关键词(命中任意一个即通过),不扫描描述/公司名/commonSkills
* @param {Array} jobs
* @param {object} config
* @returns {Array}
@@ -154,11 +173,11 @@ class JobFilterEngine {
return jobs;
}
return jobs.filter((job) => {
const title = `${job.jobTitle || job.jobName || job.name || ''}`.toLowerCase();
return kws.every((kw) => {
const title = `${job.jobTitle || ''}`.toLowerCase();
return kws.some((kw) => {
const k = String(kw || '').toLowerCase().trim();
if (!k) {
return true;
return false;
}
return title.includes(k);
});
@@ -251,7 +270,7 @@ class JobFilterEngine {
for (const job of jobs) {
const company = (job.company || job.companyName || '').toLowerCase().trim();
const jobName = (job.name || job.jobName || '').toLowerCase().trim();
const jobName = (job.jobTitle || '').toLowerCase().trim();
const key = `${company}||${jobName}`;
if (!seen.has(key)) {
@@ -334,8 +353,8 @@ class JobFilterEngine {
}
const jobText = [
job.name || job.jobName || '',
job.description || job.jobDescription || '',
job.jobTitle || '',
job.jobDescription || '',
job.skills || ''
].join(' ');

View File

@@ -190,7 +190,7 @@ module.exports = (db) => {
},
// 投递状态
applyStatus: {
comment: '投递状态: pending-待投递, applied-已投递, rejected-被拒绝, accepted-已接受',
comment: '投递状态: pending-待投递, filtered-已过滤(不符合规则未投递), applied-已投递, rejected-被拒绝, accepted-已接受, success/failed-见业务',
type: Sequelize.STRING(20),
allowNull: true,
defaultValue: 'pending'

View File

@@ -23,9 +23,60 @@ function isRecommendTab(text) {
function deriveTitleIncludeKeywordsFromTabName(text) {
const s = String(text || '').trim();
if (!s || isRecommendTab(s)) return [];
const m = s.match(/^([\u4e00-\u9fff]{2})[\u4e00-\u9fff]{2,}/);
if (m) return [m[1]];
return [];
return normalizeTitleIncludeKeywords([], s);
}
/**
* 将标题文本细分为更短子串,提升匹配通过率。
* 例:项目经理/主管(上海) -> ["项目","经理","主管"]
* @param {string} text
* @returns {string[]}
*/
function splitTitleTextToKeywords(text) {
const raw = String(text || '')
.replace(/[(].*?[)]/g, ' ')
.replace(/[\/|、,,·•\-\s]+/g, ' ')
.trim();
if (!raw) return [];
const fragments = raw.split(/\s+/).filter(Boolean);
const suffixes = [
'工程师', '经理', '主管', '专员', '顾问', '总监', '助理',
'开发', '测试', '运维', '产品', '前端', '后端', '算法', '架构'
];
const stopWords = new Set(['岗位', '职位', '方向']);
const tokens = [];
for (const f of fragments) {
if (f.length <= 1 || stopWords.has(f)) continue;
let matched = false;
for (const sf of suffixes) {
if (f.endsWith(sf) && f.length > sf.length) {
const left = f.slice(0, f.length - sf.length).trim();
if (left && left.length >= 2 && !stopWords.has(left)) tokens.push(left);
if (!stopWords.has(sf)) tokens.push(sf);
matched = true;
break;
}
}
if (!matched) tokens.push(f);
}
return [...new Set(tokens)].slice(0, 8);
}
/**
* 统一清洗 titleIncludeKeywords优先用 AI 结果,缺失时从 tabName 推导并细分。
* @param {Array<string>} aiKeywords
* @param {string} tabName
* @returns {string[]}
*/
function normalizeTitleIncludeKeywords(aiKeywords, tabName) {
const aiList = Array.isArray(aiKeywords) ? aiKeywords : [];
const fromAi = aiList.map((s) => String(s || '').trim()).filter(Boolean);
const fromTab = splitTitleTextToKeywords(tabName);
const merged = [...fromAi, ...fromTab].filter(Boolean);
return [...new Set(merged)].slice(0, 8);
}
/**
@@ -104,7 +155,7 @@ ${snippet}
excludeKeywords不适合投递的岗位特征词如与简历方向不符的销售、客服等按简历推断
commonSkills与简历主线一致的技能与技术栈仅用于简历技能匹配加分不得替代标题关键词。
titleIncludeKeywords职位名称标题中须同时包含的子串,用于过滤岗位;例如 Tab 为「售前工程师」时应有「售前」。勿把技能栈写进此数组。`;
titleIncludeKeywords职位名称标题关键词,尽量细分;例如「项目经理/主管(上海)」应输出 ["项目","经理","主管"]。勿把技能栈写进此数组。`;
let description = '';
let excludeArr = [];
@@ -125,9 +176,7 @@ titleIncludeKeywords职位名称标题中须同时包含的子串
description = String(parsed.description || '').slice(0, 4000);
excludeArr = Array.isArray(parsed.excludeKeywords) ? parsed.excludeKeywords.map(String) : [];
skillsArr = Array.isArray(parsed.commonSkills) ? parsed.commonSkills.map(String) : [];
titleIncArr = Array.isArray(parsed.titleIncludeKeywords)
? parsed.titleIncludeKeywords.map(String).map((s) => s.trim()).filter(Boolean)
: [];
titleIncArr = normalizeTitleIncludeKeywords(parsed.titleIncludeKeywords, tabName);
}
} catch (e) {
console.warn('[job_type_ai_sync] 推荐标签 AI 创建失败,使用占位:', e.message);
@@ -142,9 +191,7 @@ titleIncludeKeywords职位名称标题中须同时包含的子串
if (skillsArr.length === 0) {
skillsArr = ['沟通', '协作'];
}
if (titleIncArr.length === 0) {
titleIncArr = deriveTitleIncludeKeywordsFromTabName(tabName);
}
titleIncArr = normalizeTitleIncludeKeywords(titleIncArr, tabName);
const row = await job_types.create({
name: tabName,
@@ -322,7 +369,7 @@ ${labelsStr}
- description面向求职者的简短说明。
- excludeKeywords5~12 个字符串,用于过滤明显不合适的岗位。
- commonSkills8~20 个字符串,该方向常见技能或技术栈关键词,仅用于简历技能匹配加分,不得替代标题关键词。
- titleIncludeKeywords1~4 个短子串,投递时岗位标题须**同时包含**这些子串(如「售前工程师」对应含「售前」);勿把编程技能写进此数组。`;
- titleIncludeKeywords优先给细分词,例如「项目经理/主管(上海)」写成 ["项目","经理","主管"];勿把编程技能写进此数组。`;
const { content } = await aiService.callAPI(prompt, {
systemPrompt: '你只输出合法 JSON 对象,键为 description、excludeKeywords、commonSkills、titleIncludeKeywords不要输出其它文字。',
@@ -341,12 +388,7 @@ ${labelsStr}
const description = String(parsed.description || '').slice(0, 4000);
const excludeArr = Array.isArray(parsed.excludeKeywords) ? parsed.excludeKeywords.map(String) : [];
const skillsArr = Array.isArray(parsed.commonSkills) ? parsed.commonSkills.map(String) : [];
let titleIncArr = Array.isArray(parsed.titleIncludeKeywords)
? parsed.titleIncludeKeywords.map(String).map((s) => s.trim()).filter(Boolean)
: [];
if (titleIncArr.length === 0) {
titleIncArr = deriveTitleIncludeKeywordsFromTabName(typeName);
}
let titleIncArr = normalizeTitleIncludeKeywords(parsed.titleIncludeKeywords, typeName);
await job_types.update(
{
@@ -371,5 +413,6 @@ module.exports = {
ensureJobTypesForTabs,
parseJsonFromAi,
isRecommendTab,
deriveTitleIncludeKeywordsFromTabName
deriveTitleIncludeKeywordsFromTabName,
normalizeTitleIncludeKeywords
};