ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

无头 Chrome 自动填表投递:freehire auto-apply 如何做到“所有必答项都有答案才提交“

无头 Chrome 自动填表投递:freehire auto-apply 如何做到“所有必答项都有答案才提交“

无头 Chrome 自动填表投递:freehire auto-apply 如何做到"所有必答项都有答案才提交"

【免费下载链接】freehirefreehire — the open-source search engine for job seekers项目地址: https://gitcode.com/GitHub_Trending/fr/freehire

freehire 是一款开源的求职搜索引擎(open-source job search engine),它的 auto-apply 模块用无头 Chrome自动打开雇主招聘页、填写申请表并投递——但有一条铁律:只有当每个必答项都存在一个"候选人自己确认过"的答案时,才会点击提交;缺任何一个答案,整张表单原样搁置,绝不猜测。本文完整解析这套自动填表投递机制的设计与实现。

一、auto-apply 自动投递解决什么问题

海投岗位时,最消耗时间的不是写简历,而是逐站重复填写申请表:姓名、邮箱、电话、工作年限、签证情况……每个 ATS(Greenhouse、Lever、Ashby 等)的表单还都不一样。

freehire 的方案是三个组件协作:

组件职责入口
编排 worker定制简历 → 等待候选人审批 → 触发投递cmd/auto-apply-orchestrate/main.go
投递 worker从队列认领任务,驱动无头浏览器填表、提交cmd/auto-apply/main.go
浏览器驱动层DOM 扫描、字段对齐、答案解析、填表提交internal/api/atsapply/

队列表auto_apply_queue由迁移 migrations/0116_auto_apply_queue.sql 创建,投递 worker 只"消耗"队列里已有的任务,不负责产生任务。

二、表单从哪里来:DOM 扫描 + 平台 Schema 对齐

提交前,系统先回答一个问题:这张表单上到底有哪些字段、哪些必填?

流程是:captcha 短路检查 → 拉取平台官方 schema → 用无头 Chrome 渲染页面并扫描 DOM → 把两边**对齐(reconcile)**成一份合并字段清单。规则很有意思(见 internal/api/atsapply/reconcile.go):

  • 页面(DOM)决定"存在什么":API 声明了但页面上根本没渲染的字段会被丢弃——总不能往一个不存在的控件里写值;
  • 必填是两边的并集:曾有一个真实的 Greenhouse 岗位,country字段在 HTML 里没有任何required属性,靠 API 侧的必填标记才兜住;
  • 平台识别靠"实测"而非猜测:只有 internal/api/atsapply/layout.go 里逐字段实测过页面结构的平台(目前是 Greenhouse、Lever)才会被真正驱动;其他平台解析得再完整也会以"未实现"为由搁置。

设计文档特别强调:定位表单、找提交按钮这类判断"是从真实页面上量出来的,不是推出来的"——因为提交按钮一旦被误点,无法撤回。

三、核心机制:必答项全有答案,才允许点提交

解析阶段会把每个合并字段对照候选人的已知答案逐一把关(internal/api/atsapply/resolve.go):

  • 必答项无答案→ 记入Unmapped清单(字段 ID、标签、原因);
  • 选填项无答案→ 直接跳过,既不填写也不报错;
  • 下拉/单选→ 答案必须精确匹配平台自己给出的某个选项,否则视为无答案。

然后是整个门禁的关键判定:

Plan.FullyResolved() == (Unmapped 为空)

只有FullyResolved()为真,worker 才会真的去填表、点提交;否则本次尝试直接返回StatusParked(搁置),雇主页面上的表单一个字符都不会被动过。

搁置不是失败,而是"缺数据":worker 会把阻塞原因和逐题明细落库,候选人界面会明确显示"是哪几道题挡住了投递"(状态为blocked,见 internal/application/autoapply/status.go)。补上答案后即可重新进入流程——这与"技术性失败、值得重试"是完全不同的语义。

四、答案从哪里来:宁缺毋滥的答案来源

worker 通过 cmd/auto-apply/answers.go 调用统一的答案装配器 internal/api/candidateprofile/profile.go,按固定优先级汇集候选人"已经说过"的每句话:

  1. 基础简历 CV(候选人亲笔编辑的版本);
  2. 结构化简历(从上传的简历文件中解析);
  3. 账户信息(邮箱兜底);
  4. 自填筛查答案(期望薪资、签证担保、可到岗时间等);
  5. 答案库 answer bank(候选人在过往申请表里答过的同类问题)。

在此之上还有两个安全阀:

  • 敏感问题闸门(internal/api/atsapply/sensitive.go):薪酬、工作许可/签证、EEO 人口统计类问题永远不交给模型代答,无论模型多自信;
  • 地理位置问题(internal/api/atsapply/geography.go):候选人档案只有一段自由文本地址,无法验证对应某个下拉选项,因此在代答之前就搁置,而不是指望"碰巧没匹配上"。

最后才是受控的 LLM 代答(internal/api/atsapply/draft.go):仅限"必答、非敏感、前两路都没覆盖"的自定义问题,单次调用、只基于候选人亲口陈述的经历素材,产出的答案还要再和选项列表对一次。模型没配置时,所有字段要么确定性解析、要么搁置——能力可以缺席,猜测永远不行。

五、提交之后:四种结局与"绝不投两次"

提交动作的每个结果都被显式建模(internal/application/autoapply/runner.go):

结局含义后续处理
applied真实投递成功同一事务内记录投递 + 注销队列行,原子完成
parked必答项未全有答案不重试,等候选人补数据
unconfirmed点了提交,但既没看到确认也没看到拒绝立即死信,交人工核查
captcha_refused平台明确说"没有创建申请"可安全重试(独立计数器,上限 20 次)

两处设计尤其严谨:

  • 记录失败不重试:如果浏览器已经真提交了、但本地落库失败,走普通重试意味着第二次真实投递——所以该路径强制死信,宁可报 CRITICAL 日志也要人来看;
  • 确认是"严格标记":提交后只在页面上找确切的确认/拒绝文本,匹配不到一律视为未确认,绝不从叙述性文字里推断成功。

无头浏览器本身用的是 Go 原生 chromedp 驱动真实 Chrome(而非 Python 侧车),配合disable-blink-features=AutomationControlled一个启动参数即可通过主要自动化检测信号,选型依据记录在 openspec/changes/auto-apply-worker/design.md。对 chromedp 无法覆盖的 Ashby/Workable,还有一条默认关闭的 browser-use 云端兜底(见 internal/api/atsapply/AGENTS.md)。

六、小结

freehire auto-apply 的"只在全员必答项都有答案时才提交"并非一句口号,而是一整条可验证的防线:

  • 表单字段来自DOM × API 的并集对齐,不猜控件;
  • 答案来自候选人已确认的事实来源,敏感问题与地理位置问题先行拦截;
  • FullyResolved()门禁保证缺一个答案就整体搁置,并逐题告知候选人;
  • 提交结果四态分治 + 原子落库,保证同一份申请永远不会被投两次。

对求职自动化而言,"少投错的一次"比"多投对的一次"更有价值——这正是这套机制最可取的地方。

【免费下载链接】freehirefreehire — the open-source search engine for job seekers项目地址: https://gitcode.com/GitHub_Trending/fr/freehire

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进