career-ops 求职扫描器 Provider 接入完全指南:契约、安全护栏、测试与 PR 清单
2026/9/8 16:55:49 网站建设 项目流程

career-ops 求职扫描器 Provider 接入完全指南:契约、安全护栏、测试与 PR 清单

【免费下载链接】career-opsOpen-source AI job search: scan job portals, evaluate listings into a structured A-H report with a global 1-5 score, tailor your CV, track applications — runs locally in your AI coding CLI (Claude Code, Codex, OpenCode, Antigravity…)项目地址: https://gitcode.com/GitHub_Trending/ca/career-ops

本指南以 providers/ADDING_A_PROVIDER.md 为骨架,系统讲解 career-ops 中 provider 插件的接入规范:从「什么数据源有资格被收录」,到默认导出契约、强制安全护栏、分页与重试策略、测试要求,再到合并前的完整 PR 清单。读完本文,你将能对照清单为公开职位源编写一个符合项目规范、可直接被扫描器加载并通过全套测试的providers/{name}.mjs模块。

适用前提:career-ops 是一个在本地 AI 编码 CLI 中运行的求职工作流仓库,其扫描器通过scan.mjs拉取职位并评估为结构化的 A–H 报告。所有内容均基于当前仓库实际文件(如providers/_registry.mjsproviders/_http.mjsverify-portals.mjs等),各数据源按来源分为 ATS API、RSS/JSON 订阅源与服务端渲染的 HTML 页面三类。

Provider 是什么:一种数据源,一个模块

在 career-ops 中,provider 就是providers/{name}.mjs这样的一个模块,它把一个公共、无需登录的职位源(某个 ATS 的 API、一个 RSS/JSON 订阅,或一个服务端渲染的 HTML 列表页)映射成扫描器统一的Job结构。scan.mjsverify-portals.mjs都通过 providers/_registry.mjs 加载这类模块——不需要任何手工注册,把文件丢进providers/目录即可被识别

这一点在源码中有直接印证:loadProviders()readdirSync读取目录下所有以.mjs结尾且不以_开头的文件并importproviders/_registry.mjs)。以_开头的文件(如_http.mjs_html-entities.mjs_types.js)被当作共享帮助模块,永远不会被当作 provider 加载。

从架构上看,这一层处于文档与扫描流程中的 "Discovery —scan.mjs+providers/" 环节(见 ARCHITECTURE.md)。类型权威目录是 providers/_types.js(纯 JSDoc 注解,运行期契约由scan.mjs校验);ADDING_A_PROVIDER.md则是清单与需求集合。编写新 provider 的最佳起点是「镜像一个同形态的现有模块」——参考模块对照表见下文第 4 节。

写代码之前:这个数据源够格吗?

能写出一个可用的 provider 还不够——它读取的数据源必须首先通过 CONTRIBUTING.md 中约定的 Source Indexing Policy。这一判定针对的是数据本身的性质,而不是客户端代码怎么写。

单一公司 ATS 适配器:天然合格

如果新模块是某个单一公司的 ATS 适配器(新的 Greenhouse / Workday / Ashby 类厂商,或某公司自有的招聘 API),那么它天然合格:职位本身就是雇主自己的,适配器也只读一个来源。无需额外动作,直接跳到第 1 节。

职位板 / 聚合器 / 人才网络:政策真正起作用的地方

对这类来源,评审以数据为中心,核心判据包括:

  • 真实、可归因到雇主、对求职者免费——列表能解析到可识别的雇主,且求职者无需付费或注册即可阅读和申请。职位列表或申请流程若有 paywall 即被否决。
  • 一个 provider 只对应一个来源——provider 只读自己的数据源。把别的职位板帖子再发布一遍的"元聚合器"不是 career-ops 索引的对象;跨来源聚合属于 core 的职责。
  • 完整库存、无付费置顶——provider 必须遍历来源的完整库存,而不是某个推广位或默认过滤后的视图。

