diff --git a/scripts/launch-hr1.sh b/scripts/launch-hr1.sh new file mode 100644 index 0000000..d0144ca --- /dev/null +++ b/scripts/launch-hr1.sh @@ -0,0 +1,7 @@ +#!/bin/bash +cd /opt/data/skills/recruitment-resume-screener/scripts +LOG=/opt/data/logs/recruitment/runner-hr1-$(date +%F).log +export ACCOUNT=hr1 CDP_PORT=9223 +nohup node daily-runner.js hr1 9223 >> "$LOG" 2>&1 & +echo "hr1 runner started PID=$!" +disown diff --git a/scripts/resume-renderer.js b/scripts/resume-renderer.js new file mode 100644 index 0000000..38f1062 --- /dev/null +++ b/scripts/resume-renderer.js @@ -0,0 +1,203 @@ +#!/usr/bin/env node +/** + * 简历渲染器 v1.0(2026-08-28)— 方案A:解析库内文本 → 智联样式 HTML + * + * 用法: + * const { renderResumeHTML } = require('./resume-renderer'); + * const html = renderResumeHTML(candidateRow); // candidateRow = candidates 表一行 + * const html = renderResumeHTML(row, { standalone: true }); // 完整独立HTML页(带样式) + * + * CLI 测试: + * node resume-renderer.js [--standalone] 渲染指定候选人 + * node resume-renderer.js --latest 渲染最新一条推荐 + */ + +'use strict'; + +// ---------- 段落常量 ---------- +const SECTION_TITLES = ['求职期望', '工作经历', '项目经历', '教育经历', '个人优势', '语言能力', '所获证书', '专业技能', '学生干部经历', '实践经历', '获得荣誉']; +const ACTION_WORDS = /^(打电话|打招呼|存至本地|收藏|转给同事|打印|举报|不合适|展开|收起|询问他|要附件简历)$/; + +// ---------- 文本解析 ---------- +// 输入 raw_text,切成:头部信息 + 有序段落列表 +function parseResume(rawText) { + const lines = (rawText || '').split('\n').map(l => l.trim()).filter(l => l && !ACTION_WORDS.test(l)); + const head = []; + const sections = []; + let cur = null; + + for (const line of lines) { + if (SECTION_TITLES.includes(line)) { + cur = { title: line, lines: [] }; + sections.push(cur); + continue; + } + if (/^经历概览$/.test(line)) break; // 底部重复区,不渲染 + if (cur) cur.lines.push(line); + else head.push(line); + } + + // 头部解析:姓名=第一行;其余行归类 + const name = head[0] || ''; + const info = { name, meta: [], recent: [], party: '' }; + for (let i = 1; i < head.length; i++) { + const l = head[i]; + if (/党员|共青团员|群众/.test(l)) info.party = l; + else if (/最近投递/.test(l)) info.recent.push(l); + else info.meta.push(l); + } + return { info, sections }; +} + +// 工作经历段:把连续行聚合成"条目"(公司名行 + 职位 + 时间 + 内容) +function parseEntries(sectionLines) { + const entries = []; + let cur = null; + for (const l of sectionLines) { + const isTime = /^\d{4}\.\d{2}\s*-\s*(至今|\d{4}\.\d{2})/.test(l) || /\(\d+个月\)|\(\d+年\)/.test(l); + const isSalary = /^\d+[千万](-\d+[千万])?\/(月|年)$|^面议$/.test(l) || /^\d+(\.\d+)?千\/月$/.test(l); + if (!isTime && !isSalary && !/^(.*)$/.test(l) && l.length <= 30 && !/[。;,]/.test(l) && !cur) { + cur = { org: l, lines: [] }; + entries.push(cur); + continue; + } + if (cur) cur.lines.push(l); + else { cur = { org: '', lines: [l] }; entries.push(cur); } + // 新公司行特征:时间行之后又出现短的非标点行 → 开新条目 + if (isTime) cur._closed = true; + if (cur && cur._closed && !isTime && !isSalary && l.length <= 25 && !/[。;]/.test(l) && !/^(/.test(l)) { + cur = { org: l, lines: [] }; + entries.push(cur); + } + } + return entries.filter(e => e.org || e.lines.length); +} + +// ---------- HTML 渲染 ---------- +const CSS = ` +.zr-wrap { font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif; max-width: 860px; margin: 0 auto; background: #fff; color: #333; font-size: 14px; line-height: 1.7; } +.zr-head { padding: 24px 32px 18px; border-bottom: 1px solid #eef0f5; display: flex; justify-content: space-between; align-items: flex-start; } +.zr-name { font-size: 24px; font-weight: 600; color: #222; } +.zr-meta { margin-top: 8px; color: #666; font-size: 13px; } +.zr-meta span { margin-right: 14px; display: inline-block; } +.zr-badges { margin-top: 10px; } +.zr-badge { display: inline-block; font-size: 12px; color: #00c2b3; background: #e8f9f7; border-radius: 3px; padding: 2px 8px; margin-right: 6px; } +.zr-side { text-align: right; flex-shrink: 0; margin-left: 20px; } +.zr-score { font-size: 30px; font-weight: 700; color: #00c2b3; line-height: 1; } +.zr-score small { font-size: 12px; color: #999; font-weight: 400; display: block; margin-top: 4px; } +.zr-level { display: inline-block; margin-top: 8px; font-size: 13px; padding: 2px 12px; border-radius: 10px; } +.zr-level-rec { color: #fff; background: #00c2b3; } +.zr-level-wait { color: #d48806; background: #fff7e0; } +.zr-level-irr { color: #999; background: #f4f4f5; } +.zr-sec { padding: 18px 32px; border-bottom: 1px solid #eef0f5; } +.zr-sec-title { font-size: 16px; font-weight: 600; color: #222; margin-bottom: 14px; padding-left: 10px; border-left: 3px solid #00c2b3; } +.zr-entry { margin-bottom: 18px; } +.zr-entry:last-child { margin-bottom: 0; } +.zr-entry-head { display: flex; justify-content: space-between; align-items: baseline; flex-wrap: wrap; } +.zr-org { font-weight: 600; font-size: 15px; color: #222; } +.zr-role { color: #555; margin-left: 10px; font-size: 13px; } +.zr-time { color: #999; font-size: 12.5px; white-space: nowrap; } +.zr-desc { margin-top: 6px; color: #555; } +.zr-desc li { list-style: none; position: relative; padding-left: 14px; margin: 3px 0; } +.zr-desc li::before { content: ''; position: absolute; left: 0; top: 9px; width: 5px; height: 5px; border-radius: 50%; background: #00c2b3; } +.zr-tags { display: flex; flex-wrap: wrap; gap: 8px; } +.zr-tag { font-size: 12.5px; color: #444; background: #f4f6f8; border-radius: 3px; padding: 4px 10px; } +.zr-adv { color: #444; white-space: pre-wrap; } +.zr-empty { color: #999; text-align: center; padding: 60px 0; } +`; + +function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } + +function levelClass(lv) { + if (lv === '推荐') return 'zr-level-rec'; + if (lv === '待定') return 'zr-level-wait'; + return 'zr-level-irr'; +} + +// 时间行匹配(含 (10个月) 后缀) +function isTimeLine(l) { return /^\d{4}\.\d{2}\s*-\s*(至今|\d{4}\.\d{2})/.test(l); } + +function renderSection(sec) { + if (!sec.lines.length) return ''; + const t = sec.title; + if (t === '个人优势') { + return `
${esc(t)}
${esc(sec.lines.join('\n'))}
`; + } + if (t === '语言能力' || t === '专业技能') { + // 短行 → 标签化 + const tags = sec.lines.filter(l => l.length <= 30 && !/\d{4}\.\d{2}/.test(l)); + if (tags.length) { + return `
${esc(t)}
${tags.map(x => `${esc(x)}`).join('')}
`; + } + } + if (t === '所获证书') { + return `
${esc(t)}
${sec.lines.map(x => `🏅 ${esc(x)}`).join('')}
`; + } + // 通用段落(工作/项目/教育/学生干部等):条目化 + const entries = parseEntries(sec.lines); + const body = entries.map(e => { + const timeLines = e.lines.filter(isTimeLine); + const rest = e.lines.filter(l => !isTimeLine(l)); + const role = rest.length && !/[。;]/.test(rest[0]) && rest[0].length <= 25 ? rest[0] : ''; + const desc = (role ? rest.slice(1) : rest); + return `
+
+
${esc(e.org)}${role ? `${esc(role)}` : ''}
+ ${timeLines.length ? `
${esc(timeLines.join(' '))}
` : ''} +
+ ${desc.length ? `` : ''} +
`; + }).join(''); + return `
${esc(t)}
${body}
`; +} + +function renderResumeHTML(row, opts = {}) { + if (!row) return '
无数据
'; + const p = parseResume(row.raw_text); + const meta = p.info.meta.map(m => `${esc(m)}`).join(''); + const badges = []; + if (row.role_tag) badges.push(row.role_tag); + try { const g = JSON.parse(row.score_details || '{}'); if (g.gateReasons) badges.push(...g.gateReasons.slice(0, 3)); } catch { } + + let html = `
+
+
+
${esc(p.info.name || row.name)}
+
${meta}
+ ${p.info.party ? `
${esc(p.info.party)}
` : ''} + ${badges.length ? `
${badges.map(b => `${esc(b)}`).join('')}
` : ''} +
+
+
${row.score != null ? esc(row.score) : '—'}综合评分/35
+ ${esc(row.score_level || '未评分')} +
+
+ ${p.sections.map(renderSection).join('')} +
`; + + if (opts.standalone) { + html = ` +${esc(p.info.name || '简历')}${html}`; + } + return html; +} + +module.exports = { renderResumeHTML, parseResume }; + +// ---------- CLI ---------- +if (require.main === module) { + const Database = require('better-sqlite3'); + const db = new Database(process.env.RECRUITMENT_DB || '/opt/data/recruitment-data/recruitment-live.db', { readonly: true }); + const args = process.argv.slice(2); + const standalone = args.includes('--standalone'); + let row; + if (args.includes('--latest')) { + row = db.prepare("SELECT * FROM candidates WHERE score_level='推荐' ORDER BY scraped_at DESC LIMIT 1").get(); + } else { + const id = args.find(a => !a.startsWith('--')); + row = db.prepare('SELECT * FROM candidates WHERE id = ?').get(id); + } + if (!row) { console.error('未找到候选人'); process.exit(1); } + console.log(renderResumeHTML(row, { standalone })); + db.close(); +} diff --git a/scripts/start-hr2-runner.sh b/scripts/start-hr2-runner.sh new file mode 100644 index 0000000..c28272a --- /dev/null +++ b/scripts/start-hr2-runner.sh @@ -0,0 +1,6 @@ +#!/bin/bash +# 启动 hr2 (Edge·派卡) 每日筛选守护任务 +cd /opt/data/skills/recruitment-resume-screener/scripts || exit 1 +LOG=/opt/data/logs/recruitment/runner-hr2-$(date +%F).log +ACCOUNT=hr2 CDP_PORT=9225 nohup node daily-runner.js hr2 9225 >> "$LOG" 2>&1 & +echo "hr2 runner started PID=$!" > /tmp/hr2-runner-pid.txt