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

📅 发布时间:2026/9/8 16:55:59
career-ops 求职扫描器 Provider 接入完全指南:契约、安全护栏、测试与 PR 清单
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结尾且不以_开头的文件并importproviders/_registry.mjs。以_开头的文件如_http.mjs、_html-entities.mjs、_types.js被当作共享帮助模块永远不会被当作 provider 加载。从架构上看这一层处于文档与扫描流程中的 Discovery —scan.mjsproviders/ 环节见 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 proposalissue 模板——在设计文档上做路由决策远比在成品 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。fetch(entry, ctx)必填——必须使用ctx.fetchJson/ctx.fetchText绝不裸用fetch需要读响应头时用ctx.fetchResponse拿原始Response。可选使用ctx.maxPages与ctx.sleep(ms)。返回归一化后的Job[]。Job结构——title、url必填、必须为绝对 URL——它就是去重键、company、location为必填/基本字段可选postedAtepoch 毫秒与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等 JSDoctypedef并注明运行期契约由scan.mjsid 存在性、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()和同一个 registrydetect(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里显示为missingboard 404s, will silently drop这是误报而不是诚实的 empty。因此畸形条目应该continue/ 返回null.filter绝不能让fetch()抛出。具体分三种情形空或无内容的 bodynull、{}、[]、{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而不是裸encodeURIComponentimport { 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而不是UFFFD替换符一个劣化值会以畸形 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实体amp;、#252;必须经 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_pagesconst 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.mjsconst ctxMaxPages Number(ctx?.maxPages); const ctxCap ctxMaxPages 0 ? ctxMaxPages : Infinity; const pagesToFetch Math.min(resolveMaxPages(entry), ctxCap);忽略该提示的 provider 不算错——探测会用硬性PROBE_REQUEST_BUDGET4 个请求见 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 决定保留已收集的页面并warnworkday.mjs或者宁可 fail loud 也不交出静默的半个职位板a16z-speedrun-talent.mjs。无论哪种raisemax_pages警告都不得在分页因抓取错误停止时触发——那句话的意思是页数上限截断了健康职位板而不是职位板坏了。健康检查覆盖verify-portalsnpm 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.mjsfetchJson/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——取决于你的选择——并且 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都应是虚构的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.mjsHTML 内的 SSR JSON__NEXT_DATA__providers/join.mjs进程内解析 RSSproviders/larajobs.mjsjob.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.mjsfacet 拆分fetchJsonWithRetry/fetchTextWithRetryproviders/_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()对空或无内容 bodynull/{}/[]/{jobs: null}返回[]对真实 API 错误或非文档所述的外壳形态抛出。单个坏行被跳过continue/null.filter不致命拖垮整个目标。日期 NaN 安全toEpochMs模式。HTML/XML 实体走 providers/_html-entities.mjs无本地副本。由逐帖id/slug拼装的job.url走safeEncodeURIComponentnull→ 丢弃该帖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: 列表里加其 idURL-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.mjsverify-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),仅供参考