news 2026/10/25 1:25:25

freehire 的 Loxo 招聘机构看板适配器:从 openspec 提案到 Go 实现与看板发现工具

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
freehire 的 Loxo 招聘机构看板适配器:从 openspec 提案到 Go 实现与看板发现工具
  • 后端
  • 前端
  • 搜索引擎
  • 网页爬虫
  • 数据工程
  • AI 应用
  • 可观测性

【免费下载链接】freehire

freehire — the open-source search engine for job seekers

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

本文以 freehire 仓库中已归档的变更提案 proposal.md 为主体,结合配套设计文档、规格说明与当前仓库中的真实实现,完整讲清 Loxo 适配器这条链路的来龙去脉:为什么需要一个能同时覆盖三种主机形态的机构看板爬虫、<agency_id>-<slug>稳定去重 ID 如何从 base64 中解出、Hub标志如何做到"永不猜测雇主",以及cmd/harvest-loxo发现工具如何把搜索引擎足迹变成经人工审核的看板清单。读完你可以理解 freehire 新增一类 ATS 数据源的完整套路:适配器、注册、发现工具、测试夹具与取舍记录。

背景:Loxo 看板长什么样,为什么值得单独立项

提案的 Why 部分说明了动机:Loxo 为许多招聘机构(如 FitNext Consulting、Agile Recruiter、LA-Tech)托管公开的 careers 看板,每个看板都承载 freehire 尚未聚合的技术职位。关键前提是一次 spike 验证——这些看板"干净地"可抓取,且该模式在机构之间、主机形态之间都能泛化,一个 Loxo 适配器就能一次性解锁一整个源类别。

design.md 的 Context 一节给出了 spike 验证到的具体页面结构,这也是全部解析逻辑的事实依据:

  • 每个机构在<host>/<slug>拥有一个公开 careers 看板,host是三种形态之一:机构子域(fitnext.app.loxo.co)、裸域app.loxo.co、区域 pod(pod4.app.loxo.co)。
  • careers 页是服务端渲染 HTML,每条职位以/job/<base64>链接出现。
  • 详情页的<title>形如Role | Agency;职位描述 HTML 放在内嵌的<script type="application/json">{"description": …}块里(spike 验证过没有schema.orgJobPostingld+json)。
  • base64 职位 ID 解码后是一个稳定的<agency_id>-<slug>对。
  • 真实雇主从不暴露——看板上只有机构名。

freehire 当时已有三个"接缝"可复用:careerspage 风格的HTMLGetter(listing → detail 扇出)、huntflow已在使用的CompanyEntry.Hub标志、以及cmd/harvest-boards的实时校验发现模式。因此整个变更是纯增量式的,不引入新技术门禁或生命周期机制。

设计决策:五个关键取舍

design.md 的 Decisions 一节记录了五条决策,每一条都对应实现中的一个具体约束:

  1. Board id = 去掉协议头的 careers URL(<host>/<slug>)。备选方案"只存 slug"在 pod/子域形态下会失效(host 无法从 slug 反推);"给每个条目加 Region 主机提示"则会让一个本为 API 数据驻留设计的字段承担额外语义。带上完整 host+slug 让三种主机形态共用一条代码路径,适配器按第一个/拆分出 origin 与 slug。
  2. 详情页解析用<title>+ 内嵌 JSON,而不是 schema.org。与 careerspage 不同,Loxo 没有 JobPosting 结构化数据,因此从 title 剥掉| <agency>后缀,从application/json块读description。位置/远程为尽力而为的 DOM 读取,读不到就留空——地理信息绝不由适配器猜测,留给下游的位置词典裁决。
  3. ExternalID = 解码后的<agency_id>-<slug>。稳定、人类可读;机构 ID 前缀保证跨看板的唯一性,即使管线尚未按 board 命名空间化。
  4. 雇主归属复用Hub,不发明新机制。hub: true的看板只在标题出现显式分隔符(— Client/@ Client)时解析出客户,否则回退到机构名——镜像 huntflow 的部门面包屑解析。实际上回退是常见情况,而这恰恰诚实:发帖的本来就是机构。
  5. 发现走搜索足迹,不做子域爆破。Loxo 没有公开的机构目录,盲猜*.app.loxo.co是一个巨大且低收益的空间;一条site:app.loxo.co查询就已经浮现出约 10 个有效机构。发现工具分页收集足迹、提取(host, slug)、逐个实时校验。