若此类来源由运营商运行、或其资格不明显,应在写代码前先提交 source proposal(issue 模板)——在设计文档上做路由决策远比在成品 PR 上做便宜。规则如何被应用到实际来源,记录在 docs/SOURCE_INDEXING_LOG.md。

1. Provider 契约

一个providers/{name}.mjs文件(不能以_开头)导出一个default对象:

// @ts-check /** @typedef {import('./_types.js').Provider} Provider */ /** @type {Provider} */ export default { id: 'unique-id', // required, unique across all providers detect(entry) { ... }, // optional: claim a portals.yml entry async fetch(entry, ctx) { ... }, // required: return Job[] };

各字段的要点如下:

  • id(必填,全局唯一)——若重复,先加载的 provider 生效,后加载的文件会被跳过并给出告警。这在_registry.mjsloadProviders()中有明确实现:if (providers.has(p.id)) { console.error(duplicate ...); continue; }
  • detect(entry)(可选)——返回{ url }null。路由顺序(见_registry.mjsresolveProvider(),与文档描述一致):(1)portals.yml条目上显式的provider: {id}字段直接命中、完全绕过detect();(2) 当条目配置了parser.command+ 脚本时,由local-parser接管;(3) 否则按字母序逐个调用各 provider 的detect()先命中者胜。三种合法的detect()形态:
    1. URL-pattern——用entry.careers_url/entry.api匹配已知的宿主模式(如 greenhouse.mjs、lever.mjs、remotli.mjs)。品牌化 / 无法识别的域名不得用 URL-patterndetect()匹配——必须用形态 2 或 3,以免 provider 越权认领用户并未指向它的条目。
    2. Explicit-only——return entry?.provider === '{id}' ? { url: FEED_URL } : null,适用于没有单条目 URL 的全站 feed(如 larajobs.mjs)。
    3. 省略detect()——则该 provider 只能靠portals.yml中的显式provider: {id}到达(如 yourator.mjs)。
  • fetch(entry, ctx)(必填)——必须使用ctx.fetchJson/ctx.fetchText绝不裸用fetch);需要读响应头时用ctx.fetchResponse拿原始Response。可选使用ctx.maxPagesctx.sleep(ms)。返回归一化后的Job[]
  • Job结构——titleurl(必填、必须为绝对 URL——它就是去重键)、companylocation为必填/基本字段;可选postedAt(epoch 毫秒)与description只有在列表 payload 免费携带 description 时才填充它(不额外发单职位请求——扫描器是 zero-token 的)。唯一例外是 opt-in 的富化:条目设置fetchDetails: true(外加可选detailLimit上限)时,provider 才按职位抓取详情填充description,并受detailLimit约束,且健康探测运行时完全跳过该富化(当前为 vdab.mjs 与 smartrecruiters.mjs)。
  • 同一职位暴露多个候选 URL 时——通常是聚合器同时携带雇主上游 ATS/申请链接与自身帖子页。此时Job.url雇主链接(Source Indexing Policy 规则 2:"通往雇主的最近可验证路径");来源自身页面仅在上游链接缺失或非https:时作为回退。参照 yourator.mjs 中的resolveYouratorUrl及 remotli.mjs 中的等价实现。单一公司 ATS 适配器只有一个自然的职位 URL,无需抉择——那个 URL 就是规范 URL。

在 providers/_types.js 中,以上契约被完整形式化为ProviderJobPortalEntryContextFetchOptions等 JSDoc@typedef,并注明运行期契约由scan.mjs(id 存在性、fetch 是函数、fetch 返回数组)而非注解强制。值得一提的还有可选的dedupKey(job)——当 URL 归一化不足以去重时(例如同一 Workday 租户下多个站点路径指向同一 requisition),它返回一个 provider 作用域的标识符,null时回退到基于 URL 的去重。

tracked_companies:job_boards:两个列表

portals.yml把条目放在两个列表里,而 provider 层是两者共享的:

  • tracked_companies:——每个雇主一条(单一来源)。
  • job_boards:——每个聚合器/feed 一条(多个雇主)。

