career-ops Gmail 插件实战:从 Gmail 邮件标签自动提取职位线索注入求职流水线
本文讲解 career-ops 的 Gmail ingest 插件:如何把散落在邮箱里的职位推荐邮件(job-alert、职位匹配通知等)变成干净的职位 URL,去重后统一写入 data/pipeline.md 的待评估队列。读完你将掌握完整的配置方法(OAuth 凭据 + config/plugins.yml 双门控)、单条命令的摄入流程,以及插件在 DMARC 防伪、URL 清洗、去重和断点游标上的底层实现细节。
插件定位:把邮件变成 Pipeline 的可评估线索
career-ops 是一个本地优先、默认零密钥运行的开源 AI 求职工具链:扫描招聘平台、把职位评估成结构化的 A–H 报告与 1–5 分评级、按你的档案定制简历并追踪投递。它刻意把核心保持"零密钥、本地优先",而把需要密钥或需要对接外部服务的功能收敛到可选的插件层(见 插件总述)。
Gmail 插件(目录 plugins/gmail)正是这种"需要密钥的外部集成"的典型:它只读地接入你的 Gmail,从某个**邮件标签(label)**里拉取职位线索。它由社区贡献(@SparshGarg999 的 PR #1203)移植而来,并按插件契约重构——其定位被 manifest.json 一句话概括:
"Pull job leads from a Gmail label into your pipeline (read-only, your OAuth token)."
它属于 ingest 钩子家族((ctx) → Job[]),即"从某个外部服务拉取职位",与 search、export、notify 等钩子并列。整个调用链与能力声明如下:
| 项目 | 取值 |
|---|---|
| 插件 id | gmail(需与目录名一致,符合 [a-z0-9][a-z0-9-]*) |
| apiVersion | 1(引擎当前仅支持 1) |
| 钩子 | ingest(该插件唯一的钩子) |
| 所需环境变量 | GMAIL_CLIENT_ID、GMAIL_CLIENT_SECRET、GMAIL_REFRESH_TOKEN |
| 允许访问主机 | oauth2.googleapis.com、gmail.googleapis.com(仅这两个) |
| humanInTheLoop | true(强制,不允许自动提交类插件) |
| 附带 skill | skill.md,通过 node plugins.mjs skill gmail 按需读取 |
命令:一行摄入新线索
插件的用法在 skill.md 中写得很精简:
node plugins.mjs run gmail
即"从配置好的标签中摄入新职位线索"。这条命令由插件宿主 plugins.mjs 驱动,其执行前会做多道门控:
- 在
config/plugins.yml中确认plugins.gmail.enabled: true,否则报错并提示开启; - 检查
.env中三个 Gmail 相关环境变量是否齐全,缺任一都会给出可操作的错误信息; - 解析钩子类型——
gmail只暴露ingest一个可运行钩子,因此无需显式指定。
摄入结果默认只追加不覆盖:宿主会调用 scan.mjs 导出的 appendToPipeline,以管道文件锁(withPipelineLock)保护的方式,把新线索追加进 data/pipeline.md 的 Pending 区块(若该区块不存在则自动创建)。因此插件自身永远不直接写数据文件——这正是"引擎拥有所有写入、插件无法破坏 web 端读取的数据格式"的设计约定(见 plugins/README.md 的 Hooks 表说明)。
--dry-run:先预览再落地
命令支持 --dry-run 参数,此时只打印前 20 条新线索且不写管道:
node plugins.mjs run gmail --dry-run
这一点有测试专门守护:测试 plugin-run-isolation-and-gmail-dryrun.test.mjs 用 mock 的 ctx.fetch 模拟 Gmail 分页与邮件详情接口,断言 dry-run 场景下 data/gmail-state.json(处理后消息游标)保持字节不变——即"干跑就真的不产生任何副作用"。
配置:OAuth 凭据 + 双门控激活
第一步:.env 中的三个凭据
插件需要在你的 .env 中提供三个变量(manifest.json 的 requiredEnv 声明了这一点):
GMAIL_CLIENT_ID=你的OAuth客户端ID
GMAIL_CLIENT_SECRET=你的OAuth客户端密钥
GMAIL_REFRESH_TOKEN=授权流程拿到的长期刷新令牌
其中刷新令牌来自一次 OAuth Desktop 客户端的同意流程(consent flow)——一次性换取后长期复用,不需要每次手动授权。凭据只允许存在于 .env,绝不能写进 config/plugins.yml(该文件会被 gitignore,且可能被同步/审查,见 插件激活配置示例 的开头说明)。
从实现看,插件在 index.mjs 中通过 getAccessToken() 向 https://oauth2.googleapis.com/token 用 grant_type: refresh_token 交换短期 access token;交换失败时会把 HTTP 状态码与响应片段(截断 200 字符)写进报错,方便排查。注意此请求经由插件的 ctx.fetch 发出,该 fetch 是被引擎包装过的受管出口:强制 HTTPS、域名白名单(只有 oauth2.googleapis.com 与 gmail.googleapis.com)、每次 30x 跳转逐跳重新校验主机并在跨域跳转时剥离凭据头(见 插件引擎 的 makeGuardedFetch 与 buildCtx)。
第二步:config/plugins.yml 激活
把仓库自带的 config/plugins.example.yml 复制为 config/plugins.yml 并开启:
plugins:
gmail:
enabled: true
label: "Job Leads" # 要扫描的 Gmail 标签(可自定义,非必填,默认 "Job Leads")
days_back: 7 # 回看窗口天数(可自定义,非必填,默认 7)
激活遵循"双门控":配置文件里 enabled: true 且 .env 里所有 requiredEnv 齐全,插件才真正加载(plugins/_engine.mjs 的 pluginStatus 正是这样判定)。你也可以先用 node plugins.mjs list 查看状态——它会分别显示"禁用 / 缺哪些环境变量"两种未激活原因。
两个配置项的语义从 index.mjs 可以确认:
| 配置项 | 默认值 | 说明 | 校验规则 |
|---|---|---|---|
label |
Job Leads |
要扫描的 Gmail 标签名 | 无(字符串) |
days_back |
7 |
回看最近多少天的邮件 | 必须为正整数,否则直接抛错拒绝运行 |
days_back 会拼进 Gmail API 查询串:label:"Job Leads" newer_than:7d。若传入非正数或非整数(如 "7.5"),插件会抛出 invalid days_back ... (must be a positive integer)。
配好后如何自检
运行时若忘记某一步,node plugins.mjs run gmail 会在任何网络调用发生前给出明确报错;若想批量核对所有插件的密钥是否就位,可运行:
node plugins.mjs list # 查看每个插件的 enabled / missing env 状态
node doctor.mjs # 列出每个插件及密钥是否齐全
摄入流程源码剖析:DMARC 防伪 → URL 清洗 → 结构化 → 去重
ingest(ctx)(plugins/gmail/index.mjs)的完整执行顺序如下:
1. 分页拉取标签下的消息 id
以 label:"<label>" newer_than:<days_back>d 为查询串调用 Gmail API 的 messages.list,用 nextPageToken 循环翻页,直至取完所有消息。
2. 用本地游标跳过已处理邮件
processed_message_ids 持久化在 data/gmail-state.json(该插件自己的处理后游标,见 skill.md 说明)。每次运行先 loadProcessedIds() 载入历史集合,循环中对已处理的 m.id 直接 continue;跑完再把本次新增 id 写回。这保证了同一封邮件不会被重复读取——哪怕多次运行、甚至邮件仍留在标签里。
3. 单条抓取失败不致命
每条消息的详情(format=full)抓取单独 try/catch:某一条失败只打一条 warning 并跳过,不会中断整批摄入("per-message resilience",见 index.mjs 注释)。
4. DMARC 防伪门:伪造邮件直接丢弃
从邮件头中读取 Authentication-Results,仅当包含 dmarc=pass 才视为可信邮件(helper 的 isAuthenticEmail,fail-closed 设计)。伪造/未认证邮件会被跳过并计入已处理集合(避免反复告警),控制台会打印 skipping spoofed/unauthenticated email "<subject>"。这能挡住钓鱼邮件伪装成职位推荐的场景。
5. 从标题与正文提取职位线索
- 主题行启发式:
parseRoleAtCompany()尝试从形如{Role} at {Company}的主题中剥离常见前缀(Re:、Fwd:、Job Alert:、Alert for:等),用"at"切分出职位名与公司名;切出的字段超过 100 字符则视为不可信而放弃。 - 正文 URL 提取:
extractUrls()用正则抓出所有 http/https 链接,并递归拼接 Gmail 消息的 base64url 各 body 段解码为纯文本(getMessageBody()会处理payload.parts多段结构)。 - URL 清洁度过滤:
isCleanUrl()是防止"邮件家具"混进管道的核心守卫——它剔除以下类型(见 plugins/gmail/_helpers.mjs 注释):- 追踪/退订类:URL 含
click、track、unsubscribe、sendgrid、mailgun、doubleclick等关键词; - 图片/静态资源:
.jpg/.png/.svg/.woff2/.mp4等扩展名(连查询串一起检查,因为 CDN 会加缓存杀手),以及 WordPress 上传路径wp-content、wp-includes; - 静态资源子域:
cdn.、static.、assets.、img.、images.、media.开头的主机,以及fonts.googleapis.com、fonts.gstatic.com; - 非 HTTPS 协议与无法解析的畸形 URL。
- 追踪/退订类:URL 含
- 公司名兜底推断:
companyFromUrl()对已知 ATS 域名(boards.greenhouse.io/ 任意*.greenhouse.io/jobs.lever.co/*.lever.co)从路径首段提取 slug 作为公司名。
最终每条产出 { title, url, company, location },其中标题缺省为 Job lead (email)、location 缺省为空串,符合引擎的 Job[] 规范({ title, url, company, location })。
6. 去重与写回
- 批内去重:
seenUrls集合保证同一批内重复出现的 URL 只产出一次; - 写回游标:全部处理完成后统一
saveProcessedIds(),且仅当非 dry-run 时才落盘(if (!ctx?.dryRun))——这正是上面那个测试守护的行为。
需要强调的是:插件只负责"返回 Job[]",引擎(plugins.mjs)还会再做一次全局去重——用 existingPipelineUrls() 读取 data/pipeline.md 中已有的 URL,已存在的一律不追加,控制台输出 gmail ingest: N found, M new.,随后 → Appended M to data/pipeline.md。
数据产出与后续处理
综合 skill.md 的说明,插件的产出模型为:
Job[] = { title, url, company, location }
引擎侧对这批数据的处理策略(可在 plugins.mjs 的 cmdRun 中核实):
- 白名单化(sanitize):只保留
title/url/company/location四个规范字段,且title非空、url必须是http(s)://,其余多余键一律丢弃; - 全局去重:对比管道已有 URL,绝不重复追加;
- 仅追加:通过
appendToPipeline写入data/pipeline.md的 Pending 区块,并保持该文件既有格式; - 后续衔接:写入完成后按惯例运行 pipeline 评估命令(把线索转化为 A–H 报告与 1–5 评分),进入常规求职流程。
游标文件 data/gmail-state.json 的生命周期说明:它只记录"已处理的消息 id",不保存任何凭据或邮件内容;即便误删,插件也会从空集合开始重新扫描当前标签与回看窗口内的邮件(此时会按批内去重规则再处理一次)。
安全边界与设计约束
使用该插件前值得了解其安全模型(详见 plugins/README.md 的 "Trust model"):
- 只读 + 无自动提交:
humanInTheLoop: true为引擎强制,Gmail 插件只读取你授权的标签,不会代你投递任何申请; - 出口白名单:所有网络请求必须命中
allowedHosts(仅 Google 的两个域名),且强制 HTTPS; - 密钥最小可见:
ctx.env是冻结且仅含声明的三个变量;ctx.log会自动把密钥值替换成«redacted»; - 代码评审兜底:career-ops 是无构建步骤的纯 ESM,无法真正 VM 隔离插件导入,因此内置插件(
plugins/下)与 providers 一样经过代码评审;插件引擎的完整性锁与同意面(capability card)机制可在此类插件的安全设计中作为参照(plugins/_engine.mjs)。
适合的使用场景与限制
从插件实现可以归纳出它的适用前提:
- 会收到系统化职位邮件:例如你订阅了 ATS 平台或聚合器的职位提醒,且这些邮件能通过 DMARC 认证(
dmarc=pass); - 邮件主题符合
{Role} at {Company}范式:这类主题能产出最完整的信息(标题+公司都有了),否则退化为Job lead (email); - 回看窗口内用标签归集:建议先在 Gmail 中建过滤器,把相关职位邮件自动打上
Job Leads标签,再让插件按days_back增量摄入。
需要注意的限制:插件不输出任何 search:// 形式的伪 URL(那不是管道能打开的真实链接,见 index.mjs 头部注释);对非 DMARC 通过的邮件一律 fail-closed 跳过;公司名仅对 Greenhouse/Lever 域名做 ATS slug 推断,其余域名的职位需要后续 pipeline 评估阶段补齐信息。
参考实现文件一览
- 插件说明:plugins/gmail/skill.md
- 插件清单与声明:plugins/gmail/manifest.json
- 摄入主逻辑:plugins/gmail/index.mjs
- 纯函数辅助模块(URL 提取/清洗、DMARC 校验、主题解析、正文解码):plugins/gmail/_helpers.mjs
- 激活配置示例:config/plugins.example.yml
- 插件宿主 CLI(门控、去重、追加写管道):plugins.mjs
- 插件引擎(受管 fetch、双门控、超时与完整性锁):plugins/_engine.mjs
- dry-run 不落盘游标的回归测试:plugin-run-isolation-and-gmail-dryrun.test.mjs
- 管道追加写入实现(含文件锁与 Pending 区块插入):scan.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 StartedRust0625
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