- 后端
- 前端
- 搜索引擎
- 网页爬虫
- 数据工程
- AI 应用
- 可观测性
【免费下载链接】freehire
freehire — the open-source search engine for job seekers
本文以 freehire 仓库中filter-collections变更的任务清单为主线,完整还原「过滤器集合」这一前端机制的设计意图、数据层结构、服务端计数加载方式与视图渲染约定,并结合当前源码指出实现落地后的形态变化。读完后你将掌握:如何用一条数据项把一个任意/jobs筛选条件变成/collections枢纽上的正式卡片,以及这种「零后端」方案在类型安全、语义一致性和失败降级上的具体约束。
一、为什么需要第二种 collection
在变更之前,/collections上的一个 collection 严格等于「一组被策展的公司」:成员关系是公司级事实,被反规范化(denormalize)到职位索引的collectionsfacet 上,卡片链接到/jobs?collections=<slug>,计数来自collections这个 facet 的分布统计。这段背景在变更提案 proposal.md 和设计文档 design.md 中均有明确表述。
问题在于,用户想浏览的很多自然分组其实是职位的属性,而不是公司的属性:
- “Remote Worldwide”(完全远程 + 面向全球);
- “Senior roles”(某个公司里只有一部分职位够格);
- “Python jobs”(同一家公司里可能只有少数岗位涉及 Python)。
公司成员关系这套机器无法表达这类分组。于是变更引入第二种 collection:过滤器集合(filter collection),它的成员资格就是一个任意的/jobsfacet 筛选条件。
原变更给出的三个目标非常克制,也决定了全部实现取舍:
| 目标 | 含义 |
|---|---|
| 通用机制 | 任何可浏览的/jobs筛选都能变成一张集合卡片,代价是一条数据项 |
| 视觉一致 | 过滤器集合卡片与公司集合卡片在枢纽上外观一致 |
| 零后端表面 | 不涉及 Go 代码、数据库、迁移、重建索引、API 变更 |
对应的非目标同样重要:不做每个集合的专属落地页(/jobs是集合职位唯一的渲染面)、不把这套机制暴露到公共 API / 通知 / 已保存搜索、v1 不做视觉区分(YAGNI)。
其中的关键洞察是:/jobs信息流本来就按这些参数过滤,而searchJobs(params, 0, 0)本来就返回meta.total,所以过滤器集合根本不需要后端——它只是对既有搜索能力的一层策展式呈现。
二、任务清单全景:四个阶段、八项工作
本文所依据的原始任务清单 tasks.md 把工作切成四块,全部已勾选完成:
- 数据层
web/src/lib/collections.ts:新增FilterCollection类型、FILTER_COLLECTIONS数组(种子为remote-worldwide)、toQuery(params)辅助函数; - 服务端加载
web/src/routes/collections/+page.server.ts:保持公司集合的 facet 分布计数调用不变;对每个过滤器集合用searchJobs(toQuery(params), 0, 0).total取总数,放在Promise.all中并各自 try/catch(计数是装饰性的,失败降级为无计数);返回一个归一化的cards视图模型数组({ title, description, href, count },以href为键,两类卡片href唯一),过滤器卡片在前、公司卡片在后; - 枢纽渲染
web/src/routes/collections/+page.svelte:用统一的cards列表渲染网格(读取card.href与card.count),删除内联的/jobs?collections=链接拼接;把副标题从「Curated groups of companies」改为同时反映职位与公司; - 验证:
svelte-check通过(params/cards的类型),npm run build成功;手动加载/collections确认remote-worldwide卡片出现、带计数、链接到/jobs?work_mode=remote®ions=global,且既有公司集合卡片与计数不变。
这套四阶段划分(数据层 → 服务端 → 视图 → 验证)是本文的骨架,下面逐层展开,并用当前仓库源码补足每层的实现细节与约束条件。
三、数据层:FilterCollection类型与toQuery
3.1 类型的最终形态
任务 1.1 定义的类型是{ slug, title, description, params: Record<string, string | string[]> }。当前 collections.ts 在此基础上做了两处扩充——group与可选flag:
export type FilterCollection = { slug: string; // 卡片落在 /collections 枢纽的哪个分区。显式声明而不是从 params 推导: // 单凭一个 `skills` 钉子无法判断它指的是语言、框架还是基础设施。 group: FilterCollectionGroup; title: string; description: string; // 映射到的职位检索 facet 参数——与 /jobs 信息流接受的参数同名(见 search.StringFacets)。 // 值可以是单个字符串或列表;列表会展开成重复的查询键(OR 语义)。 params: Record<string, string | string[]>; // ISO 3166-1 alpha-2 国家码,用于给「params 只点名了城市、没钉住国家」的卡片打旗标。 // 仅用于展示,绝不参与过滤。 flag?: string; };group的取值是一个联合类型(collections.ts):remote、language、framework、infra、field、seniority、role、industry、city、job-type、company-type、ai。源码注释特意说明了为什么它不能被推导:一个skills钉子既可能是语言(go)、框架(nodejs)也可能是基础设施(kubernetes),仅从参数无法可靠归类。
flag字段对应的是一条容易踩坑的地理语义:城市类集合把城市名写进cities,但不在旁边钉住国家。原因是位置类 facet 之间是 OR 关系,cities=Austin&countries=us得到的是「所有美国职位」而不是「奥斯汀职位」。国家信息只能放进flag,仅用于绘制卡片标记(collections.ts)。
3.2 种子集合与真正的字段清单
任务 1.2 要求种子为remote-worldwide,标题 “Remote Worldwide” 并带描述。当前实现完全一致:
{ slug: 'remote-worldwide', group: 'remote', title: 'Remote Worldwide', description: 'Fully remote roles open to candidates anywhere in the world, not tied to a country or region.', params: { work_mode: 'remote', regions: 'global' }, }FILTER_COLLECTIONS之后已扩展到覆盖语言、框架、云与基础设施、工程领域、级别、角色、行业、城市、用工类型、公司类型、AI 岗位等分组。虽然任务清单只要求一条种子数据,但正因为机制是「一条数据项 = 一个集合」,后续扩充全部是纯数据改动。几个关键约束可以从注释中直接读出:
- 技能值必须是精确的 skilltag 规范名:
go而不是golang,nodejs而不是node,cpp/csharp而不是c++/c#,否则信息流返回空(collections.ts)。 - 分类值来自
categoryfacet 的规范词表:data_engineering、ml_ai等;slug只为可读性服务,可以不同(ml_ai→machine-learning)。 - 区域用
REGION_LABELS的规范词,且没有us区域——美国是国家级的,因此remote-us用countries: 'us'而不是regions。 - 城市值用 facet 的精确拼写:例如
New York City、São Paulo。
3.3toQuery:链接与计数共用的唯一来源
任务 1.3 要求toQuery(params)构造查询字符串 /URLSearchParams,并把列表值展开成重复键(OR 语义)。实现只有几行,但承担了「不一致不可能发生」的职责(collections.ts):
export function toQuery(params: Record<string, string | string[]>): string { const q = new URLSearchParams(); for (const [key, value] of Object.entries(params)) { for (const v of Array.isArray(value) ? value : [value]) { q.append(key, v); } } return q.toString(); }行为示例:
params | toQuery(params) |
|---|---|
{ work_mode: 'remote', regions: 'global' } | work_mode=remote®ions=global |
{ skills: 'go' } | skills=go |
{ skills: ['go', 'rust'] } | skills=go&skills=rust(OR 语义) |
设计文档解释了为什么这里是结构化对象而不是原始查询串(design.md):对象是卡片链接与计数请求的单一来源,且受类型检查保护;如果写成原始字符串,就必须在两个方向上都做解析,而一个拼错的 facet 键会被搜索层静默忽略,最终渲染出一个「看起来正常但没过滤」的空筛选结果——这种 bug 是不可见的。
四、服务端加载:两类集合归一化成一个视图模型
4.1 原始方案:+page.server.ts里做Promise.all
任务 2.1 ~ 2.3 描述的服务端契约是:
- 公司集合的 facet 分布计数调用保持不变(一次
facetCounts调用,取facets.collections); - 每个过滤器集合的计数 =
searchJobs(toQuery(params), 0, 0).total,放在Promise.all中,每个都包 try/catch; - 返回值是一个归一化的
cards数组{ title, description, href, count }:- 过滤器卡片
href = /jobs?<query>,排在最前; - 公司卡片
href = /jobs?collections=<slug>; - 以
href为键(两类卡片的href天然不冲突)。
- 过滤器卡片
这里的limit=0, offset=0很关键:只取total,不取任何职位记录,所以每个集合的计数成本是一次「只算数」的搜索。
4.2 当前落地形态:抽到loadCollectionSections
从当前源码看,这段逻辑已被抽到共享的服务端模块 collectionSections.ts,由枢纽页与分区页共同调用;枢纽的+page.server.ts现在只有三行核心逻辑(+page.server.ts):
export const load: PageServerLoad = async ({ fetch }) => { return { sections: await loadCollectionSections(serverApi(fetch)) }; };共享实现保留了任务清单里的全部语义,并在两处做了工程化加强:
其一,Promise.all变成有并发上限的mapLimit。枢纽一次渲染会发出上百个计数搜索,若无上限地同时发出,会形成一次搜索索引必须一次性消化的突发流量(collectionSections.ts):
const CONCURRENCY = 8; async function mapLimit<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> { const out: R[] = []; let next = 0; const worker = async () => { while (next < items.length) { const i = next++; out[i] = await fn(items[i] as T); } }; await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)); return out; }它保持结果顺序(out[i] = ...),因此并发化不改变卡片顺序。
其二,只统计「本次要展示的分区」。过滤器集合按group过滤,公司集合按kind过滤,避免无关计数(collectionSections.ts):
const companies = COLLECTIONS.filter((c) => wanted.has(c.kind)); let facetCounts: Record<string, number> | null = null; if (companies.length > 0) { try { facetCounts = (await api.facetCounts(new URLSearchParams())).facets.collections ?? {}; } catch { // 整次 facet 调用失败 → 公司计数未知,而不是 0。 } } const filterRows = await mapLimit( FILTER_COLLECTIONS.filter((fc) => wanted.has(fc.group)), CONCURRENCY, async (fc) => { let count: number | null = null; try { count = (await api.searchJobs(new URLSearchParams(toQuery(fc.params)), 0, 0)).total ?? null; } catch { // 计数是装饰性的——失败就留 null。 } // ... }, );请注意facetCounts失败时注释写的是「未知,不是 0」——这正是规范里「失败的计数抓取必须降级为无计数而不是让页面 500」的落点(spec.md)。
4.3 归一化后的行模型与排序
CollectionRow是任务 2.3 中cards概念的最终形态,字段更丰富(collectionSections.ts):slug、title、description、count: number | null、mark(卡片标记)、companies(招聘领先的公司,用于重叠 logo 堆叠)、flags(区域远程集合的国旗堆叠)。
标记解析遵循一条优先级链:flag字段 >resolveSeeAlsoMark(params)(品牌图 > 技术 logo > 国旗 > 分类图标)。见 seeAlsoMark.ts。
排序规则在末尾统一执行(collectionSections.ts):
return sections .map((s) => ({ ...s, rows: [...(grouped.get(s.key) ?? [])].sort((a, b) => (b.count ?? -1) - (a.count ?? -1)), })) .filter((s) => s.rows.length > 0);即:计数从大到小;null计数当作-1沉底;排序稳定,因此同计数保持注册表顺序;空分区被丢弃(这也是分区页能靠「拿不到 section」直接 404 的原因,见 group/[group]/+page.server.ts)。
五、视图层:卡片从统一列表渲染
任务 3.1 要求网格改用card.href与card.count,并删掉内联的/jobs?collections=拼接;任务 3.2 要求副标题从「Curated groups of companies」改为同时反映职位与公司。
副标题的改动在当前源码中可见(+page.svelte):
<p class="mt-2 max-w-2xl text-sm leading-relaxed text-muted-foreground"> Curated groups of roles and companies, with their open jobs in one feed. </p>页面本身按sections渲染,每行只消费归一化后的字段,对「这是过滤器集合还是公司集合」一无所知:
<a href={resolve('/collections/[slug]', { slug: row.slug })} class="-mx-2 flex h-12 items-center gap-3 rounded-lg px-2 transition-colors hover:bg-secondary/60" > <span class={['flex shrink-0', wide && 'w-20']}> <CollectionRowMark {row} /> </span> <span class="min-w-0 flex-1"> <span class="block truncate text-sm font-medium text-foreground">{row.title}</span> {#if row.count !== null} <span class="block font-mono text-xs text-muted-foreground"> {row.count.toLocaleString()} jobs </span> {/if} </span> </a>三点值得注意:
count !== null才渲染计数,失败降级在 UI 上是「没有那行数字」,而不是「0 jobs」——规范要求的降级语义在组件层完成。- 链接由
resolve('/collections/[slug]', ...)生成,而不是手拼字符串。这一点是相对任务清单的演进:卡片现在指向集合自己的落地页,落地页再把参数钉成/jobs信息流的 scope(详见下一节)。手拼 URL 会在paths.base非空时失效,所以项目约定所有动态路由链接都走resolve。 wide槽位对齐:同一分区里只要有一行带 logo/国旗堆叠,所有行的标记槽位都用w-20,避免卡片内标题起点参差(+page.svelte)。
枢纽页还带一个就地过滤的搜索框,只渲染命中项而不改变预览条数(PREVIEW = 5),命中的行无论预览如何都会显示——「搜索找到了却又藏起来,等于没找到」(+page.svelte)。
六、语义一致性:链接、计数与落地页不能各说各话
这是整个机制最容易腐化的地方,也正是当前源码补齐得最多的部分。
6.1 AND 跨键、OR 同键
一个过滤器集合的语义是:每个params键都必须被满足(AND),同一键的多个值任一满足即可(OR)。这与/jobs的过滤契约一致,也与集合落地页把全部参数钉成固定 scope 的行为一致(collections.ts):
function collectionMatchesJob(entry: FilterCollection, job: JobFacets): boolean { return Object.entries(entry.params).every(([key, value]) => facetMatches(key, value, job)); }测试明确把这条性质锁死——若把.every()误改成.some(),remote-brasil({ work_mode: 'remote', countries: 'br' })在只满足其中一个键时就会被误判为命中(collections.test.ts)。
6.2 单一 slug 解析器
collectionBySlug(slug)先查过滤器集合,再无则查公司集合(映射到collectionsfacet 参数),未知 slug 返回undefined(collections.ts):
export function collectionBySlug(slug: string): ResolvedCollection | undefined { const filter = FILTER_COLLECTIONS.find((c) => c.slug === slug); if (filter) { return { title: filter.title, description: filter.description, params: scopeParams(filter.params) }; } const company = COMPANY_COLLECTIONS.find((c) => c.slug === slug); if (company) { return { title: company.title, description: company.description, params: { collections: company.slug } }; } return undefined; }它是落地页路由、枢纽卡片链接、站点地图三者共用的唯一解析入口,因此不会漂移。站点地图把两类集合的落地页与分区页一并列出(sitemap.ts),并有测试断言remote-worldwide出现在路径集合里(sitemap.test.ts)。
6.3 从/jobs?<query>到落地页的演进,以及它的限制
任务 4.2 的手动验收标准是「卡片链接到/jobs?work_mode=remote®ions=global」。当前实现的链接目标是/collections/remote-worldwide,该落地页再把它变成固定 scope(+page.server.ts):
const facets = new URLSearchParams(url.searchParams); for (const [key, value] of Object.entries(collection.params)) facets.set(key, value); facets.delete('page');也就是说:访问者 URL 里的筛选先被继承,集合自身的 scope 参数覆盖其上(scope 胜出),page作为分页游标被剔除——这样共享或刷新的带筛选 URL 能服务端渲染出与客户端一致的结果。
这项演进带来一条必须写清楚的限制:ResolvedCollection.params是单值形状Record<string, string>,scopeParams()会把列表值折叠成第一个元素(collections.ts):
// A resolved collection: 展示文案加上为该信息流划定范围的固定 facet 参数。 // `params` 是单值的(JobsView 的 `scope` 所钉的形状)——当前每个集合都映射到单值; // 多值的过滤器集合需要先拓宽 `scope`(见 design 的数组接缝)。因此:枢纽卡片计数与落地页 scope 在单值参数下完全一致;一旦某个集合写了列表参数(OR 语义),落地页只会保留第一个值,两侧语义就会分叉。这正是新增集合时必须优先确认的一处。反向的防护也已经存在:skillForCollection刻意读取原始注册表而不是已折叠的params,否则{ skills: ['go', 'rust'] }会看起来和{ skills: 'go' }一样,从而错误地链接到 Go 的术语页(collections.ts)。
6.4 已有测试把哪些约束钉住了
collections.test.ts 与 collectionSections.test.ts 已在以下方面设防:
| 断言 | 防的是什么 |
|---|---|
params非空 | 空 map 会让集合 URL 渲染出未过滤的/jobs(L59-L66) |
每个键都在FACETS的 param 集合里 | 拼错键(skill、catgory)会被搜索层静默忽略,渲染出「正常但不过滤」的信息流(L68-L78) |
| 每个集合都能落到一个 section | group/kind没有对应分区的集合会被无声地从枢纽丢弃(collectionSections.test.ts) |
| 两类注册表 slug 全局唯一 | 保证以href/slug 为键的合并不冲突(L50-L57) |
七、动手:新增一个过滤器集合
基于上面的机制,新增一个集合是一个纯数据操作。以「Kotlin 职位」为例:
第一步,在FILTER_COLLECTIONS里追加一条数据。位置放在对应group的区块内,便于维护:
{ slug: 'kotlin', group: 'language', title: 'Kotlin', description: 'Open roles that use Kotlin for Android and JVM backends.', params: { skills: 'kotlin' }, }第二步,逐项自查约束清单:
slug在两类注册表中唯一;group必须是FilterCollectionGroup的成员,且COLLECTION_SECTIONS中存在同key的分区;- 每个
params键都必须是/jobs认识的 facet 参数名; - facet 值必须是规范值:技能用 skilltag 规范名、国家用 ISO 3166-1 alpha-2、区域用
REGION_LABELS中的词、城市用 facet 精确拼写; - 不要在城市旁边钉国家(位置 facet 之间是 OR,会放大结果集),国家放进
flag; - 尽量避免列表值参数(落地页 scope 只保留第一个值);
- 上线前用真实数据确认该条件有健康的非空计数。
参照的 facet 参数来源是后端search.StringFacets(query_filter.go)——它是索引字符串 facet 词汇表的单一事实来源,regions、work_mode、countries、cities、metros、skills、category、seniority、domains、company_type、employment_type、source等都在其中;前端侧对应的可选值集合在 facets.ts 的FACETS定义里。两者一致性由上面那条「键必须在FACETS中」的测试守住。
第三步,可选地补齐站点与内链能力。卡片标记由resolveSeeAlsoMark依据params推导(有技术 logo 的会自动出 logo);落地页会自动带上「该技能是什么」的术语页链接与分类市场页链接(条件是该集合只钉了一个键);若要让它出现在页脚的内链条里,加进POPULAR_COLLECTION_FALLBACK——该数组有测试断言每个 slug 都能解析,避免指向 404。
八、验证方式
任务第 4 阶段给出的是最小验证集,当前web/包的脚本与之对应(web/package.json):
# 类型检查(= svelte-kit sync && svelte-check --tsconfig ./tsconfig.json) pnpm check # 生产构建 pnpm build # 单元测试(vitest run,覆盖集合不变量与 section 归置) pnpm test建议的验证顺序与预期结果:
pnpm check:params的键值形状与CollectionRow字段类型无报错;pnpm test:FILTER_COLLECTIONS不变量、collectionBySlug、section 归置、站点地图路径等断言通过;pnpm build:构建成功;- 手动打开
/collections:新卡片出现在其分区中并带计数;打开/collections/<slug>:信息流的筛选与卡片一致;把某个集合的计数接口打成失败(或断开后端),页面仍渲染卡片、只是不显示数字。
关于失败降级,注意它有两层:服务端 try/catch 把count置null;组件层{#if row.count !== null}决定是否渲染数字。因此「计数不可用」永远不会升级为 500。
九、风险与权衡
设计文档与实现共同给出了这份权衡表:
| 风险 | 实际处置 |
|---|---|
| 枢纽加载时每个过滤器集合多一次搜索请求 | 只统计本次展示的分区;mapLimit限制并发为 8;limit=0只取总数;计数装饰性,慢/失败则无计数 |
web/src/lib/collections.ts手工镜像 Go 公司注册表 | 本次新增的是完全前端的兄弟概念,不动镜像;既有漂移隐患(规模变大则收敛进生成的契约)不变 |
| 拼错 facet 键会显示空信息流 | 类型化params+svelte-check+ 针对FACETS的键校验测试;种子参数经search.StringFacets核实 |
| 列表参数破坏「链接与计数一致」 | toQuery保证计数侧 OR 语义完整;但落地页 scope 是单值,落地页只保留首值——新增集合时应避免列表参数 |
| 视觉上未区分两类集合 | v1 有意为之(YAGNI);统一的行模型为将来分区/徽标留了门 |
部署面同样是这套方案的卖点:纯前端、只增不改。按设计文档,上线只需发布web/构建产物,无数据迁移、无重建索引;回滚就是还原三个被改动的文件,因为公司集合全程未被触碰,没有状态需要回退。
十、延伸阅读路径
- 机制取舍与备选方案:design.md、proposal.md
- 能力规范(含四个验收场景):specs/job-collections/spec.md
- 数据层与解析器:web/src/lib/collections.ts
- 服务端计数与归置:web/src/lib/server/collectionSections.ts、web/src/lib/collectionSections.ts
- 路由:collections/+page.server.ts、collections/+page.svelte、collections/[slug]/+page.server.ts、collections/group/[group]/+page.server.ts
- 测试:web/src/lib/collections.test.ts、web/src/lib/collectionSections.test.ts、web/src/lib/sitemap.test.ts
- 后端 facet 词汇表:internal/search/search/query_filter.go
- 后端
- 前端
- 搜索引擎
- 网页爬虫
- 数据工程
- AI 应用
- 可观测性
【免费下载链接】freehire
freehire — the open-source search engine for job seekers
相关推荐
freehire 过滤集合(Filter Collections)设计与实现:用一行数据把任意 /jobs 筛选变成策展卡片
freehire 过滤集合(Filter Collections)设计与实现:用一行数据把任意 /jobs 筛选变成策展卡片 在 freehire 的 /col
后端前端搜索引擎网页爬虫数据工程AI 应用可观测性freehire 过滤器集合(Filter Collections):用一条数据把任意 /jobs 筛选面变成集合卡片
freehire 过滤器集合(Filter Collections):用一条数据把任意 /jobs 筛选面变成集合卡片 freehire 的 /collecti
后端前端搜索引擎网页爬虫数据工程AI 应用可观测性freehire 精选集合(Curated Collections)实现解析:从公司级事实到职位搜索 Facet
freehire 精选集合(Curated Collections)实现解析:从公司级事实到职位搜索 Facet freehire 的 job collecti
后端前端搜索引擎网页爬虫数据工程AI 应用可观测性
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考