freehire 申请表单度量实录:各 ATS 平台必填题占比实测,以及 Lever 为何被排除在统计之外

原创2026-10-22 11:53:221,735 阅读
文章标签:后端前端搜索引擎网页爬虫数据工程AI 应用可观测性

freehire 申请表单度量实录:各 ATS 平台必填题占比实测,以及 Lever 为何被排除在统计之外

本篇技术文章基于 freehire(一个开源求职搜索引擎)对其 50 余万份 ATS 申请表单的统计度量。你将学到:如何用 SQL 对 jsonb 存储的表单语料做必填字段统计、为什么「必填」这一语义在不同 ATS 平台上不可直接比较、如何用 2% 抽样在全量语料上验证测量有效性,以及一份可复现的度量报告应当包含哪些要素(原始查询、原始输出、耗时、适用边界)。

1. 度量背景:语料来自哪里

freehire 持续抓取 Greenhouse、Ashby、Workable、Lever、Recruitee 五个 ATS 平台发布的申请表单,并将其存入 PostgreSQL 的 apply_forms 表。表结构定义在 0072_apply_forms.sql 中:job_id 为主键(每个职位至多一份当前表单)、provider 记录来源平台、payload 以 jsonb 存储平台原生词表(字段 ID、选项值、题面均不做归一化)。迁移文件中的注释说明了选 jsonb 而非列的原因:三个平台用三种不同方式描述同一张表单,而 question_67165648 这样的字段 ID 只对 Greenhouse 有意义,任何映射都是信息损失。

抓取链路同样值得了解,因为它决定了数据的新鲜度:

  • 写路径入队:cmd/ingest 在职位入库存时对需要按职位取表单的平台调用 applyform.NeedsRequestCapture,向 apply_form_outbox 入队(迁移文件 0072_apply_forms.sql 中定义了这张带租约语义的队列表);
  • 工作流消费:cmd/capture-apply-form 通过 internal/ingest/applyform/fetch.go 的 provider 注册表(fetcherFor)逐条取回表单并原子落库;
  • 读取路径:API GET /jobs/:slug/apply-form 将表单投影为候选人在职位页可见的内容。

语料规模(于 2026-09-09 对生产库 hire 只读查询):

SELECT provider, count(*) FROM apply_forms GROUP BY provider ORDER BY 2 DESC;
provider forms
greenhouse 283,363
lever 117,424
ashby 87,988
recruitee 79,584
workable 79,436

合计 647,795 份。语料是活的——抓取 worker 持续运行,所有数字都是当日的快照,这也是该度量报告给每个数字标注日期而非直接陈述的原因。

2. 核心问题:为什么 Lever 的 required 不能参与比较

这是本篇度量的关键判断。五个平台的必填标记并非同一种测量:Greenhouse、Ashby、Workable、Recruitee 各自读取平台 API 声明的布尔字段,而 Lever 没有这样的字段,只能从 HTML 标记推断。

2.1 四个平台的 required 是 API 声明的布尔值

源码证据(均在 internal/ingest/applyform/ 包内):

  • Greenhouse:GreenhouseQuestion.Required bool \json:"required"``,位于 greenhouse.go 第 65 行;
  • Workable:Required bool \json:"required"``,位于 workable.go 第 18 行;
  • Recruitee:Required bool \json:"required"``,位于 recruitee.go 第 34 行;
  • Ashby:GraphQL 查询显式请求 fieldEntries { id isRequired isHidden field },isRequired 声明于 fetch.go 第 207 行,并在 ashby.go 中映射为 entry.IsRequired。

2.2 Lever 从标记中推断必填

Lever 是第一个以渲染后的页面(而非结构化数据)发布表单的平台,解析入口 FromLever 位于 lever.go。它对必填的判断只有一条路径:anyRequired 检查控件上是否存在 HTML required 属性:

func anyRequired(group []*html.Node) bool {
	for _, n := range group {
		if attr(n, "required") != "" {
			return true
		}
	}
	return false
}

另外,题目文字本身带有必填符号:第 17 行定义 const requiredGlyph = "✱",由 tidyLabel 从标签文本中剥离——符号只用来标记文本,不参与必填判定。

2.3 用数据而非代码论证来证明排除决策

「从代码出发的论证不足以公开发表」——这是该度量报告的方法论核心。验证查询统计每个平台零必填字段表单的占比(用 2% 系统抽样):

SELECT provider,
       count(*) AS forms,
       round(100.0 * count(*) FILTER (WHERE n_req = 0) / count(*), 1) AS pct_zero_required
FROM (
  SELECT provider,
         (SELECT count(*) FROM jsonb_array_elements(payload -> 'fields') f
          WHERE (f ->> 'required')::bool) AS n_req
  FROM apply_forms TABLESAMPLE SYSTEM (2)
) t
GROUP BY provider
ORDER BY pct_zero_required DESC;

原始输出:

  provider  | forms | pct_zero_required
------------+-------+-------------------
 lever      |  2315 |              48.8
 recruitee  |  1582 |               0.0
 workable   |  1601 |               0.0
 ashby      |  1701 |               0.0
 greenhouse |  5717 |               0.0
(5 rows)

Time: 1974.453 ms (00:01.974)

结论明确,且不是一点半点程度的问题。 接近一半的 Lever 表单报告零必填字段;其余四个平台在 10,601 份抽样表单中几乎没有出现过零必填——各 0.0%。

零必填的申请表单在现实中并不存在:每一份表单都要问姓名或收件地址。所以 48.8% 不是关于 Lever「慷慨」的事实,而是从标记(而非从 API)读取必填性的失败率。无论一个 Lever 必填计数说什么,它测量的都是抓取器自己,而不是平台。

