本文使用 juejin-cli 完成草稿创建与专栏发布,截图和架构图托管在 GitHub。关于这套工具的设计,可以先看我之前的文章:《从命令到工作流:juejin-cli 如何让写作自动化更可靠》。这一篇继续「CLI-Anything」,把目光转向求职与招聘。
recruitment-cli 是我做的一个面向 AI 助手的求职与招聘工具。它把搜索、筛选、沟通计划和执行回执组织成可调用的工作流,让人可以通过终端、对话界面或自己的业务系统使用同一套能力。本文先把产品和整体架构讲清楚,再集中拆解一个问题:一次招呼发出去后超时了,下一次运行究竟应该做什么?
先认识产品:把反复筛选,接到有价值的对话之前

上图来自 recruitment-cli 官网。项目已开源:Ganzhib/recruitment-cli。安装后的命令叫 recruit。
求职时,我们通常要先限定岗位和城市,翻看职位,了解招聘者是否还活跃,再决定值得和谁进一步交流。招聘时,工作方向反过来:确定正在招聘的岗位,从推荐候选人中查阅经历,结合岗位要求与活跃状态整理联系对象。这些步骤都包含判断,也包含大量重复点选。
recruitment-cli 把重复操作收进工具,把方向和判断留给使用者。你可以先给出一个很小的任务:
帮我整理上海的前端岗位,优先看最近 7 天活跃的招聘者,先给我 5 个值得了解的机会,这次不联系。
这里的“最近 7 天”指对方的活跃时间。它能帮助缩小范围,但不保证对方一定回复,也不代表岗位刚刚发布。工具按实际扫描范围整理结果,不宣称把全站岗位都排了一遍。
换成招聘者,起点是确认当前身份和可关联岗位,再从指定岗位的推荐列表筛选候选人。岗位经验、技术关键词和城市等要求应由使用者提出;当前能力是推荐列表筛选,不是全站候选人搜索,也不替招聘者作录用判断。
从“帮我找找”到“这一步有结果可查”
产品把一次任务拆成四段,每一段都有可以交给下一步的结果。
| 阶段 | 使用者关心什么 | 工具留下什么 |
|---|---|---|
| 明确范围 | 哪个平台、哪个账号、求职还是招聘、什么岗位 | 实际账号身份、当前角色与筛选参数 |
| 整理对象 | 哪些结果值得继续了解,哪些被排除了 | 目标列表、活跃度证据、扫描数量与排除原因 |
| 准备沟通 | 这次准备联系哪些人 | 保存到本地的计划快照及计划 ID |
| 执行与查看 | 哪些完成、哪些跳过、哪些仍需核实 | 操作记录、平台回执与不确定结果 |
“先准备计划”不意味着产品必须在每个步骤都弹出一次确认。使用者已经明确授权、调用方也开启沟通权限时,AI 可以在同一轮准备并执行计划;选择只读模式时,就停在搜索和整理。决定权限的是使用者与宿主程序,模型只能在这个范围内调用工具。
实际沟通使用 BOSS 当前配置的预制招呼语。工具不会在这条流程里擅自改写话术;执行成功也只表示相应平台接口返回了成功证据,不表示对方已经阅读、回复或愿意继续交流。
三种入口,适合三类使用方式
对于命令行用户,可以直接使用 recruit search、recruit plan create 和 recruit plan run。搜索、准备和执行是可组合的动作,输出也可以用结构化 JSON 继续处理。
对于希望用自然语言安排工作的人,可以启动本地 AI Demo,或者让具备本机工具能力的 Agent 使用 CLI。对话页面展示工具调用、对应命令与结果,让“AI 正在做什么”有迹可循。这里没有要求用户手工提取 Cookie;账号登录通过本地浏览器完成。
对于开发者,可以从 npm 包根导入 client,或者把 createRecruitmentTools 提供的工具定义接进已有 Agent。自建招聘后台、内部工作台和对话产品可以复用同样的业务服务,不必从终端提示文字里反向解析结果。

公开对话 Demo 的页面明确标注“交互演示 · 虚构数据”。它用于了解交互,不连接读者的真实招聘账号。真实操作需要在本地运行环境完成安装、模型配置与平台登录。登录状态保存在本机;使用远程模型时,交给模型的对话和工具结果仍会发送到所配置的模型服务。
目前,公司招聘官网适配器还可以按照站点配置读取公开职位列表与详情,但没有实现自动填写投递表单。产品的范围是把已支持的动作接成可检查的工作流,不能把“能读职位”直接理解成“能在任意招聘网站完成投递”。
先看架构:把业务规则放在所有入口都会经过的地方