两者使用完全相同的条目契约name/careers_url/api/provider/parser)、同一套detect()和同一个 registry;detect(entry)拿到的是同一种entry形状。单一公司 provider 按tracked_companies:编写与测试,聚合器/feed 按job_boards:编写与测试。文件头部注释必须写明目标列表(参照 remotli.mjs、yourator.mjs)。

2. 强制安全护栏

这是本清单中最不能省略的部分——每一条都源于真实事故(仓库中以 issue 号追踪),而非空想。

SSRF 加固

  • 每次fetchJson/fetchText都必须传redirect: 'error'_http.mjs默认是redirect: 'follow'——那是有意的设计默认值,但对 provider 不是安全默认值:服务端重定向可能把请求引向内部地址。
  • 若最终 URL 由portals.yml数据(entry.api/entry.careers_url)拼接而来,在任何网络调用之前就要用白名单校验 hostname。参照 greenhouse.mjs 的assertGreenhouseUrl:先解析 URL(畸形直接抛错),拒绝非https:协议,拒绝不在ALLOWED_GREENHOUSE_HOSTS集合中的 hostname。仓库实测:该文件第 141–178 行区域中,detect()fetch()均在ctx.fetchJson前调用assertGreenhouseUrl,并在请求选项中显式传redirect: 'error'——注释写明这是"combined with assertGreenhouseUrl above it guarantees the final hostname stays in the allowlist"。
  • 若整个 URL 由 provider 用固定的字面量 host 拼装,则不需要白名单,但redirect: 'error'仍然必需。
  • jobvite.mjs 与telegram-channel.mjs改用redirect: 'manual':同样不跟随任何跳转,且抛出的错误携带Location头,于是"302 到登录页"能读成具名失败而非笼统错误。_http.mjsredirect:'manual'下把 3xx 当作非 ok 响应返回,并把location挂到错误对象的.location上。

防御性解析

一次会抛出的fetch()丢掉该轮目标的整个条目集,而不只是坏的那一条——异常还会浮出为运行错误,并在verify-portals/doctor --strict里显示为missing("board 404s, will silently drop"):这是误报,而不是诚实的 "empty"。因此畸形条目应该continue/ 返回null+.filter,绝不能让fetch()抛出。具体分三种情形:

  • 空或无内容的 bodynull{}[]{jobs: null})——"端点活着,但什么都没匹配到" → 返回[]

  • body 结构与文档明显不符(预期的嵌套容器缺失或类型错误、键完全对不上)→ 允许(且通常更好)抛出一个描述性异常(说出你实际拿到的键名):这能把一次无声的 API 变更暴露出来,而不是让职位板永远静默返回0。参照 ibm.mjs 的parseIbmResponsescan.mjs也会对fetch()返回非数组的情况抛错;verify-portals负责捕获。

  • 分页 provider 的循环终止条件读原始页面形态(如json.hits.hits.length < PAGE_SIZE):解析器返回[]不够——必须守住这个边界或故意抛错(这就是下文的 "fail loud vs 手交半个职位板" 之选)。

  • 日期:把日期字符串喂给Date.parse可能得到NaN。不要写Date.parse(s) || undefined(它会把合法的 epoch0——即1970-01-01时间戳——也抹成 undefined),改用 NaN 安全的辅助函数;其!value守卫用于字段缺失/为空的情况,发生在解析之前:

    function toEpochMs(value) { if (!value) return undefined; const parsed = Date.parse(value); return Number.isNaN(parsed) ? undefined : parsed; }
  • 缺少必填字段(titleurl)的行——过滤掉,不抛错。

对宿主控制的id/slug做 URL 编码

job.url由响应字段(idslugrefnr)在.map()/for循环内拼接时,该段必须用共享帮助函数safeEncodeURIComponent(文档约定位于 providers 目录的帮助模块_safe-url.mjs),而不是裸encodeURIComponent