还有第二个、独立成立的理由:main 分支曾有一条「Stop refusing every Lever posting for a captcha most of them do not have」(#2721),说明 Lever 的 117,424 行是在一条直到最近才修正的抓取规则下收集的——该规则此前因误判验证码而拒收职位。任何一条理由都足以取消比较资格。

因此: Lever 被排除在所有必填字段数字之外(最终文章正文会说明原因)。Lever 保留在字段总数比较中(01-fields-per-form.md 那份度量),因为那个口径只数字段、从不读取 required 标志。

2.4 与测试用例的相互印证

Lever 解析的测试 用的是一页按真实 Lever 申请页形态复制的 HTML 夹具,其中一道必填题同时用三种方式声明必填:标签里的 ✱、外层 div.required-field 类名、以及 input 上的 required="required"。TestFromLeverStripsTheRequiredGlyph 断言标签中不再含 ✱ 且 Required 为真——即解析器只依赖属性这一条路径。这条测试与生产数据共同说明:推断路径覆盖不全时,required 会系统性偏低,而不是随机偏低。

3. 全量语料:四个 API 来源平台的必填字段统计

排除了 Lever 之后,对全部 530,371 份表单统计必填数:

SELECT provider,
       count(*)             AS forms,
       round(avg(n_req), 1) AS avg_required,
       percentile_cont(0.5) WITHIN GROUP (ORDER BY n_req) AS median_required,
       max(n_req)           AS max_required
FROM (
  SELECT provider,
         (SELECT count(*) FROM jsonb_array_elements(payload -> 'fields') f
          WHERE (f ->> 'required')::bool) AS n_req
  FROM apply_forms
  WHERE provider <> 'lever'
) t
GROUP BY provider
ORDER BY avg_required DESC;

原始输出:

  provider  | forms  | avg_required | median_required | max_required
------------+--------+--------------+-----------------+--------------
 greenhouse | 283363 |         13.2 |              13 |           76
 workable   |  79436 |         10.3 |               9 |           75
 ashby      |  87988 |          8.3 |               8 |           40
 recruitee  |  79584 |          6.8 |               5 |           48
(4 rows)

Time: 47008.202 ms (00:47.008)

在 530,371 行上耗时 47.0 秒。这条查询与字段总数那条(01-fields-per-form.md 用 jsonb_array_length 只读 jsonb 头部、不展开数组)不同,它对每份表单的 fields 数组做了 jsonb_array_elements 展开,是整份度量工作中最贵的一条查询。度量时的操作约束值得记录:在对一台繁忙主机上执行、只跑一遍、不反复跑。

4. 这些数字支持什么

均值重新可用了——而且这是被检查出来的,不是假设的。 每个平台上均值与中位数之差都在 1.8 以内(13.2/13、10.3/9、8.3/8、6.8/5)。在 01-fields-per-form.md 中均值与中位数差距在 1.5 以内,本次同样成立。如果两者背离——比如均值 19.9 而中位数只有 6——均值描述的将是少数怪兽表单,而不是候选人真正会遇到的量,文章就必须改以中位数打头或干脆弃用该数字。

2% 抽样几乎精确地预测了全量结果——抽样 13.5 / 10.3 / 8.2 / 6.8,全量实测 13.2 / 10.3 / 8.3 / 6.8。值得记录,因为它说明探索阶段的抽样没有误导我们;同时也说明最终文章的定妆数字应当仍然采用全量口径的那组。

最坏情况是一份 Greenhouse 职位要回答 76 个必填问题;Workable 达到 75。对照中位数 13,这些显然不是典型值——这正是中位数必须与它们并列出现的原因。

大约三分之二的表单是必填的,而且一致如此:Greenhouse 是 19.9 题中的 13.2(66%),Workable 是 14.1 题中的 10.3(73%),Ashby 是 10.5 题中的 8.3(79%),Recruitee 是 8.5 题中的 6.8(80%)。选答题在任何平台上都是少数。

5. 这些数字不支持什么

  • Lever 真实必填数的任何说法。 我们不知道它,文章也不得暗示我们知道。
  • 是谁把表单做长的任何说法。 平台默认模板与雇主追加的问题,在本度量中没有被分开。
  • 任何关于填写成本的说法。 13 个必填复选框和 13 个必填文本题在这里计为同样的数,而它们绝不是同一个下午的工作量。

6. 方法论小结:一次可发表的度量需要哪些要素

这份 02-required-fields.md 是 freehire 发布博客文章前的第三份度量档案之一(计划见 2026-09-09-ats-application-forms-article.md),它示范的规范可以概括为:

  1. 数字即主张,查询加原始输出即其测试。 没有保存原始输出的数字不发布;与代码语义不符的数字是坏测量,先诊断再下笔。
  2. 排除决策先被数据证明再被引用。 不是「代码看起来如此所以排除」,而是「抽样显示 48.8% 对 0.0%,因此排除」。若数据与预期相反,停下来——要么代码读错了,要么排除不成立。
  3. 给快照打日期。 语料是活的,所有数字随抓取持续增长。
  4. 显式声明成本与边界。 47 秒的查询要写出耗时和执行约束;数字不支持什么要与支持的并列成节。
  5. 每个数字可追溯。 文章里每个数字都要能指回一份保存的度量文件,无法追溯的数字要么补测,要么删掉句子。

结合仓库源码看,这套规范有落地基础:required 字段的语义在 form.go 的 Field.Required 注释中被明确定义为「平台是否会拒收没有答案的申请」,抓取与落库的失败语义(404 丢弃、429 延后而不计次重试,见 internal/ingest/applyform/AGENTS.md)保证了语料中不会出现把平台限流误存为「空表单」的行——这正是「零必填 = 解析失败」这类判断能够成立的前提。

登录后查看全文
freehire