首页
/ career-ops Notion 插件实战:把求职 Tracker 镜像到 Notion 数据库,再回读为职位线索

career-ops Notion 插件实战:把求职 Tracker 镜像到 Notion 数据库,再回读为职位线索

2026-09-06 19:26:14作者:卓炯娓

本篇基于开源 AI 求职工作流 career-ops 的自带 Notion 插件(bundled plugin)展开,讲解如何把本地求职进度镜像到个人 Notion 数据库(export 方向),以及如何把 Notion 中带职位链接的记录重新回填进扫描管线(search 方向)。读完你可以完成 Notion 内部集成创建、数据库属性对齐、plugins.mjs run notion 双命令的配置与排障,并理解其「Notion 只是增量镜像、data/applications.md 才是唯一事实源」的架构约定。

插件定位:加法式镜像,不做第二个事实源

Notion 插件挂在 manifest.json 声明的两个 hook 上:exportsearch,二者构成一进一出两个方向:

  • 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.mjspluginStatus() 的实现:enabled && missingEnv.length === 0):

  1. config/plugins.yml 中将该插件置为 enabled: true
  2. .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.comhumanInTheLoop: true(不允许自动提交类插件),requiredEnv 恰为上述两个变量。插件引擎加载时若 requiredEnv 非空却没有 allowedHosts 会直接校验失败,因此「要密钥必白名单出网」在 manifest 层就是硬约束。

Notion 侧的准备动作

skill.md 的 Setup 描述与 index.mjs 的错误提示,需要提前在 Notion 里完成:

  1. 创建一个 parent page(建议命名为 "Career Ops"),并在其下新建一个 database(建议命名为 "Applications");
  2. 数据库包含 Company / Role / Status / Score / URL 五个属性(属性名严格对应,插件按名字解析,见下文);
  3. 把你的 Notion 页面与数据库都 share 给内部集成(integration);
  4. 把集成的 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.mjscmdRun() 统一分发(它会先校验两道闸门,未启用或缺 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.mjsexport())接收的不是文件句柄,而是 plugins.mjsbuildSnapshot() 构造的 冻结只读快照{ applications, pipeline }Object.freeze 防写入)。它逐行处理 snapshot.applications,跳过缺少 company 或 role 的行,然后按下面这张映射表构造数据库属性:

Notion 属性 类型 来源
Role title tracker 行 role,经 rich() 切分
Company rich_text tracker 行 company
Status select tracker 行 statuscanonicalStatus() 归一后的规范标签
Score number tracker 行 scoreparseScore() 解析出的数字
URL url export 不写入(有意为之,见 search 一节)

三个值得一提的实现细节:

  1. 评分解析不破坏 x/5 格式。tracker 里评分可能形如 4.2/5**4.2/5** 或裸数字。parseScore()index.mjs)先剥掉 **,再用正则 /([\d.]+)/ 提取第一个数字,因此 4.2/5 会被解析为 4.2 而非误读成 4.25(代码注释中标注了 issue #1414 的教训)。

  2. 状态取规范标签而非原文canonicalStatus()_notion.mjs)以仓库根目录下 templates/states.yml 为唯一状态事实源:运行时加载其中的 labelaliases,做去 **、trim、小写后的别名归一。alias 映射不命中时返回 null,该行就跳过 Status 属性——保证写进 Notion select 的永远是受控取值。

  3. Upsert 语义。对每一行,客户端先用 "<company> / <role>" 查询现存记录并做不区分大小写的精确比对;命中则 PATCH pages/{id} 更新属性,未命中则新建页面。既不会对同一岗位反复插行,也不会漏掉状态/评分更新。

最终 export 返回 { pushed: N }不会写任何本地文件。另外注意到 plugins.mjs 会按行数动态放大 hook 超时:默认 15s 只够几条网络 upsert,因此它按 max(15s, rows*3s) 上探到 120s 封顶,tracker 变大也不会轻易超时。

底层:Notion 客户端与安全出网

插件的 HTTP 层位于 _notion.mjscreateNotionClient(),其设计处处体现"引擎掌管网络与密钥"的分工:

  • 无模块级副作用:token 与父页面 id 通过 ctx.env 注入,文件内不做 dotenv/读 process.env 的动作,密钥是否加载由引擎统一决策;
  • API 版本与鉴权:请求头固定携带 Notion-Version: 2025-09-03Authorization: Bearer <token>(token 缺失直接抛 NOTION_ACCESS_TOKEN is not set (.env));
  • 限速:每个请求前 sleep(360ms),把速率压在约 3 req/s 以内;
  • 按名解析与分页resolveDBs() 遍历父页面 children 找 child_databasequeryDB()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.mjsclientFromCtx())。引擎侧 makeGuardedFetch()_engine.mjs)强制 HTTPS、校验每个请求与每一次重定向跳转的 host 必须在 allowedHosts 内、跨主机名跳转时剥除 authorization/cookie 头,且对每次跳转重新做 DNS 解析以阻断 SSRF 到内网/云元数据地址。传给插件的 ctx.env 也是 Object.freeze 的受限视图,且 ctx.log() 会对密钥做 «redacted» 脱敏。

search 方向:把 Notion 里带 URL 的记录变回职位线索

search hook(index.mjssearch())接受 CLI 传入的查询词,调用 findRecords() 在 Applications 数据库内做 <company> / <role> 子串(大小写不敏感)匹配,随后只保留 URL 属性为合法 http(s):// 链接的记录,并把它们映射为 Job[]{ title, url, company, location: '' }。位置字段为空,因为 Notion 记录本身不携带该信息。

命中结果交还引擎后(见 plugins.mjs 中 search 分支的处理):

  1. 先对每个 Job 做 sanitizeJob() 过滤;
  2. 再做加法式去重:凡是 pipeline 里已存在的 URL、或本次已见过的 URL,一律不再追加;
  3. 剩余新职位以追加方式写入本地 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.mdplugins/README.md;想对照镜像/回读的实现全文,直接看 plugins/notion/index.mjsplugins/notion/_notion.mjs 即可。

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