这张图展示主要调用与支撑关系。公开公司官网不需要账号锁;BOSS 等需登录的平台通过账号仓库进入会话。
src/cli 负责命令输入与展示,src/core 定义平台能力和数据契约,src/runtime 管理账号、锁、存储和浏览器生命周期,src/services 负责筛选与沟通编排。认证、页面选择器、站点协议和平台专属参数则集中在 src/platform/<id>/。
内置平台在 src/platform/builtins.ts 组装。新增平台时,可以实现平台契约并注入 client;通用服务操作的是 Selection、Target 和能力接口,不需要理解某个站点的按钮文字。
对本文来说,最关键的分工是:平台适配器负责完成一次具体动作并返回证据,业务服务负责决定这次动作是否允许发生,以及结果不明后如何继续。
例如 BOSS 适配器内部把 HTTP 与浏览器分工:可复用会话时,求职端列表读取可以走 HTTP;详情、部分招聘端操作和原生沟通按需使用浏览器。这属于平台内部选择,OutreachService 不应该因此长出两套重试规则。
CLI、SDK 和 AI 工具最终汇合到 RecruitmentClient。如果防重复逻辑只写在 CLI 命令里,业务系统调用 SDK 时就可能绕开;把它放进共享服务,才能让不同入口遵循同一套规则。
对应实现:client.ts、平台契约。下面沿一次沟通操作往下看。
切入一个故障窗口:平台做完了,本地没有收到答案
设想一份计划准备联系 5 个对象。处理第三个对象时,页面上的原生沟通按钮已经触发,平台也完成了操作,但客户端没能获得确定回执。
这个场景是用来解释设计的故障模型,不是一次真实账号发送实测。

客户端看到“超时”,至少对应两种可能:请求没有完成,或者请求已经完成但结果没有被客户端确认。只凭本地异常无法区分两者。
如果此时重新点击,第二种情况就可能变成重复联系。如果直接宣告成功,第一种情况又会变成虚假的完成记录。工具需要承认第三种结果:indeterminate,即结果不明。
这不是更换一个错误提示那么简单。要让下一次运行仍然记得这件事,需要回答三个问题:这条记录属于谁?它描述的是哪一次业务动作?什么时刻必须把它保存下来?
第一步:让记录属于真实账号,而不是某个别名或进程

main 这样的别名适合人记忆,但不适合作为防重复的业务身份。同一个平台账号可以被不同别名引用;如果每个别名各自持有锁和历史,两个入口就可能各自判断“还没发过”。
项目先根据 platformId + accountId 计算稳定绑定标识 bindingId。别名只负责查到这份身份,账号目录与执行锁以稳定身份为依据。进入平台会话时,还要读取当前实际账号;执行计划时再核对角色,避免把求职身份准备的计划拿到招聘身份下执行。
发送记录的键进一步包含:
platformId + accountId + role + targetId
注意这里没有 planId。否则,同一个目标出现在两份新计划里,就会得到两个不同的防重复键。planId 仍然写进记录用于追溯,但不承担“是不是同一次业务联系”的判断。
招聘端的目标 ID 还区分岗位与候选人,使去重粒度与具体岗位场景一致。这里的重点是先定义业务身份,再对身份计算哈希;哈希本身不会替我们决定什么才算重复。
账号锁解决的是同一本机数据目录内的并发问题:一个任务正在操作该账号时,另一个任务不能同时进入。操作记录解决的是任务结束或进程重启之后的问题。锁与记录各有职责,只有锁无法记住昨天发生过什么,只有记录又可能让两个并发任务同时读到“没有记录”。
对应实现:accounts.ts。多台电脑、不同数据目录不会自动共享这套锁和记录,这个约束同样重要。
第二步:计划保存意图,操作记录保存执行事实
计划生成时会保存账号身份、角色、筛选参数、目标快照和排除信息。它回答“这次准备处理谁”,并不会随着后来又搜到一批对象而自动变化。
不过,快照不能保证目标状态永远有效。计划创建后,对方可能不再活跃,也可能已经通过其他入口沟通过。所以真正执行前,还要调用 contact.prepare,重新核对目标、活跃度和沟通状态。
通过预检查,才进入写入阶段。OutreachService.run 的关键顺序是:
// operation 此时已经构造为 pending;以下为源码中的关键顺序。
await writeJson(file, operation);
try {
const receipt = await session.invoke('contact.send', { target: prepared.target });
operation.receipt = receipt;
operation.state = receipt.state === 'sent' ? 'sent' : 'skipped';
} catch (error) {
operation.state = 'indeterminate';
// 完整实现还会记录失败线索、保存记录并停止本批新发送。
}
这是从实现中抽出的关键片段,省略了错误信息整理及最终保存逻辑。完整代码见 outreach.ts。
顺序不能随意交换。先保存 pending,再调用远端写操作,意味着进程即使在调用期间消失,下一次运行仍然能看到“这个目标已经进入过发送阶段”。如果先发送、后创建记录,中间一旦中断,本地就可能完全没有这次尝试的痕迹。
本地文件通过“写临时文件,再 rename”的方式替换,减少读到半份 JSON 的机会。但这仍是本地文件存储,不是与平台数据库共同提交的事务,也不能据此推导出断电情况下的强持久化保证。
第三步:把恢复分支写清楚,不让未知结果回到发送入口

