byjackwener· 9 skills
Use when writing an OpenCLI adapter for a new site or adding a new command to an existing site. Guides end-to-end from first recon through field decoding, adapter coding, and verify. Replaces opencli-oneshot / opencli-explorer. For ad-hoc browser driving (no adapter), see opencli-browser instead; for a top-level orientation to opencli, see opencli-usage.
$npx -y skills add jackwener/opencli --skill opencli-adapter-authorInstalls into the current project.
Run `npx skills use "https://github.com/jackwener/opencli" --skill "jackwener/opencli/opencli-adapter-author"` and follow the generated skill instructions now. Read its complete output, redirecting it to a temporary file first if necessary. Resolve relative paths from the supporting-files directory it provides.
Use the skills in "https://github.com/jackwener/opencli" that are relevant to the current task. Run `npx skills add "https://github.com/jackwener/opencli"` and select the relevant skills, then follow their instructions.
| 1 | # opencli-adapter-author |
| 2 | |
| 3 | 你是要给一个站点写 adapter 的 agent。这份 skill 目标:**从零到通过 `opencli browser verify` 的 30 分钟内闭环**。 |
| 4 | |
| 5 | 全程用现有工具:`opencli browser *` / `opencli doctor` / `opencli browser init` / `opencli browser verify`。没有新命令。 |
| 6 | |
| 7 | 调试浏览器型 adapter 时,优先直接带上 `--trace on --keep-tab true --window foreground`。`--trace on` 每轮都落 trace artifact,`summary.md` 是失败/成功复盘入口;`--keep-tab true --window foreground` 让 tab lease 保留且浏览器窗口在前台,方便核对最终页面状态。 |
| 8 | |
| 9 | --- |
| 10 | |
| 11 | ## 前置:看你落在哪 |
| 12 | |
| 13 | 先拿 `coverage-matrix.md` 快速自测。三个问题: |
| 14 | |
| 15 | 1. 数据在浏览器里看得到吗?(否 → 先解决鉴权) |
| 16 | 2. 数据是 HTTP/JSON/HTML 吗?(否 → 不在 skill 范围) |
| 17 | 3. 需要实时推送吗?(是 → 找同数据 HTTP 接口;没有就放弃) |
| 18 | |
| 19 | 三个都 yes 继续。 |
| 20 | |
| 21 | --- |
| 22 | |
| 23 | ## 顶层决策树 |
| 24 | |
| 25 | **先定 strategy,再写 adapter。** 每次进入 Step 3/4 后、写代码前,必须产出一段 strategy note。没有这段 note,不要开始写 `clis/<site>/<name>.js`。 |
| 26 | |
| 27 | 核心判断不是 "API 比 DOM 高级",而是 **数据源有没有外部契约**。实测维护成本显示:公开/官方接口最稳;UI/DOM 语义通常也有用户可见契约;站内未文档化 XHR/GraphQL/signature endpoint 最容易漂。不要为了 "API-first" 把稳定的 UI/DOM 实现盲目迁到无契约内部接口。 |
| 28 | |
| 29 | ```md |
| 30 | Strategy: PUBLIC_API | COOKIE_API | PAGE_FETCH | INTERCEPT | DOM_STATE | UI_SELECTOR |
| 31 | Contract: stable | visible-ui | internal-unstable |
| 32 | Evidence: |
| 33 | - observed request/state: <endpoint / state global / UI-only signal> |
| 34 | - auth source: <none / browser cookie / csrf from meta / localStorage / page runtime> |
| 35 | - replay result: <status + content-type + non-empty sample shape> |
| 36 | |
| 37 | If Strategy is PAGE_FETCH or INTERCEPT: |
| 38 | - why PUBLIC_API / COOKIE_API are unavailable: |
| 39 | - why UI_SELECTOR / DOM_STATE are not safer: |
| 40 | - why the maintenance cost is acceptable: |
| 41 | ``` |
| 42 | |
| 43 | Strategy classes: |
| 44 | |
| 45 | | Strategy | 契约级别 | 用在什么时候 | 证据要求 | |
| 46 | |---|---|---|---| |
| 47 | | `PUBLIC_API` | stable | 不需要登录,Node-side `fetch` 直接拿到目标数据 | 200 + JSON/HTML 含目标数据,不是埋点/广告 | |
| 48 | | `COOKIE_API` | stable | Node-side `fetch` + `page.getCookies()` / header helper 能拿数据 | cookie/CSRF 来源清楚,replay 非空 | |
| 49 | | `UI_SELECTOR` | visible-ui | publish/upload/click/表单,或页面语义比内部接口更稳 | selector 有语义锚点;错误路径是 typed error | |
| 50 | | `DOM_STATE` | visible-ui | 数据在 hydration state / bootstrap JSON / SSR HTML 里 | state key / script JSON / HTML 结构明确 | |
| 51 | | `PAGE_FETCH` | internal-unstable | 只能在页面上下文 `fetch` 才能复用 same-origin/session/runtime | `opencli browser eval fetch(...)` 非空;必须解释为什么避不开内部接口 | |
| 52 | | `INTERCEPT` | internal-unstable | 请求签名复杂,但页面自己能自然发出请求 | 触发 UI 后能截到目标 response;必须解释为什么 UI/DOM 不够 | |
| 53 | |
| 54 | 选择规则:优先 `PUBLIC_API` / `COOKIE_API`。如果 UI/DOM 语义稳定,不要强行升级到 `PAGE_FETCH` / `INTERCEPT`。只有公开/官方接口不可用、UI/DOM 无法表达目标数据或操作时,才承担无契约内部接口的维护成本。 |
| 55 | |
| 56 | 实测:`PAGE_FETCH` / `INTERCEPT` 的 fix 频率约为 `PUBLIC_API` 的 7-8 倍,`UI_SELECTOR` 跟 `COOKIE_API` 同档。详细 ladder 推导、`api_candidates` 证据怎么填、booking #1680 等反例见 [`references/strategy-selection.md`](./references/strategy-selection.md)。 |
| 57 | |
| 58 | 边界:只复用页面自己已经合法获得的数据/能力。不教破解签名、不绕验证码/风控/访问控制;遇到不可复用签名(如必须由页面 runtime 生成且不能安全抽象)就降级到 `UI_SELECTOR` / `DOM_STATE` / `INTERCEPT`。 |
| 59 | |
| 60 | ``` |
| 61 | START |
| 62 | │ |
| 63 | ▼ |
| 64 | ┌──────────────────────────┐ |
| 65 | │ opencli doctor 通? │── no ──→ 修桥接(doctor 输出里的提示) |
| 66 | └──────────────────────────┘ |
| 67 | │ yes |
| 68 | ▼ |
| 69 | ┌────────────────────────────────────────────────────┐ |
| 70 | │ 读站点记忆: │ |
| 71 | │ 1. ~/.opencli/sites/<site>/endpoints.json │ |
| 72 | │ 2. ~/.opencli/sites/<site>/notes.md │ |
| 73 | │ 3. references/site-memory/<site>.md │ |
| 74 | └────────────────────────────────────────────────────┘ |
| 75 | │ 命中 endpoint + 字段 → 直接跳到【endpoint 验证】(不跳写 adapter!memory 可能过期) |
| 76 | │ 没命中 → 继续 |
| 77 | ▼ |
| 78 | ┌──────────────────────────┐ |
| 79 | │ 站点侦察(site-recon) │ → Pattern A/B/C/D/E |
| 80 | └──────────────────────────┘ |
| 81 | │ |
| 82 | ▼ |
| 83 | ┌──────────────────────────┐ |
| 84 | │ API 发现(api-discovery)│ §1 network → §2 state → §3 bundle → §4 token → §5 intercept |
| 85 | └──────────────────────────┘ |
| 86 | │ 拿到候选 endpoint |
| 87 | ▼ |
| 88 | ┌────────────────────────────────────────────┐ |
| 89 | │ 直接 fetch 验证 endpoint(memory 命中也要跑)│── 401/403 ──→ 回到 §4 排 token |
| 90 | │ 数据非空 + 200 │── 空/HTML ──→ 回到 site-recon 换 Pattern |
| 91 | │ memory 里的值还活着吗? │── 站点换版 ──→ 标记旧 endpoint,回 api-discovery |
| 92 | └────────────────────────────────────────────┘ |
| 93 | │ OK |
| 94 | ▼ |
| 95 | ┌───────────────────────────────────────┐ |
| 96 | │ 字段解码(memory 里的 field-map 也要抽查)│ 自解释 → 直接 / 已知代号 → field-conventions / 未知 → decode-playbook |
| 97 | │ 比一条已知字段和网页肉眼值,确认没错位 │ |
| 98 | └───────────────────────────────────────┘ |
| 99 | │ |
| 100 | ▼ |
| 101 | ┌──────────────────────────┐ |
| 102 | │ 设计 columns (output) │ 对照 output-design.md 的命名 / 类型 / 顺序 |
| 103 | └──────────────────────────┘ |
| 104 | │ |
| 105 | ▼ |
| 106 | ┌──────────────────────────┐ |
| 107 | │ opencli browser init │ 生成 ~/.opencli/clis/<site>/<name>.js 骨架 |
| 108 | │ 复制最像的邻居 adapter │ |
| 109 | │ 改 name / URL / 映射三处 │ |
| 110 | └──────────────────────────┘ |
| 111 | │ |
| 112 | ▼ |
| 113 | ┌──────────────────────────┐ |
| 114 | │ opencli browser verify │── 失败 ──→ autofix skill,用 --trace retain-on-failure 回对应步骤 |
| 115 | └──────────────────────────┘ |
| 116 | │ 成功 |
| 117 | ▼ |
| 118 | ┌──────────────────────────┐ |
| 119 | │ 字段 vs 网页肉眼对一遍 │── 数值不对 ──→ 回字段解码 |
| 120 | └──────────────────────────┘ |
| 121 | │ 对得上 |
| 122 | ▼ |
| 123 | ┌──────────────────────────┐ |
| 124 | │ 回写 ~/.opencli/sites/ │ endpoints / field-map / notes / fixtures |
| 125 | └──────────────────────────┘ |
| 126 | │ |
| 127 | ▼ |
| 128 | DONE |
| 129 | ``` |
| 130 | |
| 131 | --- |
| 132 | |
| 133 | ## Runbook(一步一步勾选) |
| 134 | |
| 135 | ``` |
| 136 | [ ] 1. opencli doctor 返回 "Everything looks good" |
| 137 | [ ] 2. 读站点记忆: |
| 138 | [ ] ~/.opencli/sites/<site>/endpoints.json 存在?里面有想要的 endpoint? |
| 139 | [ ] references/site-memory/<site>.md 存在?看"已知 endpoint"节 |
| 140 | [ ] 命中后:**跳到第 5(endpoint 验证) + 第 7(字段核对)**,不能直接跳第 9 写 adapter |
| 141 | [ ] memory 写入超过 30 天(看 `verified_at`)→ 当作过期,按冷启动走 Step 3 → 4 |
| 142 | [ ] 3. 侦察(site-recon.md): |
| 143 | [ ] **首选**:`opencli browser analyze <url>` 一步拿 pattern + 反爬 + 最近 adapter + next step |
| 144 | [ ] `analyze` 结论模糊时再手跑:`open` → `wait time 2` (或 `wait xhr <regex>`) → `network` |
| 145 | [ ] 定 Pattern(A / B / C / D / E) |
| 146 | [ ] 4. API 发现(api-discovery.md)按 Pattern 选 §: |
| 147 | [ ] Pattern A → §1 network 精读 |
| 148 | [ ] Pattern B → §2 state 抽取 + §1 深层数据 |
| 149 | [ ] Pattern C → §3 bundle / script src 搜索 |
| 150 | [ ] Pattern D → §4 token 来源 + 降级 §5 |
| 151 | [ ] Pattern E → 找 HTTP 轮询接口;找不到才 §5 |
| 152 | [ ] 5. 直接 fetch 候选 endpoint 验证: |
| 153 | [ ] 返回 200 |
| 154 | [ ] 响应含目标数据(不是 HTML / 广告) |
| 155 | [ ] 6. 写 strategy note(写代码前的强制产物): |
| 156 | [ ] 从 `PUBLIC_API / COOKIE_API / PAGE_FETCH / INTERCEPT / DOM_STATE / UI_SELECTOR` 选一个 |
| 157 | [ ] 填 Contract:`stable / visible-ui / internal-unstable` |
| 158 | [ ] 填 Evidence:observed request/state、auth source、replay result |
| 159 | [ ] 如果选 `PAGE_FETCH` / `INTERCEPT`,必须解释为什么 `PUBLIC_API` / `COOKIE_API` / `UI_SELECTOR` / `DOM_STATE` 都不适合 |
| 160 | [ ] 如果选 `UI_SELECTOR` / `DOM_STATE`,不需要为 "为什么不是 API" 过度辩护;只要说明语义锚点和 typed error 路径 |
| 161 | [ ] 7. 字段解码: |
| 162 | [ ] 自解释 → 直接用 key |
| 163 | [ ] 已知代号 → field-conventions.md 查表 |
| 164 | [ ] 未知代号 → field-decode-playbook.md(排序键对比 / 结构差分 / 常量排查) |
| 165 | [ ] 8. 设计 columns(output-design.md): |
| 166 | [ ] 命名 camelCase 且对齐邻居 adapter |
| 167 | [ ] 类型 / 单位 / 百分比格式清楚 |
| 168 | [ ] 顺序:识别列 → 业务数字 → metadata |
| 169 | [ ] 9. 写 adapter(adapter-template.md): |
| 170 | [ ] opencli browser init <site>/<name> |
| 171 | [ ] 找同站点或同类型最像的 adapter,cp 过来 |
| 172 | [ ] 改 name / URL / 字段映射 |
| 173 | [ ] 10. opencli browser verify <site>/<name> |
| 174 | [ ] 首轮通过后立刻 `--write-fixture` 生成 `~/.opencli/sites/<site>/verify/<cmd>.json` 种子 |
| 175 | [ ] 手改种子:加 `patterns`(URL / 日期 / ID 格式)+ `notEmpty`(核心字段)+ 收紧 `rowCount` |
| 176 | [ ] 再跑一次 `opencli browser verify <site>/<name>`,确认 ✓ matches fixture |
| 177 | [ ] 11. 字段值 vs 网页肉眼比对(别只看 "Adapter works!") |
| 178 | [ ] 12. 回写站点记忆(**verify 通过 + 肉眼比对对得上之后**,schema 见 `references/site-memory.md`): |
| 179 | [ ] `endpoints.json`:以 endpoint 的短名为 key,value = `{url, method, params.{required,optional}, response, verified_at: YYYY-MM-DD, notes}` |
| 180 | [ ] `field-map.json`:只追加新代号。key = 字段代号,value = `{meaning, verified_at: YYYY-MM-DD, source}`;**已存在的 key 不要覆盖**,有冲突先和网页肉眼值对齐再写 |
| 181 | [ ] `notes.md`:顶部追加一段 `## YYYY-MM-DD by <agent/user>`,写本次写 adapter 时遇到的新坑 / 新结论 |
| 182 | [ ] `verify/<cmd>.json`:**必填。** `opencli browser verify` 的期望值(args / rowCount / columns / types / patterns / notEmpty),Step 10 已经让你生成了,这里只是 checklist |
| 183 | [ ] `fixtures/<cmd>-<YYYYMMDDHHMM>.json`:存一份该 endpoint 的完整响应样本(去掉 cookie / token / 用户私有字段再存),给后续字段对比 / 离线 replay 用 |
| 184 | [ ] 调试过程中如果在 repo / adapter 目录 dump 过临时文件(`.dbg-*.html` / `raw-*.json` / 等),**在 commit 前清干净**——这些本来就该落在 `~/.opencli/sites/<site>/fixtures/` 或 `/tmp/` |
| 185 | ``` |
| 186 | |
| 187 | --- |
| 188 | |
| 189 | ## 降级路径(某步卡住跳到哪) |
| 190 | |
| 191 | | 卡在 | 现象 | 跳去 | |
| 192 | |------|------|-----| |
| 193 | | Step 4 API 发现 | `network` 空,`__INITIAL_STATE__` 也空 | §3 bundle 搜 baseURL | |
| 194 | | | bundle 搜不到 baseURL | §5 intercept | |
| 195 | | Step 5 endpoint 验证 | 401 / 403 | §4 token 排查 | |
| 196 | | | 200 但响应是 HTML | 回 Step 3 换 Pattern 判断 | |
| 197 | | | 200 但 `data: []` 空 | 参数传错 / 接口换版,回 §1 看 network 里真实请求头 | |
| 198 | | Step 7 字段解码 | 排序键对比推不出 | field-decode-playbook.md §3 结构差分 | |
| 199 | | | 还推不出 | 先输出 raw,adapter 跑起来再迭代 | |
| 200 | | Step 10 verify 失败 | `fltt` 漏了 / 字段映射错 | autofix skill;复现命令加 `--trace retain-on-failure` | |
| 201 | | | 某列永远是 `null` | 字段路径错了,回 Step 7 | |
| 202 | | Step 10 verify fixture mismatch | `[pattern]` row[i] 报错 | 先肉眼比对网页值;值对 → 是 fixture pattern 太严,放宽;值不对 → 字段映射错 | |
| 203 | | | `[column] missing column "X"` | 实际 response 没这列(站点改版 or args 影响);重新 `--update-fixture` 或修 adapter | |
| 204 | | | `[type]` actual null / undefined | 字段提取失败,回 Step 7 重抽;临时 fallback 用 union type `string\|null` 只有在语义真的可空时用 | |
| 205 | | Step 11 数值不对 | 差 10000 倍 | 单位不统一("万" vs "元") | |
| 206 | | | 百分比小 100 倍 | 响应已是 `0.025`,不要 × 100 | |
| 207 | |
| 208 | --- |
| 209 | |
| 210 | ## 参考文件 |
| 211 | |
| 212 | | 文件 | 什么时候翻 | |
| 213 | |------|----------| |
| 214 | | `references/coverage-matrix.md` | 动手前做"是否在范围内"自测 | |
| 215 | | `references/site-recon.md` | Step 3 定站点类型 | |
| 216 | | `references/api-discovery.md` | Step 4 找 endpoint | |
| 217 | | `references/strategy-selection.md` | Step 6 填 strategy note 之前:契约模型 + 实测 fix 频率 + `api_candidates` 证据用法 + 反例 | |
| 218 | | `references/field-conventions.md` | Step 7 查已知字段代号 | |
| 219 | | `references/field-decode-playbook.md` | Step 7 字段不在词典时 | |
| 220 | | `references/output-design.md` | Step 8 命名 / 类型 / 顺序 | |
| 221 | | `references/adapter-template.md` | Step 9 文件结构 + 活例子 `convertible.js` | |
| 222 | | `references/site-memory.md` | 总览:in-repo 种子 + 本地 `~/.opencli/sites/` 的两层结构 | |
| 223 | | `references/site-memory/<site>.md` | Step 2 读站点公共知识(eastmoney / xueqiu / bilibili / tonghuashun 已铺) | |
| 224 | | `references/success-rate-pitfalls.md` | Step 7 / 11 踩坑前翻:11 种"verify 能过但数据是错的"静默失败(含 aria-label locale-dependence) | |
| 225 | | `references/jsdom-fixture-pattern.md` | 当 adapter 走 `page.evaluate` 内 DOM 抽取、且 mocked-evaluate 单测漏 silent bug 时——把 HTML 冻进 `clis/<site>/__fixtures__/` 用 JSDOM 跑(含 fixture 创建 mandatory `awk 'NF>0'` 收紧 + reverse-validate 纪律) | |
| 226 | | `references/typed-errors.md` | 写 `func` 主体之前必读:5 类 typed error 落点表(ArgumentError / EmptyResultError / CommandExecutionError / AuthRequiredError / TimeoutError)+ 三大 silent anti-pattern(silent-clamp / sentinel-row / generic CliError)的反例修法 | |
| 227 | |
| 228 | --- |
| 229 | |
| 230 | ## 关键约定 |
| 231 | |
| 232 | - adapter 只引 `@jackwener/opencli/registry` + `@jackwener/opencli/errors`,不用第三方 |
| 233 | - `columns` 数组和 `func` 返回对象 keys 完全对齐(含顺序) |
| 234 | - **中间解析对象 key 不能跟 `columns` 任一项重叠**(否则 silent-column-drop audit 误判,PR #1329 R1 真踩过;改成专属命名 + push row 时 destructure aliasing) |
| 235 | - **`browser:` field 决定 func 签名**:`browser:false → (args)`,`browser:true → (page, args)`。搞反时 `args` 实际是 debug flag,所有外部参数 silent fallback 到 default(PR #1329 upstream 之前 8 个 non-browser adapter 全踩过这个) |
| 236 | - 已知失败按 [`references/typed-errors.md`](./references/typed-errors.md) 5-classification 抛对应 typed error;**不要** silent `return []`,**不要** silent `return [{sentinel}]`,**不要** `Math.max/min` silent clamp 外部参数 |
| 237 | - 写私人 adapter 用 `~/.opencli/clis/<site>/<name>.js`(免 build);要提 PR 才 copy 到 `clis/<site>/<name>.js` |
| 238 | - 站点记忆每轮回写:没记忆 → 用 skill → 产生记忆 → 下次变 5 分钟 |
| 239 | - **调试过程中的原始 dump / 抓包 / HTML 样本只能落在 `~/.opencli/sites/<site>/fixtures/` 或 `/tmp/`。严禁在 repo 根目录、`clis/<site>/` 或当前工作目录留 `.dbg-*.html / raw-*.json / sample.*` 这类临时文件**(PR diff 会带上去,别人 review 时很烦)。 |
| 240 | - **JSDOM unit-test fixture(`clis/<site>/__fixtures__/<command>.html`)是上面那条的例外**——它是有意 commit 进 repo 的 review artifact,不是临时 dump。但因此 quality bar 要更高:必须按 `references/jsdom-fixture-pattern.md` 的 5 步做完(含 mandatory `awk 'NF>0'` 空白行收紧),并 reverse-validate 一道证明 regression guard 真能挂。 |
| 241 | |
| 242 | --- |
| 243 | |
| 244 | ## 卡住了 |
| 245 | |
| 246 | - 诊断类:`opencli doctor` → 看 `notes.md` → 搜 autofix skill |
| 247 | - 字段解码类:`field-decode-playbook.md` 全三节走完 → 先输出 raw 迭代 |
| 248 | - endpoint 找不到:api-discovery §5 intercept 兜底 |
| 249 | |
| 250 | 不要猜。猜错了 verify 能通过但数据是错的,用户看到乱码才发现。 |