首页
/ career-ops 中的 ATS 文本归一化:从回归测试夹具到 generate-pdf.mjs 源码实现

career-ops 中的 ATS 文本归一化:从回归测试夹具到 generate-pdf.mjs 源码实现

2026-09-04 18:20:38作者:郦嵘贵Just

本文以仓库中的回归测试夹具 examples/ats-normalization-test.md 为核心,系统讲解 career-ops 在生成简历 PDF 前执行的"ATS 文本归一化"(text normalization)通道:哪些 Unicode 字符会被转换为 ASCII 安全等价物、为什么这些字符会让 ATS(Applicant Tracking System)解析器出问题、如何在本地验证归一化逻辑,以及 generate-pdf.mjsnormalizeTextForATS 的完整源码实现——包括夹具表格未列出的箭头、项目符号、货币符号等额外转换规则。读完后,你能够复现归一化验证流程,理解"只改正文文本、不动 CSS/JS/标签"的掩码式改写原理,并了解与 scripts/export-ats-text.mjs 平行的 ATS 文本清洗路径。

一、这个夹具解决什么问题

夹具文件的开篇明确交代了它的定位:它是 generate-pdf.mjs 中新增的文本归一化通道(对应 issue #1)的回归测试夹具,内容涵盖了会导致 ATS 系统与遗留解析器出现解析错误或显示问题的 Unicode 残留字符。归一化的目标,是把这些字符转换成 ASCII 安全的等价物。

在 career-ops 的实际工作流里,这份文件的位置由 examples/README.md 中的清单确认:

文件 说明
cv-example.md cv.md 的结构、指标格式与证明点写法示例
resume-example.md 面向美国/行业市场的 Resume 变体(1–2 页目标格式)
article-digest.md 紧凑证明点的写法模板
sample-report.md 评估流水线产出的 A-F 报告格式
ats-normalization-test.md generate-pdf.mjs Unicode 归一化的回归夹具,列出每个问题码点及其 ASCII 安全替代物
dual-track-engineer-instructor/ 双职业原型(工程师 + 讲师)的完整 profile 配置

这些示例文件均为只读参考,不参与运行时。而 ats-normalization-test.md 的特殊之处在于:它不是给人看的格式样例,而是给归一化器看的"脏输入"基准——每一个列出的码点都必须在归一化输出中消失。

二、必须被转换的问题 Unicode 字符

夹具文档中给出了完整的码点对照表,这是归一化器的最低验收标准:

名称 码点 样例行 转换结果
破折号 Em-dash U+2014 Built and sold a SaaS — now shipping AI in production. Built and sold a SaaS - now shipping AI in production.
短破折号 En-dash U+2013 2020–2024 at Acme Corp. 2020-2024 at Acme Corp.
弯双引号 U+201C / U+201D "Led the migration" was a real bullet. "Led the migration" was a real bullet.
弯单引号 U+2018 / U+2019 The team's velocity tripled. The team's velocity tripled.
省略号 U+2026 And so on… And so on...
零宽空格 U+200B Hello​world(两个词之间存在 ZWSP) Helloworld(直接移除)
不换行空格 U+00A0 5 years experience 5 years experience(替换为普通空格)

结合 generate-pdf.mjssanitizeText 的实现,可以看到实际规则比夹具表格覆盖得更宽

  • 引号:双引号规则是 [\u201C\u201D\u201E\u201F](低九引号 U+201E/U+201F 也一并处理),单引号规则是 [\u2018\u2019\u201A\u201B],全部统一为 ASCII 引号;
  • 零宽字符:不只有 U+200B,规则是 [\u200B\u200C\u200D\u2060\uFEFF],即零宽空格、零宽非连接符、零宽连接符(emoji 组合序列常用)、词连接符和 BOM 字符全部移除;
  • 箭头(夹具表未列出,源码额外覆盖): 替换为 to 替换为 from,上下箭头 ↑/↓ 替换为空格,且正则会吞掉箭头两侧的空格,避免输出中出现双重空格——注释说明原因是 PDF 文本抽取器经常直接丢掉箭头字符;
  • 中点与圆点(源码额外覆盖):·(U+00B7)和 (U+2022)替换为 |,因为这些符号在部分抽取器中会产生乱码;
  • 货币符号(源码额外覆盖): 拼写为 EUR £ 拼写为 GBP ——字体子集化的 PDF 可能丢掉货币符号。值得注意的是,¥(U+00A5)被有意保留不转换:它同时表示日元和人民币,任何拼写展开(JPY 或 CNY)都会对一半用户产生错误数据,源码注释明确写道"宁可保留符号,也不输出错误数据";
  • Markdown 加粗(源码额外覆盖):**text** 会被转成 <strong>text</strong>,因为 tailored CV 构建器(如 SUMMARY_TEXT)会直接使用 **…** 语法。这一行为后来还被 LaTeX 路径镜像——见 tests/cv-latex-bullet-bold.test.mjs 的说明:#1728 教会 HTML 路径把 **text** 渲染成 <strong>,而 build-cv-latex.mjs 早期没有对应实现,导致同一份内容在 HTML PDF 中是加粗、在 LaTeX PDF 中却打印出字面星号。

三、掩码式改写:只碰正文,不动代码

normalizeTextForATS 的完整实现在 generate-pdf.mjs,其设计有两个关键约束,函数头注释写得很清楚:"Only touches body text — preserves CSS, JS, tag attributes, and URLs." 实现分三步:

  1. 掩码:先用正则 /<(style|script)\b[^>]*>[\s\S]*?<\/\1>/gi 把所有 <style><script> 块整体替换为形如 \u0000MASK0\u0000 的占位 token,原文存入 masks 数组;
  2. 逐段清洗:以 </> 为边界扫描掩码后的 HTML——标签本身(<> 之间的片段)原样保留,标签之间的文本段交给 sanitizeText 做全部 Unicode 替换;
  3. 还原:把 \u0000MASK(\d+)\u0000 占位符按序号还原为原始 style/script 块。

返回值为 { html, replacements }:归一化后的 HTML,外加一份按类别统计的替换次数表(em-dashsmart-double-quotenbsp……),供调用方记录日志。

四、CLI 入口:如何触发归一化与验证

夹具文档"如何验证归一化器"一节给出的验证命令,完整继承如下:

# 在项目根目录、编辑 generate-pdf.mjs 之后:
node --check generate-pdf.mjs

# 归一化逻辑的快速冒烟测试(无需 Playwright):
node -e "
import('./generate-pdf.mjs').catch(()=>{});
" 2>/dev/null || true

端到端验证则是从一个已知的"脏 HTML"文件生成 CV PDF 并检查输出:

node generate-pdf.mjs /tmp/dirty-cv.html /tmp/clean-cv.pdf --format=a4
# 预期日志行:
# 🧹 ATS normalization: N replacements (em-dash=X, smart-double-quote=Y, ...)

对照 generate-pdf.mjs 的主流程可以确认这条日志的产生位置:读取输入 HTML 后,先执行章节重排(reorderCvSections,读取 config/profile.ymlcv.sections 声明的顺序)与 validateCvSectionOrder 守卫,然后:

// Normalize text for ATS compatibility (issue #1)
const normalized = normalizeTextForATS(html);
html = normalized.html;
const totalReplacements = Object.values(normalized.replacements).reduce((a, b) => a + b, 0);
if (totalReplacements > 0) {
  const breakdown = Object.entries(normalized.replacements).map(([k, v]) => `${k}=${v}`).join(', ');
  console.log(`🧹 ATS normalization: ${totalReplacements} replacements (${breakdown})`);
}

也就是说,只有确实发生了替换时才会打印日志,日志中的逐项统计(em-dash=3, nbsp=1, ...)正来自夹具表格里那些"Converts to"列对应的规则。CLI 参数方面,从源码的参数解析看,单文件模式的完整用法为:

node generate-pdf.mjs <input.html> <output.pdf> [--format=letter|a4] [--report=NNN] [--allow-reorder] [--max-pages=N] [--strict-pages]

其中 --report 仅接受纯数字(对应 tracker 行号,如 --report=018),--max-pages 默认为 2 且必须是正整数;此外还有 --batch=<manifest.json> 批处理模式——批量渲染时每个条目独立走 normalizeTextForATS(见 generate-pdf.mjs),并共享同一个 Chromium 实例。

还需要注意一个适用前提:输入路径与输出路径都必须落在 tracker 拥有的工作区内(源码中有路径穿越防护,inputPath/outputPath 会被校验在 workspace root 之内),所以夹具示例中 /tmp/dirty-cv.html 这类路径在真实工作区外运行时会被拒绝,验证时应使用工作区内的文件。

五、写作质量守则:归一化器不负责的边界

夹具文档用一整节划定了归一化器的边界——归一化器不修复写作风格。以下措辞"本就不应"出现在生成的 CV 文本中,由 modes/_shared.md 中的规则强制约束:

  • "passionate about machine learning"
  • "results-oriented professional with a proven track record"
  • "leveraged cutting-edge LLM technology"
  • "spearheaded a strategic initiative"
  • "facilitated cross-functional synergies"
  • "crafted robust, scalable solutions"
  • "in today's fast-paced digital world"
  • "5+ years of experience in artificial intelligence"
  • "demonstrated ability to drive innovative outcomes"

这些禁令在仓库中有明确的落地:modes/_writing.md 列出了共享禁词清单("passionate about" / "results-oriented" / "proven track record",并规定 spearheaded 应改用 ledran),且各语言模式文件(如 modes/ja/_shared.mdmodes/tr/_shared.mdmodes/ua/_shared.md)都镜像了同一份清单。从源码结构看,这形成了清晰的双层防御:风格层由模式提示词在生成阶段拦截(LLM 不应写出这些短语),编码层由归一化器在渲染阶段兜底(即使写入了弯引号、破折号,PDF 输出依然 ASCII 安全)。两层互不替代——夹具文档特意强调"should never appear in generated CV text in the first place",即风格问题应在源头消灭,而不是依赖归一化器补救。

六、平行的 ATS 文本清洗路径与测试佐证

归一化逻辑并非只存在于 PDF 路径。仓库还有一条面向纯文本导出的平行实现 scripts/export-ats-text.mjs,其中的 sanitizeAtsTexttests/export-ats-text.test.mjs 断言验证:

const dirty = '• Bullet item – with en-dash & em-dash — "smart quotes" & 'single'  and emoji 🚀';
const clean = sanitizeAtsText(dirty);
assert.equal(clean, '- Bullet item - with en-dash & em-dash - "smart quotes" & \'single\'   and emoji');

可以看到同一条设计原则在两个出口上一致生效:-/-、弯引号 → 直引号、不换行空格 → 普通空格、emoji 直接丢弃。而 tests/cv-latex-bullet-bold.test.mjs 则通过真实的 build-cv-latex.mjs 构建器端到端验证 LaTeX 路径对 **text** 加粗的镜像处理(含注入探针:手打的 \textbf{...} 必须保持为惰性文本而非控制序列)。这些测试共同构成对归一化契约的回归保护:任何码点转换规则的改动,都会在这些测试上显形。

七、小结:一份夹具背后的完整契约

examples/ats-normalization-test.md 虽然只有不到 50 行,但它定义了 career-ops 简历渲染管线的核心质量契约,其价值链可以概括为:

  1. 码点清单(第二节表格)是验收基准——每个列出的 Unicode 残留必须在输出中消失;
  2. 源码实现generate-pdf.mjsnormalizeTextForATS)是超集——额外覆盖箭头、中点/圆点、货币符号和 Markdown 加粗,并对歧义符号 ¥ 做出"不转换"的明确决策;
  3. 掩码机制保证 CSS、JS、标签属性与 URL 不受影响,返回值 { html, replacements } 让每次渲染都可审计(🧹 ATS normalization: 日志行);
  4. 风格禁令(第五节九条短语)划清了归一化器与写作规则的职责边界,由 modes/_shared.mdmodes/_writing.md 在生成阶段强制执行;
  5. 测试tests/export-ats-text.test.mjstests/cv-latex-bullet-bold.test.mjs)在 HTML、LaTeX、纯文本导出三条路径上锁定了这份契约。

如果你在本地修改了 generate-pdf.mjs 的归一化逻辑,按照夹具文档给出的验证流程操作即可:先 node --check 做语法校验,再用脏 HTML 输入跑一次 PDF 生成,检查 🧹 ATS normalization: 日志行中的替换计数是否符合预期。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
528
588
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
906
1.82 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
docsdocs
暂无描述
Markdown
891
5.78 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.53 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.34 K
1.45 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
987
504
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384