集成Hermes的简历渲染器(vendored, exports加CSS): 详情页简历全文改智联样式渲染(服务端渲染+样式内联), 纯文本降级兜底

This commit is contained in:
mzl92
2026-08-31 10:28:31 +08:00
parent f955869052
commit a711b9d42c
5 changed files with 227 additions and 2 deletions
+1
View File
@@ -4,6 +4,7 @@ RUN npm config set registry https://registry.npmmirror.com
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm install --omit=dev --no-audit --no-fund RUN npm install --omit=dev --no-audit --no-fund
COPY server.js ./ COPY server.js ./
COPY scripts/resume-renderer.js ./scripts/resume-renderer.js
COPY public ./public COPY public ./public
ENV PORT=8300 ENV PORT=8300
EXPOSE 8300 EXPOSE 8300
+10 -2
View File
@@ -60,7 +60,8 @@ tr:hover td{background:#f8fafc;cursor:pointer}
.chip{padding:2px 12px;border-radius:12px;font-size:12px} .chip{padding:2px 12px;border-radius:12px;font-size:12px}
.chip.ok{background:#dcfce7;color:#15803d} .chip.ok{background:#dcfce7;color:#15803d}
.chip.bad{background:#fee2e2;color:#b91c1c} .chip.bad{background:#fee2e2;color:#b91c1c}
.resume{white-space:pre-wrap;font-size:14px;line-height:1.9;background:#f9fafb;border:1px solid var(--line);border-radius:8px;padding:14px 18px;max-height:460px;overflow:auto;color:#374151} .resume{font-size:14px;background:#f9fafb;border:1px solid var(--line);border-radius:8px;padding:0;max-height:520px;overflow:auto;color:#374151}
.resume .zr-wrap{background:transparent}
.secTitle{display:inline-block;font-weight:600;color:var(--pri);margin-top:8px;border-bottom:2px solid #dbeafe;padding-bottom:2px} .secTitle{display:inline-block;font-weight:600;color:var(--pri);margin-top:8px;border-bottom:2px solid #dbeafe;padding-bottom:2px}
.kv{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:8px;margin-bottom:12px} .kv{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:8px;margin-bottom:12px}
.kv div{background:#f9fafb;border-radius:6px;padding:6px 10px}.kv span{color:var(--sub)} .kv div{background:#f9fafb;border-radius:6px;padding:6px 10px}.kv span{color:var(--sub)}
@@ -411,8 +412,15 @@ async function showDetail(id){
<div><span>门槛</span> ${esc(r.notes)||'-'}</div> <div><span>门槛</span> ${esc(r.notes)||'-'}</div>
</div> </div>
${detHtml} ${detHtml}
<h3 style="font-size:13px">简历全文</h3><div class="resume">${body}</div>`; <h3 style="font-size:13px">简历全文</h3><div class="resume" id="resumeBox">${body}</div>`;
$('detailModal').classList.remove('hidden'); $('detailModal').classList.remove('hidden');
loadResume(id);
}
async function loadResume(id){
try{
const html=await (await fetch('/api/candidates/'+encodeURIComponent(id)+'/resume-html')).text();
if(html && html.includes('zr-wrap')) $('resumeBox').innerHTML=html; // 渲染失败保留纯文本兜底
}catch(_){}
} }
async function openUserModal(){ async function openUserModal(){
$('userModal').classList.remove('hidden'); $('userModal').classList.remove('hidden');
+204
View File
@@ -0,0 +1,204 @@
#!/usr/bin/env node
/**
* 简历渲染器 v1.0(2026-08-28)— 方案A:解析库内文本 → 智联样式 HTML
* (vendored from zhaopin repo d432ef7; 仅在exports追加CSS常量, 逻辑未动)
*
* 用法:
* 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, CSS };
// ---------- 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();
}
+4
View File
@@ -84,6 +84,10 @@ const sleep = (ms) => new Promise(r => setTimeout(r, ms));
const detText = await $('#detailBody').innerText(); const detText = await $('#detailBody').innerText();
ok('弹窗打开且含简历全文', detText.includes('简历全文')); ok('弹窗打开且含简历全文', detText.includes('简历全文'));
ok('时间格式为 Y-m-d H:i:s', /\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/.test(detText)); ok('时间格式为 Y-m-d H:i:s', /\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/.test(detText));
await sleep(900);
const zrOk = await p.evaluate(() => !!document.querySelector('.zr-wrap') && !!document.querySelector('.zr-sec'));
ok('简历智联样式渲染生效(.zr-*)', zrOk);
await p.screenshot({ path: '/tmp/smoke-shots/detail-rendered.png' });
await p.screenshot({ path: '/tmp/smoke-shots/03-detail.png' }); await p.screenshot({ path: '/tmp/smoke-shots/03-detail.png' });
await p.evaluate(() => document.getElementById('detailModal').classList.add('hidden')); await p.evaluate(() => document.getElementById('detailModal').classList.add('hidden'));
+8
View File
@@ -7,6 +7,7 @@ const crypto = require('crypto');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const Database = require('better-sqlite3'); const Database = require('better-sqlite3');
const { renderResumeHTML, CSS: ZR_CSS } = require('./scripts/resume-renderer');
const PORT = process.env.PORT || 8300; const PORT = process.env.PORT || 8300;
const ADMIN_DIR = process.env.ADMIN_DIR || '/data/admin'; const ADMIN_DIR = process.env.ADMIN_DIR || '/data/admin';
@@ -247,6 +248,13 @@ app.get('/api/candidates/:id', requireAuth, (req, res) => {
res.json(row); res.json(row);
}); });
// 简历智联样式渲染(服务端渲染, 样式随响应内联)
app.get('/api/candidates/:id/resume-html', requireAuth, (req, res) => {
const row = rdb.prepare('SELECT * FROM candidates WHERE id = ?').get(req.params.id);
if (!row) return res.status(404).json({ error: '不存在' });
res.type('text/html; charset=utf-8').send(`<style>${ZR_CSS}</style>${renderResumeHTML(row)}`);
});
app.get('/api/export.csv', requireAuth, (req, res) => { app.get('/api/export.csv', requireAuth, (req, res) => {
const { level, greeted, date_from, date_to, q, today } = req.query; const { level, greeted, date_from, date_to, q, today } = req.query;
const where = []; const params = []; const where = []; const params = [];