import { safeEncodeURIComponent } from './_safe-url.mjs'; // ... const seg = safeEncodeURIComponent(job.id); if (seg === null) continue; // or .filter(Boolean) on the .map() output const url = `https://example.com/jobs/${seg}`;

原因:encodeURIComponent遇到孤立的 UTF-16 代理对会抛URIError,而"\uD800"这类转义能逃过JSON.parse存活下来——一旦抛出就跳出循环,scan.mjs按公司的catch会连本页已解析的所有帖子一起丢掉。该辅助函数改为返回null,于是你恰好丢弃那一条坏帖子——和"没有id的帖子"同等待遇。它刻意返回null而不是U+FFFD替换符:一个劣化值会以畸形 UTF-8 流入data/scan-history.tsv、tracker 与生成的文档;而当编码值同时是去重键时(如arbeitsagenturvdab),会把不同的坏帖子撞到同一个键上。行为级测试集中在共享用例(文档约定的tests/providers/url-encoding-surrogate.test.mjs,覆盖alibababamboohrphenom等接线方)。

适用范围有精确边界:只覆盖"宿主控制的 API 字段在循环中变成 URL 路径段"这一种情形。配置派生的值(来自portals.yml的公司 slug、关键词、locale)、已自带 try/catch 的调用、已按 slug 字符集校验过的值,都保持不变——为配置里一个坏字符丢掉一条真实帖子是错误权衡。

镜像方向的解码同样危险:对抓取到的 href 片段做decodeURIComponent遇到畸形百分号转义(如%ZZ)也会抛URIError——应包在 try/catch 中,失败时回退到原始片段(参照 workday.mjs、successfactors.mjs、rheinmetall.mjs)。

HTML 实体——用共享解码器

