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.mjs、providers/_http.mjs、verify-portals.mjs等),各数据源按来源分为 ATS API、RSS/JSON 订阅源与服务端渲染的 HTML 页面三类。
Provider 是什么:一种数据源,一个模块
在 career-ops 中,provider 就是providers/{name}.mjs这样的一个模块,它把一个公共、无需登录的职位源(某个 ATS 的 API、一个 RSS/JSON 订阅,或一个服务端渲染的 HTML 列表页)映射成扫描器统一的Job结构。scan.mjs与verify-portals.mjs都通过 providers/_registry.mjs 加载这类模块——不需要任何手工注册,把文件丢进providers/目录即可被识别。
这一点在源码中有直接印证:loadProviders()会readdirSync读取目录下所有以.mjs结尾且不以_开头的文件并import(providers/_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.mjs的loadProviders()中有明确实现:if (providers.has(p.id)) { console.error(duplicate ...); continue; }。detect(entry)(可选)——返回{ url }或null。路由顺序(见_registry.mjs的resolveProvider(),与文档描述一致):(1)portals.yml条目上显式的provider: {id}字段直接命中、完全绕过detect();(2) 当条目配置了parser.command+ 脚本时,由local-parser接管;(3) 否则按字母序逐个调用各 provider 的detect(),先命中者胜。三种合法的detect()形态:- URL-pattern——用
entry.careers_url/entry.api匹配已知的宿主模式(如 greenhouse.mjs、lever.mjs、remotli.mjs)。品牌化 / 无法识别的域名不得用 URL-patterndetect()匹配——必须用形态 2 或 3,以免 provider 越权认领用户并未指向它的条目。 - Explicit-only——
return entry?.provider === '{id}' ? { url: FEED_URL } : null,适用于没有单条目 URL 的全站 feed(如 larajobs.mjs)。 - 省略
detect()——则该 provider 只能靠portals.yml中的显式provider: {id}到达(如 yourator.mjs)。
- URL-pattern——用
fetch(entry, ctx)(必填)——必须使用ctx.fetchJson/ctx.fetchText(绝不裸用fetch);需要读响应头时用ctx.fetchResponse拿原始Response。可选使用ctx.maxPages与ctx.sleep(ms)。返回归一化后的Job[]。Job结构——title、url(必填、必须为绝对 URL——它就是去重键)、company、location为必填/基本字段;可选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 中,以上契约被完整形式化为Provider、Job、PortalEntry、Context、FetchOptions等 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.mjs在redirect:'manual'下把 3xx 当作非 ok 响应返回,并把location挂到错误对象的.location上。
防御性解析
一次会抛出的fetch()会丢掉该轮目标的整个条目集,而不只是坏的那一条——异常还会浮出为运行错误,并在verify-portals/doctor --strict里显示为missing("board 404s, will silently drop"):这是误报,而不是诚实的 "empty"。因此畸形条目应该continue/ 返回null+.filter,绝不能让fetch()抛出。具体分三种情形:
空或无内容的 body(
null、{}、[]、{jobs: null})——"端点活着,但什么都没匹配到" → 返回[]。body 结构与文档明显不符(预期的嵌套容器缺失或类型错误、键完全对不上)→ 允许(且通常更好)抛出一个描述性异常(说出你实际拿到的键名):这能把一次无声的 API 变更暴露出来,而不是让职位板永远静默返回
0。参照 ibm.mjs 的parseIbmResponse。scan.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; }缺少必填字段(
title、url)的行——过滤掉,不抛错。
对宿主控制的id/slug做 URL 编码
当job.url由响应字段(id、slug、refnr)在.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 与生成的文档;而当编码值同时是去重键时(如arbeitsagentur、vdab),会把不同的坏帖子撞到同一个键上。行为级测试集中在共享用例(文档约定的tests/providers/url-encoding-surrogate.test.mjs,覆盖alibaba、bamboohr、phenom等接线方)。
适用范围有精确边界:只覆盖"宿主控制的 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),实体(&、ü)必须经 providers/_html-entities.mjs 解码:
import { decodeEntities } from './_html-entities.mjs';绝不要写本地副本。项目对这类 bug 有明确前科(#1555、#1639——合并的decimal|hex正则会把一种形式静默误解析成另一种),且源码级测试会在私人解码器被重新引入时失败(#2902)。
绝对页数上限(MUST)
对于分页 provider,页数绝不能只由来源上报的值(pagination.pageCount、total)决定——那是不可信的第三方数据,一份增长或篡改的响应会把一条portals.yml行变成无界请求循环(没有按 provider 的超时,只有按请求的超时)。你必须定义自己的常量,独立于ctx.maxPages与entry.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 = 100、MAX_PAGES_CAP = 1500、resolveMaxPages()与文档示例逐字一致)。当上限截断了列表时,要警告用户("raise max_pages on this entry"),以免把部分列表误当成完整列表。
此外,来源上报的total可能不只是缺失而是错的——某些后端会静默钳制它,所以"按 total 限界走完"并不能证明完整(见 workday.mjs 的 facet 拆分,#3310)。
ctx.maxPages与健康探测
当设置了ctx.maxPages时,说明正在运行的是verify-portals的存活探测(传maxPages: 1),不是扫描。由此引出两条规则:
限制遍历(SHOULD)。走到ctx.maxPages页即停,并跳过任何按帖子的fetchDetails/ 详情富化(smartrecruiters、vdab)——探测用不上它。参照 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-portals靠err 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+ 个顺序请求(workday、radancy),且若干来源躲在按突发限流的 WAF 后面。两个机制只要 provider 分页就被期望:
- 页间延迟。模块常量,只作用于首页之后的页:
if (page > 0) await sleep(INTER_PAGE_DELAY_MS, ctx)。从 providers/_http.mjs 导入sleep(它尊重 ctx 提供的测试时钟,便于测试不用真实等待)——不要手写本地副本。150–250 ms 是常态;只有实际观测到限流才提高(careerviet、itviec用了 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:portals、node validate-portals.mjs(以及委托给前者的doctor.mjs --strict)会同时扫过tracked_companies与job_boards——两个列表共享同一套条目 schema 与同一个 enabled-name 命名空间(同名职位板与公司会被标记)。verify-portals用两层来探测可达性:
- tier 1——当
tracked_companies的careers_url/api带有可识别的 ATS slug 时,直接探测 Greenhouse / Ashby / Lever 的 slug。只有这三种 ATS 才有suggested修复,所以 fix-slugs.mjs 只改写它们的 slug——job_boards聚合器是 provider 层条目,永不携带suggested备选。 - 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、.retryAfter的Error(源码第 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模块,且满足三个条件:
- 独立。以
scan-<source>.mjs形式交付,自带 npm script、测试、docs/SUPPORTED_JOB_BOARDS.md 行、portals.example.yml段落与SYSTEM_PATHS条目。providers/保持只做 fetch。 - 只读公共页面。只读任何访客都看得到的内容:无登录、无真实用户的 session/cookie、无认证区域。
- 无绕过。绝不解算或转发 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——取决于你的选择——并且 "raise
max_pages"警告不在该抓取错误停止点上触发。 - 探测协作(若分页):
ctx.maxPages: 1下恰好一次列表请求、无fetchDetails/ 富化调用;且ctx.maxPages被设置期间ctx.fetch*的 rejection 原样传播——既不吞成[],也不重包装(参照vdab.test.mjs)。 - HTML 解析(若有):fixture 标题带实体时,必须在关键词匹配之前已被解码(编码的
&不得让职位丢失——#2923),而不只是断言decodeEntities被调用过。
如果job.url在循环里由宿主控制的id/slug拼装,还要往共享的url-encoding-surrogate测试集里加一个行为用例(一批数据中一个孤立代理值 + 一个干净值 → 不抛错、干净帖子保留、坏帖被丢)。该共享文件还承载跨 provider 的源码守卫(url:行不得裸用encodeURIComponent、用到的位置必须导入帮助函数)——你自己的{name}.test.mjs无需为此添加内容。
到达调用的 fixture 值(name/careers_url/api)都应是虚构的(Acme、ExampleCo、BigCo),绝不使用真实公司。欢迎添加引用真实观测数据的注释(例如为何选择某个页数常量)——它证明数字不是拍脑袋定的。
开发循环: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 |
| 进程内解析 RSS | providers/larajobs.mjs |
job.url由宿主控制的id/slug经safeEncodeURIComponent拼装 | 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.url走safeEncodeURIComponent,null→ 丢弃该帖;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.md与portals.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+ 个既有模块(greenhouse、workday、lever等)以同一套规则稳定运行,并被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),仅供参考