ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

freehire Airswift 适配器设计解析:用“过期标记”把已关闭职位从“读不懂”中区分出来

freehire Airswift 适配器设计解析:用“过期标记”把已关闭职位从“读不懂”中区分出来
  • 后端
  • 前端
  • 搜索引擎
  • 网页爬虫
  • 数据工程
  • AI 应用
  • 可观测性

【免费下载链接】freehire

freehire — the open-source search engine for job seekers

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

freehire 的 ingest 层需要把 Airswift 这类招聘机构站点的职位全量纳入目录,而该站点约六成条目是“仍然返回 HTTP 200、但职位早已关闭”的页面。本篇围绕 airswift-agency-adapter 设计文档,结合 airswift.go、共享的 sitemap/ld+json 助手以及 pipeline 的 board 作用域判定,讲清一个看似局部的判断——“过期就丢弃”还是“标记为不可读”——为什么直接决定整块 board 的 stale-job 关闭机制能否正常工作。读完后你可以独立评估同类“列表可用、详情页大量失效”的站点该如何建模。

背景:这条适配器从哪来

完整批次背景写在 oil & gas 机构适配器批次设计 中:Orion、Airswift、Brunel、NES Fircroft 四家招聘机构都不跑已知 ATS,各有自己的职位检索,因此每家一个独立 OpenSpec change、独立分支、独立 PR。Airswift 是其中第二个,采用dataart.go的经典形状:sitemap 枚举 + 单页 ld+json 映射,而非调用某个平台 API。

设计文档在 2026-10-08 对站点做了实测,得到四条关键事实:

观测项实测结论
https://www.airswift.com/sitemap.xml扁平<urlset>,约 2930 个<loc>
其中匹配/jobs/<slug>-<id>约 1841 条
存活职位的 ld+jsonJobPosting携带title、HTMLdescription、datePosted、validThrough、employmentType、hiringOrganization、jobLocation
hiringOrganization.name每条都是字面量"Airswift",不是真实终端雇主
过期职位返回HTTP 200,没有JobPosting块,页面渲染<div class="c-jobs-article-expired ...">,文案为 “Thank you for your interest in this role, but we are no longer accepting applicants.”

最后一条是全篇的支点:这是一个表现层但稳定的信号(一个 CSS class),而不是易碎的自然语言匹配。

目标与非目标

目标只有两条:枚举 sitemap,把每个存活职位的 ld+json 映射成Job;把过期职位直接丢弃(与 404 同一类别),而不是每次运行都为全站约 55%–60% 的目录写一个unreadableDetail桩。

非目标同样明确:

  • 不做任何超出常量"Airswift"的公司解析——设计文档已确认它不是聚合站;
  • 对未过期页面上缺失/畸形的JobPosting,仍然沿用既有的unreadableDetail约定,不特殊处理。原因在文档里写得很直接:这种情况依旧“语义不明”(可能是站点改版),与“确认已关闭”不是一回事。

核心决策一:走解析树找 class,不做原始文本子串匹配

适配器本来就把页面解析成*html.Node树以提取ldJobPosting,所以复用共享的遍历工具几乎零成本。实现位于 airswiftExpired:

func airswiftExpired(root *html.Node) bool { found := false walk(root, func(n *html.Node) bool { if found { return false } if n.Type == html.ElementNode && strings.Contains(Attr(n, "class"), "c-jobs-article-expired") { found = true return false } return true }) return found }

walk与Attr来自 html.go:walk是深度优先遍历,visit返回false时不再下钻(这里用它做命中即止的短路);Attr读取指定属性值。相比对整段 HTML body 做strings.Contains,这种写法的收益是免疫空白折叠与属性顺序变化——浏览器与解析器对属性重排、换行、实体转义的处理不会再影响判定结果。

一个值得注意的实现细节:这里用的是strings.Contains(Attr(n, "class"), ...),而不是共享的 HasClass。测试夹具 airswiftExpiredHTML 中,该div的 class 是c-jobs-article-expired o-content-editor o-text-centre,即元素同时带多个类名——子串判断在这种情形下自然命中。代价是它也会匹配任何包含该子串的类名(例如形如xx-c-jobs-article-expired的命名);从代码可以推断这是有意的宽松,把“主题换皮但保留语义类名”的容错留了出来。