这里有两个容易忽略的细节。
第一个是 pending 的恢复语义。再次运行时,如果发现已有 pending,项目会把它转为 indeterminate,然后跳过这个目标。它不会把 pending 理解成“还没完成,可以再试一次”。因为本地不知道进程是在真正发送之前,还是之后中断的。
第二个是 skipped 的来源。预检查发现目标不适合联系时,结果会标为跳过,但不写入永久操作记录;新的计划以后还能重新考虑这个目标。进入 contact.send 后返回的确定跳过结果则会保存,下次读取时按既有记录处理。相同展示词背后,是否进入过发送阶段决定了不同的恢复行为。
继续用 5 个对象举例:前两个有确定结果,第三个变为结果不明,当前批次就停止继续发送第四、第五个。以后使用者再次执行同一计划时,前三个按已有记录处理,尚未执行的目标可以继续。只要结果中仍有 indeterminate,整体状态就是 attention,提醒使用者存在待核实事项。
这里存在一个明确的代价:如果进程恰好在 pending 保存之后、远端调用之前中断,实际上可能还没有发出任何消息,但下一次也不会自动发送。设计选择保守保留不确定性,代价是可能需要人工处理一次没有真正发生的联系。
这套机制减少的是在共享记录仍有效的前提下,由重新运行造成的重复尝试。它没有实现平台端的“恰好一次”投递保证。当前流程也没有可让客户端使用的服务端幂等键,不能把本地操作 ID 当成平台已经认可的幂等协议。
第四步:只有匹配目标的业务回执,才可以写成 sent
如果“点击按钮成功”就足以写成 sent,前面的记录再完整,也只是完整地保存了一个未经证实的结论。
BOSS 适配器先注册请求和响应观察器,再触发原生按钮。观察器核对接口路径与目标参数,并把响应关联到本次匹配的请求;只有对应业务结果成功,才生成发送回执。打开简历、弹出窗口或者点击动作返回,都不单独构成沟通成功的证据。
超时、异常和协议错误会保留可用于排查的线索,例如接口路径、HTTP 状态、平台业务码,以及是否观察到请求。源码还在 finally 中清理监听器和定时器,避免上一轮观察器继续影响后续操作。
对应实现:BOSS 沟通与回执观察。
这些证据仍然有范围:平台接口确认完成,不等于用户已经阅读;本地没有观察到请求,也不应该被上层自动解释为“确定没有副作用”。共享服务对发送阶段抛出的错误保守地记录为结果不明,牺牲一部分自动恢复能力,换取更简单、可追踪的执行规则。
AI 入口如何遵守同一条规则
模型可能在一次报错后建议“再试一次”。因此,防重复不能只依靠提示词要求模型谨慎。
工具层把操作分为 read、local-write 和 remote-write。recruit_plan 可以准备并保存计划,真正执行已有计划的 recruit_run_plan 则要求调用方传入 allowContact 策略。这个策略与模型生成的工具参数分开,模型不能在自己的参数中授予自己沟通权限。
即使已经授权发送,调用仍然落到 client 与 OutreachService,所以已有记录和结果不明处理不会消失。client 的通用 invoke 也拒绝写能力,contact.send 必须由计划执行流程编排;第三方若直接使用底层适配器会话,就需要自行承担这部分责任。
对应实现:agent-tools.ts。这使“是否允许发送”和“是否允许再次发送”成为两项独立判断:前者来自调用方授权,后者来自业务状态。
如何开始,以及这篇分析验证到了哪里
想先体验产品,可以从官网查看安装说明,或打开在线 Demo 理解交互。真实运行需要 Node.js ≥ 22.16 和 Chrome/Chromium。下面使用已发布的 v0.1.2 Release 安装包,把版本固定下来:
npm install -g https://github.com/Ganzhib/recruitment-cli/releases/download/v0.1.2/ganzhibin-recruitment-cli-0.1.2.tgz
recruit --version
recruit demo
本地 Demo 中配置兼容模型服务、模型名称和 API Key,然后通过浏览器扫码登录 BOSS。第一次可以只做搜索和计划准备,查看结果后再决定是否开启沟通。习惯终端的读者也可以从只读查询开始:
recruit auth login boss
recruit auth status boss
recruit search "前端" --platform boss --city 上海 --active-within 7d -n 5
recruit greet "前端" --platform boss --city 上海 --active-within 7d -n 5 --dry-run
安装与使用细节见 接入指南。本文对照公开仓库提交 fa6643a 的源码撰写,核对了 Release 安装包入口与 CLI 版本,并运行了工作流和 AI 工具调用相关的 13 项离线测试,全部通过。其中覆盖了不确定发送后继续计划、遗留 pending 恢复、跨别名账号锁和身份变化阻止执行等情形。测试使用虚构平台与数据;产品图来自实际官网,故障时序图属于基于代码的分析,没有为写文章触发真实账号的沟通操作。
这也说明当前实现的边界:记录依赖本地数据目录;不同设备不自动共享防重复状态;结果不明还需要到平台沟通记录中人工核实;页面与协议变化需要持续适配。源码中的 attention 让问题能够被看到,并没有替用户完成最后的核实工作。
把这一点放回产品里看,recruitment-cli 的价值既在于把搜索、筛选与沟通接起来,也在于让使用者知道流程停在哪里。一个可以重复运行的 CLI,需要区分“可以继续处理剩余目标”和“可以把刚才的动作再做一遍”。这正是本文这条架构主线最终落到的使用体验。