
后端前端搜索引擎网页爬虫数据工程AI 应用可观测性【免费下载链接】freehirefreehire — 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/ldjson 助手以及 pipeline 的 board 作用域判定讲清一个看似局部的判断——“过期就丢弃”还是“标记为不可读”——为什么直接决定整块 board 的 stale-job 关闭机制能否正常工作。读完后你可以独立评估同类“列表可用、详情页大量失效”的站点该如何建模。背景这条适配器从哪来完整批次背景写在 oil gas 机构适配器批次设计 中Orion、Airswift、Brunel、NES Fircroft 四家招聘机构都不跑已知 ATS各有自己的职位检索因此每家一个独立 OpenSpec change、独立分支、独立 PR。Airswift 是其中第二个采用dataart.go的经典形状sitemap 枚举 单页 ldjson 映射而非调用某个平台 API。设计文档在 2026-10-08 对站点做了实测得到四条关键事实观测项实测结论https://www.airswift.com/sitemap.xml扁平urlset约 2930 个loc其中匹配/jobs/slug-id约 1841 条存活职位的 ldjsonJobPosting携带title、HTMLdescription、datePosted、validThrough、employmentType、hiringOrganization、jobLocationhiringOrganization.name每条都是字面量Airswift不是真实终端雇主过期职位返回HTTP 200没有JobPosting块页面渲染div classc-jobs-article-expired ...文案为 “Thank you for your interest in this role, but we are no longer accepting applicants.”最后一条是全篇的支点这是一个表现层但稳定的信号一个 CSS class而不是易碎的自然语言匹配。目标与非目标目标只有两条枚举 sitemap把每个存活职位的 ldjson 映射成Job把过期职位直接丢弃与 404 同一类别而不是每次运行都为全站约 55%–60% 的目录写一个unreadableDetail桩。非目标同样明确不做任何超出常量Airswift的公司解析——设计文档已确认它不是聚合站对未过期页面上缺失/畸形的JobPosting仍然沿用既有的unreadableDetail约定不特殊处理。原因在文档里写得很直接这种情况依旧“语义不明”可能是站点改版与“确认已关闭”不是一回事。核心决策一走解析树找 class不做原始文本子串匹配适配器本来就把页面解析成*html.Node树以提取ldJobPosting所以复用共享的遍历工具几乎零成本。实现位于 airswiftExpiredfunc 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.gowalk是深度优先遍历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文档给定了严格顺序URL 里取不出 id → 丢弃false。与所有 sitemap 枚举型适配器一致取不出 id 的页面本来就不可能进入目录任何 close 作用域都触达不到它。抓取失败 →detailUnreadable(err)判定。这是 helpers.go 的共享逻辑结构化匹配*StatusError404/410视为平台明确答复“职位已不存在”丢弃其它错误超时、拒绝、5xx、解码失败说明“这次没读到”返回unreadableDetail桩。抓取成功但存在c-jobs-article-expired标记 → 直接丢弃false。这一步相对dataart.go/energyjobline.go是新增分支那两个适配器把“页面抓到了但没有 JobPosting”一律当成unreadableDetail因为它们没有可区分“页面真坏了”和“这个职位就是关了”的信号Airswift 有所以用它。无过期标记、也无JobPosting→unreadableDetail。这是dataart.go/energyjobline.go的默认行为语义是“发生了预期之外的事”而非“确认关闭”。有JobPosting→ 正常映射。第 3 步必须在第 4 步之前这点在任务清单里也被单独标注tasks.md 的 2.6“ensure its 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的契约是okfalse即从结果里消失helpers.go而unreadableDetail(...)返回oktrue且Unreadable: true表示“这条职位确实被列出来了但这次没读到内容”。为什么这不只是“少写点桩”stale-sweep 契约设计文档专门用了一节解释这件事而它在源码中能被完整验证。Unreadable的计数不是失败。pipeline 的 Stats 注释写明Unreadable统计“列表点名了、但详情没读到”的职位故意不计入Failed因为平台本身是应答了的。于是 board 会以consecutive_failures 0记录为成功。真正的后果出在 board 作用域关闭board-scoped close的准入条件上。boardReadWhatItListedlisted : 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 runs stale-job close” 日志区别只在于后者是一次性事故前者会是永久状态。反过来把“确认过期”丢进与 404/410 同一类别还有一处细节支持boardReadWhatItListed的分母明确不包含被 404/410 丢弃的职位注释写得很清楚——“平台说它没了”是证据而非空洞一块整体过期的 board 本来就应当允许退休。Airswift 的过期标记提供的正是这种“证据”级的确定性。这也解释了 boardListedAnyPosting 为什么要与boardReachedPostings分开前者是last_yield_at的盖章依据方向必须宽松否则一块“列表正常、详情全挂”的 board 会一路老化成空 feed 并关掉所有职位后者是“是否允许证明覆盖”的权限闸门方向必须严格。两者相差的正是Skipped与Unreadable这两项。传输层为什么 sitemap 与详情页是两个参数文档没有把这点当作决策项但源码注释补上了完整理由。airswift结构体持有两个 gettertype airswift struct { sitemap XMLGetter // the sitemap.xml (one request per run) pages HTMLGetter // per-posting detail pages, rate-paced }因为两条路径的限流表现完全不同sitemap 每次运行只有一次请求详情页在无节流并发下会 429。因此注册时只有pages被包上节流器registry.goNewAirswift(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))——ldjson 里的description是 HTML先反转义再做净化。远程标记Remote: isRemote(location)即从位置文本推断匹配英文remote与俄文удал…没有使用站点结构化字段因为站点未提供。发布时间parseDate(p.DatePosted)按2006-01-02布局解析。测试矩阵五条分支各有对应用例任务清单按 TDD 组织先写失败测试再实现airswift_test.go 的用例与设计的五步顺序一一对应用例输入期望TestAirswiftFetchSitemapThenDetailAndMapssitemap 混入/、/about-us与一条职位只产出 1 条且ExternalID1279815、CompanyAirswift、LocationDoha, Qatar、PostedAt2026-10-01TestAirswiftBrokenSitemapErrorsTheCrawlsitemap 抓取报错Fetch返回 error而不是空成功TestAirswiftExpiredPostingIsDroppedNotUnreadable仅含c-jobs-article-expired的页面okfalse被丢弃不是UnreadableTestAirswiftUnexpectedMissingJobPostingIsUnreadable既无过期标记也无 ldjsonUnreadabletrueCompany回退为配置值TestAirswiftGoneDetailDropsThePosting一条正常 一条 404只保留正常那条TestAirswiftOtherFetchErrorIsUnreadable一条正常 一条超时超时那条产生一个 unreadable 桩TestAirswiftProviderAndBoardless—Provider() airswift且实现boardless“一条坏详情页不影响整块爬取”的上限由共享的defaultDetailWorkers 8与fetchDetails的有界 worker 池保证任一fetch返回okfalse只让该位置留空不会中断其余请求且存活结果保持原有相对顺序。风险与权衡设计文档列了两条都值得原样保留过期标记的类名可能随站点改版变化。文档的处置是“接受”一旦不再命中症状会退化成 energyjobline 形状的“几乎所有职位都 Unreadable”信号而这条信号本身已经足够响亮、也已被同一套监控覆盖。换句话说失败模式是可见的不是静默的。误判会把真实职位悄悄丢掉。这一条在评审后做了独立复核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. 确认数量合理后才加入线上 boardboardless无需 --board go run ./cmd/add-board --providerairswift --companyAirswift --apply第 3 步之所以能省略--board是因为适配器实现了boardless()airswift.go而 Config.Validate 对实现了该标记的 provider 放开Board必填校验。同理BoardDedupeKey 对无 board 的条目直接返回okfalse——它不与任何条目构成重复爬取目标。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 共享助手、ldjson 共享助手、节流器与速率常数、board 作用域关闭判定、批次整体设计。赞分享后端前端搜索引擎网页爬虫数据工程AI 应用可观测性【免费下载链接】freehirefreehire — the open-source search engine for job seekers项目地址https://gitcode.com/GitHub_Trending/fr/freehire点击查看免费下载相关推荐freehire 油气招聘机构适配器设计Orion、Airswift、Brunel 与 NES Fircroft 的抓取实践freehire 油气招聘机构适配器设计Orion、Airswift、Brunel 与 NES Fircroft 的抓取实践 本文以仓库内的设计文档 2026后端前端搜索引擎网页爬虫数据工程AI 应用可观测性六个机制在写 closed_atfreehire 永不硬删除职位的软关闭生命周期设计六个机制在写 closed_atfreehire 永不硬删除职位的软关闭生命周期设计 freehire 是一款开源的职位搜索引擎the open sourc后端前端搜索引擎网页爬虫数据工程AI 应用可观测性freehire 职位生命周期机制深度解析closed_at 的六种写入机制与关闭语义freehire 职位生命周期机制深度解析closed_at 的六种写入机制与关闭语义 本文基于仓库文档 docs/agents/job lifecycle.后端前端搜索引擎网页爬虫数据工程AI 应用可观测性上一篇grok-build v0.2.87 版本解读自动订阅升级、/docs 导航与按模型推理强度配置下一篇最硬核的SSH陷阱Endlessh如何用代码混淆技术抵御攻击创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考