核心决策二:detail的五步决策顺序

这是整份设计里最需要被逐条读完的部分。实现见 airswift.detail,文档给定了严格顺序:

  1. URL 里取不出 id → 丢弃(false)。与所有 sitemap 枚举型适配器一致:取不出 id 的页面本来就不可能进入目录,任何 close 作用域都触达不到它。
  2. 抓取失败 →detailUnreadable(err)判定。这是 helpers.go 的共享逻辑:结构化匹配*StatusError,404/410视为平台明确答复“职位已不存在”,丢弃;其它错误(超时、拒绝、5xx、解码失败)说明“这次没读到”,返回unreadableDetail桩。
  3. 抓取成功但存在c-jobs-article-expired标记 → 直接丢弃(false)。这一步相对dataart.go/energyjobline.go是新增分支:那两个适配器把“页面抓到了但没有 JobPosting”一律当成unreadableDetail,因为它们没有可区分“页面真坏了”和“这个职位就是关了”的信号;Airswift 有,所以用它。
  4. 无过期标记、也无JobPosting→unreadableDetail。这是dataart.go/energyjobline.go的默认行为,语义是“发生了预期之外的事”,而非“确认关闭”。
  5. 有JobPosting→ 正常映射。

第 3 步必须在第 4 步之前,这点在任务清单里也被单独标注(tasks.md 的 2.6:“ensure it's ordered after the expired check”——即回退分支要排在过期检查之后)。源码里两条分支的先后顺序与文档完全一致:

if airswiftExpired(root) { return Job{}, false } var p airswiftPosting if !ldJobPosting(root, &p) { return unreadableDetail(id, link, ce.Company), true }

顺带说明两个返回值的含义差异,它是理解全篇的关键:fetchDetails的契约是ok=false即从结果里消失(helpers.go),而unreadableDetail(...)返回ok=true且Unreadable: true,表示“这条职位确实被列出来了,但这次没读到内容”。

为什么这不只是“少写点桩”:stale-sweep 契约

设计文档专门用了一节解释这件事,而它在源码中能被完整验证。

Unreadable的计数不是失败。pipeline 的 Stats 注释写明:Unreadable统计“列表点名了、但详情没读到”的职位,故意不计入Failed,因为平台本身是应答了的。于是 board 会以consecutive_failures = 0记录为成功。

真正的后果出在 board 作用域关闭(board-scoped close)的准入条件上。boardReadWhatItListed:

listed := st.Ingested + st.Rejected + st.ATSCovered + st.Skipped + st.Unreadable return st.Unreadable*100 <= listed*maxUnreadablePercent

其中maxUnreadablePercent = 5。也就是说,一旦某块 board 有超过 5% 的列表职位变成Unreadable,这次运行就不再算作“证明过这块 board 的覆盖情况”,post-run sweep 会放弃对该 board 的 stale-job 关闭。

把这两个数字放在一起:Airswift 每次运行约 55%–60% 是过期职位。如果按dataart.go的方式全部标成unreadableDetail,那么这块 board 的不可读率将永久维持在 55% 以上,sweep 的关闭逻辑会被静默地无限期搁置——不是一次偶发 bug,而是稳态。设计文档把它类比为 energyjobline 首次运行时“99.99% 不可读”导致的那条 “withholding this run's stale-job close” 日志,区别只在于后者是一次性事故,前者会是永久状态。

反过来,把“确认过期”丢进与 404/410 同一类别,还有一处细节支持:boardReadWhatItListed的分母明确不包含被 404/410 丢弃的职位,注释写得很清楚——“平台说它没了”是证据而非空洞,一块整体过期的 board 本来就应当允许退休。Airswift 的过期标记提供的正是这种“证据”级的确定性。

这也解释了 boardListedAnyPosting 为什么要与boardReachedPostings分开:前者是last_yield_at的盖章依据,方向必须宽松(否则一块“列表正常、详情全挂”的 board 会一路老化成空 feed 并关掉所有职位);后者是“是否允许证明覆盖”的权限闸门,方向必须严格。两者相差的正是Skipped与Unreadable这两项。

传输层:为什么 sitemap 与详情页是两个参数

文档没有把这点当作决策项,但源码注释补上了完整理由。airswift结构体持有两个 getter:

type airswift struct { sitemap XMLGetter // the sitemap.xml (one request per run) pages HTMLGetter // per-posting detail pages, rate-paced }

因为两条路径的限流表现完全不同:sitemap 每次运行只有一次请求,详情页在无节流并发下会 429。因此注册时只有pages被包上节流器(registry.go):

NewAirswift(c, pacedHTMLGetter(c, airswiftRequestInterval, airswiftRequestBurst)),

而 pacer.go 里这段常数注记几乎是一份小型事故复盘,值得完整理解:

  • 首次实跑(无节流的 8 worker 突发、约 1646 条职位)全部详情请求 429,而在此之前的手工轻量探测毫无异常——说明限流只在持续量级下才显现;
  • 惩罚期长于触发它的突发:突发后约 2 分钟、间隔 3 秒的探测仍是 429,闲置约 15 分钟才恢复;
  • 按 2 秒/请求的短样本(5 次请求、约 10 秒)“看起来很干净”,但真实节流爬取(892 次请求、约 61 分钟)仍然全量 429;
  • 按 5 秒/请求再测(15 次请求、持续 75 秒)也干净,真实节流爬取(889 次请求、含重试约 2.5 小时)依然全量 429;
  • 因此最终取15 * time.Second、burst = 1(约 0.067 req/s),并明确写明:这仍不是被证明的上限;若 15 秒/请求的完整爬取仍然 429,就应当像 Rigzone 那样把该 provider 移入推迟组,而不是再猜第四次。

这段注释的方法论价值高于参数本身:短样本会系统性低估“按更长窗口计费”的预算,与 workstream 那条注记是同一种形状。

映射字段与“故意不读”的字段

airswiftPosting 只解码五个字段的子树:

type airswiftPosting struct { Title string `json:"title"` Description string `json:"description"` DatePosted string `json:"datePosted"` JobLocation []airswiftPlace `json:"jobLocation"` }

validThrough、employmentType、hiringOrganization虽然实际存在(设计文档的实测清单里列出了它们),但没有被解码:hiringOrganization因为恒为"Airswift"而不携带信息,公司名直接取常量airswiftCompany;其余字段不属于本次映射所需。这是一个“只取够用字段”的最小结构,而不是遗漏——从结构体与detail的字段赋值可以确认这一点。

其余映射要点:

  • id 提取:airswiftJobURLPattern锚定 host 与/jobs/,只捕获末尾数字 id,并允许一个可选的结尾斜杠;测试 TestAirswiftJobID 覆盖了列表根、/about-us、/jobs/等应返回空串的形态。
  • 位置:取jobLocation[0].address的addressLocality与addressCountry,用joinNonEmpty拼成"Doha, Qatar"。jobLocationAddress()对空数组返回零值,避免越界。
  • 描述清洗:sanitizeHTML(html.UnescapeString(p.Description))——ld+json 里的description是 HTML,先反转义再做净化。
  • 远程标记:Remote: isRemote(location),即从位置文本推断(匹配英文remote与俄文удал…),没有使用站点结构化字段,因为站点未提供。
  • 发布时间:parseDate(p.DatePosted),按2006-01-02布局解析。

测试矩阵:五条分支各有对应用例

任务清单按 TDD 组织(先写失败测试再实现),airswift_test.go 的用例与设计的五步顺序一一对应:

用例输入期望
TestAirswiftFetchSitemapThenDetailAndMapssitemap 混入/、/about-us与一条职位只产出 1 条,且ExternalID=1279815、Company="Airswift"、Location="Doha, Qatar"、PostedAt=2026-10-01
TestAirswiftBrokenSitemapErrorsTheCrawlsitemap 抓取报错Fetch返回 error,而不是空成功
TestAirswiftExpiredPostingIsDroppedNotUnreadable仅含c-jobs-article-expired的页面ok=false(被丢弃),不是Unreadable
TestAirswiftUnexpectedMissingJobPostingIsUnreadable既无过期标记也无 ld+jsonUnreadable=true,Company回退为配置值
TestAirswiftGoneDetailDropsThePosting一条正常 + 一条 404只保留正常那条
TestAirswiftOtherFetchErrorIsUnreadable一条正常 + 一条超时超时那条产生一个 unreadable 桩
TestAirswiftProviderAndBoardless—Provider() == "airswift"且实现boardless

“一条坏详情页不影响整块爬取”的上限由共享的defaultDetailWorkers = 8与fetchDetails的有界 worker 池保证:任一fetch返回ok=false只让该位置留空,不会中断其余请求,且存活结果保持原有相对顺序。

风险与权衡

设计文档列了两条,都值得原样保留:

  1. 过期标记的类名可能随站点改版变化。文档的处置是“接受”:一旦不再命中,症状会退化成 energyjobline 形状的“几乎所有职位都 Unreadable”信号,而这条信号本身已经足够响亮、也已被同一套监控覆盖。换句话说,失败模式是可见的,不是静默的。
  2. 误判会把真实职位悄悄丢掉。这一条在评审后做了独立复核(2026-10-08):在两个携带可用JobPosting的存活页面(project-manager-1281250、hull-structura-lead-1280438)的原始 HTML 中,字面量c-jobs-article-expired的 grep 计数为 0;并且这是从原始响应体确认的,不是渲染视图——标记在存活页面的 DOM 里确实不存在,而非仅被 CSS 隐藏。

需要注意的是,这个复核证明的是“抽检的两个页面无假阳性”,并不能推出“所有存活页面都不会误判”。文档据此接受的残余风险,与上一条同源:真出现误判,唯一可依赖的仍是不可读率与职位数量的异常信号。

迁移与回滚

发布路径被拆成“代码”和“数据”两段,顺序明确:

# 1. 适配器 + registry 行 + 测试走常规评审 # 2. 合并部署后,先手工跑一次爬取,核对职位数量 # 预期约为 1841 的 40%–45%,即 ~750–850 条存活职位(与 recon 自身的估计一致) # 3. 确认数量合理后,才加入线上 board(boardless,无需 --board) go run ./cmd/add-board --provider=airswift --company='Airswift' --apply

第 3 步之所以能省略--board,是因为适配器实现了boardless()(airswift.go),而 Config.Validate 对实现了该标记的 provider 放开Board必填校验。同理,BoardDedupeKey 对无 board 的条目直接返回ok=false——它不与任何条目构成重复爬取目标。

cmd/add-board的默认行为是只报告不写入,只有加--apply才落库(cmd/add-board/main.go)。回滚同样干净:删掉 registry 那一行,再把该 board 置为 retired(--retire,保留历史行而非删除)。

任务清单把 5.1、5.2 保留为未勾选状态,这不是遗漏,而是它们属于合并后的手工步骤,不在单元测试覆盖范围内(tasks.md 第 5 节标题即写明“manual, not part of the tests”)。

设计要点回顾

把这份设计压缩成可复用的判断规则:

  • 区分“平台说它没了”与“这次没读到”,是链接型适配器最重要的一次分类。前者丢弃、不留痕;后者必须留unreadableDetail桩,否则“站点改版”会被误读成“职位全部下线”。
  • 表现层信号(CSS class)优于自然语言文本。同一份文档里,“no longer accepting applicants” 只是给人看的佐证,真正被代码判定的是c-jobs-article-expired这个类名。
  • 不可读率是有限额的基础设施指标。maxUnreadablePercent = 5意味着任何一个稳定超过 5% 的“不可读”来源都必须被重新归类,否则它不是数据问题,而是让关闭机制永久失效的配置问题。
  • 限流要按窗口而非瞬时判断。Airswift 两次“短样本干净、真实爬取全量 429”的经历说明,手工探测得出的安全速率不能直接当作预算。

相关阅读:Airswift 适配器源码、测试、sitemap 共享助手、ld+json 共享助手、节流器与速率常数、board 作用域关闭判定、批次整体设计。

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

【免费下载链接】freehire

freehire — the open-source search engine for job seekers

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

相关推荐

上一篇:grok-build v0.2.87 版本解读:自动订阅升级、/docs 导航与按模型推理强度配置
下一篇:最硬核的SSH陷阱:Endlessh如何用代码混淆技术抵御攻击

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

RELATED READING

延伸阅读

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