【免费下载链接】freehire
freehire — the open-source search engine for job seekers
freehire 是一个开源的职位搜索引擎(open-source search engine for job seekers)。它的站点每天新增约 1.4 万个技术类职位页面,而 Google 的爬虫一天只主动抓取 65 个——如果只靠"等爬虫来",绝大多数新职位在上架很久之前就过期了。freehire 的解法是一套围绕Google Indexing API + IndexNow的"账本式配额设计":不是把 URL 塞进队列排队,而是记下"已经告诉过谁",每次运行重新挑选最值得报的新页面。本文带你拆解这套设计的来龙去脉。
为什么"等爬虫"在 69 万页规模下失效
设计这套系统的起点,是一组生产环境的实测数据(2026-09-14):
| 指标 | 数值 |
|---|---|
| Googlebot 当天抓取的职位页 | 65 个 |
| sitemap 声明的职位页 | ~690,000 |
| 每天新增技术类职位 | ~14,000 |
| 从 sitemap 抽样做 URL Inspection 的结果 | URL is unknown to Google |
URL is unknown to Google不是"被拒绝"或"质量低",而是从未被抓过。而职位页面是"易腐"的——雇主一撤下原链接,页面就该关闭,几个月后才被首次抓取的页面,在可达之前就已经过期了。
于是 freehire 把策略反转过来:不等人来爬,主动告诉搜索引擎"这里有个新页面"。相关背景还记录在 docs/seo-baseline.md 里。
核心设计:账本(Ledger),不是队列(Outbox)
这是整个方案最有意思的一点。schema 里其他队列(search_outbox、recent_feed_outbox等)都满足"排空速度 > 填充速度",唯独这个不能:
- Google Indexing API 的默认配额是每天 200 次 publish,而每天新增上万职位;
- 如果用常规 outbox(ingest 写入、worker 消费),队列会无限增长,最老的一行永远轮不到;
- 所以 migrations/0162_job_search_pings.sql 里记录的从来不是"待办",而是**"已发送"的事实**。
job_search_pings (job_id, engine, kind, pinged_at)每次cmd/search-ping运行时,通过反连接(anti-join)找出"该引擎还没被告知过的最新合格职位",挑完即发。选择逻辑可以随时改变,不存在需要消化的积压。
"最新优先"是唯一的选择策略
在配额远小于目录的情况下,按created_at DESC取最新,一举两得:
- 职位页只在招聘中才值得公告,公告越早价值越高;
- 它顺带替代了一个过滤器——sitemap 会排除"likely-evergreen"(长期常开)职位,这个类别只存在于搜索索引里无法在这里 join,但它是靠"长期不关闭"赚来的,所以最新行天然不可能是它。
技术职位优先,其他类别兜底
目录并不只有 IT。合格门槛是"搜索索引愿意收录":开放、canonical、公开、有正文、类别可解析。在此之上分两层(tier):
| 层级 | 内容 | 配额待遇 |
|---|---|---|
TierTech(is_tech为真) | 技术类职位 | Google 的 200/天先给它 |
TierBeyondTech | 其余有类别的职位(医疗、零售、物流…) | 只能用剩下的 |
注意这是优先级,不是资格规则——医疗或物流职位同样是站点主张的页面、同样带JobPosting标记。而 IndexNow 没有配额,每一层每次都能领到一整批。选择查询写在 internal/platform/db/queries/job_search_pings.sql,两层是"一次查询跑两遍"而不是ORDER BY is_tech DESC——后者无法走created_at索引,会为挑 200 条而排序全部开放职位。
两个事件:创建与关闭,都是 URL_UPDATED
一个职位页会被公告两次:出现时(created)、关闭后(closed),但两次都发URL_UPDATED,见 migrations/0163_job_search_pings_kind.sql 的设计说明:
- 关闭后的页面仍是 HTTP 200,保留
JobPosting标记,只是validThrough移到了过去——这是 Google 文档认可的"下架职位"方式之一,所以需要的是重新抓取; - 对仍在线的页面发
URL_DELETED属于误用 API,惩罚就是配额本身。
kind列因此把同一职位拆成两条独立事实,否则反连接会因为"创建"那行而永久跳过关闭公告。另外,新职位先花预算:新职位带来访客,关闭公告只是整理一个不属于自己的索引。在 200/天 对 1.4 万/天的量级下,第一遍就把配额吃完是正确行为;配额一旦提升,关闭公告无需改代码自动开始流动。
两个引擎的分工:Google Indexing API vs IndexNow
| Google Indexing API | IndexNow | |
|---|---|---|
| 覆盖引擎 | 只有 Google | Bing、Yandex、Seznam、Naver |
| 预算 | 200 次/天(可申请上调) | 无公开配额 |
| 凭证 | 服务账号,且必须是 Search Consoleowner | 站点公开提供的一个 key 文件 |
| 批量 | 一次调用只报 1 个 URL(批量省的是 HTTP,不是配额) | 一次最多 10,000 个 |
| 部分失败 | 正常现象 | 不可能——整批同答 |
两个适配器分别实现在 internal/job/searchping/google.go 和 internal/job/searchping/indexnow.go,统一实现Engine接口:Name/DailyBudget/Accepts/Announce。Announce返回的是引擎接受的 URL 子集——账本只记引擎"拿走"的,从不记"递出"的,否则部分失败的批次会被整批入账,没发出去的部分就再也不会被选中。
公司页:只能走 IndexNow 的资产
通过 Bing Webmaster API 实测(2026-09-15),Bing 索引里的 25.5 万个页面中,曝光最高的正是/companies/<slug>,背后的查询形态全是"公司名 + careers";Bingbot 两天里抓了 6,275 个公司页 vs 3,679 个职位页。公司页是 freehire 自己装配的"某雇主全部在招岗位",雇主自己往往根本不发布——这是真正值钱的页面。
但 Google Indexing API 只接受JobPosting和BroadcastEvent两类页面,把公司页发过去就是违反条款,惩罚是配额。所以拒绝逻辑写在引擎自己身上(Engine.Accepts):Google 拒收KindCompany,IndexNow 全收。公司页另有独立账本 migrations/0164_company_search_pings.sql,且没有 kind 列——公司页只有一个事件(存在),职位全部关闭它也仍是 200 页面。
藏在细节里的四个陷阱
1. 配额日在太平洋时间午夜重置。预算日既不是 UTC 也不是主机时区。代码里直接time.LoadLocation("America/Los_Angeles")并导入time/tzdata内嵌时区库——因为固定-8会在夏令时期间让边界晚一小时,那小时的发送"看起来没花钱,实际 Google 在记账",运行就会超支。剩余配额不是问 API 问来的(API 只会在用完时回429,不会自报余量),而是从账本读回来的:PingsSince数出该引擎今天已发的条数。
2. 发送是"至少一次"(at-least-once),方向是刻意选的。HTTP 调用无法加入记录它的数据库事务,两种顺序必有一个吃亏:先发后记,失败代价是一次重复公告(有界、可见、无害);先记后发,失败则这个职位永久且静默地失去公告机会(已入账的行永远不会再被选中)。loud and bounded 的失败才是该保留的那个。
3.siteFullUser不是siteOwner。用"Full"权限把服务账号加进 Search Console 可以读所有报告,但 Indexing API 会回403 ... Failed to verify the URL ownership——报错里根本不会提示你"去改角色"。
4. IndexNow 的 key 天生是公开的。它放在站点静态目录(web/static/<key>.txt)里供引擎回验,是所有权证明而非机密。key 同时存在于站点文件和 worker 环境变量INDEXNOW_KEY两处,一旦漂移,IndexNow 会对每次提交回403,看起来像集成坏掉了。所以启动时VerifyKey会抓取文件比对一次,不一致就拒绝运行。
一小时一次的运行与无感回滚
入口 cmd/search-ping/main.go 每次运行给每个"引擎 × 事件"发一批后退出,建议按小时调度,SEARCH_PING_BATCH(默认 200)限制单批规模:
./search-ping # 正式公告 ./search-ping -dry-run # 只打印将要发送的 URL,不发送事件竞争引擎"一天"的固定顺序就是策略本身:新建·技术→新建·其他→公司页→关闭。有配额的引擎按顺序消耗剩余量,无配额引擎(IndexNow)每遍各领一整批;429 一到就停止该引擎的剩余批次(继续发只会烧掉明天的配额——除了 429 以外的失败都耗配额)。
回滚同样优雅:清掉GOOGLE_INDEXING_KEY_FILE和INDEXNOW_KEY两个环境变量即可。没有凭证的引擎视为"未配置",静默跳过;两个都没有时,运行是一次连数据库都不打开的空操作,定时器可以原样保留。
小结
这套设计给大规模内容站做了 SEO 收录的团队留下了几条通用经验:
- 配额是设计约束,不是管道参数——先确认"一天能花多少",再决定数据结构;
- 账本优于 outbox:当消费速度永远追不上生产速度时,记录"已做"比排队"待做"更可靠;
- 把平台规则写进引擎适配器(
Accepts、DailyBudget),让策略顺序和具体引擎解耦; - 接受 at-least-once,选那个失败"响亮且有界"的写入顺序。
核心实现都在 internal/job/searchping/ 目录下,配合 docs/seo-baseline.md 中收录的实测数据,可以完整复现这套决策的过程。
【免费下载链接】freehire
freehire — the open-source search engine for job seekers
相关推荐
Linux桌面快捷启动:3分钟让Umi-OCR离线OCR识别触手可及
Linux桌面快捷启动:3分钟让Umi OCR离线OCR识别触手可及 作为一款功能强大的免费开源OCR工具,Umi OCR提供了截图识别、批量处理、二维码识别等
桌面应用OCR计算机视觉Rendr.js与SEO:如何让你的单页应用被搜索引擎完美收录
Rendr.js与SEO:如何让你的单页应用被搜索引擎完美收录 想要让你的单页应用在Google搜索中获得好排名吗?🤔 传统的客户端渲染应用面临着一个巨大的挑
后端软件架构WebSlides SEO优化:如何让你的演示文稿被搜索引擎收录
WebSlides SEO优化:如何让你的演示文稿被搜索引擎收录 WebSlides是一款强大的HTML演示文稿制作工具,让你的演示内容不仅美观,还能被搜索引擎
前端UI组件
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考