Files
zhaopin/scripts/resume-renderer.js

204 lines
9.7 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 <candidate_id> [--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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 `<div class="zr-sec"><div class="zr-sec-title">${esc(t)}</div><div class="zr-adv">${esc(sec.lines.join('\n'))}</div></div>`;
}
if (t === '语言能力' || t === '专业技能') {
// 短行 → 标签化
const tags = sec.lines.filter(l => l.length <= 30 && !/\d{4}\.\d{2}/.test(l));
if (tags.length) {
return `<div class="zr-sec"><div class="zr-sec-title">${esc(t)}</div><div class="zr-tags">${tags.map(x => `<span class="zr-tag">${esc(x)}</span>`).join('')}</div></div>`;
}
}
if (t === '所获证书') {
return `<div class="zr-sec"><div class="zr-sec-title">${esc(t)}</div><div class="zr-tags">${sec.lines.map(x => `<span class="zr-tag">🏅 ${esc(x)}</span>`).join('')}</div></div>`;
}
// 通用段落(工作/项目/教育/学生干部等):条目化
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 `<div class="zr-entry">
<div class="zr-entry-head">
<div><span class="zr-org">${esc(e.org)}</span>${role ? `<span class="zr-role">${esc(role)}</span>` : ''}</div>
${timeLines.length ? `<div class="zr-time">${esc(timeLines.join(' '))}</div>` : ''}
</div>
${desc.length ? `<ul class="zr-desc">${desc.map(d => `<li>${esc(d)}</li>`).join('')}</ul>` : ''}
</div>`;
}).join('');
return `<div class="zr-sec"><div class="zr-sec-title">${esc(t)}</div>${body}</div>`;
}
function renderResumeHTML(row, opts = {}) {
if (!row) return '<div class="zr-empty">无数据</div>';
const p = parseResume(row.raw_text);
const meta = p.info.meta.map(m => `<span>${esc(m)}</span>`).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 = `<div class="zr-wrap">
<div class="zr-head">
<div>
<div class="zr-name">${esc(p.info.name || row.name)}</div>
<div class="zr-meta">${meta}</div>
${p.info.party ? `<div class="zr-meta"><span>${esc(p.info.party)}</span></div>` : ''}
${badges.length ? `<div class="zr-badges">${badges.map(b => `<span class="zr-badge">${esc(b)}</span>`).join('')}</div>` : ''}
</div>
<div class="zr-side">
<div class="zr-score">${row.score != null ? esc(row.score) : '—'}<small>综合评分/35</small></div>
<span class="zr-level ${levelClass(row.score_level)}">${esc(row.score_level || '未评分')}</span>
</div>
</div>
${p.sections.map(renderSection).join('')}
</div>`;
if (opts.standalone) {
html = `<!DOCTYPE html><html lang="zh"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>${esc(p.info.name || '简历')}</title><style>${CSS}</style></head><body style="margin:0;background:#f5f6f8;">${html}</body></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();
}