@@ -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 => ( { '&' : '&' , '<' : '<' , '>' : '>' , '"' : '"' , "'" : ''' } [ 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 ( ) ;
}