career-ops Notion 插件实战:把求职 Tracker 镜像到 Notion 数据库,再回读为职位线索
本篇基于开源 AI 求职工作流 career-ops 的自带 Notion 插件(bundled plugin)展开,讲解如何把本地求职进度镜像到个人 Notion 数据库(export 方向),以及如何把 Notion 中带职位链接的记录重新回填进扫描管线(search 方向)。读完你可以完成 Notion 内部集成创建、数据库属性对齐、plugins.mjs run notion 双命令的配置与排障,并理解其「Notion 只是增量镜像、data/applications.md 才是唯一事实源」的架构约定。
插件定位:加法式镜像,不做第二个事实源
Notion 插件挂在 manifest.json 声明的两个 hook 上:export 与 search,二者构成一进一出两个方向:
- export(镜像):把 tracker 中的每一行(公司 / 岗位 / 状态 / 评分)推送到你 Notion 页面下的 "Applications" 数据库;
- search(回读):在 Notion 数据库中查找携带职位 URL 的记录,命中后交给引擎写入 pipeline,成为新的求职线索。
skill.md 第一段即点明设计红线:data/applications.md(本地用户数据层)始终保持为 source of truth,Notion 只是一个 additive mirror(加法式镜像)。在 index.mjs 的文件头注释里也重复了这一约定——Notion 是 OPT-IN MIRROR 而非 replacement backend,网页等读取端只认本地 tracker,插件核心从不把 Notion 当主存储写,也不改动任何 mode 文件。这意味着:即使你完全不配置 Notion,扫描与求职流程不受任何影响;缺失 token 或 parent page id 只会让该插件被跳过,不会拖垮核心。
安装与启用:满足两道闸门才会被加载
插件的加载遵循 career-ops 插件体系的通用约定:默认全关(opt-in),只有同时满足以下两个条件才真正运行(对应 _engine.mjs 中 pluginStatus() 的实现:enabled && missingEnv.length === 0):
- 在
config/plugins.yml中将该插件置为enabled: true; .env中存在 manifest 声明的全部requiredEnv。
以 config/plugins.example.yml 中的写法为参考,在本地 config/plugins.yml(该文件是用户自有配置、已被 gitignore、不会被自动更新)里启用:
plugins:
notion:
enabled: true
密钥只进 .env,绝不写进 plugins.yml。本插件需要两个环境变量:
NOTION_ACCESS_TOKEN:Notion 内部集成(internal integration)的 token;NOTION_PARENT_PAGE_ID:名为 "Career Ops" 的父页面 id。
从 manifest.json 可以看到该插件的安全声明:allowedHosts 仅允许 api.notion.com,humanInTheLoop: true(不允许自动提交类插件),requiredEnv 恰为上述两个变量。插件引擎加载时若 requiredEnv 非空却没有 allowedHosts 会直接校验失败,因此「要密钥必白名单出网」在 manifest 层就是硬约束。
Notion 侧的准备动作
按 skill.md 的 Setup 描述与 index.mjs 的错误提示,需要提前在 Notion 里完成:
- 创建一个 parent page(建议命名为 "Career Ops"),并在其下新建一个 database(建议命名为 "Applications");
- 数据库包含 Company / Role / Status / Score / URL 五个属性(属性名严格对应,插件按名字解析,见下文);
- 把你的 Notion 页面与数据库都 share 给内部集成(integration);
- 把集成的 token 与父页面 id 写入
.env。
值得一提的细节:_notion.mjs 中的 resolveDBs() 按名字解析数据库,它会读取父页面 children 中类型为 child_database 的 block,再逐个查询其标题与 data source id 映射。也就是说插件从不要求 workspace id,也从不硬编码某个 database id——只要你父页面下的 "Applications" 数据库在且共享给集成即可。若找不到,你会看到 No "Applications" database found under the Career Ops page — create it and share the integration with it.。
命令用法
插件的全部入口集中在 plugins.mjs run notion <hook>,由 plugins.mjs 的 cmdRun() 统一分发(它会先校验两道闸门,未启用或缺 env 时给出可操作的报错并 exit(1)):
| 命令 | 作用 |
|---|---|
node plugins.mjs run notion export |
把 tracker 每行(公司 / 岗位 / 状态 / 评分)推送/更新到 Notion 的 "Applications" 数据库 |
node plugins.mjs run notion export --dry-run |
只预览将推送的记录(打印 would push: <company> — <role>),不产生任何网络写入 |
node plugins.mjs run notion search "<query>" |
返回 Notion 中携带职位 URL 且匹配查询的记录,作为 Job[] 追加进 pipeline |
例如先预览再正式镜像:
node plugins.mjs run notion export --dry-run
node plugins.mjs run notion export
回读职位线索(查询词会按 <company> / <role> 做不区分大小写的子串匹配):
node plugins.mjs run notion search "platform engineer"
export 方向:tracker 行如何映射进 Notion 数据库
export hook(index.mjs 的 export())接收的不是文件句柄,而是 plugins.mjs 中 buildSnapshot() 构造的 冻结只读快照({ applications, pipeline },Object.freeze 防写入)。它逐行处理 snapshot.applications,跳过缺少 company 或 role 的行,然后按下面这张映射表构造数据库属性:
| Notion 属性 | 类型 | 来源 |
|---|---|---|
Role |
title | tracker 行 role,经 rich() 切分 |
Company |
rich_text | tracker 行 company |
Status |
select | tracker 行 status 经 canonicalStatus() 归一后的规范标签 |
Score |
number | tracker 行 score 经 parseScore() 解析出的数字 |
URL |
url | export 不写入(有意为之,见 search 一节) |
三个值得一提的实现细节:
-
评分解析不破坏
x/5格式。tracker 里评分可能形如4.2/5、**4.2/5**或裸数字。parseScore()(index.mjs)先剥掉**,再用正则/([\d.]+)/提取第一个数字,因此4.2/5会被解析为 4.2 而非误读成 4.25(代码注释中标注了 issue #1414 的教训)。 -
状态取规范标签而非原文。
canonicalStatus()(_notion.mjs)以仓库根目录下 templates/states.yml 为唯一状态事实源:运行时加载其中的label与aliases,做去**、trim、小写后的别名归一。alias 映射不命中时返回null,该行就跳过 Status 属性——保证写进 Notion select 的永远是受控取值。 -
Upsert 语义。对每一行,客户端先用
"<company> / <role>"查询现存记录并做不区分大小写的精确比对;命中则PATCH pages/{id}更新属性,未命中则新建页面。既不会对同一岗位反复插行,也不会漏掉状态/评分更新。
最终 export 返回 { pushed: N },不会写任何本地文件。另外注意到 plugins.mjs 会按行数动态放大 hook 超时:默认 15s 只够几条网络 upsert,因此它按 max(15s, rows*3s) 上探到 120s 封顶,tracker 变大也不会轻易超时。
底层:Notion 客户端与安全出网
插件的 HTTP 层位于 _notion.mjs 的 createNotionClient(),其设计处处体现"引擎掌管网络与密钥"的分工:
- 无模块级副作用:token 与父页面 id 通过
ctx.env注入,文件内不做dotenv/读process.env的动作,密钥是否加载由引擎统一决策; - API 版本与鉴权:请求头固定携带
Notion-Version: 2025-09-03与Authorization: Bearer <token>(token 缺失直接抛NOTION_ACCESS_TOKEN is not set (.env)); - 限速:每个请求前
sleep(360ms),把速率压在约 3 req/s 以内; - 按名解析与分页:
resolveDBs()遍历父页面 children 找child_database,queryDB()走data_sources/{id}/query接口、page_size: 100并跟随has_more/next_cursor拉全量; - 记录摘要:
summarize()把 Notion 行规整为{ company, role, status, score, jobUrl, url }——Company/Role 用 title/rich_text 拼接,Status 取 select 名,Score 取 number,URL取 url 属性; - 文本切块:
rich()按 1900 字符切段(给 Notion 2000 字符 rich_text 上限留安全余量)。
更重要的是出网护栏:插件把 ctx.fetch 注入客户端(见 index.mjs 的 clientFromCtx())。引擎侧 makeGuardedFetch()(_engine.mjs)强制 HTTPS、校验每个请求与每一次重定向跳转的 host 必须在 allowedHosts 内、跨主机名跳转时剥除 authorization/cookie 头,且对每次跳转重新做 DNS 解析以阻断 SSRF 到内网/云元数据地址。传给插件的 ctx.env 也是 Object.freeze 的受限视图,且 ctx.log() 会对密钥做 «redacted» 脱敏。
search 方向:把 Notion 里带 URL 的记录变回职位线索
search hook(index.mjs 的 search())接受 CLI 传入的查询词,调用 findRecords() 在 Applications 数据库内做 <company> / <role> 子串(大小写不敏感)匹配,随后只保留 URL 属性为合法 http(s):// 链接的记录,并把它们映射为 Job[]:{ title, url, company, location: '' }。位置字段为空,因为 Notion 记录本身不携带该信息。
命中结果交还引擎后(见 plugins.mjs 中 search 分支的处理):
- 先对每个 Job 做
sanitizeJob()过滤; - 再做加法式去重:凡是 pipeline 里已存在的 URL、或本次已见过的 URL,一律不再追加;
- 剩余新职位以追加方式写入本地 pipeline(命令行会提示
Appended N to data/pipeline.md. Run /career-ops pipeline to evaluate.),随后照常进入评估流程。
这解释了一个常见困惑:export 写入的行(公司/岗位/状态/评分)不会被 search 再回读——因为它们没有 URL 属性,而这是有意的:它们本来就在你的 tracker 里,无需重复进入 pipeline。search 面向的典型场景是「你在 Notion 里手动维护的带外职位投递库 / 线索表」:它只负责把带链接的、你在 Notion 侧自行新增的职位捞回管线。
小结与常见排障
从源码注释与错误路径可归纳出这套插件的自洽约定:Notion 是用户自有的增量视图,导出只增改你自己的数据库、永不写本地文件;回读只产出带职位 URL 的线索并经引擎去重后进 pipeline;本地 tracker 始终是唯一事实源。若遇到问题,按引擎提示逐级排查:
Plugin "notion" is not enabled. Set plugins.notion.enabled: true in config/plugins.yml.→ 未过第一道闸门;Plugin "notion" is missing NOTION_ACCESS_TOKEN, NOTION_PARENT_PAGE_ID in .env.→ 未过第二道闸门(也可用node doctor.mjs统一体检所有插件与密钥状态);NOTION_ACCESS_TOKEN is not set (.env)/Set NOTION_PARENT_PAGE_ID in .env→ 客户端初始化或解析数据库时发现缺项;No "Applications" database found under the Career Ops page…→ 父页面下没有名为 Applications 的数据库,或未 share 给集成;- 若提示超时/失败,多半是 upsert 走网络逐行进行,可先
--dry-run确认行数规模。
关于信任与安全模型(含 bundled 插件为何可信任、本地插件与注册表 successor 机制),可继续阅读 docs/PLUGINS.md 与 plugins/README.md;想对照镜像/回读的实现全文,直接看 plugins/notion/index.mjs 与 plugins/notion/_notion.mjs 即可。
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 StartedRust0624
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