- 后端
- 前端
- 搜索引擎
- 网页爬虫
- 数据工程
- AI 应用
- 可观测性
【免费下载链接】freehire
freehire — the open-source search engine for job seekers
本文以 freehire 开源仓库中的company-remote-regions变更提案为骨架,完整拆解companies.remote_regions这一“公司级远程招聘区域”切面的设计动机、Schema 归属规则、外部数据集格式、纯函数映射字典、一次性回填 Worker、API 过滤参数与前端筛选器的落地方式。读完后你能掌握的核心能力是:在一个已经存在“任务派生切面”的项目里,如何安全地引入一条完全独立、不会被周期性重算覆盖的策展数据流水线,并理解它后来为何被任务派生方案取代。
一、为什么需要一个独立于任务的remote_regions
提案开篇就划清了两个极易混淆的概念,这是理解整篇设计的关键分界:
| 切面 | 语义 | 数据来源 | 维护者 |
|---|---|---|---|
companies.regions | “我们当前爬到的职位分布在哪些区域” | 由公司开放职位的jobs.regions聚合而来 | 周期性重算(cmd/recount-companies/RefreshCompanyFacets) |
companies.remote_regions | “这家公司声明在哪些区域远程招聘” | 外部策展目录(Atul Kumar 远程公司目录,25 页、约 700 家公司) | 一次性回填 Worker,重算永不触碰 |
提案用一句原文点明动机:这是一种declared company fact(公司声明的事实),与任务派生的 facets 有本质区别。它带来一个无法从爬虫数据中推导出的信号——用户可以把公司目录筛选到“愿意在我所在区域远程招聘的雇主”,即使我们恰好没有爬到该雇主在当地的职位。
这也是整个变更的核心约束:新列不能放在RefreshCompanyFacets的写集里。因为周期性重算会用IS DISTINCT FROM守卫重写regions、countries、domains、company_types、company_sizes这五个任务派生数组,一旦策展值被纳入同一写集,下一次重算就会静默覆盖它。
二、变更清单:六件事构成一条完整流水线
提案的 “What Changes” 段落给出的六项变更,恰好构成一条从数据落库到用户可见的链路:
- 新增
companies.remote_regions text[]列,取值来自区域词表;周期性切面重算永不触碰它。原始来源字符串保留在company_info.remote_regions_raw中,用于映射审计。 - 新增检入仓库的数据集(提案写作
sources/remote-companies.jsonl,{name, website, region}记录),使回填可复现、来源可追溯。 - 新增
internal/remoteregion包:一个纯函数Map(raw string) []string,把自由文本区域标签映射为宏区域代码。 - 新增
cmd/backfill-remote-regions:run-once-and-exit 的 Worker,读取数据集、逐条映射、按 slug 匹配公司、只更新已存在的公司,并输出 matched / unmatched / mapped / unmapped 计数。 - 新增
remote_regions切面参数:GET /api/v1/companies(数组重叠匹配,空值等于不约束),与既有regions/countries完全一致的组合语义。 - 新增前端筛选切面:
web/中公司筛选 UI 增加remote_regions过滤器。
Schema 与归属规则
列定义本身极简,采用可加性迁移,所有存量公司默认读到空集合:
ALTER TABLE public.companies ADD COLUMN remote_regions text[] DEFAULT '{}'::text[] NOT NULL;对应仓库中的迁移文件是 migrations/0005_company_remote_regions.sql。该文件的注释明确记录了两条运维约定:
- 新卷由
initdb在0004之后自动应用; - 已有生产卷必须在部署“读取该列的二进制”之前手工执行该语句,否则新代码会读到不存在的列。
注释还固化了归属关系:该列是“声明式公司事实”,与任务派生的companies.regions不同,RefreshCompanyFacets/cmd/recount-companies从不引用它,因此回填值能穿过每一次重算存活。
设计取舍:专用列而非company_infoJSONB
提案的 “Decisions” 用一段明确对比了两种落库方式:
- 选专用列的理由:这个值是一等公民的筛选切面,用户按它过滤,机制与既有
regions/countries一致,都是&&(数组重叠)谓词;JSONB 路径若不做抽取和索引,就无法用同一套重叠谓词过滤。代价只是一次迁移。 - JSONB 仍然承担职责:原始来源字符串写入
company_info.remote_regions_raw(无需过滤,仅供审计),于是“JSONB 存溯源、列存可查询的规范化集合”形成分工。
三、数据集:为什么是 CSV,以及它放在哪
提案正文写sources/remote-companies.jsonl,而设计文档与任务清单(4.1)最终明确为sources/remote-companies.csv,表头列为Name、Website、Region,由源目录外部导出后原样检入,行数 734。这是提案与最终实现之间的一处细节差异,读者应以设计文档为准。
几个容易忽略但很重要的约定:
- CSV 是源数据的原始格式,Worker 用 Go 标准库
encoding/csv解析,不引入额外依赖。 sources/是外部输入文件的存放位置,与代码、迁移分离,便于区分“我们生成的”与“我们引入的”。Region单元格保持逐字原样——Worldwide、Europe, Americas、USA East Coast等都不在数据集层做归一化。规范化是remoteregion.Map的职责,数据集只负责忠实记录。
这条“数据集只存原样、映射只在代码里”的分工,是让映射策略可评审、可回滚的前提。
四、纯函数字典:把自由文本映射成宏区域代码
设计文档给出的最重要论据是一个规模判断:数据集Region列是从检入 CSV 枚举出的 108 个互不相同的字符串构成的封闭集合。因此这不是开放式 NLP 问题,而是一个有限字典问题——每个源字符串都可以成为一个测试用例。
internal/remoteregion.Map的设计要点:
- 纯函数:
raw string → []string,不读数据库、不发网络请求,可独立单元测试,Worker 保持“薄”。 - 输出受限:所有返回值必须属于区域词表,且去重。当前仓库中该词表的权威定义位于 internal/dict/vocab/vocab.go:
RegionValues = []string{ "global", "north_america", "latam", "eu", "uk", "mena", "africa", "apac", "cis", }- 最佳努力(best-effort)而非严格:这是与
internal/location“策展字典、绝不做地理编码器”的设计同构,但放宽为“时区 / 窄地理标签映射到最近的宏区域”,属于明确的产品决策。 - 复合字符串拆分:按
,和/切分后逐段映射,再合并去重。
已定案(durable)的判断
设计文档把这些边界判断写成了“长期有效”的决策,值得逐条记住:
| 源标签 | 映射结果 | 设计意图 |
|---|---|---|
Worldwide | [global] | 全球招聘的显式声明 |
Europe | [eu] | 干净标签直接命中 |
Americas | [north_america, latam] | 复合标签展开为两个宏区域 |
Ukraine | [cis](不是eu) | 因此Ukraine, Poland→{cis, eu} |
UK | [uk](永不为eu) | 词表中uk是独立区域 |
EMEA | {eu, mena, africa} | 三区域展开 |
East American / European / African timezones | {north_america, eu, africa} | 时区语义转宏区域 |
UTC-8 to UTC+2、UTC-10 to UTC+2、UTC-5 to UTC+3、UTC-9 to UTC+1、UTC -3 to -5 | {north_america, eu} | 跨美洲到欧洲的宽时区取两个最近宏区域,既不给global也不给空 |
UTC+2 | [eu] | 单偏移时区映射到唯一区域 |
Western Asia | [mena] | 窄地理就近归并 |
| 字典无法安置的标签 | [] | 绝不猜测 |
最后一行是全篇最重要的负向约束:无法安置就返回空切片,而 Worker 仍会把原始字符串写进company_info.remote_regions_raw,保证“未映射”这件事本身可被审计、可在修字典后重跑。
任务清单的2.1把这些规则落成了表驱动测试,并额外加了一条词表封闭性断言:任意输入下,输出的每个代码都必须是RegionValues成员。
五、回填 Worker:UPDATE-only 语义与幂等性
cmd/backfill-remote-regions的结构刻意对齐既有的cmd/backfill-company-info:使用worker.Main/worker.Bootstrap,按normalize.Slug(name)匹配公司。这是仓库里“加载外部公司数据集”的既有先例——按 slug 匹配、只写自己负责的列、绝不触碰任务派生切面。
SetCompanyRemoteRegions的行为边界
任务清单3.1把查询契约写得非常具体:
- 按 slug UPDATE,设置
remote_regions并把remote_regions_raw合并进company_info(JSONB),同时更新updated_at; - 标为
:execrows,让调用方能区分“匹配到并写入”与“未匹配”; - 不写
name、job_count、collections、is_reference,也不写任何任务派生切面数组。
永久贴上is_reference这一点尤其关键:它意味着 Worker不会把 PDF 里有、库里没有的公司插成引用行。设计文档在 Non-Goals 中显式确认:“只标注已存在的公司;若日后想做成远程雇主目录,那是另一个变更。”
计数与幂等
Worker 报告四个统计量,它们组合起来就能定位问题来源:
| 计数 | 含义 | 异常时的含义 |
|---|---|---|
matched | slug 命中已有公司 | 偏低 → 名字规范化口径不一致 |
unmatched | slug 未命中,无操作 | 直接暴露 slug 匹配缺口 |
mapped | 区域字符串成功映射出非空集合 | 偏低 → 字典覆盖不足 |
unmapped | 映射为空切片 | 需补字典规则,原始串已留存 |
幂等性由 UPDATE 语义天然给出:同一数据集重跑会写入同样的值。任务清单5.1的单元测试用一个 fake store 覆盖了这四种情况及重跑场景;3.3的集成测试(//go:build integration+ testcontainers)额外验证两件事:命中公司只改remote_regions与remote_regions_raw,其余列不变;未命中的 slug 返回 0 行且不插入任何公司。
重算守卫
任务清单6.1要求一条集成测试,断言RefreshCompanyFacets执行后,公司已被回填的remote_regions保持不变,并确认该查询没有引用这一列。设计文档把这个不变量称为需要“编码进companiesspec 并用测试守住”的约束,原因也很直白:未来某次编辑一旦把列加进重算写集,就会静默清空全部策展数据。
六、API 切面与前端筛选项
GET /api/v1/companies?remote_regions=
companies能力规格被显式修订:公司列表接口新增可重复的remote_regions切面参数,语义与既有切面完全一致。
- 单切面内多值 OR:数组重叠(
&&),公司数组与请求值至少共享一个即命中; - 不同切面之间 AND,并且与
q名称搜索可组合; - 参数缺省即不约束;
meta.total必须反映完整过滤组合后的总数,保证分页正确。
SQL 侧的实现可以在 internal/platform/db/queries/companies.sql 看到同构谓词:
AND (coalesce(cardinality(sqlc.arg('remote_regions')::text[]), 0) = 0 OR remote_regions && sqlc.arg('remote_regions')::text[])CountCompanies必须保持逐字一致的 WHERE(同文件 L96),否则meta.total会与当前页不匹配。空数组通过cardinality(...) = 0短路成“无约束”,因此同一个预编译语句同时服务“全量列表”和“带切面过滤”。
Handler 侧(internal/api/handler/companies.go)的关键实现细节是:所有切面只解析一次,然后同时喂给列表查询与计数查询,避免两个 WHERE 漂移;并且每个切面都是非 nil 切片,让 pgx 发送'{}'而不是NULL——NULL会击穿cardinality()短路。此外该文件里还维护了一个切面列表,用于判断请求是否带有任何过滤条件:一次漏写曾导致“仅按行业过滤的请求被当成未过滤”,从而返回全库估算总数而不是自身真实总数。
前端筛选器
web/中公司筛选弹窗新增一个remote_regions切面:宏区域词表渲染为 pills、URL 同步参数、计入激活过滤计数,与既有公司regions切面的交互保持一致。当前代码中对应定义在 web/src/lib/facets.ts:
{ param: 'remote_regions', label: 'Remote hiring', control: 'pills', options: REGION, excludable: false },其中REGION复用任务切面同一套区域标签,保证文案不会两处漂移。
七、上线与回滚
提案的迁移计划是一个四步、可回退的顺序:
- 先发迁移:加列
remote_regions text[] NOT NULL DEFAULT '{}',按仓库“未应用迁移”约定,在部署读取该列的二进制前完成。 - 再发代码:新查询、Handler、UI 一起上线;此空列读作“无约束切面”,是安全的。
- 执行一次回填:
cmd/backfill-remote-regions读取数据集,把策展值写入。 - 回滚策略:列默认为空、切面是纯增量;回退二进制后残留列无害;真正删除列需要后续迁移。
风险表同样值得完整继承,因为它解释了后续演进的方向:
- 映射有损且带有主观性(例如
Americas → [north_america, latam]、Western Asia → mena)→ 用company_info.remote_regions_raw保留原始串,使每次映射判断可审计,修字典后可重跑。 - slug 不匹配会漏公司(PDF 里的名字与我们的规范化 slug 不一致)→ v1 接受,靠
unmatched计数暴露缺口。 - 重算回归风险→ 用测试把“重算不得触碰该列”钉死。
- 数据陈旧(PDF 是时间点快照)→ 回填幂等可重跑,本变更不引入陈旧度机制。
- 明确的接缝(out of scope):只做 slug 匹配,不做网站/域名兜底。
八、现状:这条策展流水线已被任务派生方案取代
必须指出,本文讨论的提案是历史设计,仓库后续存在一个独立变更把它整体推翻了。根据 openspec/changes/archive/2026-07-09-derive-remote-regions/proposal.md,切换理由是覆盖面与自更新能力:策展目录只匹配到 293 家公司,且永不更新;而仓库已持有约 19.9 万个开放远程职位、其中 93% 已解析出区域,因此“公司实际在哪些区域远程招聘”完全可以从自家目录派生。
由此产生的当前事实(可在仓库中逐项验证):
internal/remoteregion、cmd/backfill-remote-regions、sources/remote-companies.csv、SetCompanyRemoteRegions均已删除,company_info.remote_regions_raw审计字段被一次性清理。remote_regions改由同一个RefreshCompanyFacets维护:它是regions在work_mode = 'remote'上的作用域 siblings,实现为镜像regCTE 的remote_reg(internal/platform/db/queries/companies.sql),并出现在 UPDATE 的 SET 与IS DISTINCT FROM守卫中(同文件 L661、L681)。- 因此当前不变式为
remote_regions ⊆ regions,hybrid/onsite/ 未分类职位永不贡献;地理未解析的远程职位什么都不贡献(延续“绝不猜测”)。 - 列、API 切面参数、前端 “Remote hiring” pill 三者对外形状完全不变,只换了数据来源。
- 守卫测试也随之翻转:当前集成测试断言重算会填充
remote_regions,并验证 onsite 区域被排除、仅有 onsite 职位的公司得到空集合(internal/platform/db/company_remote_regions_integration_test.go)。
这条演进脉络本身就是对本文主题最好的注脚:remote_regions的列契约、过滤语义、UI 入口被稳定保留下来,被替换掉的只是“谁拥有这一列”这一归属规则。原始提案的价值不仅在于它把策展信号正确落成了一等切面,更在于它一开始就把“重算不得覆盖该列”写成显式不变量——正因如此,后来的所有权反转才能是一次干净的、可验证的替换,而不是一次静默的数据事故。
延伸阅读:公司切面的完整维护规则见 docs/agents/company-facets.md,服务端规格见 openspec/specs/companies/spec.md,迁移历史见 migrations/0005_company_remote_regions.sql。
- 后端
- 前端
- 搜索引擎
- 网页爬虫
- 数据工程
- AI 应用
- 可观测性
【免费下载链接】freehire
freehire — the open-source search engine for job seekers
相关推荐
Path of Building Community Fork:流放之路离线构建规划器完全指南
Path of Building Community Fork:流放之路离线构建规划器完全指南 Path of Building Community Fork(
桌面应用游戏开发remoteintech.company 公司档案解析:Appen(Appen.md)的数据结构与远程招聘实践
remoteintech.company 公司档案解析:Appen(Appen.md)的数据结构与远程招聘实践 导读 本文以 src/companies/app
数据集freehire 领域发现式 ATS Board 采集:从公司官网到可验证招聘板的两阶段流水线
freehire 领域发现式 ATS Board 采集:从公司官网到可验证招聘板的两阶段流水线 freehire 的开源招聘搜索引擎需要持续扩充可爬取的 ATS
后端前端搜索引擎网页爬虫数据工程AI 应用可观测性
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考