freehire Gupy 板发现设计:用 discoverer 可选接口让采集器自行枚举候选 Board 后端前端搜索引擎网页爬虫数据工程AI 应用可观测性【免费下载链接】freehirefreehire — the open-source search engine for job seekers项目地址https://gitcode.com/GitHub_Trending/fr/freehire点击查看免费下载本文基于 freehire 仓库中归档的设计文档 design.md配套 proposal.md、tasks.md 与 board-harvest 规格增量完整还原cmd/harvest-boards工具如何为没有公开种子清单的 ATS 平台Gupy增加 discovery 发现模式一个discoverer可选接口、一个同时实现probe与discover的gupyProber以及发现只替换候选来源、下游管道不变的核心决策。读完本篇你能掌握 Go 中 opt-in 接口的扩展手法、基于平台全局 feed 的分页枚举算法去重、截断、边界并能结合当前仓库源码看到该设计落地后经历的真实演化。背景harvest-boards 管道与 Gupy 的无种子清单问题设计文档的 Context 一节给出了cmd/harvest-boards的既有形态harvest-boards provider seed.json → loadSeed → mapSeeds → newBoards对 sources/provider.yml 去重 → probeAll并发 prober.probe(slug) → (name, openJobs, err)保留 openJobs0 → appendEntries各平台的 prober 都位于 prober.go背后是一个基础接口prober以及一组可选标记接口seedMapper/dedupKeyer——文档称之为既定的 opt-in 接口惯例。当前仓库中这套惯例仍然成立且已经扩展到seededProber、idProber、seedResolver、dedupKeyer等多个可选能力注册表probers中挂了 50 余个 provider见 prober.go。问题出在 Gupy按设计文档的说法Gupy 的 board 是数字companyId没有任何公开的种子清单可供采集唯一的发现途径是枚举 Gupy 的全局 feedhttps://employability-portal.gupy.io/api/v1/jobs?limitoffset——该接口跨所有雇主返回职位约每 40 条职位覆盖 25 家不同公司且超过前 100 条后按offset分页再对单个?companyIdidlimit1查询即可拿到该公司的careerPageName与开放职位total。proposal.md 补充了规模背景当时 freehire 仅持有 33 个 Gupy board而通过 feed 枚举理论上可以扩展到数百家。核心决策discovery 只替换候选来源这是整份设计最关键的一条决策种子loadSeedmapSeeds只是候选 id 的来源其后的去重、并发探测、追加全部是 provider 无关的保持不变。因此 discovery 被建模为一个替代的候选来源而不是一条并行管道。discoverer 接口文档新增了一个与seedMapper/dedupKeyer同款的 opt-in 接口type discoverer interface { discover(ctx context.Context, c httpClient) ([]string, error) }当前仓库中它原样存在于 prober.go注释明确写道其 board 无法以种子清单形式获得的 prober 的可选能力从平台 API 自行枚举候选 board id……与seedMapper/dedupKeyer的可选标记惯例一致。run() 的候选来源分支run()按参数选择候选来源文档表述为 2 参/3 参对应是否携带 seed 文件带provider seed.json→ 走种子路径行为不变仅provider且 prober 实现了discoverer→ 候选 discover(...)仅provider但 prober 不是 discoverer→ usage error对纯种子型 provider 的行为保持不变。当前实现中这一分支收敛在 main.go 的 resolveCandidatesif seedPath { d, ok : p.(discoverer) if !ok { return nil, nil, fmt.Errorf(provider needs a seed file (it has no discovery support)) } boards, err : d.discover(ctx, c) return boards, nil, err }入口 main.go 对位置参数只接受 1 或 2 个provider 必填seed 可选并支持-pace、-workers、--apply三个旗标其中-workers默认值为defaultProbeWorkers 16main.go#L49。保留已提交文件 我们验证过的事实信条发现得到的 id 仍要流过同一条newBoards→probeAll→appendEntries管道每个发现的companyId都会被再次 probe拿到权威的careerPageName与职位数并重新确认 board 仍然存活。设计文档把这条原则概括为 committed file our validated facts——落盘的目录是项目自己验证过的事实集合而非对外部数据集的再分发。board-harvest 规格 将其固化为可验收的 Requirementharvest 工具只以 live-validated 的 board 扩充目录文件不存在、关闭或不可达的候选 SHALL 被跳过而绝不中止运行。gupyProber一个 prober 两种能力设计文档规定gupyProber同时实现prober.probe与discoverer.discover两个方法的契约如下以设计稿原文为准。probe单板校验请求GET …/jobs?companyIdidlimit1解码{data:[{careerPageName}], count}返回(careerPageName, total, nil)名称取不到时回退为companyIdslug-fallback 信条跳过语义total0或请求出错时返回(,0,nil)——skip 而非 error与 greenhouseProber.probe 的形状完全一致4xx 视为不是活板静默跳过。discover全局 feed 分页枚举循环offset 0, gupyPageSize, 2·gupyPageSize, …每次GET …/jobs?limitgupyPageSizeoffsetoffset把每条职位的companyId收进按首次出现顺序去重的切片终止条件二选一某页返回 0 条职位或offset到达gupyMaxOffset防止 feed永不枯竭时的安全阀最终返回去重后的 id 字符串切片。常量与注册常量设计稿取值说明gupyFeedURLemployability-portal.gupy.io/api/v1/jobs全局 feed发现的唯一来源gupyPageSize100分页大小gupyMaxOffset未定值远高于 Gupy 实际职位数限制扫描上界注册一行probers[gupy] gupyProber{}。设计稿还特别注明feed 主机名作为本地常量复制在工具内而不是从internal/sources导出——因为该工具位于internal/sources包之外这与既有的greenhouseBoardsAPI常量prober.go#L16是同一种刻意的复制避免为一个 dev tool 加宽核心包的 API。当前 gupy.go 中的三个常量注释逐字沿用了这个理由。设计权衡为什么已发现的 board 还要再 probe 一次设计文档单列一节解释这个看似冗余的两次往返enumerate probe-eachdiscovery 给出的公司一定已有 ≥1 个开放职位它们出现在 feed 里但 probe 仍然要跑它返回权威的careerPageNamefeed 条目里虽也有名字但 probe 保持各 provider 统一的行为并重新确认存活以及开放职位的total计数对一次性运行的 host 工具而言双往返的代价可接受有界并发16 workers 客户端 429 退避。当前仓库中429 退避的对应实现是 refusals.go 的拒绝分类只有 429 算拒绝与 main.go 的 refusalsDominated 守卫当拒绝数压倒应答数、或全部候选都失败、或所有存活 board 都与种子期望冲突时运行以非零码退出——这样被平台限流的中断采集不会被误当成候选枯竭而落盘空结果。风险与 Trade-offs设计稿的 Risks 一节列了三条全部值得保留量级Volume所有公司可能意味着数千个不同 id从而数千次 probe 请求。有界并发16 429 退避保持礼貌它是 run-once 的 host 工具而非 cron jobgupyMaxOffset给枚举封顶。脏名字Messy namescareerPageName有时是营销口号。保持 slug-fallback 信条取不到名字时用 id并依赖入库前的 diff 人工审阅不做名字清洗YAGNI。分页形态Feed pagination shape假定offset能把 feed 翻到枯竭。若 feed 对offset设了上限扫描会提前结束少发现一些公司而不是失败——gupyMaxOffset与空页停止两个机制都让终止干净。测试设计fake httpClient 与四条断言线设计稿的 Testing 一节规定使用prober_test.go风格的 fakehttpClient按 URL 子串路由覆盖discover多页 feed 且公司重复 → 按序去重的不同 id空页停止翻页gupyMaxOffset上界被遵守probecompanyId→(careerPageName, total)的映射空/零 →(,0,nil)缺名字 → id 回退run()discoverer 分支无 seed、seed 分支、非 discoverer 且无 seed 时的 usage error三选一的分支选择。当前仓库的 gupy_test.go 正是这份测试设计的落地四条测试一一对应设计稿要求当前测试验证点注册表解析 gupy 且实现 discovererTestGupyRegisteredAsDiscovererL10-L18probers[gupy]存在且满足discoverer断言probe 映射/回退/skipTestGupyProbeL20-L51表驱动 4 用例有publicationName取名、无描述页回退 subdomain、空列表 skip、404 skip去重 空页停止TestGupyDiscoverPaginatesDedupsAndStopsL69-L85页内重复大小写折叠与跨页重复均只收一次空页即停gupyMaxOffset上界TestGupyDiscoverStopsAtTheFeedsEndL106-L120恰好扫满gupyMaxOffset/gupyPageSize页不再请求此外TestGupyDiscoverTruncatesOnPageErrorL89-L103覆盖了设计稿未写明、但实现中补强的一条中途某页拉取失败时扫描截断为迄今已收集到的页面并记日志而不是让整个采集失败——这样一次坏页只会让本次采集变短不会把它误读成 feed 枯竭。迁移与 Rollout纯人工流程设计稿的 Migration 一节只有一句No automated流程是在 host 上运行harvest-boards gupy无 seed 参数触发 discovery人工审阅sources/gupy.yml的 diffcommit通过 sources rsync 部署——无 schema 变更、无运行时变更。这与 main.go 头部注释 描述的运行约定一致默认只报告仅在--apply下写入保留的 board 以statuspending入库——已被 probe 但尚未被管道抓取证明这正是 pending 的含义。需要说明的是当前仓库的 board 目录已经从设计稿时代的sources/provider.yml提交文件演进为数据库中的 boardcatalogmain.go 使用 boardcatalog.LoadForProvider 做去重addBoards 以 StatusPending 入库diff 审阅对应到当前的 report-only 默认行为 --apply两段式确认但落盘前必须逐条验证的信条没有变。源码演进从 companyId 到职业页 subdomain设计稿归档于 2026-06-17其中companyId键控的 API 形态employability-portal.gupy.io/api/v1/jobs?companyIdid在随后发生了平台侧变更。当前仓库的源码注释给出了完整时间线可以作为设计文档与现行实现之间差异的权威解释internal/ingest/sources/gupy.go 的注释companyId 键控的列表接口自 2026-09-30 起对所有查询返回 nginx 404且再无任何公开接口按 companyId 列表portal 自己的/api/job-search/jobs会忽略companyId参数因此board 键从数字 companyId 改为职业页 subdomainpage.gupy.io一家公司可拥有多个职业页内部流动页、保密页每个页各自是一个 board专门的一次性迁移工具 cmd/rekey-gupy-boards/main.go 记录了代价1,511 个 gupy board 自 2026-09-30 起全部爬取失败73,156 条职位停留在 open 状态该工具分两趟把目录与已存职位从 companyId 迁移到职业页命名空间并从已存职位 URL 反推出 companyId → 职业页的映射。在此之后落地的 gupy.go 保留了设计稿的全部结构分页枚举 逐一 probe 空页/上界终止 slug-fallback但数据源全部换血常量当前取值用途gupyListURLhttps://private-api.gupy.io/job-publication/public/jobs单个职业页的公开职位列表?subdomainpage查询gupyCareerPageURLhttps://private-api.gupy.io/v2/public-career-page/page职业页描述名称来源gupyFeedURLhttps://portal.gupy.io/api/job-search/jobsportal 跨职业页的职位搜索——发现来源gupyPageSize100不变分页大小gupyMaxOffset10000实测 2026-10-19offset 9900 仍返回满页、10000 无数据故上界即 feed 的实测终点两个方法的当前行为与设计稿的逐点对照probegupy.go#L41-L69先查?subdomainpage列表404Gupy 应答 404 Career Page not Found或空列表返回(,0,nil)skip存活后再取职业页描述名称优先publicationName、次选name、再回退 subdomain。skip 语义与设计稿完全一致只是名字来源从 feed 的careerPageName换成了v2/public-career-page描述接口。discovergupy.go#L79-L110分页结构与终止条件不变但候选提取改为正则gupyJobHost (?i)^https?://([a-z0-9_-])\.gupy\.io/从每条职位的jobUrl中捕获职业页 subdomain并strings.ToLower折叠大小写后按首次出现顺序去重某页拉取失败时log.Printf后break截断而非中止。这个演化的意义在于设计稿论证的discovery 只是候选来源的架构决策经受住了平台 API 变更的考验——当 companyId 列表整体 404 时需要重写的只是 prober 内部三个 URL 和候选提取逻辑discoverer接口、run()分支、去重/probe/追加管道、以及全部测试骨架都不必动。这正是设计稿把 discovery 建模为替代候选来源而非并行管道的回报。关键文件索引设计文档本文主体design.md、proposal.md、tasks.md、spec.md工具实现cmd/harvest-boards/main.go、cmd/harvest-boards/prober.go、cmd/harvest-boards/gupy.go、cmd/harvest-boards/gupy_test.go、cmd/harvest-boards/refusals.go摄入适配器与背景internal/ingest/sources/gupy.go、cmd/rekey-gupy-boards/main.go赞分享后端前端搜索引擎网页爬虫数据工程AI 应用可观测性【免费下载链接】freehirefreehire — the open-source search engine for job seekers项目地址https://gitcode.com/GitHub_Trending/fr/freehire点击查看免费下载相关推荐freehire 自动投递可用性过滤器设计解析用 auto_apply_available 让候选人在投递前看清 ATS 能力边界freehire 自动投递可用性过滤器设计解析用 auto_apply_available 让候选人在投递前看清 ATS 能力边界 本文以 freehire后端前端搜索引擎网页爬虫数据工程AI 应用可观测性freehire 经验银行接入助手的设计与实现从入口文案到可选参数路由合并freehire 经验银行接入助手的设计与实现从入口文案到可选参数路由合并 本文以 freehire 仓库中的设计规范 2026 07 28 experien后端前端搜索引擎网页爬虫数据工程AI 应用可观测性freehire iCIMS Source 适配器用 sitemap 枚举 JSON-LD 详情页接入最大 ATS 缺口freehire iCIMS Source 适配器用 sitemap 枚举 JSON LD 详情页接入最大 ATS 缺口 本文以 freehire 的 i后端前端搜索引擎网页爬虫数据工程AI 应用可观测性上一篇wechat-cli开发者指南项目架构、Click命令设计与npm跨平台二进制分发深度剖析下一篇如何快速上手Easel10分钟搭建你的AI社媒内容工作台完整指南创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考