如果 provider 解析 HTML/XML(而非 JSON API),实体(&amp;&#252;)必须经 providers/_html-entities.mjs 解码:

import { decodeEntities } from './_html-entities.mjs';

绝不要写本地副本。项目对这类 bug 有明确前科(#1555、#1639——合并的decimal|hex正则会把一种形式静默误解析成另一种),且源码级测试会在私人解码器被重新引入时失败(#2902)。

绝对页数上限(MUST)

对于分页 provider,页数绝不能只由来源上报的值pagination.pageCounttotal)决定——那是不可信的第三方数据,一份增长或篡改的响应会把一条portals.yml行变成无界请求循环(没有按 provider 的超时,只有按请求的超时)。你必须定义自己的常量,独立于ctx.maxPagesentry.max_pages

const DEFAULT_MAX_PAGES = 100; // when the entry sets no max_pages const MAX_PAGES_CAP = 1500; // hard ceiling even for a user override // — neither is tied to ctx.maxPages or to // what the source reports function resolveMaxPages(entry) { const v = entry?.max_pages; if (Number.isInteger(v) && v > 0) return Math.min(v, MAX_PAGES_CAP); return DEFAULT_MAX_PAGES; }

来源上报的值只能通过Math.min(...)与这个上限进入公式,绝不可单独决定页数。参照 workday.mjs(第 23/28/83 行的DEFAULT_MAX_PAGES = 100MAX_PAGES_CAP = 1500resolveMaxPages()与文档示例逐字一致)。当上限截断了列表时,要警告用户("raise max_pages on this entry"),以免把部分列表误当成完整列表。

此外,来源上报的total可能不只是缺失而是错的——某些后端会静默钳制它,所以"按 total 限界走完"并不能证明完整(见 workday.mjs 的 facet 拆分,#3310)。

ctx.maxPages与健康探测

当设置了ctx.maxPages时,说明正在运行的是verify-portals存活探测(传maxPages: 1),不是扫描。由此引出两条规则:

限制遍历(SHOULD)。走到ctx.maxPages页即停,并跳过任何按帖子的fetchDetails/ 详情富化(smartrecruitersvdab)——探测用不上它。参照 workday.mjs:

const ctxMaxPages = Number(ctx?.maxPages); const ctxCap = ctxMaxPages > 0 ? ctxMaxPages : Infinity; const pagesToFetch = Math.min(resolveMaxPages(entry), ctxCap);

忽略该提示的 provider 不算——探测会用硬性PROBE_REQUEST_BUDGET(4 个请求,见 verify-portals.mjs 第 458 行)包住ctx.fetchJson/ctx.fetchText,下一次调用即抛ProbePageBudgetReached(第 450 行定义的类),所以每个 provider 无论是否配合都被限制住。但忽略它会让探测变慢、向数据源发出真实请求,而且按 provider 的单测会断言maxPages: 1下恰好只发一次列表请求。

探测期间不要把ctx.fetch*的 rejection 包起来或吞掉(MUST,若你有按页catch)。verify-portalserr instanceof ProbePageBudgetReached识别预算截断,并把它解读为"端点存活、计数未知"而不是"职位板坏了"。如果某个按页/按关键词的catch把它吞成[]或重抛成new Error(...),探测就会把一个健康的职位板误判为missing。因此当设置了ctx.maxPages时,ctx.fetch*的 rejection 必须原样向上传播;而在真实扫描(无ctx.maxPages)中,recall-first 的"吞掉并保留已得页面"行为仍然没问题——vdab.mjs 展示了两个分支。

"raisemax_pages"警告必须始终与entry.max_pages/DEFAULT_MAX_PAGES触顶绑定——绝不能挂在ctx.maxPages截断或探测预算截断上。

分页节流与重试(分页 provider)

大职位板的全量遍历是 100+ 个顺序请求(workdayradancy),且若干来源躲在按突发限流的 WAF 后面。两个机制只要 provider 分页就被期望:

  • 页间延迟。模块常量,只作用于首页之后的页:if (page > 0) await sleep(INTER_PAGE_DELAY_MS, ctx)。从 providers/_http.mjs 导入sleep(它尊重 ctx 提供的测试时钟,便于测试不用真实等待)——不要手写本地副本。150–250 ms 是常态;只有实际观测到限流才提高(careervietitviec用了 750 ms),或按公开的速率限制来定(agentic-jobs对 30 req/60 s 用 2100 ms)。别给从未抱怨过的 feed 过度镀金。
  • 有界重试。每个页面请求——以及分页前可能存在的、一次性解析配置的请求——都包在fetchJsonWithRetry/fetchTextWithRetry_http.mjs)里。它们对 429、任何 5xx 和传输错误(超时 / 中止 / DNS)做指数退避 + 抖动重试;绝不重试非 429 的 4xx 或已拒绝的重定向。Retry-After头会被尊重但被钳制,所以恶意的Retry-After: 86400拖不垮整轮扫描。默认策略是{ retries: 2, baseDelayMs: 500, maxDelayMs: 8_000 };用第 4 个policy参数改节奏(workday.mjs、oraclecloud.mjs 因其 API 在 WAF 后面用了{ retries: 3 })。

耗尽后的处置是你的决定,不是帮助函数的。withRetry会重新抛出,错误对象上携带.attempts(真实请求次数)。按 provider 决定:保留已收集的页面并warn(workday.mjs),或者宁可 fail loud 也不交出静默的半个职位板(a16z-speedrun-talent.mjs)。无论哪种,"raisemax_pages"警告都不得在分页因抓取错误停止时触发——那句话的意思是页数上限截断了健康职位板,而不是职位板坏了。

健康检查覆盖(verify-portals

npm run verify:portalsnode validate-portals.mjs(以及委托给前者的doctor.mjs --strict)会同时扫过tracked_companiesjob_boards——两个列表共享同一套条目 schema 与同一个 enabled-name 命名空间(同名职位板与公司会被标记)。verify-portals用两层来探测可达性:

  1. tier 1——当tracked_companiescareers_url/api带有可识别的 ATS slug 时,直接探测 Greenhouse / Ashby / Lever 的 slug。只有这三种 ATS 才有suggested修复,所以 fix-slugs.mjs 只改写它们的 slug——job_boards聚合器是 provider 层条目,永不携带suggested备选。
  2. tier 2——其余每个条目(Workday、SmartRecruiters、品牌招聘页、任何job_boardsfeed)交给扫描器的 provider 层并传ctx.maxPages: 1,让检查真的调用fetch(entry, ctx)

没有任何 provider 认领的条目(无provider:、无detect()命中)落入skipped——这是覆盖率漏洞,不是 "ok";--strict只让missing(存活探测却 404)变红,从不让skipped变红。所以你的 provider 在portals.example.yml(见第 5 节清单)里的条目必须被detect()认领或带显式provider:——无论它位于哪个列表。

超时与 User-Agent

使用 providers/_http.mjs(fetchJson/fetchText/makeHttpCtx)——它已内置AbortController超时(默认 10 秒,慢 feed 可在单次调用选项里传timeoutMs调高)与共享 User-Agent;非 2xx 响应会抛出携带.status.body.retryAfterError(源码第 48–51 行正是这样挂载属性)。若来源通过 WAF/CDN 屏蔽默认 UA,从同一模块导入BROWSER_LIKE_USER_AGENT——不要自造常量。

只读公共、无认证的来源

provider 只能读取无需登录的开放 API/feed。把用户数据(CV、求职管线)发送到外部服务不属于 core 的范畴(见 CONTRIBUTING.md 的 "What we do NOT accept")。

基于浏览器的扫描器(仅限独立脚本)

有些来源没有可达的 API、只在浏览器里渲染列表(scan-interamt.mjs 是前例,Dayforce 招聘站同构)。驱动真实浏览器(Playwright)的扫描器只能作为独立的顶层脚本被接受,永远不作为providers/*.mjs模块,且满足三个条件:

  1. 独立。scan-<source>.mjs形式交付,自带 npm script、测试、docs/SUPPORTED_JOB_BOARDS.md 行、portals.example.yml段落与SYSTEM_PATHS条目。providers/保持只做 fetch。
  2. 只读公共页面。只读任何访客都看得到的内容:无登录、无真实用户的 session/cookie、无认证区域。
  3. 无绕过。绝不解算或转发 CAPTCHA,绝不伪装其他客户端的 cookie、token 或头。若来源在公共列表前放了交互式挑战,扫描器以具名错误报告并停止,而非绕过去。

浏览器扫描器比 provider 更慢更脆(标记变更就会破坏它),所以在 PR 里要说明实测内容:哪些页面、多少条列表、以及该来源对裸fetch的应答——让评审者看清为什么 provider 不够用。

3. 测试

每个 provider 一个文件:tests/providers/{name}.test.mjs。它是自动发现的(tests/**/*.test.mjs),无需在 test-all.mjs 里注册。RSS/HTML provider 应导出其纯解析函数以便直接做单元测试。

不要重复验证共享帮助函数(URL 编码安全、HTML 实体)——它们有自己的测试。provider 测试只检查本 provider 的输出确实经过了它们。必须覆盖:

  • provider 的id
  • detect()——对 URL-patterndetect():正向用例;不可信 host、非 HTTPS、畸形 URL、null/ 非字符串 / 缺失careers_url全部 →null(不得抛出)。对 explicit-onlydetect()entry.provider命中时返回{ url }且无需存在careers_url/api;其余 →null
  • fetch():对来源真实响应形态的归一化;缺必填字段的行被过滤;每次请求都传redirect: 'error'——断言opts.redirect === 'error',而不仅是"调用发生过";白名单守卫在fetchJson/fetchText被调用之前抛出。
  • 空或无内容 body →[];body 形态不是端点文档所述 → 描述性抛出。两个分支都要断言。
  • 分页(若有):即便来源上报更多页,provider 自己的DEFAULT_MAX_PAGES也能截停它;ctx.maxPages会更早截停。
  • 分页 + 瞬时故障(若有):第 2 页出现重试无法清除的 429 / 5xx 时,要么保留第 1..N 页并警告,要么 fail loud——取决于你的选择——并且 "raisemax_pages"警告在该抓取错误停止点上触发。
  • 探测协作(若分页):ctx.maxPages: 1下恰好一次列表请求、无fetchDetails/ 富化调用;且ctx.maxPages被设置期间ctx.fetch*的 rejection 原样传播——既不吞成[],也不重包装(参照vdab.test.mjs)。
  • HTML 解析(若有):fixture 标题带实体时,必须在关键词匹配之前已被解码(编码的&amp;不得让职位丢失——#2923),而不只是断言decodeEntities被调用过。

如果job.url在循环里由宿主控制的id/slug拼装,还要往共享的url-encoding-surrogate测试集里加一个行为用例(一批数据中一个孤立代理值 + 一个干净值 → 不抛错、干净帖子保留、坏帖被丢)。该共享文件还承载跨 provider 的源码守卫(url:行不得裸用encodeURIComponent、用到的位置必须导入帮助函数)——你自己的{name}.test.mjs无需为此添加内容。

到达调用的 fixture 值(name/careers_url/api)都应是虚构的(AcmeExampleCoBigCo),绝不使用真实公司。欢迎添加引用真实观测数据的注释(例如为何选择某个页数常量)——它证明数字不是拍脑袋定的。

开发循环:node test-all.mjs --only providers/{name}。提交 PR 前:完整跑node test-all.mjs--only不是合并门槛)。

4. 参考模块对照表

你需要什么示例
简单 JSON API,无分页providers/greenhouse.mjs +tests/providers/greenhouse.test.mjs
尊重ctx.maxPages的分页providers/workday.mjs
用共享decodeEntities做 HTML 抓取providers/icims.mjs
HTML 内的 SSR JSON(__NEXT_DATA__providers/join.mjs
进程内解析 RSSproviders/larajobs.mjs
job.url由宿主控制的id/slugsafeEncodeURIComponent拼装providers/phenom.mjs、providers/bamboohr.mjs
经共享帮助函数做重试/退避,默认策略providers/a16z-speedrun-talent.mjs、providers/getro.mjs
经共享帮助函数做重试/退避,自定义策略providers/workday.mjs、providers/oraclecloud.mjs
检测被钳制的total,经查询扇出 + 去重恢复providers/workday.mjs(facet 拆分)

fetchJsonWithRetry/fetchTextWithRetry(providers/_http.mjs)接受可选第 4 参数policy: { retries, baseDelayMs, maxDelayMs },供调优需求不同于共享默认({ retries: 2, baseDelayMs: 500, maxDelayMs: 8_000 })的 provider 使用。从_http.mjs的源码看,共享实现已经帮你处理好了这些棘手语义:429/5xx/传输错误判定(isRetryableError)、被拒绝的重定向不重试(isRefusedRedirectError)、Retry-After解析且被钳制、抖动封顶在maxDelayMs之下、每次重试把真实请求数写到err.attempts——这些都不该在每个 provider 里重新推导。

5. 合并前(Pre-PR)清单

对照清单逐项自检,全部通过再提 PR:

  • 职位板 / 聚合器 / 人才网络专用:来源通过 Source Indexing Policy——真实可归因到雇主的列表、对求职者免费、一个 provider 只对应一个来源(非其他职位板的元聚合器);运营商运行或边缘情形 → 先开 source proposal。单一公司 ATS 适配器跳过此条。
  • 若帖子 payload 同时携带雇主上游 URL 与来源自身页面,job.url取雇主 URL(规则 2)、来源页面仅作回退。(单一来源 ATS 只有一个 URL。)
  • id唯一;文件名不以_开头。
  • detect()对垃圾输入永不抛错;返回null而非失败。
  • 每个网络调用都传redirect: 'error';配置派生的 URL 在请求前过白名单。
  • fetch()对空或无内容 body(null/{}/[]/{jobs: null})返回[];对真实 API 错误或非文档所述的外壳形态抛出。单个坏行被跳过(continue/null+.filter),不致命拖垮整个目标。
  • 日期 NaN 安全(toEpochMs模式)。
  • HTML/XML 实体走 providers/_html-entities.mjs,无本地副本。
  • 由逐帖id/slug拼装的job.urlsafeEncodeURIComponentnull→ 丢弃该帖;url:行无裸encodeURIComponent。抓取的 href 片段传给decodeURIComponent时包 try/catch 并回退原始片段。
  • 分页有自己的DEFAULT_MAX_PAGES——页数永不由来源单独决定(pageCount/total)。
  • 分页在ctx.maxPages存在时遵守它、期间跳过fetchDetails富化,且按页catch在探测期间把ctx.fetch*的 rejection原样传播(让ProbePageBudgetReached身份存活)。
  • 分页:经共享sleep的页间延迟(仅首页之后的页);页面请求包fetchJsonWithRetry/fetchTextWithRetry;写明耗尽策略(保留部分 + 警告,或 fail loud),且不误触发 "raisemax_pages" 警告。
  • tests/providers/{name}.test.mjs覆盖第 3 节全部内容。
  • node test-all.mjs全套绿灯(不只是--only)。
  • 在 docs/SUPPORTED_JOB_BOARDS.md 按职位板名字母序加一行(该表是排序的)。
  • 更新 templates/portals.example.yml:(1) 若detect()匹配 host,在 "Provider auto-detection" 下加 URL-pattern 行;否则在段落里加显式provider:;(2) 在 "Built-in provider examples" 块里,给分组加注释掉的Example {Name} Co段落(每个字段都用默认值),逐字照抄相邻分组的(→ tracked_companies: …)/(→ job_boards: …)表头格式——对没有detect()的 provider,还要在 "Provider auto-detection" 部分末尾的 "job boards / aggregators … explicitprovider:" 列表里加其 id(URL-patterndetect()已被 (1) 覆盖);(3) 在匹配的地区/主题分区里放一条真实、未注释的条目,仅当其携带的信息多于 (2) 中的段落时——真实careers_url/api、可解析的 slug 或 board id、或 provider 特有键(每个公司 ATS,以及任何带 slug/URL 的职位板如 Getro)。对不带逐条目 URL 或配置的裸provider: {id}feed(地区性职位板、RSS feed)跳过 (3)——那里真实条目与 (2) 逐字节相同。
  • 在改既有 provider 而非新增?上述SUPPORTED_JOB_BOARDS.mdportals.example.yml条目在变更时也要"同步维护"。若修复改变了可观测行为(分页、默认值、URL 格式、什么算错误或空职位板),用grep检索仓库里对该行为的每处文字描述——按 provider 名、也按变更实质,而不只是函数名——并在同一 PR 中一并修正。

小结:一次接入,贯穿三份文档

ADDING_A_PROVIDER.md通读下来会发现,接入新职位源本质上是在三条证据线上对齐:资格线(Source Indexing Policy,决定数据源能不能进)、实现线(providers/_types.js 的契约 + providers/_registry.mjs 的零注册加载 + providers/_http.mjs 的共享传输层,安全、超时、重试、节流都收敛在共享帮助函数里)、以及验证线tests/providers/{name}.test.mjs+verify-portals探测语义)。只要新模块的每个网络调用都经过ctx.fetch*、每条护栏都在 Pre-PR 清单上打勾、全套node test-all.mjs保持绿色,你的 provider 就能与仓库里 90+ 个既有模块(greenhouseworkdaylever等)以同一套规则稳定运行,并被scan.mjs无缝纳入职位发现流程。

【免费下载链接】career-opsOpen-source AI job search: scan job portals, evaluate listings into a structured A-H report with a global 1-5 score, tailor your CV, track applications — runs locally in your AI coding CLI (Claude Code, Codex, OpenCode, Antigravity…)项目地址: https://gitcode.com/GitHub_Trending/ca/career-ops

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询