career-ops 中的 clean-markers:为 AI 生成的求职文本加装不可见 Unicode 审计与清理关卡
【免费下载链接】career-opsOpen-source AI job search: scan job portals, evaluate listings into a structured A-H report with a global 1-5 score, tailor your CV, track applications — runs locally in your AI coding CLI (Claude Code, Codex, OpenCode, Antigravity…)项目地址: https://gitcode.com/GitHub_Trending/ca/career-ops
在 career-ops 的工作流中,职位描述、申请表单和猎头邮件都属于不可信数据——它们可能携带肉眼不可见的 Unicode 控制字符(零宽空格、bidi 方向控制符、Unicode 标签字符等),而 AI Agent 在改写简历、生成求职信或填写申请答案时,很可能把这些隐藏字符原样复制进最终产物。clean-markers.mjs是 career-ops 内置的一个零依赖审计/清理工具:它能对生成的文本文件做不可见字符扫描(audit),或在清理后重新验证(clean),并以退出码作为"发送前关卡"(pre-send gate)接入脚本或 CI。读完本文,你将掌握该工具的完整命令用法、它拦截的 20 余类隐藏字符清单、退出码契约,以及 HTML 场景下保护<style>/<script>块不被误改的源码级实现机制。
一、问题背景:不可信输入如何污染生成的求职文档
按 docs/CLEAN_MARKERS.md 的描述,问题的链路是:
- 招聘网站、ATS 表单、猎头邮件中粘贴的网页文本可能含有不可见 Unicode(例如用于隐藏标记的 bidi 控制符、零宽连接符);
- Agent 基于这些输入生成简历(CV)、求职信(cover letter)或申请答案(application answers);
- 用户看到的是"干净"的文档,但字节层面携带了隐藏字符,可能在 ATS 解析、邮件渲染或 PDF 转换中造成难以排查的异常。
clean-markers.mjs的定位就是在这条链路末端加一道关卡:在生成的文本文件离开你的机器、或在 HTML/Markdown 被渲染成最终文档之前,先审计或清除这些用户从未主动输入过的字符。
工具边界同样在文档中明确声明:
- 只处理文本文件,不编辑 PDF 元数据;
- 运行时不安装任何包(never runs
npm install),仅使用 Node 标准库; - 除非显式传入
--ascii,否则不改动任何可见标点。
二、使用方式:audit / clean / --ascii 三档操作
文档给出的完整用法如下(均可从仓库根目录直接执行):
node clean-markers.mjs audit output/acme-cv.html # report only, never modifies node clean-markers.mjs clean output/acme-cover-letter.md node clean-markers.mjs clean --ascii output/cover-letter.txt # also normalize smart quotes / dashes node clean-markers.mjs audit output/*.html output/*.md # globs OK三个要点:
audit永不修改文件。它的用途是"证明"一份文档是干净的。只要有一个文件 FAIL,进程退出码就是1,因此可以直接用作脚本或 CI 步骤中的发送前关卡。clean会剥离不可见 Unicode。注意:不换行空格(non-breaking space,U+00A0)被视为正常排版字符,既不会被标记也不会被替换——这是有意为之,避免破坏文档原有的排版意图。--ascii(仅对clean的文本模式有效)额外把弯引号转直引号、em/en 破折号转连字符、省略号转...,适合纯文本质求职信或邮件场景。
当生成的文件即将对外发出或被渲染时,典型调用序列是:
node clean-markers.mjs audit output/acme-cv.html output/acme-cover-letter.md node clean-markers.mjs clean output/acme-cv.html output/acme-cover-letter.md三、拦截范围:被标记的不可见字符全集
结合 clean-markers.mjs 的源码,label()函数定义了工具的判定规则,可分为四类:
| 类别 | 码点 | 名称(源码中的标注) |
|---|---|---|
| 零宽标记 | 0x200B | ZERO-WIDTH SPACE |
| 零宽标记 | 0x200C | ZWNJ(零宽非连接符) |
| 零宽标记 | 0x200D | ZWJ(零宽连接符) |
| 零宽标记 | 0x2060 | WORD JOINER |
| 零宽标记 | 0xFEFF | BOM/ZWNBSP |
| 软连字符 | 0x00AD | SOFT HYPHEN |
| 分隔符 | 0x180E | MONGOLIAN VOWEL SEP |
| bidi 控制 | 0x200E/0x200F | LRM / RLM |
| bidi 控制 | 0x202A–0x202E | LRE / RLE / PDF(bidi) / LRO / RLO |
| bidi 隔离 | 0x2066–0x2069 | LRI / RLI / FSI / PDI |
| Unicode 标签 | 0xE0000–0xE007F | TAG(区间判定,报告中按TAG U+XXXXX输出) |
| 变体选择符 | 0xFE00–0xFE0F、0xE0100–0xE01EF | VARIATION-SEL(区间判定) |
从源码结构看,标签字符和变体选择符不是逐一点名,而是用区间判断(isTag、isVS)覆盖,报告输出为TAG U+xxxxx、VARIATION-SEL U+xxxxx这样的动态标签,因此能兜住整个区段而无需穷举。
扫描函数scanText()按 code point 遍历全文,把每个命中的标签名计次,最终产出一个{标签: 数量}的对象;audit模式把它以 JSON 形式打印在 FAIL 行里,便于定位污染分布:
❌ FAIL output/acme-cv.html: hidden chars: {"ZWJ":3,"BOM/ZWNBSP":1}四、退出码契约:把它接成 CI 里的发送前关卡
clean-markers.mjs 末尾的参数解析与退出逻辑约定了三档退出码:
- 未提供任何文件时打印用法并以退出码 2结束;
- 任一文件 FAIL(audit 发现隐藏字符,或 clean 后复扫仍不干净)时退出码 1;
- 全部干净时退出码 0。
这意味着它可以直接嵌入 shell 脚本或 CI 步骤:退出码非零即阻断发送,无需解析日志文本。这也是文档中"Exit code 1 if any file FAILS, so it works as a pre-send gate"承诺的底层依据。
五、实现解析:clean 模式的去污与复扫验证
cleanText(t, ascii)的清理策略是逐字符删除(clean-markers.mjs):
- 软连字符(
0x00AD)直接跳过丢弃; - 命中
NAMED映射、标签区、变体选择符区间的字符直接跳过丢弃; - 其余字符原样保留。
随后若传了--ascii,再做四组替换:
out.replace(/[‘’‚‛]/g, "'") // 单引号族 → 直单引号 .replace(/[“”„‟]/g, '"') // 双引号族 → 直双引号 .replace(/[–—―]/g, '-') // en/em/水平破折号 → 连字符 .replace(/…/g, '...') // 省略号 → 三个点清理完成后,工具会重新读盘、重新扫描(re-scan),以复扫结果作为本次操作的最终判定:
const after = readFileSync(file,'utf8'); const re = scanText(isHtml ? maskCode(after).masked : after); const ok = Object.keys(re).length===0;只有复扫结果为空才输出✅ cleaned text,否则输出⚠️——这种"写完再验"的闭环保证了clean的可靠性不依赖单次替换的假设。
另一个值得注意的设计:clean模式下,如果文件本身没有隐藏字符且没有--ascii,工具不会写盘(Object.keys(found).length || opts.ascii为假时直接跳过写操作),避免无谓地改变文件 mtime 或触发下游 diff。
六、HTML 的特殊处理:<style>与<script>永不进入扫描与清理
简历类 HTML 产物(如 career-ops 的 templates/cv-template.html 渲染结果)必然内嵌 CSS 与 JS。如果盲目对全文做替换,可能破坏样式表或脚本内容。因此对.html/.htm文件,工具会先用正则把整块<style>…</style>、<script>…</script>掩码(mask)成占位 token__CLEAN_MARKERS_MASK_n__,只对外部正文做扫描与清理,之后再还原(clean-markers.mjs):
// Never scan or transform inside <style>/<script> — marker removal and --ascii must not touch CSS/JS. // (same approach as generate-pdf.mjs) const CODE_BLOCK = /<(style|script)\b[^>]*>[\s\S]*?<\/\1>/gi;源码注释明确说明这与 generate-pdf.mjs 中 HTML 净化阶段"先掩码、后还原"的做法一致——从源码结构看,该文件同样会把<style>/<script>等区块替换为占位符再处理正文,两处实现共享同一套防御思路:对"文本之外"的内容保持字节级不动。
七、支持的文件类型与跳过的边界情况
TEXT_EXT白名单(clean-markers.mjs)限定为:
.html .htm .md .txt .json .csv .svg .xml .tex边界行为:
- 文件不存在:打印
⚠️ <file>: not found,且该文件不阻断整体判定; - 扩展名不在白名单(如
.pdf、图片):打印➖ <file>: unsupported type (…) — skipped,同样视为通过; .tex在列,配合 career-ops 的 LaTeX 简历链路(generate-latex.mjs、modes/latex.md)生成的.tex中间产物也纳入了保护范围;- PDF 一律不处理,这与文档"does not edit PDF metadata"的声明吻合。
八、在 career-ops 升级体系中的位置
clean-markers.mjs 被列在 update-system.mjs 的系统文件清单中,即它属于升级流程会维护的系统层脚本,而非用户本地自定义文件;CHANGELOG.md 中亦记录了该工具作为特性引入:"clean-markers — audit/strip hidden Unicode from generated text outputs"。
一个实用的组合方式:在批量投递脚本里,先对output/下所有生成物执行audit作为发送前关卡,失败(退出码 1)则暂停并向用户展示隐藏字符分布,再决定是否用clean(加--ascii视下游渠道而定)处理后重发。整个流程无需任何第三方依赖,只需 Node 运行时即可在本地完成。
小结
clean-markers.mjs用不到百行的零依赖实现,解决了"不可信网页输入 → AI 生成文档"链路中最隐蔽的一类污染:不可见 Unicode。它以audit/clean两种模式、三档退出码、20 余类隐藏字符的精确判定(含标签区与变体选择符区间)、以及 HTML 中<style>/<script>块的掩码保护,构成了 career-ops 生成文本离开本机前的一道可脚本化、可进 CI 的质量关卡。
【免费下载链接】career-opsOpen-source AI job search: scan job portals, evaluate listings into a structured A-H report with a global 1-5 score, tailor your CV, track applications — runs locally in your AI coding CLI (Claude Code, Codex, OpenCode, Antigravity…)项目地址: https://gitcode.com/GitHub_Trending/ca/career-ops
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考