ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

freehire 过滤器集合(Filter Collections)实现解析:在 /collections 枢纽中用一行数据扩展出可检索的职位聚合页

freehire 过滤器集合(Filter Collections)实现解析:在 /collections 枢纽中用一行数据扩展出可检索的职位聚合页
  • 后端
  • 前端
  • 搜索引擎
  • 网页爬虫
  • 数据工程
  • AI 应用
  • 可观测性

【免费下载链接】freehire

freehire — the open-source search engine for job seekers

项目地址:https://gitcode.com/GitHub_Trending/fr/freehire
点击查看免费下载

本文以 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 把工作切成四块,全部已勾选完成:

  1. 数据层web/src/lib/collections.ts:新增FilterCollection类型、FILTER_COLLECTIONS数组(种子为remote-worldwide)、toQuery(params)辅助函数;
  2. 服务端加载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唯一),过滤器卡片在前、公司卡片在后;
  3. 枢纽渲染web/src/routes/collections/+page.svelte:用统一的cards列表渲染网格(读取card.href与card.count),删除内联的/jobs?collections=链接拼接;把副标题从「Curated groups of companies」改为同时反映职位与公司;
  4. 验证:svelte-check通过(params/cards的类型),npm run build成功;手动加载/collections确认remote-worldwide卡片出现、带计数、链接到/jobs?work_mode=remote&regions=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(); }

行为示例:

paramstoQuery(params)
{ work_mode: 'remote', regions: 'global' }work_mode=remote&regions=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>

三点值得注意:

  1. count !== null才渲染计数,失败降级在 UI 上是「没有那行数字」,而不是「0 jobs」——规范要求的降级语义在组件层完成。
  2. 链接由resolve('/collections/[slug]', ...)生成,而不是手拼字符串。这一点是相对任务清单的演进:卡片现在指向集合自己的落地页,落地页再把参数钉成/jobs信息流的 scope(详见下一节)。手拼 URL 会在paths.base非空时失效,所以项目约定所有动态路由链接都走resolve。
  3. 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&regions=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)
每个集合都能落到一个 sectiongroup/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

建议的验证顺序与预期结果:

  1. pnpm check:params的键值形状与CollectionRow字段类型无报错;
  2. pnpm test:FILTER_COLLECTIONS不变量、collectionBySlug、section 归置、站点地图路径等断言通过;
  3. pnpm build:构建成功;
  4. 手动打开/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

项目地址:https://gitcode.com/GitHub_Trending/fr/freehire
点击查看免费下载

相关推荐

上一篇:SakuraLLM日语翻译模型:如何实现Galgame与轻小说的精准离线翻译?
下一篇:3分钟掌握AI马赛克神器:DeepMosaics智能图像处理全解析

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

RELATED READING

延伸阅读

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