首页
/ career-ops Gmail 插件实战:从 Gmail 邮件标签自动提取职位线索注入求职流水线

career-ops Gmail 插件实战:从 Gmail 邮件标签自动提取职位线索注入求职流水线

2026-09-06 19:25:05作者:蔡丛锟

本文讲解 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[]),即"从某个外部服务拉取职位",与 searchexportnotify 等钩子并列。整个调用链与能力声明如下:

项目 取值
插件 id gmail(需与目录名一致,符合 [a-z0-9][a-z0-9-]*
apiVersion 1(引擎当前仅支持 1)
钩子 ingest(该插件唯一的钩子)
所需环境变量 GMAIL_CLIENT_IDGMAIL_CLIENT_SECRETGMAIL_REFRESH_TOKEN
允许访问主机 oauth2.googleapis.comgmail.googleapis.com(仅这两个)
humanInTheLoop true(强制,不允许自动提交类插件)
附带 skill skill.md,通过 node plugins.mjs skill gmail 按需读取

命令:一行摄入新线索

插件的用法在 skill.md 中写得很精简:

node plugins.mjs run gmail

即"从配置好的标签中摄入新职位线索"。这条命令由插件宿主 plugins.mjs 驱动,其执行前会做多道门控:

  1. config/plugins.yml 中确认 plugins.gmail.enabled: true,否则报错并提示开启;
  2. 检查 .env 中三个 Gmail 相关环境变量是否齐全,缺任一都会给出可操作的错误信息;
  3. 解析钩子类型——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.jsonrequiredEnv 声明了这一点):

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/tokengrant_type: refresh_token 交换短期 access token;交换失败时会把 HTTP 状态码与响应片段(截断 200 字符)写进报错,方便排查。注意此请求经由插件的 ctx.fetch 发出,该 fetch 是被引擎包装过的受管出口:强制 HTTPS、域名白名单(只有 oauth2.googleapis.comgmail.googleapis.com)、每次 30x 跳转逐跳重新校验主机并在跨域跳转时剥离凭据头(见 插件引擎makeGuardedFetchbuildCtx)。

第二步: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.mjspluginStatus 正是这样判定)。你也可以先用 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 才视为可信邮件(helperisAuthenticEmail,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 含 clicktrackunsubscribesendgridmailgundoubleclick 等关键词;
    • 图片/静态资源:.jpg/.png/.svg/.woff2/.mp4 等扩展名(连查询串一起检查,因为 CDN 会加缓存杀手),以及 WordPress 上传路径 wp-contentwp-includes
    • 静态资源子域:cdn.static.assets.img.images.media. 开头的主机,以及 fonts.googleapis.comfonts.gstatic.com
    • 非 HTTPS 协议与无法解析的畸形 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.mjscmdRun 中核实):

  1. 白名单化(sanitize):只保留 title/url/company/location 四个规范字段,且 title 非空、url 必须是 http(s)://,其余多余键一律丢弃;
  2. 全局去重:对比管道已有 URL,绝不重复追加;
  3. 仅追加:通过 appendToPipeline 写入 data/pipeline.md 的 Pending 区块,并保持该文件既有格式;
  4. 后续衔接:写入完成后按惯例运行 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 评估阶段补齐信息。

参考实现文件一览

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