【免费下载链接】freehire
freehire — the open-source search engine for job seekers
本文以 freehire 仓库中openspec/changes/add-auto-apply-available-filter/design.md为主线,完整拆解一个"索引期派生布尔 facet(分面)"的设计取舍:为什么它不落 Postgres 列、为什么 provider 白名单要复制一份并手动同步、为什么"false"必须被省略而不是写出,以及上线时为什么必须先改索引设置、再发二进制、最后跑一次全量重建。读完本文,你可以独立复现这条链路——从 Go 侧的行到线形状投影、Meilisearch 可过滤属性的声明与过滤片段构造,到 Svelte 前端的暂存过滤器、URL 序列化与侧边栏 chip。
问题背景:候选人在投递之前无从判断
openspec/changes/add-auto-apply-available-filter/proposal.md给出的动机十分具体:候选人在为一份职位花费一整个 CV 定制流程之前,无法知道该职位的 ATS(Applicant Tracking System)是否是cmd/auto-apply至少能够"尝试"的那一类。一次针对生产环境的自动投递队列复查发现:整个生命周期内只有一次成功提交,多数滞留(park)的条目失败在两类原因上——
- provider 缺口:例如 Recruitee 完全没有 fill/submit 路径;
- 浏览器驱动无法识别的表单:页面能加载,但 chromedp 的 DOM 扫描拿不到可填字段。
这两类失败在"是否值得投入定制"这个问题上其实是可以提前知晓的,因为它们的输入就是职位自己的source字段。把这件事做成过滤器,就能让候选人在没有任何 Postgres 新状态、没有任何 backfill worker 的前提下,自己向"至少 provider 层面合格"的职位靠拢,而不是在事后才发现落差。
设计文档特别强调信号的诚实性(Goals 中第二条):它承诺的是provider 资格(provider eligibility),而不是提交成功(submission success)。这一点会贯穿到 UI 文案、真值-缺省语义和测试断言的每一个细节里。
信号的语义:固定白名单与它的四个成员
specs/auto-apply-availability-facet/spec.md的第一条 Requirement 把可用性定义成一个固定允许清单:当职位source属于greenhouse、lever、ashby、workable之一时标记auto_apply_available;其余任何来源(显式包含recruitee)都不标记。
这四家来自internal/api/atsapply现有的两套映射(union):
| 映射 | 位置 | 执行方式 | 成员 |
|---|---|---|---|
fillProviders | client.go | chromedp 直接填表/提交 | greenhouse |
browserUseProviders | browseruse_fill.go | browser-use 云端 agent 兜底 | ashby、workable、lever |
并集 =AutoApplyProviders | autoapply.go | 过滤器的唯一判定依据 | 上述四家 |
源码注释把几个容易误读的点说得很清楚,值得逐条记住:
fillProviders的注释把自己定义为"Submit真正能填/能提交的 provider 的单一事实来源",并且在 resolve(起草前)与 Submit(填表前)两处都会检查,保证两者不会各自漂移;同时要求每个条目必须在layout.go里有一份页面布局描述,否则会"带着匹配不到任何元素的 selector 去点提交"。browserUseProviders里Lever 的动机与其他两家不同:它有可用的 fill 路径,但那条路径大多无法收尾——其表单带不可见 hCaptcha(2026-09-10 抽样的 12 个存活职位中 10 个有),本包自己的 Chrome 大约八次尝试过一次;多次探针(headless/windowed、Xvfb、机房 IP 与住宅 IP、有无鼠标滚动预热、有无 HeadlessChrome UA)都没有改善通过率。因此 Lever 被有意路由到按次计费的云端 agent,而不是留在一条"基本都会 park"的免费路径上。browserUseProviders注释还明确了 Greenhouse 为何不在其中(它已有 fill 路径),以及白标/未知布局职位为何不在其中(结构性原因:chromedp 自己的 DOM 扫描失败时根本没有MergedFieldschema 可以用来构建 Plan)。- Recruitee 缺席是结构性缺口而非决策:
internal/ingest/applyform.Fetchers根本没有为它注册 Fetcher(它的表单随 ingest 抓取免费带回并直接写入),所以Client.fetchSchema会以errNoSchemaFetcher/reasonSubmissionNotImplemented早早 park,根本走不到 browser-use 执行器。
这一点必须与"真正的完成"区分开:internal/api/atsapply/client.go里还有一个导出给外部的SubmittableProviders(),注释明确指出仓库里已有三份"容易混淆"的清单——jobview.AutoApplyProviders(可以尝试fill 的四家)、handler 的入队集合(可以入队的五家)、以及SubmittableProviders(真正可以完成提交的一家)。只有第三份代表"submitted"。过滤器用的是第一份,这正是它必须被描述为 best-effort 的根本原因。
架构定位:常量为什么落在jobview而不是search
设计文档最初的决定是"在internal/search里复制一份常量",但tasks.md记录了 PR 评审后的一次重要修正(strelov1/freehire#2768),这条修正链是理解当前代码形态的关键:
- spec 自己要求"facet 被服务且可过滤"(served and filterable),这意味着信号必须出现在**公开线形状(public wire shape)**上,而不只是搜索索引文档里。
- 原设计把
AutoApplyAvailable bool放在JobDocument上,而JobDocument只在 Meilisearch 文档中扁平化;真正对外返回的职位对象jobview.Job(每个列表/详情/搜索响应都通过views[i] = hit.Job提取)根本没有携带它。 - 因此字段被移到
jobview.Job本身,provider 常量也随之下移到internal/job/jobview/autoapply.go——因为jobview 无法 importinternal/search,常量必须待在"需要它的那一层"。
这与仓库的分层规则完全一致:job是第 5 层,search是第 6 层,api是第 8 层。jobview不能 importatsapply(api,第 8 层),所以 provider 集合只能重新声明,并明确标注为"手动同步的镜像"。当前autoapply.go的注释正是这样写的,并且解释了为什么search.FromJob不再自己重算——它复用jobview.FromRow已经算好的值,让两份副本漂移的位置少一处。
计算方式:索引期推导,绝不落库
这是设计文档最核心的一个 Decision:从source在索引构建时计算,而不是存成 Postgres 列。
理由是与其他派生布尔 facet 的对比。is_tech、requires_clearance这类 facet 需要真实的存储列,因为它们的推导要重新处理自由文本(描述、LLM 或词典扫描),在每次索引构建时重做代价太高。而本 facet 的全部输入是一个已被索引、永远无需重处理的枚举列,所以在构建时内联重算"从构造上就是正确的"——它不可能与source漂移,也不需要 migration、backfill worker,更不需要IS DISTINCT FROM保护的按块扫描。被否决的替代方案(生成列或backfill-derive风格的一次性 pass)被判定为纯开销:把一个"本来免费可重算"的事实冻结成第二个冗余真相来源。
落到代码上,计算发生在jobview.FromDomain里的一行查表:
// internal/job/jobview/jobview.go RequiresClearance: f.RequiresClearance != nil && *f.RequiresClearance, AutoApplyAvailable: AutoApplyProviders[f.Source],这一行位于 jobview.go。它带来两个直接后果:
- 自愈:任何未来增量推送都会重新得出正确值,无需额外步骤;
- 首次索引即生效:spec 场景"A newly ingested posting from an eligible provider is marked on first index"天然满足。
但已存在的职位不会自动获得该属性。原因在于增量推送只重发content_hash发生变化的文档(见 internal/job/job/job.go 附近对content_hash覆盖范围的注释),而一个既有职位的source不会因此变化。所以 spec 专门有一条 Requirement 要求在全量重建之后再跑一次完整 reindex——这正是is_tech/requires_clearance已经记录过的同一个陷阱,只是本 facet 不需要 Postgres backfill 进程,因为那里什么都没存。
线路形状:true-or-absent,绝不写出 false
jobview.Job上的字段定义把语义写进了注释:
// AutoApplyAvailable marks a posting whose ATS provider (Source) is one // internal/api/atsapply can currently attempt to fill and submit for (see // AutoApplyProviders) — a best-effort, provider-level eligibility signal, // never a guarantee that a real attempt would succeed. True-or-absent like // RequiresClearance: omitted rather than false, because "not one of the // providers we can drive" must not be read as "checked and ineligible". AutoApplyAvailable bool `json:"auto_apply_available,omitempty"`见 jobview.go。设计文档对应决策叫"True-or-absent via Go's ownomitempty, no special-casing"——AIInterview bool \json:"ai_interview,omitempty"`` 已经免费获得"为真才出现"的行为,新字段照抄同一模式,不需要任何定制 marshalling。
这条语义不是洁癖,它直接决定过滤器的正确性。query_filter.go的注释把风险讲得最透:索引里从来没有被写成 false 的文档,所以attr = false会匹配到空集,"会悄悄清空一个调用者本来期望接近整个目录的结果集"。因此"未标记"的正确问法是对正向取 NOT。
搜索侧接线:三个改动点
1. Meilisearch 可过滤属性
facetSettings()的FilterableAttributes中新增了"auto_apply_available",见 client.go。旁边的注释点出了这次改动唯一危险的部分:
在 LIVE 索引声明该属性之前,一个请求该 facet 的二进制会为所有调用者把
/api/v1/jobs/facets打成硬 500,所以 settings patch 必须在二进制切换之前发布。
2. 参数常量与StringFacets登记
query_filter.go增加了导出常量AutoApplyAvailableParam = "auto_apply_available",并把"auto_apply_available": "auto_apply_available"加入StringFacets。注释说明了它与ai_interview的一个差别:它是jobview.Job的成员,因此既进入索引文档,也进入对外服务对象,而不是"仅文档"字段。
3. 真值-缺省谓词构造
trueOrAbsentParams现在有三个成员,共享一个片段构造器:
var trueOrAbsentParams = map[string]bool{ RequiresClearanceParam: true, AIInterviewParam: true, AutoApplyAvailableParam: true, } func trueOrAbsentFragment(attr, val string) string { if val == "false" { return "NOT " + EqBool(attr, true) } return EqBool(attr, true) }见 query_filter.go。由此得到确定的查询语义:
| 请求 | 生成的 Meilisearch 片段 | 结果 |
|---|---|---|
?auto_apply_available=true | auto_apply_available = true | 仅被标记的职位 |
?auto_apply_available=false | NOT auto_apply_available = true | 未标记的职位(含完全缺省的文档) |
| 不传该参数 | 无片段 | 全量,行为与改动前完全一致 |
4./jobs/facets分布自动纳入
这里没有任何新增代码:facetAttributes()遍历search.StringFacets,自动把每个条目登记进分布请求,唯一的排除表facetsNoDistribution只有company_slug。见 internal/api/handler/facets.go。tasks.md也记录了这一结构性事实——它不需要单独的分布测试,正如requires_clearance/ai_interview也没有。
前端接线:刻意不用通用 facet rail
设计文档的 Decision 说得很直接:rail 系统(多选原始值)在这里是错误机制,理由与它不适用于source本身相同——本 facet 只有一个有意义的状态(开/关)。复用visa/hideAIInterview的既有接线,比教通用系统为单一调用者新增"布尔复选框控件变体"代码更少。
过滤器模型
facetModel.ts 中:
- 类型新增
autoApplyAvailable: boolean,注释明确"序列化为auto_apply_available=true,真值-缺省",并指出关闭时必须省略参数而不是写=false; - 默认值
autoApplyAvailable: false; filtersToParams:if (f.autoApplyAvailable) p.set('auto_apply_available', 'true');(注意与相邻的hideAIInterview不同——后者是取反序列化ai_interview=false,本 facet 绝不能照抄);filtersFromParams:f.autoApplyAvailable = p.get('auto_apply_available') === 'true';activeFilterCount:if (f.autoApplyAvailable) n += 1;
状态层:暂存与即时两套
tasks.md第 4.0 项记录了一个实现期发现:visa/hideAIInterview的接线跨越的不止facetModel.ts。于是同步补齐了两处:
- stagedFilters.svelte.ts 的
setAutoApplyAvailable(on: boolean)(弹窗内的暂存态,Show results时才应用); - filters.ts 的
setAutoApplyAvailable(on: boolean)(setNow的离散输入形状,供侧边栏 chip 的移除按钮直接调用)。
弹窗控件与文案
FilterModal.svelte 里新增了独立的 "Auto-apply" 小节:
<h3 class="mb-2 mt-6 text-sm font-semibold tracking-tight">Auto-apply</h3> <label class="flex cursor-pointer items-center gap-2 text-sm"> <input type="checkbox" class="size-4 rounded border-border" checked={staged.value.autoApplyAvailable} onchange={(e) => staged.setAutoApplyAvailable(e.currentTarget.checked)} /> <span>Auto-apply available</span> </label> <p class="mt-1 text-xs text-muted-foreground"> Best-effort — successful submission isn't guaranteed. </p>这行 caption 就是风险缓解措施本身。设计文档在 Risks 中承认:Ashby/Workable 被纳入是明确的产品决策,尽管它们的 browser-use 兜底当前在生产上默认关闭(那些职位目前 100% 在提交处 park),所以复选框必须以"尽力而为、不保证提交成功"的措辞呈现。设计文档明确指出"不做代码层面的缓解",因为改变实际执行策略超出了本次改动的范围。
同时,它被计入relocationrail 条目的 badge 计数(与 visa / AI-interview / clearance 同一个兜底资格面板),并落在 FilterSummary.svelte 的侧边栏 chip:
if (f.autoApplyAvailable) push('Auto-apply', [ { key: 'auto_apply_available', text: 'Available', exclude: false, remove: () => store.setAutoApplyAvailable(false) }, ]);漂移防护:两个方向的测试才构成闭环
这是设计文档中最值得复用的工程经验。provider 集合被手工复制,因此它天然存在漂移风险;而单向测试抓不住双向漂移。
方向一:本包内快照
autoapply_test.go 的TestAutoApplyProviders_ExactExpectedSet断言常量恰好是那四个 provider。它的注释老实承认了自己的局限:jobview(第 5 层)看不到 atsapply(api,第 8 层)的真实映射,所以它只能抓住"字面量被误改",抓不住 atsapply 侧单方面改动而此处未跟进。
方向二:从能同时看到两边的一侧校验
auto_apply_facet_sync_test.go 的TestAutoApplyFacetProvidersMatchThisPackagesOwnMaps断言jobview.AutoApplyProviders等于fillProviders ∪ browserUseProviders的实时并集。由于 api 位于 job 之上,它可以自由 import jobview,这是唯一能覆盖反方向的位置。测试注释直言:没有它,一次只改fillProviders/browserUseProviders而不改 jobview 的编辑,会同时通过两个包的所有检查,而 facet 会静默地错误报告资格。
两个测试合起来才覆盖双向;设计文档的 Open Questions 也留了口子——是否把三份映射收敛到一个低层共享包(如internal/dict/atsprovider),等集合真正需要变化、且重复确实造成漂移事故后再做,不阻塞本次改动。
语义层测试
- provider 资格与 JSON 键存在性:
TestFromDomain_AutoApplyAvailableFacet覆盖四家合格 provider、recruitee、任意非 ATS 来源(djinni),并额外断言序列化后auto_apply_available键的存在与否——因为omitempty会整个丢掉键,类型化的结构体字段观察不到这件事。 - 文档透传:auto_apply_available_test.go 只验证
FromJob把已算好的值原样送进文档 JSON,不再重算。 - 过滤片段字符串:
TestFilterFromValues_AutoApplyAvailable覆盖 true / false / 缺省三种输入,以及TestFacetSettings_AutoApplyAvailableIsFilterable。 - 真实 Meilisearch 端到端:auto_apply_available_integration_test.go(
-tags=integration,需要 Docker)索引一条 greenhouse 与一条 recruitee 职位,验证正向只命中前者、false只命中后者(即缺省属性的文档确实被 NOT 覆盖)、省略参数返回两条;另有TestAutoApplyAvailableParamIsKnown确认该参数在过滤词表中,否则"参数不认识就放宽"的端点会静默返回所有 provider 且不作声。
上线顺序:settings → binary → reindex
design.md的 Migration Plan 与 spec 的最后两条 Requirement 把它写成了硬性顺序,tasks.md第 5 节则保留了三个尚未勾选的运维动作(5.1 确认部署顺序、5.2 跑全量 reindex、5.3 抽查),说明代码已合入但正式发布仍需按序执行:
- 先发布代码(wire 字段、Meilisearch 设置新增、查询过滤接线、前端复选框),走常规部署流水线。
- 在携带新
StringFacets//jobs/facets条目的二进制开始服务之前,把 Meilisearch 设置补丁(新增可过滤属性)打到线上索引。这是本仓库对每个可过滤属性都遵守的既有规则;顺序颠倒会让所有调用者的/jobs/facets直接 500。 - 二进制上线之后,跑一次完整重建,把属性补到已索引的开放职位上:
make reindex # 等价于 go run ./cmd/reindex(需要运行中的 Postgres 与 Meilisearch)见 Makefile。跳过第 3 步会让既有职位全部未标记——症状会读作"自动投递处处不可用",而不是"索引尚未追上"。
- 回滚:不需要超出常规 revert 的任何动作。Postgres 里什么都没存,所以回退代码后再跑一次全量 reindex 就能把该属性从线上索引里干净移除。
发布后抽查:GET /api/v1/jobs?auto_apply_available=true应只返回四家合格 provider 的职位,且包含早于本次改动的职位(tasks 5.3)。
API 契约与对外文档
web/static/openapi.yaml同步新增了两处(照requires_clearance的形状):
- 查询参数
auto_apply_available(boolean):描述为"该职位的 ATS provider 是否是 freehire 自动投递当前可以尝试填充并提交的",并强调 best-effort; Jobschema 属性auto_apply_available(boolean):"仅当职位 provider 属于可尝试范围时存在且为true;否则缺省——provider 资格信号,绝不保证成功提交"。
因为字段是真实的jobview.Job成员,go run ./cmd/gen-contracts生成的前端契约类型web/src/lib/generated/contracts.ts也会自动带上它——这也是 tasks 1.3 把字段从JobDocument移到jobview.Job带来的附带收益。
按设计文档的 Non-Goals,本复选框不加入通用FACETS/ rail 数组,也不加入公开的 filter-docs 页面,与visa/hideAIInterview的既有先例保持一致。补充说明:设计文档写作时 rawsource被记录在web/src/lib/filterSections.test.ts的NOT_OFFERED集合中,而当前该文件注释已说明"今天没有任何 facet 被隐藏",source后来获得了自己的 rail 行;但本 facet 使用专用复选框的理由独立成立——它只有开/关一个有意义的状态,而 rail 渲染的是原始值的多选。
明确不在范围内的事项
设计文档的 Non-Goals 划出了清晰边界,避免读者误读该过滤器的能力:
- 不改变
atsapply实际能为哪些 provider 填表/提交,也不改变 Ashby/Workable 的 browser-use 兜底是否在生产中强制执行; - 不建模单次尝试的障碍(captcha、未识别布局、缺失答案)——这些只在尝试时可知,属于产品层面明确选择交付"provider 级 best-effort 信号"的后果;
- 不新增Postgres 列、migration 或 backfill worker;
- 不复活raw
source的对外暴露——本次只新增一个派生的语义布尔值。
小结:一个可复用的"索引期派生 facet"模板
这项改动的价值不止于一个复选框,它给出了一套可复用的模式。当你要新增的过滤维度满足以下条件时,可以跳过 migration 与 backfill:输入已经是一个被索引且无需重处理的低基数字段。此时正确做法是在行到线形状的投影处(本仓库是jobview.FromDomain)内联查表,让search.FromJob只做透传,用omitempty表达真值-缺省,用trueOrAbsentParams共享谓词构造,用前端专用复选框而非通用 rail,用两个方向的测试锁住任何手工复制的常量,最后严格遵守 settings → binary → reindex 的发布顺序并准备好"revert 再重建"的回滚路径。相关设计与验收细节可直接查阅 design.md、proposal.md、tasks.md 与两份 capability spec:auto-apply-availability-facet、filter-modal。
【免费下载链接】freehire
freehire — the open-source search engine for job seekers
相关推荐
freehire ATS 自动投递浏览器驱动层详解:internal/api/atsapply 的扫描、对账、解析与提交
freehire ATS 自动投递浏览器驱动层详解:internal/api/atsapply 的扫描、对账、解析与提交 本文以 freehire 仓库中的 i
freehire Auto-Apply Worker 详解:队列驱动的 chromedp 无人值守自动投递系统设计
freehire Auto Apply Worker 详解:队列驱动的 chromedp 无人值守自动投递系统设计 本文围绕 freehire 仓库中的设计文档
freehire「Preparing」阶段设计解析:让 CV 定制与真实投递在数据层分开
freehire「Preparing」阶段设计解析:让 CV 定制与真实投递在数据层分开 在 freehire 的求职跟踪(Tracking Kanban)中,
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考