diff --git a/api/middleware/job/managers/jobManager.js b/api/middleware/job/managers/jobManager.js index cbff7fa..633db3f 100644 --- a/api/middleware/job/managers/jobManager.js +++ b/api/middleware/job/managers/jobManager.js @@ -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} 个`); diff --git a/api/middleware/schedule/handlers/deliverHandler.js b/api/middleware/schedule/handlers/deliverHandler.js index a887f03..0617aa6 100644 --- a/api/middleware/schedule/handlers/deliverHandler.js +++ b/api/middleware/schedule/handlers/deliverHandler.js @@ -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_types(excludeKeywords、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; } /** diff --git a/api/middleware/schedule/services/jobFilterEngine.js b/api/middleware/schedule/services/jobFilterEngine.js index c498f99..bec0203 100644 --- a/api/middleware/schedule/services/jobFilterEngine.js +++ b/api/middleware/schedule/services/jobFilterEngine.js @@ -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(' '); diff --git a/api/model/job_postings.js b/api/model/job_postings.js index 8dbe4e2..80502af 100644 --- a/api/model/job_postings.js +++ b/api/model/job_postings.js @@ -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' diff --git a/api/services/job_type_ai_sync_service.js b/api/services/job_type_ai_sync_service.js index 7841ec1..3b31af4 100644 --- a/api/services/job_type_ai_sync_service.js +++ b/api/services/job_type_ai_sync_service.js @@ -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} 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:面向求职者的简短说明。 - excludeKeywords:5~12 个字符串,用于过滤明显不合适的岗位。 - commonSkills:8~20 个字符串,该方向常见技能或技术栈关键词,仅用于简历技能匹配加分,不得替代标题关键词。 -- titleIncludeKeywords:1~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 };