career-ops 本地解析器指南:让 scan.mjs 以 0 LLM Token 读取公司招聘页
本文为 career-ops 的本地解析器(local parser)机制编写。读完你将掌握:如何在 portals.yml 中配置 scan_method: local_parser 指向自己编写的解析脚本、解析器 stdout 必须遵守的 JSON 契约、{careers_url} / {company} 占位符的安全展开规则、解析失败时的 ATS API 回退逻辑,以及 Agent 扫描模式下 local_parser_ok 如何阻断 Playwright/API 重复抓取以节省 Token。
scan.mjs 是 career-ops 的零 Token 扫描器:它纯靠 HTTP + JSON 抓取招聘职位,不消耗任何 LLM Token。本地解析器是其中的“Nivel 0”能力——当某公司的招聘页是 SSR 或静态 HTML、或有稳定的文档化端点时,与其让 Agent 用 Playwright 打开页面、把整页内容塞进模型上下文,不如写一个本地命令,让它把归一化后的职位 JSON 直接打印到 stdout。解析器以普通子进程运行,scanner 继续沿用同一套标题过滤、去重和 pipeline 输出流程。
何时使用本地解析器
满足以下条件时,适合为某公司写一个本地解析器:
- 招聘页是 SSR 或静态 HTML,结构稳定,不需要浏览器渲染就能拿到职位列表;
- 公司提供了文档化的 JSON/端点,本地请求比浏览器自动化更简单可靠;
- 存在其他确定性数据源,用脚本解析比 Playwright 快照便宜且结果可复现。
两点前提需要注意:
- 解析器可以是 JavaScript、Python、shell、Go 或机器上可用的任何可执行文件(具体允许的解释器见下文安全边界一节)。
- career-ops 不捆绑任何公司专属解析脚本。仓库只提供执行机制,用户自带脚本,并让
portals.yml指向它。
scan.mjs 的 Provider 架构中的位置
scan.mjs 启动时自动加载 providers/*.mjs 目录下的所有 provider(_ 前缀的文件视为共享工具,不会被当作 provider,如 providers/ADDING_A_PROVIDER.md 所述)。每个 provider 导出 id、可选的 detect(entry) 和必选的 fetch(entry, ctx)。
Provider 的解析顺序在 providers/_registry.mjs 的 resolveProvider 中明确定义,共三级:
- 条目显式声明
provider: {id}时直接命中,跳过detect(); local-parser优先于所有 API provider:只要条目配置了parser.command+ script 且能安全解析,就命中它(providers/local-parser.mjs 的detect()会先校验整个调用是否安全,校验不过返回null而不是报错);- 其余 provider 的
detect()按文件加载顺序(字母序)依次尝试,首个命中者胜出。
scan.mjs 在运行摘要中会把本地解析器单独计数,例如 Scanning 5 companies; 2 local parser; 1 skipped — no provider matched(见 scan.mjs)。
Portal 配置:把 parser 指向你的脚本
大多数本地解析器是公司专属的:脚本本身已经知道源 URL、选择器、端点特性、分页和归一化规则。这种常见情况下,scanner 只需要知道要执行哪条命令。templates/portals.example.yml 中的注释块给出的标准写法如下:
tracked_companies:
- name: Example Company
careers_url: https://example.com/careers
scan_method: local_parser
parser:
command: node
script: scripts/parsers/example-company-jobs.js
format: jobs-json-v1
enabled: true
同一注释块还给出了 Python 版本:
- name: Example Python Company
careers_url: https://example.org/jobs
scan_method: local_parser
parser:
command: python3
script: scripts/parsers/example_python_company_jobs.py
format: jobs-json-v1
enabled: true
参数与占位符展开
args 是可选的,用法完全由解析器作者自定义:让一个脚本复用到多家公司(传 {careers_url} 或 {company})、开调试开关、存 JSON 快照、控制任何脚本专属行为都可以。
执行时 scan.mjs 不经过 shell 展开,直接以参数数组方式调用(execFile,无 shell 插值);{careers_url} 和 {company} 两个占位符会在每个 parser.script 和 parser.args 元素中先做替换再执行。展开逻辑与安全校验都实现在 providers/local-parser.mjs 的 expandParserArg / buildParserArgs 中。
此外还有两个可配置的执行限制(providers/local-parser.mjs):
| 配置项 | 默认值 | 说明 |
|---|---|---|
parser.timeout_ms |
20000(20 秒) | 子进程超时时间 |
parser.max_buffer_bytes |
2000000(2 MB) | stdout 缓冲区上限,超出即失败 |
子进程的 cwd 被固定到 career-ops 项目根目录(providers/local-parser.mjs),这样相对路径的 script 参数与 detect() 阶段校验的是同一个文件,与调用方所在目录无关。
Stdout 契约:三种 JSON 形状
解析器必须向 stdout 打印以下三种 JSON 形状之一(其余输出会被判定为非法 JSON 而失败):
[
{ "title": "Senior AI Engineer", "url": "https://example.com/jobs/123", "location": "Remote" }
]
{
"jobs": [
{ "title": "Senior AI Engineer", "url": "https://example.com/jobs/123", "location": "Remote" }
]
}
{
"results": [
{ "title": "Senior AI Engineer", "url": "https://example.com/jobs/123", "location": "Remote" }
]
}
契约细节(由 providers/local-parser.mjs 的 normalizeParserJob 实现印证):
title和url必填;任一缺失的职位行会被静默丢弃(测试用例 tests/providers/local-parser.test.mjs 验证了这一点:fixture 脚本输出的 3 行中 2 行缺字段,最终只保留 1 行);company可选,省略时 scanner 使用tracked_companies条目的name;- 相对 URL 会针对
careers_url解析为绝对 URL(normalizeJobUrl)——绝对 URL 是去重键,这一点在 providers/ADDING_A_PROVIDER.md 的 Job 契约中同样强调; - 字段名有一定容错:
title也接受name;url也接受jobUrl/job_url/applyUrl/apply_url;location若是数组会拼成"Remote, NY"形式,若是对象则取name或text字段(providers/local-parser.mjs)。
仓库自带的 fixture 脚本 tests/providers/_fixture-local-parser.mjs 是一个可以直接参考的最小解析器样例:它根据 process.argv 分支输出标准数组、{jobs: [...]} 信封或非法 JSON,恰好覆盖了契约的三种正例和一种反例。
Token 节省:为什么值得写本地解析器
scan.mjs 的职位发现阶段消耗 0 个 LLM Token:解析器在本地运行,只有归一化后的职位行进入 pipeline。
对比之下,在 Agent 扫描模式(/career-ops scan)下,Playwright 快照和 API 响应会把大体积的页面/JSON 负载送进模型上下文。文档为此定义了一条硬性规则(modes/scan.md):
- Agent 在内存中维护一个
local_parser_ok集合,存放 Nivel 0(本地解析器)成功完成的公司名。成功标准:parser.command+parser.script存在且脚本无致命错误、stdout 是合法 JSON、没有超时或进程崩溃; - 对集合中的公司:跳过 Nivel 1(Playwright)——不
browser_navigate其careers_url;跳过 Nivel 2(API)——不 WebFetch 其api:字段;Nivel 3(WebSearch)只运行通用查询,命中该同公司的结果直接丢弃; - 通用门户查询(
site:jobs.ashbyhq.com、职位关键词等)照常运行,用于发现tracked_companies之外的新雇主; - 解析器失败的公司不加入
local_parser_ok,Nivel 1/2 照常适用。
推荐的执行顺序(modes/scan.md)是:
- Level 0:Local Parser → 有
parser:配置的公司;构建local_parser_ok; - Level 1:Playwright → 有
careers_url且不在local_parser_ok中的公司; - Level 2:API → 有
api:且不在local_parser_ok中的公司; - Level 3:WebSearch → 所有启用的
search_queries,过滤掉local_parser_ok公司的命中。
文档还提到,Cohere + Mobileye fixture 上的实测基准(tiktoken cl100k_base,Playwright vs parser vs API 三档对比表)存放在 feature/local-parser-integration-tests 分支,可用 npm run test:scan-tokens 复跑——具体数值以该分支文档副本为准,当前主线不内嵌这些测量结果。
失败处理:解析器失败不等于公司被丢弃
本地解析器在 ATS API 检测之前运行。如果解析器失败,而该公司的 careers_url(或 api: 字段)能识别出 Greenhouse、Ashby、Lever 等 API 源,scan.mjs 会记录解析器失败,并回退到该公司的 API 路径,而不是把它从本次扫描中丢掉。
这段回退逻辑在 scan.mjs 中:fetch 抛错且 provider 是 local-parser 时,调用 resolveProvider(company, providers, { skipIds: ['local-parser'] }) 重新解析——此时显式跳过 local-parser,让其余 API provider 的 detect() 重新竞争。回退成功则用 api 源抓取,并向运行摘要的 errors 列表写入 local parser failed, used API fallback: <原因>;回退也不可用(skipIds 后没有 provider 命中)才把原始解析器错误抛出。Agent 侧的规则与此一致:解析器失败的公司不进入 local_parser_ok,走 Playwright/API 兜底(modes/scan.md)。
产物存储约定
Scanner 只需要 stdout。如果解析器额外写出完整 JSON 快照(用于调试或审计),约定存放在 data/parser-output/{company}/ 下。这些生成的 JSON 产物必须保持在 git 之外;.gitkeep 占位文件是唯一允许提交的例外,用于保留目录结构(modes/scan.md 与 cookbook 的 “Artifact Storage” 一节一致)。
安全边界:career-ops 如何防住不可信配置
parser.command / parser.script 来自 portals.yml,而共享或模板化的配置不能视为完全可信。providers/local-parser.mjs 的注释明确说明防御目标:命令绝不能是 rm、curl 之类的任意二进制。实现上的规则包括:
- 解释器白名单:
python3、python、node、deno、bun、sh、bash(ALLOWED_INTERPRETERS);不在白名单内的 command 必须是位于项目根目录内的文件(resolveInsideRoot用realpathSync解析后校验不逃逸出项目树); - 脚本必须在仓库内:白名单解释器的第一个参数必须是一个仓库内脚本,且
args[0]恰好等于它——这是为了堵死node --eval/python -c这类内联代码执行; - 占位符注入防护:
{careers_url}展开前必须能解析为合法 URL 且协议为http:/https:;{company}展开后不允许以-开头(防止被解析为 CLI flag,即 argument injection)。由于execFile原样传参(无 shell),这是仅剩的注入面,见 safeCareersUrl / safeCompany; - detect 即校验:
detect()内部调用resolveInvocation,任何无法安全解析的调用(未知命令、仓库外脚本、内联代码 flag)都直接返回null跳过,而不是带着风险去执行。
这些规则有专门的测试覆盖:拒绝无 script 的解释器命令、拒绝 ../escaped 路径、拒绝以连字符开头的公司名、拒绝非 http(s) 的 careers_url,全部在 tests/providers/local-parser.test.mjs 中。
动手验证
不用写真实爬虫,仓库自带 fixture 就能验证整套链路(配置解析 → 子进程执行 → 占位符展开 → JSON 解析 → 归一化 → 丢弃非法行):
- 查看 fixture 脚本:tests/providers/_fixture-local-parser.mjs,它按
echo/envelope-jobs/invalid参数分支输出三种载荷; - 运行 provider 测试:
node tests/providers/local-parser.test.mjs,通过pass/fail断言逐条验证detect()的正反例、{company}/{careers_url}插值(echo Company: Acme Corp URL: https://example.com/acme)、相对 URL 解析(/job2→https://example.com/job2)和非法 JSON 的报错; - 对照 docs/local-parser-cookbook.md 复核 stdout 契约,再参考 providers/ADDING_A_PROVIDER.md 了解若你想把某个 JSON 端点直接做成内置 provider(而非本地脚本)时
Job契约的完整要求。
写解析脚本时的最小清单:command 用白名单解释器;script 用相对仓库根的路径且在根内;只向 stdout 打印 JSON(日志走 stderr 或文件);每行保证 title + 绝对或可解析的相对 url;需要多家公司复用时通过 args 里的 {careers_url} / {company} 传参,而不是在脚本里硬编码。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00