配套的哲学是"harvester 提案,人工审核":工具输出带技术职位计数的草稿条目,操作者只播种真正存活且带技术职位的看板;提交进仓库的看板集合始终是项目验证过的事实集。

提案的 Non-Goals 同样重要:付费的 Loxo Open API(bearer-token 门槛)不在范围内——免密钥的 careers 页才是这里的信源;跨源去重(机构转发 vs 客户自有 ATS 帖)是被接受的取舍;一次性 Loxo 链接的linksource留作后续一行代码的接缝;发现工具绝不自动提交看板。

适配器实现:internal/ingest/sources/loxo.go

提案中写的路径是internal/sources/loxo.go,在当前仓库中该实现位于 internal/ingest/sources/loxo.go(sources 包已整体迁移到internal/ingest/sources)。文件头部的注释完整复述了 spike 结论:base64 解码出<agency_id>-<slug>去重 ID,详情数据来自og:title、og:description的Location: … Salary:片段与内嵌 JSON 块。

注册与 Board id 拆分

适配器通过NewLoxo(c HTMLGetter)构造,在源注册表 registry.go 中登记(NewLoxo(c)一行,对应提案所说"one line insources.All"),Provider()返回"loxo"。Fetch的入口逻辑体现了 board id 的设计:

func (s loxo) Fetch(ctx context.Context, e CompanyEntry) ([]Job, error) { base, err := url.Parse("https://" + e.Board) // e.Board 形如 "fitnext.app.loxo.co/fitnext" ... root, err := s.http.GetHTML(ctx, base.String()) // 抓 SSR 列表页 locs := loxoJobLinks(base, root) // 提取并解析所有 /job/<base64> return fetchDetails(locs, defaultDetailWorkers, ...) }

(见 loxo.go#L39-L52)e.Board直接就是<host>/<slug>,拼上https://即列表页 URL;所有职位链接随后相对同一个 host 解析,因此子域、裸域、pod 三种形态共用一条路径。

列表页链接提取

职位链接由正则捕获并相对 board host 解析:

var loxoJobIDPattern = regexp.MustCompile(`/job/([A-Za-z0-9=_-]+)(?:$|[/?#])`)

(loxo.go#L94-L96)注意结尾的(?:$|[/?#])是有意为之:Loxo 的链接可能带?t=…缓存破坏参数或子动作后缀,必须把它们排除在 base64 token 之外。测试 loxo_test.go 中的TestLoxoJobLinksHostResolution用同一份真实列表夹具(testdata/loxo/listing_fitnext.html)分别以fitnext.app.loxo.co、pod4.app.loxo.co、app.loxo.co作为 base 各解析一次,断言每次得到 22 条链接且全部解析到https://<host>/job/前缀——这正是提案"Host variant is honored"场景的实现级验证。

详情页映射:title、内嵌 JSON 与"尽力而为"的地理信息

mapLoxoDetail被单独拆出(与带 HTTP 的detail分离),以便用保存的 HTML 夹具做无网络单测。映射规则(loxo.go#L66-L92):

  • 标题:优先读og:title,缺失时回退到<title>并用loxoStripAgencySuffix剥掉尾部的| <agency>;
  • 描述:loxoDescription遍历所有type="application/json"的 script 块,取第一个含非空description字段的块。注释特别指出:块内是原始 HTML 标记(非实体转义),所以不能做UnescapeString,否则会破坏文本节点中的字面实体,交给调用方的sanitizeHTML统一处理;
  • 位置/远程:loxoLocation从og:description的<role>Location: <loc>Salary: <pay>…串中提取Location:与Salary:之间的片段;没有该串就返回空。Remote由共享的isRemote推断(匹配英文 "remote" 与俄语 "удал…")。
  • 防御性判断:若 ExternalID 提取失败,或 title 与 description 同时为空,mapLoxoDetail返回ok=false——后者意味着这不是一个已渲染的职位页,直接丢弃而不是产出垃圾行。

稳定去重 ID:四种 base64 编码的容错解码

loxoExternalID(loxo.go#L98-L115)对提取到的 token 依次尝试StdEncoding、RawStdEncoding、URLEncoding、RawURLEncoding四种解码;解码结果必须含-且全部为可打印字符,才认定为<agency_id>-<slug>,否则原样返回 raw token而非报错。这个设计保证:同一个职位无论哪次爬取都映射到同一个ExternalID,重爬 upsert 到同一目录行(对应 loxo-source 规格中"Re-crawl dedups to one row"场景),同时编码细节变化不会让整个看板解析失败。夹具测试断言NDI0NzQtN3RzZTNpa2M2NDJ5emowdg==解码为42474-7tse3ikc642yzj0v(loxo_test.go#L23-L49)。

详情扇出:有界并发 + 单帖失败隔离

提案明确要求"detail 页在有限并发工作池下抓取,只丢弃抓取或解析失败的职位,单个坏帖绝不中止整块看板的爬取"。实现复用了共享的fetchDetails泛型函数(helpers.go#L30-L54):sem := make(chan struct{}, workers)信号量限流(defaultDetailWorkers = 8),每个 posting 一个 goroutine,ok=false的结果置为 nil 后在保序输出时跳过。TestLoxoFetchMapsAndIsolatesFailures用内联 HTML 构造了一个"路由到的职位正常、未路由的职位 404"的场景,断言只返回 1 个 Job 且整次Fetch无错——正是规格"Per-posting detail isolation"的落地。

共享主机的限流包装器

当前实现中一个提案未提及、后来加入的细节是loxoThrottle(loxo.go#L200-L234):裸域app.loxo.co是多租户共享主机,很多机构看板共用它,连续爬取(列表 + 详情扇出)会触发 429;而机构专属子域和 pod 不受此限制。NewLoxo因此把传入的HTMLGetter包装为限流器:仅对 host精确等于app.loxo.co的请求(loxoIsSharedHost明确排除子域,避免误伤fitnext.app.loxo.co),持锁串行化并按最小间隔loxoSharedHostGap = 350ms拉开请求。这是多租户共享主机适配的通用技巧:限流粒度绑定到"真正共享配额的那个 host",而不是整个 provider。

Hub 雇主归属:显式分隔符,否则回退,永不猜测

loxoCompany(loxo.go#L162-L181)实现了提案的 Hub 语义:

func loxoCompany(title string, e CompanyEntry) (company, roleTitle string) { if e.Hub { for _, sep := range []string{" — ", " @ "} { if i := strings.LastIndex(title, sep); i >= 0 { client := strings.TrimSpace(title[i+len(sep):]) role := strings.TrimSpace(title[:i]) if client != "" && role != "" { return client, role } } } } return e.Company, title }

实现与提案/规格有几处值得注意的收紧:

  • 分隔符集合是 em-dash 空格(" — ")与" @ ";strings.LastIndex取最后一次出现,避免复合标题误切;
  • ASCII 的" - "被刻意排除:它与复合职位名("Full-Stack")和地点后缀("- Nashville")冲突,当作客户标记会猜错。代码注释直书"never guess",与 freehire 的整体偏向一致;
  • 非 Hub 条目永远返回配置的机构名,标题原样保留。

loxo_test.go#L103-L120 的用例表把五种情况钉死:em-dash 客户、@客户、无分隔符回退、ASCII 连字符不算分隔符、非 Hub 一律回机构名。这组用例就是 loxo-source 规格中"Client resolved from an explicit delimiter"与"Fallback to the agency name"两个场景的可执行化。

发现工具:cmd/harvest-loxo的完整工作流

提案宣布cmd/harvest-loxo"镜像cmd/harvest-boards":从搜索引擎足迹枚举 Loxo 机构看板、逐个实时校验、输出草稿条目供人工审核。当前仓库的实现 cmd/harvest-loxo/main.go 完整落地了这条管线,文件头的用法注释给出了三种运行方式:

go run ./cmd/harvest-loxo < footprint-urls.txt go run ./cmd/harvest-loxo footprint-urls.txt go run ./cmd/harvest-loxo --apply footprint-urls.txt

需要DATABASE_URL;不带--apply时只报告不写库(与cmd/add-board同一约定)。工作流分为五步:

  1. 足迹读取(readLines,main.go#L142-L160):从 stdin 或种子文件逐行读取 Loxo 足迹 URL(操作者用site:app.loxo.co之类的查询自行收集——工具本身不爬搜索引擎,这正是规格对harvest-boards哲学的说明)。
  2. 候选提取(candidateFromURL,main.go#L182-L199):每条 URL 变成一个(host, slug)候选。career 页是单段路径;/job/<base64>是两段路径、不携带 slug,返回ok=false;login、api、signup等保留词被排除。host 校验用isLoxoHost:精确匹配app.loxo.co或以.app.loxo.co结尾——刻意不匹配notapp.loxo.co这类形近域名。结果按host/slug去重且保序。
  3. 与现有目录去重:loadExistingBoards经boardcatalog.LoadForProvider查出目录中已有的loxo看板,已知候选不再重复探测。
  4. 并发实时校验(probeAll,8 个 worker):probe直接调用 Loxo 适配器的Fetch——能返回至少一个职位才算存活;429 回退由共享 HTTP 客户端处理。存活看板的显示名从列表页<title>Job Listing | Agency</title>用正则loxoTitlePattern提取(提取失败回退到 slug)。
  5. 技术相关度计数与输出:对每个存活看板,用classify.Parse(j.Title).Category分类并统计技术职位数(isTechCategory判定:类别非空且不在vocab.NonTechCategories黑名单内),按host/slug — 公司名 — N jobs, M tech格式打印,供操作者跳过无技术职位的机构(对应提案"mostly non-tech agencies"风险的处置)。

带--apply时,addBoards把校验通过的看板以status='pending'、Hub: true写入看板目录(boardcatalog.InsertInput),重复 board 记为 duplicate 而非错误(唯一索引在别处兜底)。规格中"工具只提案、不决定提交集合"的约束由这一报告/写入两阶段行为保证。

值得对照提案说明一处演进:提案时代的落地形态是sources/loxo.yml看板文件("所有条目hub: true"),而当前仓库中harvest-loxo已改为直接写看板目录表(boardcatalog,见 migrations/0123_boards.sql),仓库中不再有sources/loxo.yml文件。从源码看,看板集合的持久化从 yml 文件迁移到了数据库目录,"提案-审核"的人机边界保持不变。

测试策略:真实 HTML 夹具 + 行为用例

loxo-source 规格反复提到"fixture-based unit tests that fail loudly if the parse breaks"(HTML 形态漂移风险的主要防线)。仓库中的夹具与测试:

  • 夹具:testdata/loxo/listing_fitnext.html 与 testdata/loxo/detail_fitnext.html——来自 spike 机构的真实页面(tasks.md 要求夹具覆盖"带 title 分隔符客户"与"纯机构标题"两种详情页,仓库当前保存的是后者形态的 fitnext 夹具)。
  • 行为用例(loxo_test.go):TestLoxoProvider(provider 名)、TestLoxoMapDetailFixture(真实详情页 → title 剥后缀、描述来自内嵌 JSON、URL 规范化、ExternalID 解码、Remote (LATAM)位置解析)、TestLoxoJobLinksHostResolution(三种 host 形态各解析 22 条链接)、TestLoxoFetchMapsAndIsolatesFailures(坏帖丢弃不致命)、TestLoxoCompany(Hub 分隔符用例表)。

这套测试与 tasks.md 中 RED→GREEN 的记录一一对应(第 2~4 节),也印证了提案 Impact 部分"复用 careerspage 风格HTMLGetter"的说法:夹具解析走的是golang.org/x/net/html节点树,与同包其他 HTML 适配器共享jobLinks、metaProperty、titleText等公共 helper(html.go)。

已知取舍与扩展接缝

design.md 的 Risks 一节给出了四个风险及处置,值得作为"接入一类新数据源"的决策模板引用:

  • 跨源重复:机构转发某职位、客户又在自家 ATS 直发同一职位,会产生两行——freehire 只在单个source内去重。提案将其记为"被接受的取舍(seam,暂不缓解)",视为合法的两个不同 listing;未来按 title+company 的跨源模糊去重是已记录的未来接缝。
  • 大多数机构非技术:发现工具的技术职位计数让操作者跳过空板,下游逐职位 non-tech 门禁过滤其余——因此适配器层不需要技术门禁。
  • HTML 形态漂移:title 后缀、JSON 块格式可能变。防线是夹具测试大声失败;spike 显示该形态今天在机构间是统一的。
  • 雇主欠归属:宁可记为机构名也不冒险猜错客户,与 freehire 的 "never guess" 偏向一致。

迁移计划同样克制:纯增量——新适配器文件 + 注册表一行 + 新看板条目 + 新cmd/harvest-loxo,无 schema、无迁移;部署时按其他看板文件的方式为loxo配置错开的 ingest timer。

小结

这条归档变更展示了 freehire 扩展数据源的标准动作:先用 spike 验证页面形态可泛化,再在 openspec 中固化 Why/What/Capabilities/Impact 与逐条 Decisions,随后以"列表 → 详情扇出 + 稳定解码 ID + 尽力而为的地理信息 + 永不猜测的雇主归属"四件套实现适配器,配套一个"足迹 → 校验 → 计数 → 提案"的发现工具,最后用真实 HTML 夹具把解析脆弱性钉死在测试里。对想在同类聚合型仓库中新增抓取适配的读者,proposal.md、design.md、tasks.md、loxo-source 规格、loxo-harvest 规格 与 internal/ingest/sources/loxo.go、cmd/harvest-loxo/main.go 构成了一条从设计到代码、可直接对照阅读的完整证据链。

  • 后端
  • 前端
  • 搜索引擎
  • 网页爬虫
  • 数据工程
  • AI 应用
  • 可观测性

【免费下载链接】freehire

freehire — the open-source search engine for job seekers

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

相关推荐

上一篇:ComfyUI-WanVideoWrapper企业级部署:多节点分布式渲染方案
下一篇:fastlane frame_screenshots(frameit)实战指南:用 Framefile.json 为 iOS/macOS/Android 截图批量套上设备边框

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/25 1:25:18

AI通识课:零基础看懂机器学习本质与落地逻辑

简介&#xff1a;本资源是面向AI零基础学习者的人工智能通识入门课程讲义&#xff0c;聚焦概念建立、技术脉络梳理与现实应用衔接&#xff0c;帮助初学者系统构建AI认知框架&#xff0c;缓解因术语繁多、分支庞杂导致的学习畏难情绪。压缩包为单个6.25MB的PDF文件&#xff0c;完…

作者头像 李华
网站建设 2026/10/25 1:23:14

EasyGBS视频监控平台架构与智能分析技术解析

1. EasyGBS技术架构解析EasyGBS作为新一代视频监控管理平台&#xff0c;其核心架构设计充分考虑了GB28181标准的兼容性与现代智能分析需求的结合。系统采用微服务架构设计&#xff0c;主要包含以下几个关键模块&#xff1a;信令服务模块&#xff1a;基于SIP协议实现GB28181标准…

作者头像 李华
网站建设 2026/10/25 1:19:58

Headscale测试覆盖:测试策略与覆盖率分析

Headscale测试覆盖&#xff1a;测试策略与覆盖率分析 概述 Headscale作为Tailscale控制服务器的开源自托管实现&#xff0c;其测试策略和覆盖率直接关系到项目的稳定性和可靠性。本文深入分析Headscale的测试架构、策略设计以及当前测试覆盖状况&#xff0c;为开发者提供全面的…

作者头像 李华
网站建设 2026/10/25 1:19:15

Docker实战 | 使用Docker部署DumbAssets固定资产管理平台

【Docker项目实战】使用Docker部署DumbAssets固定资产管理平台一、DumbAssets介绍1.1 DumbAssets简介1.2 DumbAssets主要特点二、本次实践规划2.1 本地环境规划2.2 本次实践介绍三、本地环境检查3.1 检查Docker服务状态3.2 检查Docker版本3.3 检查docker compose 版本四、拉取D…

作者头像 李华
网站建设 2026/10/25 1:19:12

BoxJs脚本安全检测终极指南:如何识别和防范恶意脚本

BoxJs脚本安全检测终极指南&#xff1a;如何识别和防范恶意脚本 BoxJs作为一款功能强大的脚本管理工具&#xff0c;在自动化签到、任务执行等场景中广受欢迎。然而&#xff0c;随着脚本生态的不断发展&#xff0c;恶意脚本的安全威胁也日益凸显。本指南将为您提供完整的BoxJs脚…

作者头像 李华
网站建设 2026/10/25 1:18:08

Astral项目v1.0.8版本发布:全面优化CI/CD流程与多架构构建支持

Astral项目v1.0.8版本发布&#xff1a;全面优化CI/CD流程与多架构构建支持 Astral是一个基于Flutter框架开发的跨平台应用项目&#xff0c;它能够同时在Android和Windows平台上运行。该项目采用了现代化的持续集成和持续部署(CI/CD)流程&#xff0c;通过GitHub Actions实现自动…

作者头像 李华