
Opencli Adapter Author
- 13.4k installs
- 27.7k repo stars
- Updated July 30, 2026
- jackwener/opencli
opencli-adapter-author is an agent skill for writing OpenCLI site adapters from recon and API discovery through verify-ready clis/site/name.js implementations.
About
opencli-adapter-author guides agents through writing OpenCLI adapters for new sites or new commands on existing sites, replacing opencli-oneshot and opencli-explorer. The workflow targets a sub-30-minute loop from zero to passing opencli browser verify using opencli browser commands, doctor, init, and verify with trace-friendly debugging flags. A coverage-matrix pre-check confirms browser-visible HTTP/JSON/HTML data without realtime push requirements. Agents must produce a strategy note before coding, choosing among PUBLIC_API, COOKIE_API, PAGE_FETCH, INTERCEPT, DOM_STATE, or UI_SELECTOR with contract stability evidence. The decision tree covers site memory reads, site-recon patterns, api-discovery sections, endpoint validation, field decoding, output column design, adapter skeleton generation under clis/site/name.js, and verify with autofix loops. Browser adapters prefer --trace on --keep-tab true --window foreground for retained tabs and summary.md artifacts. References include strategy-selection.md, site-memory, field-conventions, and decode-playbook for unstable internal endpoints.
- Targets a 30-minute closed loop to opencli browser verify using existing browser, doctor, init, and verify commands.
- Requires a strategy note before coding with six strategy classes and contract stability evidence.
- Decision tree covers site memory, recon patterns, API discovery, endpoint validation, and field decoding.
- Documents maintenance-cost guidance favoring PUBLIC_API and COOKIE_API over unstable PAGE_FETCH or INTERCEPT.
- Debugging guidance uses --trace on, --keep-tab true, and --window foreground for browser adapters.
Opencli Adapter Author by the numbers
- 13,374 all-time installs (skills.sh)
- +332 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #27 of 550 CLI & Terminal skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
opencli-adapter-author capabilities & compatibility
- Capabilities
- strategy note generation before adapter coding · site memory and recon pattern routing · api discovery and endpoint validation · field decoding and output column design · adapter init skeleton and verify autofix loops
- Use cases
- orchestration · debugging
What opencli-adapter-author says it does
从零到通过 `opencli browser verify` 的 30 分钟内闭环
先定 strategy,再写 adapter。
`PAGE_FETCH` / `INTERCEPT` 的 fix 频率约为 `PUBLIC_API` 的 7-8 倍
npx skills add https://github.com/jackwener/opencli --skill opencli-adapter-authorAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 13.4k |
|---|---|
| repo stars | ★ 27.7k |
| Security audit | 2 / 3 scanners passed |
| Last updated | July 30, 2026 |
| Repository | jackwener/opencli ↗ |
How do I add a new OpenCLI adapter for a site with the right fetch strategy and a passing browser verify loop?
Author OpenCLI site adapters end to end from recon and API discovery through field decoding, adapter coding, and browser verify.
Who is it for?
Developers extending OpenCLI with new site adapters who need structured recon, strategy selection, and verify loops.
Skip if: Skip for ad-hoc browser driving without an adapter; use opencli-browser instead.
When should I use this skill?
User asks to write or extend an OpenCLI adapter, add a site command, or debug browser verify failures.
What you get
A verified OpenCLI adapter with documented strategy, decoded fields, and passing opencli browser verify.
- Verified site adapter under clis/
- Strategy note with contract evidence
By the numbers
- Each OpenCLI adapter follows a fixed three-part structure: declaration, args, and func
Files
opencli-adapter-author
你是要给一个站点写 adapter 的 agent。这份 skill 目标:从零到通过 `opencli browser verify` 的 30 分钟内闭环。
全程用现有工具:opencli browser * / opencli doctor / opencli browser init / opencli browser verify。没有新命令。
调试浏览器型 adapter 时,优先直接带上 --trace on --keep-tab true --window foreground。--trace on 每轮都落 trace artifact,summary.md 是失败/成功复盘入口;--keep-tab true --window foreground 让 tab lease 保留且浏览器窗口在前台,方便核对最终页面状态。
---
前置:看你落在哪
先拿 coverage-matrix.md 快速自测。三个问题:
1. 数据在浏览器里看得到吗?(否 → 先解决鉴权) 2. 数据是 HTTP/JSON/HTML 吗?(否 → 不在 skill 范围) 3. 需要实时推送吗?(是 → 找同数据 HTTP 接口;没有就放弃)
三个都 yes 继续。
---
顶层决策树
先定 strategy,再写 adapter。 每次进入 Step 3/4 后、写代码前,必须产出一段 strategy note。没有这段 note,不要开始写 clis/<site>/<name>.js。
核心判断不是 "API 比 DOM 高级",而是 数据源有没有外部契约。实测维护成本显示:公开/官方接口最稳;UI/DOM 语义通常也有用户可见契约;站内未文档化 XHR/GraphQL/signature endpoint 最容易漂。不要为了 "API-first" 把稳定的 UI/DOM 实现盲目迁到无契约内部接口。
Strategy: PUBLIC_API | COOKIE_API | PAGE_FETCH | INTERCEPT | DOM_STATE | UI_SELECTOR
Contract: stable | visible-ui | internal-unstable
Evidence:
- observed request/state: <endpoint / state global / UI-only signal>
- auth source: <none / browser cookie / csrf from meta / localStorage / page runtime>
- replay result: <status + content-type + non-empty sample shape>
If Strategy is PAGE_FETCH or INTERCEPT:
- why PUBLIC_API / COOKIE_API are unavailable:
- why UI_SELECTOR / DOM_STATE are not safer:
- why the maintenance cost is acceptable:Strategy classes:
| Strategy | 契约级别 | 用在什么时候 | 证据要求 |
|---|---|---|---|
PUBLIC_API | stable | 不需要登录,Node-side fetch 直接拿到目标数据 | 200 + JSON/HTML 含目标数据,不是埋点/广告 |
COOKIE_API | stable | Node-side fetch + page.getCookies() / header helper 能拿数据 | cookie/CSRF 来源清楚,replay 非空 |
UI_SELECTOR | visible-ui | publish/upload/click/表单,或页面语义比内部接口更稳 | selector 有语义锚点;错误路径是 typed error |
DOM_STATE | visible-ui | 数据在 hydration state / bootstrap JSON / SSR HTML 里 | state key / script JSON / HTML 结构明确 |
PAGE_FETCH | internal-unstable | 只能在页面上下文 fetch 才能复用 same-origin/session/runtime | opencli browser eval fetch(...) 非空;必须解释为什么避不开内部接口 |
INTERCEPT | internal-unstable | 请求签名复杂,但页面自己能自然发出请求 | 触发 UI 后能截到目标 response;必须解释为什么 UI/DOM 不够 |
选择规则:优先 PUBLIC_API / COOKIE_API。如果 UI/DOM 语义稳定,不要强行升级到 PAGE_FETCH / INTERCEPT。只有公开/官方接口不可用、UI/DOM 无法表达目标数据或操作时,才承担无契约内部接口的维护成本。
实测:PAGE_FETCH / INTERCEPT 的 fix 频率约为 PUBLIC_API 的 7-8 倍,UI_SELECTOR 跟 COOKIE_API 同档。详细 ladder 推导、api_candidates 证据怎么填、booking #1680 等反例见 `references/strategy-selection.md`。
边界:只复用页面自己已经合法获得的数据/能力。不教破解签名、不绕验证码/风控/访问控制;遇到不可复用签名(如必须由页面 runtime 生成且不能安全抽象)就降级到 UI_SELECTOR / DOM_STATE / INTERCEPT。
START
│
▼
┌──────────────────────────┐
│ opencli doctor 通? │── no ──→ 修桥接(doctor 输出里的提示)
└──────────────────────────┘
│ yes
▼
┌────────────────────────────────────────────────────┐
│ 读站点记忆: │
│ 1. ~/.opencli/sites/<site>/endpoints.json │
│ 2. ~/.opencli/sites/<site>/notes.md │
│ 3. references/site-memory/<site>.md │
└────────────────────────────────────────────────────┘
│ 命中 endpoint + 字段 → 直接跳到【endpoint 验证】(不跳写 adapter!memory 可能过期)
│ 没命中 → 继续
▼
┌──────────────────────────┐
│ 站点侦察(site-recon) │ → Pattern A/B/C/D/E
└──────────────────────────┘
│
▼
┌──────────────────────────┐
│ API 发现(api-discovery)│ §1 network → §2 state → §3 bundle → §4 token → §5 intercept
└──────────────────────────┘
│ 拿到候选 endpoint
▼
┌────────────────────────────────────────────┐
│ 直接 fetch 验证 endpoint(memory 命中也要跑)│── 401/403 ──→ 回到 §4 排 token
│ 数据非空 + 200 │── 空/HTML ──→ 回到 site-recon 换 Pattern
│ memory 里的值还活着吗? │── 站点换版 ──→ 标记旧 endpoint,回 api-discovery
└────────────────────────────────────────────┘
│ OK
▼
┌───────────────────────────────────────┐
│ 字段解码(memory 里的 field-map 也要抽查)│ 自解释 → 直接 / 已知代号 → field-conventions / 未知 → decode-playbook
│ 比一条已知字段和网页肉眼值,确认没错位 │
└───────────────────────────────────────┘
│
▼
┌──────────────────────────┐
│ 设计 columns (output) │ 对照 output-design.md 的命名 / 类型 / 顺序
└──────────────────────────┘
│
▼
┌──────────────────────────┐
│ opencli browser init │ 生成 ~/.opencli/clis/<site>/<name>.js 骨架
│ 复制最像的邻居 adapter │
│ 改 name / URL / 映射三处 │
└──────────────────────────┘
│
▼
┌──────────────────────────┐
│ opencli browser verify │── 失败 ──→ autofix skill,用 --trace retain-on-failure 回对应步骤
└──────────────────────────┘
│ 成功
▼
┌──────────────────────────┐
│ 字段 vs 网页肉眼对一遍 │── 数值不对 ──→ 回字段解码
└──────────────────────────┘
│ 对得上
▼
┌──────────────────────────┐
│ 回写 ~/.opencli/sites/ │ endpoints / field-map / notes / fixtures
└──────────────────────────┘
│
▼
DONE---
Runbook(一步一步勾选)
[ ] 1. opencli doctor 返回 "Everything looks good"
[ ] 2. 读站点记忆:
[ ] ~/.opencli/sites/<site>/endpoints.json 存在?里面有想要的 endpoint?
[ ] references/site-memory/<site>.md 存在?看"已知 endpoint"节
[ ] 命中后:**跳到第 5(endpoint 验证) + 第 7(字段核对)**,不能直接跳第 9 写 adapter
[ ] memory 写入超过 30 天(看 `verified_at`)→ 当作过期,按冷启动走 Step 3 → 4
[ ] 3. 侦察(site-recon.md):
[ ] **首选**:`opencli browser analyze <url>` 一步拿 pattern + 反爬 + 最近 adapter + next step
[ ] `analyze` 结论模糊时再手跑:`open` → `wait time 2` (或 `wait xhr <regex>`) → `network`
[ ] 定 Pattern(A / B / C / D / E)
[ ] 4. API 发现(api-discovery.md)按 Pattern 选 §:
[ ] Pattern A → §1 network 精读
[ ] Pattern B → §2 state 抽取 + §1 深层数据
[ ] Pattern C → §3 bundle / script src 搜索
[ ] Pattern D → §4 token 来源 + 降级 §5
[ ] Pattern E → 找 HTTP 轮询接口;找不到才 §5
[ ] 5. 直接 fetch 候选 endpoint 验证:
[ ] 返回 200
[ ] 响应含目标数据(不是 HTML / 广告)
[ ] 6. 写 strategy note(写代码前的强制产物):
[ ] 从 `PUBLIC_API / COOKIE_API / PAGE_FETCH / INTERCEPT / DOM_STATE / UI_SELECTOR` 选一个
[ ] 填 Contract:`stable / visible-ui / internal-unstable`
[ ] 填 Evidence:observed request/state、auth source、replay result
[ ] 如果选 `PAGE_FETCH` / `INTERCEPT`,必须解释为什么 `PUBLIC_API` / `COOKIE_API` / `UI_SELECTOR` / `DOM_STATE` 都不适合
[ ] 如果选 `UI_SELECTOR` / `DOM_STATE`,不需要为 "为什么不是 API" 过度辩护;只要说明语义锚点和 typed error 路径
[ ] 7. 字段解码:
[ ] 自解释 → 直接用 key
[ ] 已知代号 → field-conventions.md 查表
[ ] 未知代号 → field-decode-playbook.md(排序键对比 / 结构差分 / 常量排查)
[ ] 8. 设计 columns(output-design.md):
[ ] 命名 camelCase 且对齐邻居 adapter
[ ] 类型 / 单位 / 百分比格式清楚
[ ] 顺序:识别列 → 业务数字 → metadata
[ ] 9. 写 adapter(adapter-template.md):
[ ] opencli browser init <site>/<name>
[ ] 找同站点或同类型最像的 adapter,cp 过来
[ ] 改 name / URL / 字段映射
[ ] 10. opencli browser verify <site>/<name>
[ ] 首轮通过后立刻 `--write-fixture` 生成 `~/.opencli/sites/<site>/verify/<cmd>.json` 种子
[ ] 手改种子:加 `patterns`(URL / 日期 / ID 格式)+ `notEmpty`(核心字段)+ 收紧 `rowCount`
[ ] 再跑一次 `opencli browser verify <site>/<name>`,确认 ✓ matches fixture
[ ] 11. 字段值 vs 网页肉眼比对(别只看 "Adapter works!")
[ ] 12. 回写站点记忆(**verify 通过 + 肉眼比对对得上之后**,schema 见 `references/site-memory.md`):
[ ] `endpoints.json`:以 endpoint 的短名为 key,value = `{url, method, params.{required,optional}, response, verified_at: YYYY-MM-DD, notes}`
[ ] `field-map.json`:只追加新代号。key = 字段代号,value = `{meaning, verified_at: YYYY-MM-DD, source}`;**已存在的 key 不要覆盖**,有冲突先和网页肉眼值对齐再写
[ ] `notes.md`:顶部追加一段 `## YYYY-MM-DD by <agent/user>`,写本次写 adapter 时遇到的新坑 / 新结论
[ ] `verify/<cmd>.json`:**必填。** `opencli browser verify` 的期望值(args / rowCount / columns / types / patterns / notEmpty),Step 10 已经让你生成了,这里只是 checklist
[ ] `fixtures/<cmd>-<YYYYMMDDHHMM>.json`:存一份该 endpoint 的完整响应样本(去掉 cookie / token / 用户私有字段再存),给后续字段对比 / 离线 replay 用
[ ] 调试过程中如果在 repo / adapter 目录 dump 过临时文件(`.dbg-*.html` / `raw-*.json` / 等),**在 commit 前清干净**——这些本来就该落在 `~/.opencli/sites/<site>/fixtures/` 或 `/tmp/`---
降级路径(某步卡住跳到哪)
| 卡在 | 现象 | 跳去 |
|---|---|---|
| Step 4 API 发现 | network 空,__INITIAL_STATE__ 也空 | §3 bundle 搜 baseURL |
| bundle 搜不到 baseURL | §5 intercept | |
| Step 5 endpoint 验证 | 401 / 403 | §4 token 排查 |
| 200 但响应是 HTML | 回 Step 3 换 Pattern 判断 | |
200 但 data: [] 空 | 参数传错 / 接口换版,回 §1 看 network 里真实请求头 | |
| Step 7 字段解码 | 排序键对比推不出 | field-decode-playbook.md §3 结构差分 |
| 还推不出 | 先输出 raw,adapter 跑起来再迭代 | |
| Step 10 verify 失败 | fltt 漏了 / 字段映射错 | autofix skill;复现命令加 --trace retain-on-failure |
某列永远是 null | 字段路径错了,回 Step 7 | |
| Step 10 verify fixture mismatch | [pattern] row[i] 报错 | 先肉眼比对网页值;值对 → 是 fixture pattern 太严,放宽;值不对 → 字段映射错 |
[column] missing column "X" | 实际 response 没这列(站点改版 or args 影响);重新 --update-fixture 或修 adapter | |
[type] actual null / undefined | 字段提取失败,回 Step 7 重抽;临时 fallback 用 union type `string\ | |
| Step 11 数值不对 | 差 10000 倍 | 单位不统一("万" vs "元") |
| 百分比小 100 倍 | 响应已是 0.025,不要 × 100 |
---
参考文件
| 文件 | 什么时候翻 |
|---|---|
references/coverage-matrix.md | 动手前做"是否在范围内"自测 |
references/site-recon.md | Step 3 定站点类型 |
references/api-discovery.md | Step 4 找 endpoint |
references/strategy-selection.md | Step 6 填 strategy note 之前:契约模型 + 实测 fix 频率 + api_candidates 证据用法 + 反例 |
references/field-conventions.md | Step 7 查已知字段代号 |
references/field-decode-playbook.md | Step 7 字段不在词典时 |
references/output-design.md | Step 8 命名 / 类型 / 顺序 |
references/adapter-template.md | Step 9 文件结构 + 活例子 convertible.js |
references/site-memory.md | 总览:in-repo 种子 + 本地 ~/.opencli/sites/ 的两层结构 |
references/site-memory/<site>.md | Step 2 读站点公共知识(eastmoney / xueqiu / bilibili / tonghuashun 已铺) |
references/success-rate-pitfalls.md | Step 7 / 11 踩坑前翻:11 种"verify 能过但数据是错的"静默失败(含 aria-label locale-dependence) |
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 纪律) |
references/typed-errors.md | 写 func 主体之前必读:5 类 typed error 落点表(ArgumentError / EmptyResultError / CommandExecutionError / AuthRequiredError / TimeoutError)+ 三大 silent anti-pattern(silent-clamp / sentinel-row / generic CliError)的反例修法 |
---
关键约定
- adapter 只引
@jackwener/opencli/registry+@jackwener/opencli/errors,不用第三方 columns数组和func返回对象 keys 完全对齐(含顺序)- 中间解析对象 key 不能跟 `columns` 任一项重叠(否则 silent-column-drop audit 误判,PR #1329 R1 真踩过;改成专属命名 + push row 时 destructure aliasing)
- `browser:` field 决定 func 签名:
browser:false → (args),browser:true → (page, args)。搞反时args实际是 debug flag,所有外部参数 silent fallback 到 default(PR #1329 upstream 之前 8 个 non-browser adapter 全踩过这个) - 已知失败按 `references/typed-errors.md` 5-classification 抛对应 typed error;不要 silent
return [],不要 silentreturn [{sentinel}],不要Math.max/minsilent clamp 外部参数 - 写私人 adapter 用
~/.opencli/clis/<site>/<name>.js(免 build);要提 PR 才 copy 到clis/<site>/<name>.js - 站点记忆每轮回写:没记忆 → 用 skill → 产生记忆 → 下次变 5 分钟
- *调试过程中的原始 dump / 抓包 / HTML 样本只能落在 `~/.opencli/sites/<site>/fixtures/` 或 `/tmp/`。严禁在 repo 根目录、`clis/<site>/` 或当前工作目录留 `.dbg-.html / raw-.json / sample.` 这类临时文件**(PR diff 会带上去,别人 review 时很烦)。
- JSDOM unit-test fixture(`clis/<site>/__fixtures__/<command>.html`)是上面那条的例外——它是有意 commit 进 repo 的 review artifact,不是临时 dump。但因此 quality bar 要更高:必须按
references/jsdom-fixture-pattern.md的 5 步做完(含 mandatoryawk 'NF>0'空白行收紧),并 reverse-validate 一道证明 regression guard 真能挂。
---
卡住了
- 诊断类:
opencli doctor→ 看notes.md→ 搜 autofix skill - 字段解码类:
field-decode-playbook.md全三节走完 → 先输出 raw 迭代 - endpoint 找不到:api-discovery §5 intercept 兜底
不要猜。猜错了 verify 能通过但数据是错的,用户看到乱码才发现。
Adapter Template
一份 adapter 就是一次 cli({...}) 调用。文件结构固定,三段:declaration、args、func。
拿 clis/eastmoney/convertible.js 当活例子,对照拆解。
---
活例子:convertible.js
注意(2026-05 起):下面这份convertible.js的 limit clamp 和CliError('HTTP_ERROR' / 'NO_DATA')是 grandfathered 写法(在 `scripts/typed-error-lint-baseline.json` 里)。结构布局(cli 声明 / args / columns / map)仍然是好范本,但 error 处理 + limit 校验请按下文 §3 + [`typed-errors.md`](./typed-errors.md) 写。新写 adapter 抄这个文件别连Math.max(1, Math.min(...))和CliError(...)一起抄过去。
// eastmoney convertible — on-market convertible bond listing.
import { cli, Strategy } from '@jackwener/opencli/registry';
import { CliError } from '@jackwener/opencli/errors';
const SORTS = {
change: { fid: 'f3', order: 'desc' },
drop: { fid: 'f3', order: 'asc' },
turnover: { fid: 'f6', order: 'desc' },
price: { fid: 'f2', order: 'desc' },
premium: { fid: 'f237', order: 'desc' },
value: { fid: 'f236', order: 'desc' },
ytm: { fid: 'f239', order: 'desc' },
};
cli({
site: 'eastmoney',
name: 'convertible',
description: '可转债行情列表(默认按成交额排序)',
domain: 'push2.eastmoney.com',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'sort', type: 'string', default: 'turnover', help: '排序:turnover / change / drop / price / premium' },
{ name: 'limit', type: 'int', default: 20, help: '返回数量 (max 100)' },
],
columns: ['rank', 'bondCode', 'bondName', 'bondPrice', 'bondChangePct',
'stockCode', 'stockName', 'stockPrice', 'stockChangePct',
'convPrice', 'convValue', 'convPremiumPct', 'remainingYears', 'ytm', 'listDate'],
func: async (args) => {
const sortKey = String(args.sort ?? 'turnover').toLowerCase();
const sort = SORTS[sortKey];
if (!sort) throw new CliError('INVALID_ARGUMENT', `Unknown sort "${sortKey}". Valid: ${Object.keys(SORTS).join(', ')}`);
const limit = Math.max(1, Math.min(Number(args.limit) || 20, 100));
const url = new URL('https://push2.eastmoney.com/api/qt/clist/get');
url.searchParams.set('pn', '1');
url.searchParams.set('pz', String(limit));
url.searchParams.set('po', sort.order === 'desc' ? '1' : '0');
url.searchParams.set('np', '1');
url.searchParams.set('fltt', '2');
url.searchParams.set('invt', '2');
url.searchParams.set('fid', sort.fid);
url.searchParams.set('fs', 'b:MK0354');
url.searchParams.set('fields', 'f12,f14,f2,f3,f6,f229,f230,f232,f234,f235,f236,f237,f238,f239,f243');
url.searchParams.set('ut', 'bd1d9ddb04089700cf9c27f6f7426281');
const resp = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0' } });
if (!resp.ok) throw new CliError('HTTP_ERROR', `convertible failed: HTTP ${resp.status}`);
const data = await resp.json();
const diff = Array.isArray(data?.data?.diff) ? data.data.diff : [];
if (diff.length === 0) throw new CliError('NO_DATA', 'eastmoney returned no convertible data');
return diff.slice(0, limit).map((it, i) => ({
rank: i + 1,
bondCode: it.f12,
bondName: it.f14,
bondPrice: it.f2,
bondChangePct: it.f3,
stockCode: it.f232,
stockName: it.f234,
stockPrice: it.f229,
stockChangePct: it.f230,
convPrice: it.f235,
convValue: it.f236,
convPremiumPct: it.f237,
remainingYears: it.f238,
ytm: it.f239,
listDate: String(it.f243 ?? ''),
}));
},
});---
三段解剖
1. Declaration — 标头
cli({
site: 'eastmoney', // 第一级命名空间,目录名一致
name: 'convertible', // 第二级,CLI 上的子命令
description: '...', // 一句话,出现在 `opencli list` 和 `opencli <site> -h`
domain: 'push2.eastmoney.com', // 主要请求域名(诊断面板用)
strategy: Strategy.PUBLIC, // PUBLIC / COOKIE / INTERCEPT / UI
browser: false, // PUBLIC 几乎总是 false;COOKIE/INTERCEPT/UI 一律 true
...
});2. Args & Columns
args: [
{ name: 'sort', type: 'string', default: 'turnover', help: '...' },
{ name: 'limit', type: 'int', default: 20, help: '...' },
],
columns: ['rank', 'bondCode', 'bondName', /* ... */ ],规则:
type:string/int/float/booldefault必填(缺失的命令会拒绝启动)columns数组必须跟func返回的 object keys 完全对上,顺序也一致(决定表格列顺序)- 列名 camelCase,跟
cli({...})其他 adapter 保持统一 - 中间解析对象 key 不能跟 columns 任一项重叠 —— 否则
silent-column-dropaudit 会把它当 row 候选误判。{pid, html, start}这类中间结构改成{postId, body, offset},最后在 push row 时再 destructure aliasing 回 column 命名。背景:PR #1329 R1 codex-mini0 catch 的(before → after)
3. func — 主体
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
func: async (args) => {
// 1. 解析参数 — 越界一律抛,不要 silent clamp
const n = Number(args.limit ?? 20);
if (!Number.isInteger(n) || n <= 0) throw new ArgumentError('limit must be a positive integer');
if (n > 100) throw new ArgumentError('limit must be <= 100');
const limit = n;
// 2. 构造 URL / 请求
const url = new URL(...);
url.searchParams.set(...);
// 3. 发请求 — fetch 抛 / HTTP 非 2xx 都归 CommandExecutionError
let resp;
try {
resp = await fetch(url, { headers: { /* ... */ } });
} catch (error) {
throw new CommandExecutionError(`request failed: ${error?.message || error}`);
}
if (!resp.ok) throw new CommandExecutionError(`request failed: HTTP ${resp.status}`);
// 4. 解析 + 业务校验 — 业务空 → EmptyResultError,不要 sentinel row 也不要 return []
const data = await resp.json();
const diff = Array.isArray(data?.data?.diff) ? data.data.diff : [];
if (diff.length === 0) throw new EmptyResultError('site command', 'API returned no rows');
// 5. map 到 columns 同名 keys
return diff.slice(0, limit).map((it, i) => ({
rank: i + 1,
bondCode: it.f12,
// ...
}));
},站点级 helper:≥ 2 个同站 adapter 都做相同 limit / page 校验时,把校验抽成 clis/<site>/utils.js 的 normalizeLimit(value, default, max, label) / normalizePositiveInteger(value, default, label, { min }),避免每个 adapter 都 inline 一遍。模板见 `typed-errors.md` §2 和 `clis/1point3acres/utils.js`。1 个 adapter 用就直接 inline,不要为 1 处单点抽 helper。
参数形态(踩过最多次的坑:搞反签名后 args 实际是 debug flag,所有 args.foo 静默 undefined → fallback 到 default。#1329 upstream 之前 8 个 non-browser adapter 写错过签名,全部 silently fallback 到默认参数):
browser: false:func: async (args, debug?) => { ... }—— 单参 args,不会收到pagebrowser: true:func: async (page, args, debug?) => { ... }—— 双参 (page, args),第一参是浏览器上下文args:所有args[]声明的参数解析后的 object
错误处理:用 typed error 5-classification(参见 `typed-errors.md`),不要 CliError('XXX', ...) 直传,不要 return [] 了事,不要 return [{sentinel}] 装一行业务数据冒充 empty。autofix skill 靠 typed error 的 exit code(66 = empty / 77 = auth / 75 = timeout / 2 = argument / 1 = exec)决定要不要重试。
---
COOKIE adapter 骨架(需要登录态)
PUBLIC 模式不够(接口 401 / 302 到 login / 响应是"请登录"页)就走这里。要点三条:
1. 读 cookie 走 page.getCookies(...),不要读 `document.cookie`。 2. 拿 HTML 走 Node 端 fetch + 手动解码,不要塞进 `page.evaluate` 里。 3. Declaration 加 browser: true;不需要真的打开目标页时 navigateBefore: false。
import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
const BASE = 'https://www.example.com';
const HOST = 'www.example.com';
const ROOT = '.example.com'; // 根域(auth 常在这里)
async function readCookie(page) {
const seen = new Map();
for (const opts of [{ domain: HOST }, { domain: ROOT }]) {
try {
const cookies = await page.getCookies(opts);
for (const c of cookies || []) {
if (!seen.has(c.name)) seen.set(c.name, c.value);
}
} catch { /* try next domain */ }
}
return [...seen].map(([k, v]) => `${k}=${v}`).join('; ');
}
async function fetchHtml(url, { cookie, encoding = 'utf-8', headers = {} } = {}) {
let resp;
try {
resp = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0',
'Accept-Language': 'zh-CN,zh;q=0.9',
Referer: `${BASE}/`,
...(cookie ? { Cookie: cookie } : {}),
...headers,
},
redirect: 'follow',
});
} catch (error) {
throw new CommandExecutionError(`example request failed: ${error?.message || error}`);
}
if (!resp.ok) throw new CommandExecutionError(`example request failed: HTTP ${resp.status}`);
const buf = await resp.arrayBuffer();
return new TextDecoder(encoding).decode(buf);
}
cli({
site: 'example',
name: 'me',
access: 'read',
description: '示例:需要登录的私有页面',
domain: HOST,
strategy: Strategy.COOKIE,
browser: true,
navigateBefore: false, // 本命令不需要先开目标页
args: [{ name: 'limit', type: 'int', default: 20, help: '返回条数' }],
columns: ['index', 'title', 'time'],
func: async (page, args) => {
const limit = Number(args.limit ?? 20);
if (!Number.isInteger(limit) || limit <= 0) throw new ArgumentError('limit must be a positive integer');
const cookie = await readCookie(page);
const html = await fetchHtml(`${BASE}/inbox`, { cookie, encoding: 'gbk' });
if (/请登录|需要登录|<title>Login/i.test(html)) {
throw new AuthRequiredError(HOST);
}
// parse html → rows
if (!rows.length) throw new EmptyResultError('example me', 'inbox is empty');
return rows.slice(0, limit);
},
});JSON API 用 page.fetchJson(),不要手写 page.evaluate(fetch(...))
如果接口必须在浏览器上下文里请求(依赖当前页面 cookie / CORS / origin),用内置 primitive:
const data = await page.fetchJson(`${BASE}/api/list`, {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
body: { page: 1, size: limit },
});它固定 credentials: 'include',带 timeout,HTTP 非 2xx / 非 JSON 会抛统一 runtime error。adapter 里不用再手写 page.evaluate(fetch(...));如果你需要额外包一层业务语义,按 `typed-errors.md` 映射到 CommandExecutionError / AuthRequiredError / EmptyResultError。
页面内 DOM 逻辑用 page.evaluate(fn, ...args)
新 adapter 优先写函数形式,外部变量通过参数传入:
const href = await page.evaluate((selector) => {
const link = document.querySelector(selector);
return link ? link.getAttribute('href') : null;
}, 'a[data-testid="profile"]');fn 在浏览器页面上下文执行,不能读取 Node 侧闭包变量;参数必须能被 JSON.stringify 序列化。字符串形式 page.evaluate('document.title') 仍可用于简单表达式和既有代码,但不要再写依赖隐式 auto-IIFE 的模板字符串函数。
HTML 不走 browser fetch
三个坑,踩一个就重写:
- HttpOnly cookie 看不见:绝大多数登录站点把 auth cookie 标
HttpOnly,document.cookie永远读不到它,只能通过 CDP 的 cookie jar 拿(page.getCookies)。塞到page.evaluate里就等于回到document.cookie那条路,必挂。 - `navigateBefore: false` 时当前 tab 不在目标站:页面 origin 可能是
about:blank或上一条命令留下的别处,从那儿发 fetch 到目标域就是 cross-origin,浏览器 CORS 一挡就是 "Failed to fetch"。 - 非 UTF-8 编码解码麻烦:GBK / Big5 / Shift-JIS 的站(Discuz / phpBB 老版 / 日站)在
page.evaluate里用response.text()拿到的是乱码,TextDecoder('gbk').decode(buf)的写法只在 Node 侧干净。
规则:JSON 型浏览器接口用 page.fetchJson();HTML 型 COOKIE adapter 一律 Node 侧 fetch,浏览器只当 cookie jar 用。
Selector 稳定性 — 不要 select 用户可见文本
issue #1474 触发:同一个发送按钮在英文 Chrome 是 aria-label="Submit",在中文 Chrome(chrome://settings/languages 设中文)变 aria-label="提交",CSS 选择器 button[aria-label="Submit"] 在中文环境下直接 0 匹配,silent empty result。
根因不是 i18n bug,是选择器的 anchor 选错了。把页面 DOM 属性按 "locale-stable vs locale-dependent" 分两类:
| 类 | 例子 | locale 切换会变吗 | 用作 primary selector? |
|---|---|---|---|
| locale-stable 标识 | data-testid、data-*、稳定 id / class | 通常不变(开发者内部 ID) | ✅ 首选,但要先确认不是 hash / A-B test |
| semantic / scope anchor | role、结构关系、邻近稳定容器 | 不按 locale 翻译,但常常不唯一 | ⚠️ 只作 scope/filter;不要单独用 button[role="button"] |
| locale-dependent 文本 | aria-label、title、placeholder、alt、textContent | 变(被 i18n 框架翻译) | ❌ 仅当 stable 选择器全都不存在时的兜底 |
ChatGPT 的 web 端就是反例驱动的:有些 controls 暴露稳定 data-testid,有些 surfaces 只暴露 aria-label / placeholder。这种站必须先用 stable selector,再用多语言 fallback list:
// clis/chatgpt/utils.js(简化活例)
const COMPOSER_SELECTORS = [
'#prompt-textarea',
'[data-testid="composer"] [contenteditable="true"]',
'[aria-label="Chat with ChatGPT"]', // en
'[aria-label="与 ChatGPT 聊天"]', // zh-CN
'[placeholder="Ask anything"]',
'[placeholder="有问题,尽管问"]', // zh-CN
];
const SEND_BUTTON_SELECTORS = [
'button[data-testid="send-button"]:not([disabled])',
'button[aria-label="Send prompt"]:not([disabled])',
'button[aria-label="发送提示"]:not([disabled])',
];写 fallback list 的纪律:
1. stable selector 放最前(#prompt-textarea / [data-testid="send-button"]),locale-dependent 的放后面当兜底 2. `role` 只能当 semantic / scope filter:dialog [role="textbox"] 可以;裸 button / [role="button"] 不够,因为同页可能有多个按钮 3. 每种 locale 至少列一条(en + zh-CN 是底线;扩到 ja / ko / ar 看站点用户分布) 4. commit 前 grep `aria-label=` / `placeholder=` / `title=` 看是不是漏了 fallback locale——见 success-rate-pitfalls.md §11 5. 失败要 typed fail-fast:找不到 control 应该 CommandExecutionError / send-failed,不要返回空 rows 或假成功 6. 不要给 framework 加 `--i18n "zh:提交,ja:送信"` 这种 flag —— 等于把 fallback list 从 adapter 挪到 CLI,多一层 indirection 还要维护翻译字典。这是 over-engineering,已经在评审时被否
为什么不在 daemon 端固定 Chrome locale?因为 opencli 不启动 Chrome——daemon 是连用户已经在跑的 Chrome(CDP via extension),用户可能就是中文 UI / 中文资料检索需求。强制 en-US 会破坏用户的正当工作流。
Cookie 域的双查
for (const opts of [{ domain: HOST }, { domain: ROOT }]) { ... }不是所有站都这么玄学,但下面这几类踩坑最多:
| 站点类型 | 坑 |
|---|---|
| Discuz!X / phpBB / vBulletin 论坛 | Auth cookie 设在 .<root>.com,HttpOnly;业务页在 www.<root>.com。只查 www. 会漏 |
多子域账户体系(account.x.com vs api.x.com) | 登录时写在 account 域,API 域读取时拿不到 |
| 新版 Chrome SameSite=Lax 默认 | 某些 cookie 查 url: 才给返,查 domain: 不给 |
双查成本很低,不确定就两个都查,用 Map 去重第一次出现的 name。
空态抛 EmptyResultError,不塞 sentinel 行
历史上这里写的是"返回一行说明 row 比 return [] 安全"。这条已经反过来了——见 PR #1329 R3 的 four anti-pattern fixes。现在的契约:
import { EmptyResultError } from '@jackwener/opencli/errors';
// ❌ 老写法:sentinel 行污染 row 合同,让 listing→detail round-trip 拿到 tid='' 白跑
if (/暂时没有提醒内容/.test(html)) {
return [{ index: 0, from: '', summary: '暂时没有提醒内容', time: '', threadUrl: '' }];
}
// ✅ 新写法:empty 是合法状态,但不是 row。exit code 66 让 agent 直接 branch
if (/暂时没有提醒内容/.test(html)) {
throw new EmptyResultError('1point3acres notifications', '暂时没有提醒内容');
}更多反例和详细 routing 见 `typed-errors.md` §3。
---
同类型 adapter 对照
| 类型 | 代表 | 参考 |
|---|---|---|
| clist 分页排行 | convertible.js / rank.js / etf.js / sectors.js | 都共享 fs + fid + po 结构 |
| ulist 批量报价 | quote.js | secids 逗号拼接 |
| K 线历史 | kline.js | fields1 / fields2 控列,CSV 解析 |
| 报表(datacenter-web) | longhu.js / holders.js | reportName 驱动 |
| 7x24 新闻 | kuaixun.js | np-listapi 栏目 id |
| 公司公告 | announcement.js | np-anotice-stock |
| 指数/北上 | index-board.js / northbound.js | push2 专用端点 |
新写一条时,选最像的那类,复制后改 name / URL / fields / column 映射三处。
---
Verify fixture(每个 adapter 配一份 ~/.opencli/sites/<site>/verify/<name>.json)
verify fixture 是"adapter 产出长什么样"的结构锚点。没有它,opencli browser verify 只能证"adapter 能跑完不抛",证不出数据没错位。必写。
详细 schema 见 site-memory.md 的 verify/<cmd>.json 节。这里只讲两个容易踩的地方:
args 形态:object vs array
args 字段决定 verify 怎么调你的 adapter:
- 对象形态
{ "limit": 3 }→ 展开成--limit 3,标准 named-flag adapter 用这个 - 数组形态
["123", "--limit", "3"]→ 原样 append 到命令后,positional 主语型 adapter 必须用这个
repo 约定"主语优先 positional"——thread 详情型、url 解析型、关键词搜索型都用 positional:
// clis/1point3acres/thread.js — 接收 <tid> 作为主语
cli({
site: '1point3acres',
name: 'thread',
args: [
{ name: 'tid', type: 'string', required: true, positional: true },
{ name: 'limit', type: 'int', default: 20 },
],
// ...
});对应 fixture:
{
"args": ["1234567", "--limit", "3"],
"expect": { "rowCount": { "min": 1, "max": 3 }, "...": "..." }
}不要写成 { "tid": "1234567", "limit": 3 }——这会被展开成 --tid 1234567 --limit 3,commander 把 --tid 当未知 flag 报错,或者 adapter 根本不认。
种子 → 手改
named-flag adapter(hot / latest 类)可以直接让工具生成种子:
# 1. 让 verify 先跑一遍,--write-fixture 生成种子(默认追加 --limit 3)
opencli browser verify 1point3acres/hot --write-fixture
# 2. 手改 ~/.opencli/sites/1point3acres/verify/hot.json
# - patterns: 加 URL / 日期 / ID 正则
# - notEmpty: 加核心字段(title / author / url)
# - rowCount: 收紧到业务合理区间
# 3. 再跑 verify,fixture 吃得动就 OK
opencli browser verify 1point3acres/hotpositional adapter 目前 --write-fixture 没法表达主语,首份 fixture 要手写:
# 1. 先直跑 adapter 看输出长啥样
opencli 1point3acres thread 1173710 --limit 2 --format json | head
# 2. 照着响应手写 ~/.opencli/sites/1point3acres/verify/thread.json
# (args 一定用数组: ["1173710", "--limit", "2"])
# 3. 跑 verify 核对
opencli browser verify 1point3acres/thread机器生成的种子只有 rowCount.min=1 / columns / types,挡不住字段值错位。patterns + notEmpty 无论哪种情形都是肉写的。
---
私人 adapter vs repo 贡献
~/.opencli/clis/<site>/<name>.js # 私人
clis/<site>/<name>.js # repo 贡献两者在 `cli({...})` 层面完全一样。差别只在运行入口:
- 私人:写完立即可跑(
opencli <site> <name>) - repo:要
npm run build才被注册
先在 ~/.opencli/clis/ 调通再拷贝到 clis/。
API Discovery
Layer 2:这个站的目标数据 endpoint 是什么? 已经分完类(site-recon.md)再进来。
五种手段。按优先级降级用,命中即走。
---
§0 进入 §1 之前:先看两条红线
这两条不看清楚,后面的 endpoint 验证会一直在错的前提下兜圈子。
0.1 反爬厂商 → 决定 fetch 能不能从 Node 走
opencli browser analyze <url> 的 anti_bot 字段给答案;手查看 cookies 也行:
| cookie / body 信号 | 厂商 | 裸 Node fetch / curl 结果 | 策略 |
|---|---|---|---|
acw_sc__v2 / acw_tc / ssxmod_itna;body 含 arg1 = '32-HEX' 或 /ntc_captcha/ | Aliyun WAF | 返回 slider HTML,不是真数据 | 先在浏览器上下文里验证 endpoint;HTML 型 COOKIE adapter 最终仍走 Node-side fetch + page.getCookies() |
__cf_bm / cf_clearance / __cfduid;body 含 Cloudflare Ray ID / Checking your browser | Cloudflare | TLS 指纹被标记,失败 | 同上:先 browser-context probe,最终 adapter 仍按模板选 fetch 路线 |
_abck / bm_sz / bm_sv | Akamai | 即使带 cookie 也常被挡 | 同上 |
body 含 geetest / gt_captcha | Geetest | 滑块/拼图,程序无解 | 超出 skill 范围,放弃或 UI 策略 |
规则:看到上面四种任一个,先不要拿裸 Node fetch 做 endpoint 验证。先用 browser-context probe 或目标 origin 页面确认接口能通;最终 adapter 的 fetch 路线仍按 adapter-template.md 选,HTML 型 COOKIE adapter 继续走 Node-side fetch + page.getCookies()。
0.2 跨 subdomain = CORS 默认关
jobs.51job.com 页面 fetch cupid.51job.com 的 API,默认会被浏览器 CORS 预检挡住——除非目标接口回了 Access-Control-Allow-Origin。
判断:
opencli browser eval "fetch('https://<target-subdomain>/api/...', {credentials:'include'}).then(r=>r.status).catch(e=>'cors:'+e.message)"- 返回 status 数字 → CORS 通,继续
- 返回
cors:...或TypeError: Failed to fetch→ 挡住了
挡住时:不要把 credentials:'include' 当万能药——这只解决"带 cookie",不解决"跨 origin"。降级路径:
1. 换同 origin 的 endpoint(同一个 subdomain 下的 API 往往更宽松) 2. 用 opencli browser open https://<target-subdomain>/,让页面在目标 subdomain 本身打开,再 fetch 相对路径 3. 真跨域且无替代 → 走 §5 intercept,从页面自身发的请求里抓响应
---
§1 network 精读(首选,Pattern A / D 命中率最高)
拿候选
opencli browser network默认输出是 JSON,每个候选都带:
key— 稳定引用(GraphQL 的operationName或METHOD host+pathname)shape— response body 的路径→类型映射(不含原 body,省 token)status / url / method / ct / size
静态资源 / 埋点 / 追踪默认已过滤。默认会保留 JSON / XML / plain text / text/javascript 这类 API 响应;如果你确定浏览器 DevTools 里有目标请求但这里缺失,用 --all 查一遍是否被 content-type 或 URL 噪音过滤挡掉。
如果是冷启动,先看 opencli browser analyze <url> 里的 api_candidates:
verdict: "likely_data":优先 replay 这条,拿 status / content-type / sample shape 填 strategy noteverdict: "maybe_data":可以试,但必须人工核对字段是否是目标业务数据verdict: "noise":多半是 analytics / beacon / personalization,不要因为 XHR 数量多就判 Pattern Averdict: "blocked":401/403;先排 cookie / token / CSRF,别直接退到 selector
real_data_score 是证据,不是自动 strategy。最终仍要在 strategy note 里写 replay 结果和降级理由。
按 shape 初筛
挑 key 里含业务词(list / detail / Timeline / User / Tweets / Quote)的优先看 shape:
$.data是object且下面出现array(N)/total/page→ 基本是它- 路径里出现
nickname / avatar / title / price / tweets / items→ 就是它 - shape 只有
$: string或全是 HTML 噪音 → 下一条
按期望字段反查(--filter)
已经知道目标 body 该含哪些字段就直接让 CLI 把列表筛到只剩候选,不用自己 scroll 翻 shape:
opencli browser network --filter author,text,likes- 字段以英文逗号分隔;AND 语义,必须每个字段都作为 shape 路径的任意一段出现才保留(
$.data.items[0].author命中author、items、data都算) - 区分大小写(JSON key 本来就 case-sensitive)
- 输出 envelope 新增
filter/filter_dropped,count是过滤后数量 - 0 命中不是 error,返回
entries: [];说明字段组合不对,换一组或去掉约束再试 - 不要跟
--detail一起用——--detail按 key 取单条、--filter是列表缩窄,组合会报invalid_args - 空值 /
,,,→invalid_filter结构化错误 - capture 依然按全量持久化,后续
--detail <key>能找到被过滤掉的条目
拉完整 body
候选定了再拉完整 body(by key,不是 index — 数组顺序会随每次 capture 变):
opencli browser network --detail <key>capture 会持久化到 ~/.opencli/cache/browser-network/<session>.json(默认 TTL 24h),所以 --detail 即使跨多条其他命令也还在。
关键 request headers
browser network 当前只抓响应(body + status + ct),抓不到请求头。要看请求头就在 DevTools Network 面板里点这条 request,或用 browser eval 手动 fetch(url) 复现一次观察浏览器发出去的头:
| 看到 | 含义 | 对应策略 |
|---|---|---|
只有 Cookie | 登录态靠 cookie | Strategy.COOKIE |
Authorization: Bearer xxx | token 鉴权 | 先找 token 来源(localStorage / cookie / bundle 硬编码) |
X-Csrf-Token: xxx 同时存在 cookie 里 | CSRF 防护 | Strategy.COOKIE,从 cookie 读 ct0 类字段拼头 |
X-Workspace-Id / X-Tenant-Id | 多租户业务头 | 先调 /workspaces 拿 ID,缓存下来 |
| 啥自定义头都没有 | 匿名接口 | Strategy.PUBLIC |
触发懒加载接口
默认页加载完后滚动 / 点击才会出的接口不在首屏 network 里。需要:
# 滚到底(虚拟列表)
opencli browser eval "window.scrollTo(0, document.body.scrollHeight)"
opencli browser wait time 2
opencli browser network
# 点某个按钮
opencli browser click <N>
opencli browser wait time 2
opencli browser network---
§2 __INITIAL_STATE__ / inline HTML(Pattern B)
首屏数据常挂在这几个全局变量上:
opencli browser eval "Object.keys(window).filter(k=>k.startsWith('__'))"命中的常见名:
| 全局 | 框架 |
|---|---|
__NEXT_DATA__ | Next.js |
__NUXT__ | Nuxt.js |
__INITIAL_STATE__ | 自定义 Vue / React SSR |
__PRELOADED_STATE__ | Redux SSR |
__REMIX_CONTEXT__ | Remix |
取数据:
opencli browser eval "JSON.stringify(window.__NEXT_DATA__).slice(0, 3000)"关键:inline state 只覆盖首屏的一部分(通常是 SEO 相关字段)。分页 / 评论 / 懒加载还是得回 §1 抓 API。
把首屏 state 当作 adapter 的兜底数据源:公开访问时 state 里有 → 直接 parse;数据更新快 / 分页 → 回到 API。
---
§3 JS bundle / script src 搜索(Pattern C,也是 A/D 的降级)
扫 script src
opencli browser eval "[...document.querySelectorAll('script[src]')].map(s=>s.src).filter(s=>!/\\.(css|png|jpg|svg|woff|mp4)$/.test(s)&&!/googletagmanager|crazyegg|sentry|doubleclick|amazon-adsystem|cloudflare/.test(s))"看结果里的 hostname:
- 明显像 API 的域名(
api.xxx / push.xxx / data.xxx / gateway.xxx)→ 直接去试 - 主 bundle(
main.js / app.js / index.xxx.js)→ 继续下一步下载 bundle 搜 baseURL
搜 bundle 里的 baseURL
opencli browser eval "(async()=>{const s=[...document.querySelectorAll('script[src]')].map(e=>e.src).find(s=>/main|app|index|bundle|chunk/.test(s));if(!s)return'no bundle';const t=await fetch(s).then(r=>r.text());const patterns=['baseURL','baseUrl','BASE_URL','apiHost','apiBase','API_HOST','API_BASE'];const hits=[];for(const p of patterns){let i=-1;while((i=t.indexOf(p,i+1))>-1&&hits.length<5)hits.push(t.slice(Math.max(0,i-5),i+80));}return hits})()"命中 baseURL:"https://api.foo.com" 直接拿 host 拼 endpoint。
直接试候选 endpoint
像 eastmoney 这种经验 endpoint 可以直接喂:
opencli browser eval "fetch('https://push2.eastmoney.com/api/qt/clist/get?fs=m:1+t:2&pn=1&pz=5&fltt=2&fid=f3&po=1&fields=f2,f3,f12,f14').then(r=>r.json())"200 且数据对得上就认。
URL 后缀探测
有些站直接在 URL 加 .json 就是 REST:
https://www.reddit.com/r/rust.json— Reddit 全覆盖https://xueqiu.com/S/SH600000.json— 雪球部分页
# 当前页加 .json 试
opencli browser eval "fetch(location.pathname.replace(/\\/$/,'')+'.json').then(r=>r.ok?r.json():'no')"---
§4 Token / CSRF 来源排查(Pattern D)
已经在 network 里看到请求带自定义头,怎么拿到那个值:
Cookie 里
opencli browser eval "document.cookie.split('; ').reduce((o,x)=>{const[k,v]=x.split('=');o[k]=v;return o},{})"常见 token cookie 名:ct0(Twitter CSRF)、xq_a_token(雪球)、SESSDATA(B 站)、_csrf / csrfToken(通用)。
`document.cookie` 只能看到 non-HttpOnly 的 cookie。 上面那条命令侦察阶段够用,真写 adapter 时 auth 经常是 HttpOnly,一定要用 page.getCookies(...) 从 CDP cookie jar 拿——见 adapter-template.md 的 "COOKIE adapter 骨架"。
论坛 / BBS 引擎(Discuz!X / phpBB / vBulletin)还多一坑:auth cookie 设在根域 .example.com(不是 www.example.com),且 HttpOnly。要查 { domain: '.<root>' } 和 { domain: 'www.<root>' } 两次,否则 adapter 在有 cookie 的前提下仍然 401。
localStorage / sessionStorage 里
opencli browser eval "Object.keys(localStorage).map(k=>k+' => '+localStorage.getItem(k).slice(0,50))"找 token / auth / jwt / bearer 关键字。
Bundle 硬编码
有些站的 Bearer 是全站一个常量(Twitter 的匿名 Bearer)。在 bundle 里搜:
opencli browser eval "(async()=>{const s=[...document.querySelectorAll('script[src]')].map(e=>e.src).find(s=>/main|app|bundle/.test(s));const t=await fetch(s).then(r=>r.text());const m=t.match(/Bearer\\s+[\\w-]{20,}/g);return m?.slice(0,3)||'not found'})()"Store action 绕签名
Vue + Pinia / Redux / React Context 有时直接调 store method 能绕过签名逻辑(method 内部会自己拿签名再发请求):
# Pinia
opencli browser eval "typeof __pinia !== 'undefined' ? Object.keys(__pinia.state.value) : 'no pinia'"
# 直接调 store action(每个站点具体 action 名要查)
opencli browser eval "window.__pinia.state.value.someStore.someMethod({...})"---
§5 installInterceptor(最后降级)
所有手段都试过还拿不到请求签名时,让页面自己发请求,adapter 做 MITM 拦截响应:
// func 里
await page.evaluateWithArgs(installInterceptorCode, {
config: { domain: 'api.xxx.com', path: '/foo' },
});
await page.goto('https://xxx.com/trigger-page');
// 等页面自己发那条请求
const intercepted = await page.evaluate('window.__opencli_intercepted');
return intercepted.response;代价是要等页面真的触发请求,慢、不稳。只在 §1-4 都不行时用。
---
诊断不出来怎么办
按这个顺序试到命中:
§1 network ──→ 命中?yes → 走
│ no
↓
§2 state ──→ 命中?yes → 走
│ no
↓
§3 bundle ──→ 命中?yes → 走
│ no
↓
§4 token ──→ 401 解除?yes → 走
│ no
↓
§5 intercept → 让页面自己发四条都命不中的站(罕见):多半是视觉化渲染(canvas / webgl),数据不以 HTTP/JSON 形式存在。这种放弃或换源。
Coverage Matrix
skill 明确承诺能搞定什么、搞不定什么。动手前先看一眼这张表,判断目标站落在哪一格。
状态标记:
- ✅ 已验证:有可跑通的真实 adapter / dry-run 证据
- 🟡 已列招但未硬跑:文档把方法写全了,但这一版没拿真实站点跑过;第一次遇到时按文档走,踩坑回来补
site-memory - ❌ 不支持:skill 明确不碰,走绕开方案
---
支持(skill 里有对应的招)
| 维度 | 支持 | 状态 | 走哪节 |
|---|---|---|---|
| 页面形态 | 列表页 / 排行页 | ✅ | adapter-template.md(convertible.js / rank.js 类) |
| 详情页(单对象) | ✅ | adapter-template.md(stock.js / holders.js 类) | |
| 时间序列(K 线 / 分钟线) | ✅ | adapter-template.md(kline.js) | |
| 嵌套列表(列表里含列表) | 🟡 | adapter-template.md + output-design.md 打平规则 | |
| 站点类型 | SPA(React/Vue,JSON XHR) | ✅ | site-recon.md Pattern A + api-discovery.md §network |
| SSR(HTML with inline data) | 🟡 | site-recon.md Pattern B + api-discovery.md §state | |
| JSONP / push/script[src] | ✅ | site-recon.md Pattern C + api-discovery.md §bundle(eastmoney / tonghuashun 已覆盖) | |
| SPA + 独立 BFF domain | 🟡 | api-discovery.md §bundle §suffix | |
Strategy(详见 strategy-selection.md) | 裸 fetch() 拿到 | ✅ | PUBLIC_API(一方文档化接口,最稳:fixes/adapter-year=1.18) |
| cookie 透传 | ✅ | COOKIE_API(官方 web 接口 + 用户登录态,fixes/adapter-year=2.01) | |
| publish / upload / click / 表单 | ✅ | UI_SELECTOR(DOM 的 a11y / semantic 也是契约,fixes/adapter-year=1.92) | |
| hydration state / inline JSON | 🟡 | DOM_STATE(fixes/adapter-year=0.91 小样本 N=11,按 UI_SELECTOR 同档) | |
| page-context fetch(CORS / same-origin runtime) | 🟡 | PAGE_FETCH(无契约内部 endpoint,fixes/adapter-year=8.41,必须正向论证) | |
| 触发 UI 拦截响应 | 🟡 | INTERCEPT(无契约,fixes/adapter-year=8.69,必须正向论证) | |
| 字段形态 | 自解释(title / price / current) | ✅ | 直接映射 |
| 已登记代号 | ✅ | field-conventions.md 查表 | |
| 未登记代号 | 🟡 | field-decode-playbook.md 排序键对比法 | |
嵌套路径 data.diff[].f2 | ✅ | field-decode-playbook.md §3 结构差分 | |
| 分页 | page / pn / pageNum | ✅ | adapter-template.md 例子 |
cursor / next_cursor | ✅ | adapter 里 while 循环,收集到 limit | |
offset / start | ✅ | 同上 | |
| 响应格式 | JSON | ✅ | 默认 |
JSONP(?callback=) | ✅ | 去掉 callback 参数直接请求,返回仍是 JSON 字符串包裹 | |
| CSV 字符串(eastmoney kline) | ✅ | response.split(',') 按列序解 | |
| HTML 表格(tonghuashun) | 🟡 | page.evaluate 里用 querySelectorAll 拿 |
🟡 的维度意思:方法在文档里,但这一版没拿真实站点跑过端到端。第一次遇到时按文档走,遇到和文档不一致的地方记到 ~/.opencli/sites/<site>/notes.md,下一次再打开就是 ✅。
---
不支持(承认搞不定,skill 不教)
| 场景 | 原因 | 绕开方案 |
|---|---|---|
| 首次登录获取 token | 需要用户真实输入账密 | 让用户先在 browser session 里手动登录,adapter 拿 cookie 就行 |
| 复杂 anti-bot(captcha) | 反爬拒流量 | 放弃,换同数据的其他站点 |
| 加密字段(客户端 crypto) | 要破解 bundle 逆向 | 换 endpoint;实在不行发请求到 intercept 让页面自己解 |
| WebSocket 流式数据 | 状态管理复杂 | 退回 HTTP 轮询版本(多数站都有) |
| 私有 binary 协议 | 非 HTTP/WS | 不在 skill 范围 |
| 视觉化图表(只有 canvas) | 数据埋在渲染层 | 找对应 API;找不到就放弃 |
| 签名算法涉及静态密钥 | 需要长期跟踪 bundle 变更 | 走 Strategy.INTERCEPT,让页面自己发带签名的请求 |
| 频控 / rate-limit 严格 | 多发几次就 429 | adapter 层控并发 + 加退避;但 skill 不解决 |
---
决定用不用 skill 的快速自测
三个问题:
1. 数据能在浏览器里看到吗? 看不到(登录墙 / 付费墙)→ 先解决鉴权,再回来 2. 数据来源是 HTTP/JSON/HTML 之一吗? 不是(binary / 加密)→ 不在 skill 范围 3. 需不需要每秒推送? 需要 → 找同数据 HTTP 接口;没有就放弃
三个都 yes 再往下走。
---
本轮硬验证 / 当前证据
| 类型 | 证据 adapter | 覆盖维度 |
|---|---|---|
| PUBLIC + 自解释字段 + SPA | ~/.opencli/clis/coingecko/top.js(本轮 dry run) | Strategy.PUBLIC + REST JSON + 自解释字段 + 列表页 |
| COOKIE + 代号字段 + JSONP | clis/eastmoney/*.js × 13(PR #1091 merged) | Strategy.PUBLIC(匿名 ut=)+ JSONP + f-代号 + 列表/详情/K 线 |
| COOKIE + SPA | clis/bilibili/*.js × 10+(已存在) | Strategy.COOKIE + browser:true + wbi 签名 |
本 PR 新增的 skill 还未硬验证的维度:🟡 行,尤其 SSR Pattern B + Bearer/CSRF + 未登记代号解码。这些放到第一批真实用户 adapter 写作中打磨,skill 文档先落,踩坑回来补 site-memory。合 PR 之前先拿 coingecko 跑第二轮(带着第一轮写出的 ~/.opencli/sites/coingecko/)验证 memory 命中 → endpoint re-verify → 字段抽查 → 写 adapter 这条回路。
Field Conventions
响应字段代号在主要站点上的解码表。写 adapter 前先查一遍,查不到再用 SKILL.md 里 Step 2 的实测法推出来,推完补到本表。
---
eastmoney(东方财富)
域名:push2.eastmoney.com / push2his.eastmoney.com / datacenter-web.eastmoney.com / np-listapi.eastmoney.com / np-anotice-stock.eastmoney.com
通用行情字段(push2 clist/ulist/stock)
| 代号 | 含义 | 备注 |
|---|---|---|
f1 | 精度位数 | fltt=2 后可忽略 |
f2 | 最新价 | 要 fltt=2 才是格式化浮点 |
f3 | 涨跌幅 % | 同上 |
f4 | 涨跌额 | 同上 |
f5 | 成交量(手) | |
f6 | 成交额(元) | 接口默认是"元",个别接口是"万元",看 f152 |
f7 | 振幅 % | |
f8 | 换手率 % | |
f9 | 市盈率(动态) | |
f10 | 量比 | |
f12 | 代码 | 股票/债券/ETF/指数统一 |
f13 | market | 数字市场代号,见下表 |
f14 | 名称 | |
f15 | 最高 | |
f16 | 最低 | |
f17 | 今开 | |
f18 | 昨收 | |
f20 | 总市值 | |
f21 | 流通市值 | |
f23 | 市净率 | |
f62 | 主力净流入 | 单位是"元",但有的接口返回"万元",要核对 |
f66 | 超大单净流入 | 同上 |
f72 | 大单净流入 | |
f78 | 中单净流入 | |
f84 | 小单净流入 | |
f100 | 所属板块 | |
f152 | 精度(当 fltt 不传时要除以 10^f152) | 所以一律 fltt=2 |
Convertible bond(可转债)专属
| 代号 | 含义 |
|---|---|
f229 | 正股价 |
f230 | 正股涨跌幅 |
f232 | 正股代码 |
f234 | 正股名称 |
f235 | 转股价 |
f236 | 转股价值 |
f237 | 转股溢价率 |
f238 | 剩余年限 |
f239 | 到期收益率 (YTM) |
f243 | 上市日期 (YYYYMMDD int) |
K-line push2his stock/kline/get
返回形如 data.klines: ["2024-01-02,10.5,10.8,10.9,10.4,12345,..."] 的 CSV 字符串数组。用 fields1 / fields2 控制列。典型列序:
date, open, close, high, low, volume, turnover, amplitude, changePct, changeAmt, turnoverRateMarket 前缀(secid 格式 <market>.<code>)
| 前缀 | 市场 |
|---|---|
1. | Shanghai (SSE) |
0. | Shenzhen (SZSE) + Beijing (BSE) |
116. | Hong Kong (HKEX) |
105. | NASDAQ |
106. | NYSE |
107. | AMEX |
100. | 指数(HSI / SPX / DJIA / 各板块指数) |
判断 symbol 是否已经是 secid 时,只在数字前缀属于上表时才认,否则视作普通 code(防止 00700.HK 这种 <digits>.<alpha> 误判)。
fs 市场/板块过滤码
| 代码 | 含义 |
|---|---|
m:0+t:6,m:0+t:80,m:1+t:2,m:1+t:23,m:0+t:81+s:2048 | 沪深 A 股全集 |
m:1+t:2,m:1+t:23 | 沪 A |
m:0+t:6,m:0+t:80 | 深 A |
m:0+t:81+s:2048 | 北证 A |
m:0+t:80 | 创业板 |
m:1+t:23 | 科创板 |
m:116+t:3,m:116+t:4,m:116+t:1,m:116+t:2 | 港股 |
m:105,m:106,m:107 | 美股 |
b:MK0021 | ETF |
b:MK0354 | 可转债 |
m:90+t:2 | 行业板块 |
m:90+t:3 | 概念板块 |
m:90+t:1 | 地域板块 |
datacenter-web 报表 reportName
| 名称 | 内容 |
|---|---|
RPT_DAILYBILLBOARD_DETAILS | 龙虎榜 |
RPT_F10_EH_FREEHOLDERS | 十大流通股东 |
---
xueqiu(雪球)
域名:stock.xueqiu.com / xueqiu.com
行情 API 返回字段是人类可读,不需要词典:
| 字段 | 含义 |
|---|---|
symbol | SH600000 / 00700 / AAPL 样式 |
name | 名称 |
current | 最新价 |
chg | 涨跌额 |
percent | 涨跌幅 % |
volume | 成交量 |
amount | 成交额 |
high52w / low52w | 52 周高低 |
market_capital | 总市值 |
pe_ttm | 市盈率 TTM |
pb | 市净率 |
鉴权:需要 xq_a_token cookie。走 Strategy.COOKIE + browser: true。
---
bilibili
域名:api.bilibili.com / space.bilibili.com / passport.bilibili.com
B 站接口字段也大多人类可读。关键坑是 wbi 签名:
- 凡 URL 含
/wbi/的接口都需要w_rid + wts签名 - 签名算法依赖每日轮换的
img_key + sub_key(从nav接口拿) - 平台 SDK 在
clis/bilibili/utils.js:apiGet(page, path, { signed: true, params })自动签 - 普通 cookie JSON 接口优先用
page.fetchJson(url)
| 字段 | 含义 |
|---|---|
mid | 用户 UID |
aid / bvid | 视频 ID(av 号 / bv 号) |
cid | 视频分 P ID |
uname / upname | up 主昵称 |
view / danmaku / reply / favorite / coin / share / like | 各类计数 |
pubdate / ctime | 发布/创建时间(秒级 unix) |
---
tonghuashun(同花顺)
域名:q.10jqka.com.cn / d.10jqka.com.cn / data.10jqka.com.cn
同花顺多数接口返回 HTML 表格,需要 DOM 解析。JSONP 接口要设 Referer: http://q.10jqka.com.cn/,否则返回空。
| 字段(API) | 含义 |
|---|---|
openPrice / closePrice | 开/收盘 |
zdf | 涨跌幅 % |
hsl | 换手率 |
zf | 振幅 |
---
字段不在词典时
看 field-decode-playbook.md。里面是标准 SOP(排序键对比、结构差分、精度排查),10 分钟能推一条。
推完一个代号补回本文件,下次直接查。
Field Decode Playbook
响应里出现 f237 / zdf / oc5 / x4 这种看不懂的代号时走这套流程。目标:10 分钟内搞定一条未知字段,不靠猜。
适用于 field-conventions.md 没收录的站点、没收录的代号、或者收录了但怀疑对不上的情况。
---
决策树
拿到一条响应,有字段看不懂?
├── 字段值是字符串 / 时间 / URL → 走 §1 「肉眼对网页」
├── 字段值是数字 → 走 §2 「排序键对比法」
├── 字段值是数组 / 对象 → 走 §3 「结构差分法」
└── 改参数响应不动 → 走 §4 「常量 / 精度位排查」---
§1 肉眼对网页(字符串/时间/URL 类)
最快。打开对应网页,响应的这条记录在哪行,把响应值跟页面上的文字一一对照。
# 举例:eastmoney convertible,响应里有 f243=20231215,页面上这支债写着"上市日期 2023-12-15"
# 直接判断 f243 是上市日期(YYYYMMDD int)时间字段的判别:
| 观察到的形态 | 大概率是 |
|---|---|
10 位整数 1712345678 | unix 秒 |
13 位整数 1712345678000 | unix 毫秒 |
8 位整数 20240101 | YYYYMMDD int |
6 位整数 240101 | YYMMDD int |
字符串 2024-01-01 / 2024/01/01 | ISO 日期 |
字符串 01/01/2024 | 美式日期,小心月日顺序 |
URL 字段的判别:
# 如果是相对路径,拼 domain 回浏览器验证
opencli browser eval "window.location.origin + '/<path>'"---
§2 排序键对比法(数字类核心手段)
数字字段是最容易踩坑的一类——看起来都是小数,但可能是涨跌幅、换手率、振幅、溢价率、市盈率……单位和量级也可能带陷阱。
流程
1. 找到一个能改变排序的参数
| 站点 | 排序参数 | 值域 |
|---|---|---|
| eastmoney (push2 clist) | fid + po | fid 是字段代号,po 是 0=asc / 1=desc |
| xueqiu | order_by | percent / volume / amount / ... |
| bilibili | order | pubdate / click / stow / ... |
| tonghuashun | sort | 数字代号 |
| 通用 | 页面点击表头切排序,抓包看新参数 | — |
2. 用两个已知含义的值各抓一份响应
比如 eastmoney,fid=f2(最新价)和 fid=f3(涨跌幅):
opencli browser eval "fetch('<url>&fid=f2&po=1').then(r=>r.json()).then(d=>d.data.diff.slice(0,3))"
opencli browser eval "fetch('<url>&fid=f3&po=1').then(r=>r.json()).then(d=>d.data.diff.slice(0,3))"3. 对比两组数据:
- 第一条记录的
symbol/name变了吗?没变说明这个参数无效或者白名单窄 - 新的第一条里,哪个字段数量级 / 正负号 / 小数位数变化最大?那个字段就是和
fid对应的业务语义 - 单调性:按
desc取前 3 条,看新旧两组里目标字段是不是都单调下降
4. 用第三个参数交叉验证
opencli browser eval "fetch('<url>&fid=f6&po=1').then(r=>r.json()).then(d=>d.data.diff.slice(0,3))" # 成交额对照网页上"成交额排行"的前三名。对得上就认。
实例:推 f237(可转债溢价率)
# 按价格排,拿一条观察
opencli browser eval "fetch('https://push2.eastmoney.com/api/qt/clist/get?fs=b:MK0354&pn=1&pz=1&fid=f2&po=1&fltt=2&fields=f12,f14,f2,f3,f236,f237,f239').then(r=>r.json()).then(d=>d.data.diff[0])"
# 返回 {f12:'123456', f14:'XX转债', f2:180.5, f3:2.1, f236:98.5, f237:83.2, f239:-4.1}
# 按 f237 排
opencli browser eval "fetch('...&fid=f237&po=1...').then(...)"
# 第一条变了,f237 值变成 400+
# 打开 eastmoney 可转债页,切"溢价率"排序,第一条的溢价率确实是 400+ —— f237 就是溢价率 %---
§3 结构差分法(数组 / 嵌套对象类)
响应顶层是 {data: {diff: [...]}} 还是 {list: [{...}, {...}]} 还是 {rows: [{k:v}, ...]},不同接口差别大。
流程
1. 先数一次嵌套路径
opencli browser eval "fetch('<url>').then(r=>r.json()).then(j=>({keys:Object.keys(j), type:Array.isArray(j)?'array':'object'}))"2. 一层一层剥
opencli browser eval "fetch('<url>').then(r=>r.json()).then(j=>{const d=j.data; return {keys:Object.keys(d), sample: d[Object.keys(d)[0]]}})"3. 数数组长度对照 pz / pageSize
如果请求 pz=20 拿回的数组正好 20,那就是结果数组;如果是 1 那可能是 pagination meta。
4. 同一字段在不同条目间是否变化
# 取前三条的 keys,看哪些 key 的值在变(业务数据),哪些不变(常量/配置)
opencli browser eval "fetch('<url>').then(r=>r.json()).then(d=>d.data.diff.slice(0,3).map(x=>({f2:x.f2,f3:x.f3,f152:x.f152})))"---
§4 常量 / 精度位排查
有的字段每条都一样,是精度指示器或类型标记。
| 典型现象 | 通常含义 | 处理 |
|---|---|---|
每条值都是 2 或 3 | 精度位数(小数几位) | 除以 10^n 还原浮点 |
每条值都是 0 或 1 | 涨跌方向 / 停牌标记 | 枚举,查同类 adapter |
每条值都是 6 / 8 / 80 | 市场代号 / 分类 ID | 查 field-conventions.md 市场表 |
| 每条值都是空字符串 | 接口不返回但字段有占位 | 请求里去掉 / 用别的字段 |
eastmoney 精度坑:不传 fltt=2 时,价格字段是 int * 10^f152。所以一律加 fltt=2,避免所有除法问题。
---
§5 写完要做的事
推出一个新代号后:
1. 补进 `references/field-conventions.md`:找到对应站点的表格加一行。下次直接查。 2. 在 adapter 代码里留一条注释:如果是实测推出来的不常见代号,写一行 // f237 = convertible premium rate (verified 2026-04-20 against page) 方便复核。 3. 通过 `opencli browser verify` 验一次:字段值能对上网页上眼见的数字。
---
§6 通用坑
| 坑 | 症状 | 解 |
|---|---|---|
| 单位"万"当"元"用 | 成交额少 4 个零 | eastmoney f6 是元、f62 / f64 等净流入是万,核对每条接口 |
| 百分比没 × 100 | 涨跌幅显示 0.0XX | 响应已经乘过 100 或者站点用小数 —— 看一眼网页 |
| 股票代码被截 | 600000 变 60000 | 某些接口返回 int,前导 0 掉了。永远用 String(code).padStart(6, '0') 处理 A 股代码 |
| 时间区无前导 0 | 2024-1-1 排序坏 | parse 后用 ISO 回写 |
| JSON 里数字是字符串 | 无法 .toFixed() | Number(x) 显式转 |
---
§7 真推不出来
三条兜底:
1. 找同站点已有的 adapter — ls clis/<site>/ 翻文件,别站点邻居的 adapter 通常共享字段命名 2. 找开源实现 — GitHub 搜 <域名> fields 或 <域名> api,往往有 Python / Go 库已经解码过 3. 灰度:把这条字段先输出成 raw,命令仍可用,下一版再语义化
不要猜。猜错了后面验证不到、线上跑出来用户看到乱码也会误导决策。
JSDOM-against-frozen-fixture pattern (for in-browser DOM extractors)
When this pattern applies
You're writing an adapter where the data extraction happens inside the live browser via page.evaluate(...) — not in Node-side post-processing. Typical signal: the adapter has a function literal stringified into page.evaluate('(' + fn.toString() + ')()'), walking document.querySelector and other DOM APIs.
These extractors are invisible to mocked page.evaluate unit tests — those tests feed pre-baked results to the func, so the real DOM walk never runs. PR #1312 found two such silent in-browser bugs in dianping that only surfaced on live verify:
1. shop title fallback split on ASCII [] while the page renders full-width 【】, so name was always empty. 2. headText.replace(/\s+/g, ' ') collapsed rating "4.8" with reviews "21241条", and a head-wide /\d+条/ regex captured 4.821241 → 5.
If either category looks plausible for your site, freeze a representative HTML snapshot and replay it through JSDOM in a unit test.
File layout
- Test file:
clis/<site>/<site>.test.js(alongside the adapter file) - Fixture file:
clis/<site>/__fixtures__/<command>.html
Reference implementation: clis/dianping/__fixtures__/{shop,search}.html (see PR #1313 for the original test + PR #1318 for the whitespace-strip follow-up that this doc grew out of).
Creating the HTML fixture
The whole point is to commit a representative snapshot of the live page's DOM — so the JSDOM unit test exercises the real selector paths the live extractor walks.
Mandatory steps (in order)
1. Capture the page's HTML from a live verify run:
opencli browser open https://www.example.com/<page>
# In another shell, dump page.content():
opencli browser eval 'document.documentElement.outerHTML' \
> /tmp/raw-<command>.html2. Strip noise blocks that JSDOM doesn't need and that change every page load (so committed fixtures wouldn't survive a re-capture diff anyway):
- All
<script>...</script>content - All
<style>...</style>content - All
<iframe>...</iframe>content - All
<!-- ... -->HTML comments - All
<link rel="preload" ...>/ tracking pixels
3. Replace `<img src="...">` with a placeholder (src="placeholder.png") — real CDN URLs leak account-scoped tokens and are noise.
4. Trim to the minimum subtree that exercises the extractor and triggers the bug you're guarding against. For dianping shop, that was .shop-head + .desc-info + .review-title; for search, 3 of 15 result <li> cards (rank 1, 2, 3).
5. MANDATORY whitespace normalization step — strip all whitespace-only lines:
awk 'NF>0' /tmp/raw-<command>.html > clis/<site>/__fixtures__/<command>.htmlJSDOM's HTML parser is whitespace-tolerant; blank lines have zero semantic effect on the test, but they bloat the committed diff and obscure the meaningful DOM subtree from reviewers. Skipping this step is the most common silent quality regression in fixture creation. PR #1318 cleaned 239 leftover blank lines from dianping's two fixtures (84.6% / 54.8% of file content) and the JSDOM tests still passed unchanged.
6. Commit at clis/<site>/__fixtures__/<command>.html.
Anti-patterns to avoid
- ❌ "Strip script/style content" but leave the surrounding newlines.
Removing inline script body without collapsing the now-blank line is the source of the noise — Step 5 exists specifically to clean this up.
- ❌ Trim to minimum subtree, skip Step 5. The fixture works for the
test but reviewers see hundreds of blank lines.
- ❌ Pretty-print the mega-line (e.g.
<div>...</div>collapsed
onto one giant line by the source page). Some bugs depend on text-node adjacency without intervening whitespace (e.g. 4.8 and 21241条 immediately adjacent → headText fusion bug). Pretty-print inserts whitespace that masks the very condition you're testing for. Step 5 only deletes empty lines — never re-flow content.
- ❌ Re-capture from live and overwrite the committed fixture without
re-running Steps 2-5. The fixture is a frozen snapshot; if the page layout changes, that's a separate decision (update test expectations + re-trim + re-strip).
Writing the JSDOM unit test
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { JSDOM } from 'jsdom';
import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { extractShopFields } from './shop.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const SHOP_FIXTURE = readFileSync(join(__dirname, '__fixtures__/shop.html'), 'utf8');
describe('shop adapter — extractor against frozen HTML fixture', () => {
let originalDocument;
let originalLocation;
beforeEach(() => {
originalDocument = globalThis.document;
originalLocation = globalThis.location;
});
afterEach(() => {
globalThis.document = originalDocument;
globalThis.location = originalLocation;
});
function loadFixture(html, url) {
const dom = new JSDOM(html, { url });
globalThis.document = dom.window.document;
globalThis.location = dom.window.location;
return dom;
}
it('extracts the canonical fields and avoids known silent bugs', () => {
loadFixture(SHOP_FIXTURE, 'https://www.example.com/shop/123');
const data = extractShopFields();
expect(data.ok).toBe(true);
expect(data.name).toBe('...');
// Add explicit regression guards for each known silent bug.
expect(data.reviewsRaw).toBe('...'); // not the fused "<rating><reviews>" form
});
});For this to work, the adapter's extractor must be a top-level function that uses bare document / location (not window.document), so the same code is exercised by:
- live browser: injected via
${extractFn.toString()}into
page.evaluate
- JSDOM unit test: with
globalThis.documentswapped
If your adapter currently has the extractor as an IIFE inside a template literal, refactor to a top-level export function first. Reference: clis/dianping/{shop,search}.js extracts extractShopFields() and extractSearchRows() with bare document/location.
Reverse-validation (mandatory before claiming the test catches the bug)
A test that "passes 18/18" doesn't prove it would have caught the original bug — only that it agrees with the current implementation. Before trusting a regression guard:
1. Make a backup of the adapter source. 2. Reintroduce the buggy variant of the relevant extractor. 3. Run the test. It MUST fail with an assertion that points at the silent bug. 4. Restore from backup.
For dianping bug #2 (rating/reviews fusion):
// BUGGY VARIANT — replaces the .reviews selector path
const buggyMatch = headText.match(/(\d+)条/);
let reviewsRaw = buggyMatch ? buggyMatch[0] : '';If after Step 5 of fixture creation the test still fails on this buggy variant with expected '21241条' to be '21241条' actually receiving '821241条' (the fused digits), the regression guard is intact. If the test still passes with the buggy variant, the fixture is too stripped / normalization went too far / the assertion is too loose — go back and tighten.
This is the same discipline as --write-fixture Step 10 in the main runbook (verify fixture catches what it should), applied to the JSDOM HTML fixture instead of the response JSON fixture.
See also
references/adapter-template.md— basic adapter file structurereferences/output-design.md— column naming for the post-extract mappingreferences/success-rate-pitfalls.md— broader "verify can pass while
data is silently wrong" catalog; the mocked-page.evaluate gap that motivates this whole pattern is one entry there
Output Design
adapter 的 columns 不是随便列。要让下游(用户、其他 adapter 合并、agent 后续分析)都能直接读。
---
核心约定
1. 命名
camelCase,全英文:
| 好 | 差 |
|---|---|
marketCap | market_cap / 市值 / MarketCap |
change24hPct | change_percentage_24h / 涨跌幅24h / changePct_24h |
bondCode | bond_code / BOND_CODE |
pubTime | publish_time / pubdate |
原则:
- 缩写:
pct(百分比)/pe(市盈率)/pb(市净率)/ytm(到期收益率)/id - 时间后缀:
Time(具体时刻)/Date(日期)/Ts(unix 秒) - 百分比一律
Pct结尾,数值是"已乘 100"形式(2.5表示 2.5%,不是0.025) - 数量后缀:
Count(整数计数)/Total(累计值)
2. 类型
| 字段类 | JS 类型 | 格式 |
|---|---|---|
| 价格 / 金额 | number | 原始小数,别除 1000 别取整 |
| 百分比 | number | 已 × 100(2.5 = 2.5%) |
| 计数 / rank | number | 正整数 |
| 代码 / id / symbol | string | 股票代码 '600000' 保留前导 0 |
| 名称 / 标题 | string | 去首尾空白 |
| 时间 | string ISO('2024-01-15T10:30:00Z')或 number unix 秒 | 不要本地字符串 '2024/1/15' |
| 布尔 | boolean | 不用 0/1 |
| URL | string | 绝对路径,相对路径要拼 host |
特殊:
- 缺失用
null,不用0/''(0 和空字符串有业务含义,别混) - 枚举 → string,不要 int 代号(
'listed'比0清楚)
3. 顺序
固定三段:
[识别列 ...] [业务数字 ...] [metadata ...]识别列(前 1-3 列):rank / symbol / code / bondCode / name / title / id
业务数字(中间):价格、涨跌幅、成交量、市值等业务语义
metadata(最后 1-3 列):pubTime / updateTime / source / url
4. 必有列(按类型)
| adapter 类型 | 必须包含 |
|---|---|
| 排行 / 列表 | rank + 识别列 + 业务数字 |
| 时序 / K 线 | date(或 ts)+ 数值 |
| 详情(单对象) | 识别列 + 业务字段 |
| 新闻 / 公告 | title + pubTime + url |
5. 控量
单条 ≤ 15 列。超了就得考虑:
- 拆成多个 adapter(列表版 + 详情版)
- 次要字段合进
extras: {...}对象 - 只有少数用户关心的字段默认隐藏(靠参数开关)
---
对齐邻居 adapter
写新 adapter 前先看同站点现有 adapter 怎么命名:
grep -h "columns:" clis/<site>/*.js复用同类列名。比如 clis/eastmoney/convertible.js 用 bondCode / bondName / stockCode / stockName,新写 eastmoney 某个涉及股票代码的 adapter 就沿用 stockCode / stockName,不要发明 securityId / securityName。
---
常见错误
| 错 | 对 |
|---|---|
columns: ['id', 'name', 'data.price'](点路径) | 把 data.price 在 func 里打平成 price |
{date: '2024-01-15 10:30'}(空格 + 非 ISO) | '2024-01-15T10:30:00Z' 或 Date.toISOString() |
{pct: '2.5%'}(字符串 + 单位) | {changePct: 2.5} 纯数字 |
{volume: '1.2万'} | {volume: 12000} |
{code: 600000}(整数丢前导 0) | {code: '600000'} string,或 String(code).padStart(6, '0') |
| columns 和 func 返回的 keys 对不上 | 列出的每个 key 必须在返回对象里,顺序也一致 |
---
description 字段
adapter description 是用户第一眼看到的,写清楚:
1. 数据是什么:"A 股涨幅排行" vs "大盘指数分时" 2. 默认行为:"默认按涨幅排序,前 20 条" 3. 重要参数:"支持 market 参数切换沪深/北证"
不要:
"get data"— 废话"查询 xxx 数据"— 也是废话- 塞完整 URL / 字段代号列表 — 留给 help
一行 30 字左右够了。
---
args 命名
limit而不是count / num / n / sizesort而不是sort_by / order_by / sortKeymarket而不是exchange / platform / typesymbol/code/query根据业务选,保持和邻居 adapter 一致- 布尔参数用
enableX / includeX,默认 false 免得用户改变认知负担
help 文案给所有合法值,别让用户猜:
{ name: 'sort', type: 'string', default: 'turnover', help: '排序:turnover / change / drop / price / premium' }---
示例对比
差的:
columns: ['股票代码', 'name', 'PRICE', 'change%', 'vol', 'time']问题:中英混、大小写乱、百分号字符串、缩写不统一。
好的:
columns: ['rank', 'stockCode', 'stockName', 'price', 'changePct', 'volume', 'updateTime']识别列在前,metadata 在后,命名统一 camelCase。
Site Memory
站点记忆分两层:in-repo 种子(skill 自带的已知站点公共知识)+ 本地工作目录(每台机器跑过的站点累积产物)。
---
两层结构
skills/opencli-adapter-author/references/site-memory/<site>.md
— 公共种子。手写 + PR 审核进入。多 agent 共享的第一批起点。
— 已铺:eastmoney / xueqiu / bilibili / tonghuashun
~/.opencli/sites/<site>/
— 本地累积。agent 跑 adapter 过程里自动写入,跨 session 复用。
— 不进 git,不进 PR。用法:开头先读本地,命中 不跳写 adapter,仍要跑 Step 5 endpoint 验证 + Step 7 字段抽查(memory 可能过期或站点换版);没命中读 in-repo;都没有走完整 recon。
---
Layer 1 — In-repo 种子(references/site-memory/<site>.md)
每个覆盖站点一个 .md,结构固定:
# <site>
## 域名
主 API / 备 API / 登录 / 静态资源
## 默认鉴权
`Strategy.XXX` + 必需 cookie/header + 获取方式
## 已知 endpoint(选最常用的 5-10 条)
- `GET <url>` — 返回 X,分页参数 Y
- ...
## 字段(指向 `field-conventions.md` 的某一节)
## 坑 / 陷阱
- fltt=2 必传
- 单位是"万"不是"元"
- ...
## 可参考的 adapter
`clis/<site>/<name>.js` × N审核门槛高,里面写的东西必须是"多数人都会踩到"的共识。一次性试错、站点局部怪癖放 Layer 2。
---
Layer 2 — 本地工作目录(~/.opencli/sites/<site>/)
agent 每跑一次相关 adapter 就可以自动写/读:
~/.opencli/sites/<site>/
notes.md — 累积笔记(时间戳 + 写入人 + 发现)
endpoints.json — 已验证的 endpoint 目录
field-map.json — 字段代号 → 含义(key 为字段代号,value 为 {meaning, verified_at, source})
verify/ — `opencli browser verify` 期望值(值级校验锚点,每个 adapter 一份)
<cmd>.json
fixtures/ — 完整响应样本(给字段对比 / 离线 replay;**调试时的原始 dump 也只能落在这里或 /tmp/**)
<cmd>-<ts>.json
last-probe.log — 最近一次侦察输出(下次接着用)verify/ vs fixtures/ 别混:
verify/<cmd>.json是结构期望(rowCount / columns / types / patterns / notEmpty),每 adapter 一份、会被 verify 读。fixtures/<cmd>-<ts>.json是原始响应样本,给人 / 下一个 agent 做字段比对用,verify 不会读。
endpoints.json 格式(schema 锁死)
key = endpoint 的短名(clist / kline / search 等),不要用全 URL 当 key。
{
"clist": {
"url": "https://push2.eastmoney.com/api/qt/clist/get",
"method": "GET",
"params": {
"required": ["fs", "fields"],
"optional": ["pn", "pz", "fid", "po", "fltt"]
},
"response": "data.diff[] 数组",
"verified_at": "2026-04-20",
"notes": "fltt=2 必传"
}
}字段说明:
url/method:原样存,query string 不入url,都归paramsparams.required/params.optional:参数名列表。不存具体值(值会变,记例子放notes)response:一句话写清响应形状入口(data.diff[] 数组/result.data.items/纯数组),而不是把整个响应贴进来verified_at:YYYY-MM-DD。超过 30 天下次读到当作过期重验notes:一两句关键坑(fltt=2 必传/ms 单位 begin之类),不要写长文
field-map.json 格式(schema 锁死)
key = 字段代号(f237 / f152),value 三件套:
{
"f237": {
"meaning": "convertible premium rate (%)",
"verified_at": "2026-04-20",
"source": "field-decode-playbook sort-key comparison vs page"
}
}meaning:人话 + 单位/精度(%/元/万元/× 10^f152等)verified_at:YYYY-MM-DDsource:怎么推出来的,让下次能复查(field-decode-playbook sort-key/网页标签对照/bundle 搜索 var pricePct =)- 已存在的 key 不要默默覆盖。有冲突时先用
fixtures/里的真实样本 + 网页肉眼值再确认一遍
verify/<cmd>.json 格式(schema 锁死)
每个 adapter 一份,opencli browser verify <site>/<cmd> 会自动读。没有这份 = verify 只能证"能跑",证不出数据对——所以是必填产物。
{
"args": { "limit": 3 },
"expect": {
"rowCount": { "min": 1, "max": 3 },
"columns": ["rank", "tid", "title", "url"],
"types": {
"rank": "number",
"tid": "string|number",
"title": "string",
"url": "string"
},
"patterns": {
"url": "^https://www\\.1point3acres\\.com/bbs/thread-"
},
"notEmpty": ["title", "url"]
}
}字段说明:
args:verify 调 adapter 时要带的参数。支持两种形态:- 对象:
{ "limit": 3 }→ 展开成--limit 3,用于标准 named flag 适配器 - 数组:
["123", "--limit", "3"]→ 原样追加到命令后,用于 positional 主语型适配器(<tid>/<url>/<query>)。repo 约定"主语优先 positional",所以这类适配器只能用数组形态 expect.rowCount.{min,max}:包含边界。稳定列表接口收紧到[min, max],动态接口给一个宽区间expect.columns:每行必须都有这些 key(严格要求——漏了就 fail)expect.types:支持|union(string|null)和any通配。写多少列类型看列的稳定性;波动大的列直接any比频繁改 fixture 好expect.patterns:正则表达式 字符串(注意\\转义)。null/undefined会被跳过,不要用正则校验可空字段expect.notEmpty:trim 后不能为空的列。这是"adapter 没吃掉核心业务字段"的最后一道保险expect.mustNotContain:Record<col, string[]>。列值里不允许出现这些子串。用来挡"字段内容污染"——比如description里混进了address:/category:的邻居节点文字、title前面粘了面包屑前缀。notEmpty挡不住这种软污染expect.mustBeTruthy:列数组。列值必须是 JS truthy。用来挡"silent|| 0/|| false兜底"——数值列返回 0 / 空字符串 / false 都会被notEmpty放过,但业务上通常是"没抓到"
什么时候手写 vs --write-fixture 自动生成
--write-fixture只是种子:生成rowCount.min=1/columns/types,没有patterns/notEmpty/mustNotContain/mustBeTruthy——纯类型 fixture 挡不住数值错位 / 字段污染 / silent fallback。- 拿到种子后必须手改,四件套一起上:
patterns:URL / 日期 / ID 等格式列notEmpty:核心业务字段mustNotContain:描述类文本列容易被兄弟节点污染时,把禁词(address:/category:等)列出来mustBeTruthy:数值 / 布尔业务列,挡|| 0/|| false- adapter 是 positional 主语型(
<tid>/<url>/<query>)时,--write-fixture的args要手写成数组形态。工具不会替你决定形态。 - 站点换版导致 fixture 过时:
--update-fixture覆盖。改之前先用肉眼核对一次网页值,别闭着眼把错的响应固化下来。 - 规避反模式:不要为了让 verify 通过去放松 pattern。失败的 pattern 说明 adapter 输出有问题,要收紧 adapter,不是收紧 fixture。放松 fixture 等于默认把错数据接受下来。
notes.md 格式
## 2026-04-20 by opencli-user
写 `convertible.js` 时遇到:
- f237 推断是溢价率(排序对比法,页面对照)
- `fltt=2` 不加的话价格是整数 × 10^f152
- `fs=b:MK0354` 过滤可转债顶部追加新段落,老的不删。每段有日期 + 写入人。
fixtures/<cmd>-<YYYYMMDDHHMM>.json 格式
一份该 endpoint 的完整响应样本。用途:
- 未来字段代号再变时,拿样本和
field-map.json做 regression 对比 - 站点换版时,新响应和旧 fixture 做 diff 看哪个字段结构变了
存之前脱敏:去掉 cookie / token / 登录态相关 header、去掉用户自己的 uid / 用户名 / 手机号 / 邮箱。
---
runbook 里的读/写时机
Step 2 开始前 → 读 ~/.opencli/sites/<site>/
→ 读 references/site-memory/<site>.md
命中后 → 不跳写 adapter,仍要跑 Step 5 (endpoint 验证) + Step 7 (字段抽查)
verified_at 超 30 天 → 当作过期,按冷启动走 Step 3 → 4
Step 10 verify 首轮通过后 → 写 ~/.opencli/sites/<site>/verify/<cmd>.json
- 先 `--write-fixture` 拿种子,再手改 patterns / notEmpty / rowCount
- 没这份后续 verify 挡不住数据错位,**必填**
Step 11 肉眼对比通过后 → 写 ~/.opencli/sites/<site>/
- endpoints.json:按 schema 追加或更新 verified_at
- field-map.json:只追加新 key,已有的不默默覆盖
- notes.md:顶部追加一段
- fixtures/:脱敏后存一份完整响应样本(区别于 verify/)回写是 commit,不是 stash:不过 Step 10 verify + Step 11 肉眼对比不写,防止把错的映射喂给下一轮。
---
不要写进 ~/.opencli/sites/ 的东西
- 真实账户 cookie / token — 不要保存任何鉴权凭据
- 用户私有数据(返回体里有个人敏感字段的 → 脱敏再存 fixtures)
- 过期超过 30 天的 last-probe.log(自动清)
不要写进 repo / adapter 目录 的东西
调试过程里的临时 dump(.dbg-*.html / raw-*.json / sample-* / trace-*.txt)只能落在 ~/.opencli/sites/<site>/fixtures/ 或系统 /tmp/。PR diff 会把 repo 根目录和 clis/<site>/ 下的文件一起带走——别人 review 时看到一堆调试副产物会很烦。
---
没有 site-memory 时
新站点没对应 .md,也没本地目录 → 完整走 recon + discovery,跑完直接写 ~/.opencli/sites/<site>/,后面就有了。
bilibili(B 站)
域名
| 用途 | 域名 |
|---|---|
| 主 API | api.bilibili.com |
| 个人主页 / 空间 | space.bilibili.com |
| 登录 / 鉴权 | passport.bilibili.com |
| 动态 | t.bilibili.com / api.vc.bilibili.com |
| 直播 | api.live.bilibili.com |
默认鉴权
Strategy.COOKIE + browser: true- 核心 cookie:
SESSDATA(登录态)、bili_jct(CSRF token,部分写接口要带到 headerReferer + csrf) - 未登录也能调多数读接口,但有 wbi 签名要求
关键:wbi 签名
- 凡 URL 含
/wbi/的接口都要w_rid + wts签名 - 签名算法依赖每日轮换的
img_key / sub_key,从api.bilibili.com/x/web-interface/nav的wbi_img字段拿 - 不要自己重新实现:
clis/bilibili/utils.js里的apiGet(page, path, { signed: true, params })已经封装好 - 普通 cookie JSON 接口优先用
page.fetchJson(url);站点级签名逻辑仍复用utils.js
已知 endpoint
GET api.bilibili.com/x/web-interface/nav— 登录态 + 拿 wbi keyGET api.bilibili.com/x/space/wbi/arc/search?mid=<uid>— 用户视频(需 wbi 签)GET api.bilibili.com/x/space/acc/info?mid=<uid>— 用户资料GET api.bilibili.com/x/web-interface/view?bvid=BV...— 视频详情GET api.bilibili.com/x/web-interface/popular?ps=20&pn=1— 热门GET api.bilibili.com/x/web-interface/ranking/v2?rid=0— 排行GET api.bilibili.com/x/v2/reply/wbi/main?type=1&oid=<aid>&mode=3— 评论(需 wbi)GET api.bilibili.com/x/web-interface/search/all/v2?keyword=<q>— 综合搜索GET api.vc.bilibili.com/dynamic_svr/v1/dynamic_svr/space_history?host_uid=<uid>— 用户动态(新版走api.bilibili.com/x/polymer/web-dynamic/v1/feed/space)GET api.bilibili.com/x/v2/history/cursor— 观看历史(需登录)GET api.bilibili.com/x/v3/fav/folder/created/list-all— 收藏夹列表(需登录)
字段
字段基本人类可读,见 ../field-conventions.md 的 bilibili 节(mid / aid / bvid / cid / view / danmaku / reply / favorite / coin / share / like / pubdate / ctime)。
坑 / 陷阱
1. wbi 签名缓存 `img_key / sub_key`:24 小时内有效,每次请求都重新 fetch nav 会被限频。utils.js 内部做了缓存 2. `mid` 和 `uid` 一回事:接口不统一,mid= 居多,个别接口要 host_mid= 3. 视频 ID 两种:aid(老数字)和 bvid(BV1xxx),视频详情要用 bvid 4. `ps=` 最大 50(popular/ranking),超过了多发几页拼 5. 动态接口换版:老的 dynamic_svr 已经废,新的走 x/polymer/web-dynamic/v1/feed/space,写新 adapter 直接用新接口 6. 评论分页靠 `next` 游标,不是页号 7. B 站限频 `-352 风控`:短时间高频调会拦截,adapter 层加 500ms 间隔 8. 搜索要先 "cold start":空 cookie 的 session 第一次搜会 412,先访问首页拿 buvid3 cookie 9. 直播接口在 `api.live.bilibili.com`,不是主域名
可参考的 adapter
| 模板类型 | 参考文件 |
|---|---|
| 用户资料 / 视频列表 | clis/bilibili/user-videos.js / me.js |
| 视频详情 / 字幕 | clis/bilibili/download.js / subtitle.js |
| 评论 | clis/bilibili/comments.js |
| 搜索 | clis/bilibili/search.js |
| 热门 / 排行 | clis/bilibili/hot.js / ranking.js |
| 动态 | clis/bilibili/dynamic.js |
| 关注 | clis/bilibili/following.js |
| 收藏 | clis/bilibili/favorite.js |
| 观看历史 | clis/bilibili/history.js |
通用工具:clis/bilibili/utils.js。新 adapter 先 import { apiGet, fetchJson } from './utils.js',不要重写。
eastmoney(东方财富)
域名
| 用途 | 域名 |
|---|---|
| 行情列表 / 批量报价 | push2.eastmoney.com |
| K 线历史 | push2his.eastmoney.com |
| 报表类(龙虎榜 / 十大股东) | datacenter-web.eastmoney.com |
| 7x24 快讯 | np-listapi.eastmoney.com |
| 公司公告 | np-anotice-stock.eastmoney.com |
| 静态页(网页端入口) | quote.eastmoney.com / data.eastmoney.com |
默认鉴权
Strategy.PUBLIC + browser: false- 统一带
ut=bd1d9ddb04089700cf9c27f6f7426281(push2 系列的公共 token) - User-Agent 随意给个
Mozilla/5.0就行,不加会偶发被拦
已知 endpoint
GET push2.eastmoney.com/api/qt/clist/get— 列表 / 排行- 必需:
fs(市场/板块过滤码)、fields(字段清单) - 可选:
pn(页码,1-based)、pz(每页数量)、fid(排序字段)、po(0=asc / 1=desc)、fltt(2=浮点格式化)、invt(2=固定)、np(1=固定) - 返回:
data.diff[]数组 GET push2.eastmoney.com/api/qt/ulist.np/get— 批量报价(给定 secids)- 必需:
secids(逗号拼接的<market>.<code>)、fields - 返回:
data.diff[]数组 GET push2.eastmoney.com/api/qt/stock/get— 单只详情- 必需:
secid、fields GET push2his.eastmoney.com/api/qt/stock/kline/get— K 线历史- 必需:
secid、klt(周期:1 分 / 5 / 15 / 30 / 60 / 101 日 / 102 周 / 103 月)、fqt(0=不复权 / 1=前复权 / 2=后复权)、fields1、fields2 - 返回:
data.klines[]— CSV 字符串数组 GET datacenter-web.eastmoney.com/api/data/v1/get— 报表类- 必需:
reportName(如RPT_DAILYBILLBOARD_DETAILS)、columns、pageSize、pageNumber、sortColumns、sortTypes - 返回:
result.data[] GET np-listapi.eastmoney.com/nlist/api/list/get— 7x24 快讯- 必需:
client=web、column_id、limit、last_time GET np-anotice-stock.eastmoney.com/api/security/ann— 公司公告- 必需:
sr=-1、page_size、page_index、ann_type、stock_list
字段
字段代号词典见 ../field-conventions.md 的 eastmoney 节(f2 / f3 / f12 / f14 / f62 / f229-f243 等全集)。
市场前缀、fs 过滤码、secid 格式也都在那里。
坑 / 陷阱
1. `fltt=2` 必传:否则价格字段是 int × 10^f152 的整数表示,要自己除 2. 资金流单位混乱:f6 成交额是元,但 f62 / f66 / f72 / f78 / f84 等净流入大部分接口返回"万元",核对单条接口后再乘 3. secid vs code:0.000001 / 1.600000 是 secid(market + code),只有纯 code 时要先判断所属市场前缀再拼。用 clis/eastmoney/_secid.js 导出的 resolveSecid(input)(单股)或 splitSymbols(s)(批量参数拆分),不要自己硬拼前缀 4. 港股代码 `00700.HK` 不是 secid:只有前缀属于 {0, 1, 105, 106, 107, 116, 100, 90} 才当 secid 5. kline CSV 列序:fields2=f51,f52,f53,f54,f55,f56,f57,f58,f59,f60,f61 对应 date,open,close,high,low,volume,turnover,amplitude,changePct,changeAmt,turnoverRate 6. datacenter-web 字段名大写:SECURITY_CODE 不是 security_code
可参考的 adapter
| 模板类型 | 参考文件 |
|---|---|
| clist 分页排行 | clis/eastmoney/convertible.js / rank.js / etf.js / sectors.js |
| ulist 批量报价 | clis/eastmoney/quote.js |
| K 线历史 | clis/eastmoney/kline.js |
| 报表类 | clis/eastmoney/longhu.js / holders.js |
| 7x24 快讯 | clis/eastmoney/kuaixun.js |
| 公司公告 | clis/eastmoney/announcement.js |
| 指数 / 北上 | clis/eastmoney/index-board.js / northbound.js |
| 资金流 | clis/eastmoney/money-flow.js |
新写 eastmoney adapter 时,照最像的那条 copy + 改 name / URL 参数 / 字段映射三处。
tonghuashun(同花顺 / ths)
域名
| 用途 | 域名 |
|---|---|
| 行情页 / 热榜 | q.10jqka.com.cn |
| 数据中心(多维筛选) | data.10jqka.com.cn |
| 行情推送 | d.10jqka.com.cn / dq.10jqka.com.cn |
| 基本面 / F10 | basic.10jqka.com.cn |
| 资讯 / 新闻 | news.10jqka.com.cn |
默认鉴权
Strategy.COOKIE + browser: true(多数接口有 cookie 风控)Referer: http://q.10jqka.com.cn/是关键 header,很多接口缺它直接返回空- 有些 JSONP 接口没 cookie 要求,但 Referer 还是要带
- 不支持登录态扩展(加密/签名客户端逻辑复杂)
已知 endpoint
GET d.10jqka.com.cn/v6/line/hs_<code>/01/last1200.js— K 线(JSONP,返回quotebridge_v6_line_...({...}))GET q.10jqka.com.cn/thsq/quote/v6/<code>— 实时行情快照(页面内 XHR)GET q.10jqka.com.cn/stock/attention/— 热度榜(HTML,抽表)GET data.10jqka.com.cn/funds/ggzjl/field/zdf/order/desc/page/1/ajax/1/— 资金流排行(HTML 表格 + JSONP 混合)GET news.10jqka.com.cn/tapp/news/push/stock/?tag=&page=1&limit=20— 快讯GET data.10jqka.com.cn/ifinance/hotNews/— 热点新闻
字段
见 ../field-conventions.md 的 tonghuashun 节(openPrice / closePrice / zdf / hsl / zf 等)。
数值字段多数是字符串(带百分号或"万"单位),解析时 parseFloat 并处理单位。
坑 / 陷阱
1. `Referer` 必须带,否则 302 到错误页 2. 多数 JSONP 接口回调名固定:quotebridge_v6_...,adapter 里直接 .replace(/^[\w_]+\((.*)\)$/, '$1') 剥壳再 JSON.parse 3. HTML 表格接口(如资金流):page.evaluate 里 document.querySelectorAll('table tr') 抽,每列顺序在同类 adapter 之间复用 4. `<code>` 格式 `hs_600000`:hs 是上海,sz 是深圳;港美股前缀不同 5. 数据中心接口有日期快照切换:date=YYYYMMDD 参数默认当天,历史日期要显式传 6. 限频特别严:连续 10 次会跳风控页,adapter 层 1s 间隔起 7. 响应 gzip 强制:某些接口不带 Accept-Encoding 会 406,fetch 默认 OK,node 原生 http 要显式加
可参考的 adapter
| 模板类型 | 参考文件 |
|---|---|
| 热度榜 | clis/ths/hot-rank.js(目前唯一一个,其他待补) |
ths 覆盖度低,新写 adapter 参考 eastmoney 同类型 adapter 的结构,把 URL / 解析逻辑换成 ths 版本。
xueqiu(雪球)
域名
| 用途 | 域名 |
|---|---|
| 行情 / 搜索 / 关注列表 | stock.xueqiu.com / xueqiu.com |
| 动态 / 评论 / 热帖 | xueqiu.com |
| 基金(蛋卷) | danjuanfunds.com |
默认鉴权
Strategy.COOKIE + browser: true- 核心 cookie:
xq_a_token(匿名也有,登录后含用户身份) page.evaluate里fetch(url, { credentials: 'include' })带 cookie- 浏览器先访问一次
xueqiu.com触发 cookie 下发,再去调接口(否则xq_a_token不存在 → 400)
已知 endpoint
GET stock.xueqiu.com/v5/stock/quote.json?symbol=SH600000— 单股详情GET stock.xueqiu.com/v5/stock/batch/quote.json?symbol=SH600000,SZ000001— 批量报价GET stock.xueqiu.com/v5/stock/chart/kline.json?symbol=SH600000&begin=<ts>&period=day&type=before&count=-100— K 线GET stock.xueqiu.com/v5/stock/screener/quote/list.json?market=CN&type=sh_sz— 排行- 可选:
order_by=percent(涨幅)/volume(成交量)/amount(成交额)/market_capital GET stock.xueqiu.com/v5/stock/search.json?keyword=<q>— 搜索GET xueqiu.com/statuses/hot/list.json?since_id=-1&max_id=-1&size=20&type=stock— 热门帖GET xueqiu.com/statuses/search.json?source=all&q=<q>— 动态搜索GET xueqiu.com/service/v5/stock/portfolio/list.json— 关注组合(需登录)GET xueqiu.com/v4/statuses/user_timeline.json?user_id=<uid>— 用户动态
字段
字段基本人类可读(symbol / name / current / chg / percent / volume / amount / market_capital / pe_ttm / pb),详见 ../field-conventions.md 的 xueqiu 节。
坑 / 陷阱
1. 必须先访问首页:冷启动直接调 API 会 400 cookie is invalid,先 page.goto('https://xueqiu.com/') 再 fetch 2. `symbol` 前缀硬编码:SH/SZ/HK/US —— 港股 00700 是 'HK00700',美股 AAPL 是 'AAPL'(无前缀) 3. kline 的 `begin` 是毫秒 unix,不是秒 4. screener 的 `type`:sh_sz / hk / us 必传,漏了拿空数组 5. 评论接口 `comments.json` 要传 `statusId`,不是 status_id 6. 限频明显:同 symbol 短时间反复调会 403,adapter 层建议加 300ms 间隔 7. 蛋卷基金(`danjuanfunds.com`)是独立子品牌:cookie 域不同,要单独获取
可参考的 adapter
| 模板类型 | 参考文件 |
|---|---|
| 单股 / 批量报价 | clis/xueqiu/stock.js |
| K 线 | clis/xueqiu/kline.js |
| 排行 | clis/xueqiu/hot-stock.js |
| 热帖 / feed | clis/xueqiu/hot.js / feed.js |
| 关注 | clis/xueqiu/watchlist.js |
| 搜索 | clis/xueqiu/search.js |
| 评论 | clis/xueqiu/comments.js |
| 基金持仓(蛋卷) | clis/xueqiu/fund-holdings.js |
工具:clis/xueqiu/utils.js 有 fetchWithRetry + symbol normalize,直接用别重写。
Site Recon
Layer 1:这是哪种站? 分类完直接进 api-discovery.md 找 endpoint。
本文件只做分类,不讲 endpoint 怎么找。
---
一步诊断(推荐)
opencli browser analyze <url>返回一份 JSON:
{
"pattern": { "pattern": "A", "reason": "3 JSON XHR responses observed", "json_responses": 3, "auth_failures": 0 },
"anti_bot": { "detected": false, "vendor": null, "evidence": [], "implication": "No known anti-bot signatures. Node-side fetch may work; try COOKIE first, fall back to browser-context fetch if blocked." },
"initial_state": { "__INITIAL_STATE__": false, "__NUXT__": false, "__NEXT_DATA__": false, "__APOLLO_STATE__": false },
"nearest_adapter": { "site": "xueqiu", "example_commands": ["xueqiu search", "xueqiu hot"], "reason": "2 existing adapters target this site — reuse strategy/cookie config" },
"recommended_next_step": "Pick the most specific JSON endpoint from `opencli browser network` and try a bare Node fetch with cookies; escalate to browser-context fetch only if blocked."
}analyze 一步把 Pattern 分类 / 反爬厂商识别 / 最近 adapter 匹配 / 下一步建议给完。直接按 recommended_next_step 走,多数情况不用手跑三步诊断。
手动三步诊断(analyze 给不出明确结论时)
opencli browser open <url>
opencli browser wait time 2
opencli browser network看 network 输出判:
network 看到什么 | 站点类型 | 特征 |
|---|---|---|
大量 /api/... JSON 请求,包含目标数据 | A. SPA / JSON XHR | React/Vue,数据走 fetch |
| 有请求但都是广告 / 埋点,无目标数据 | B. SSR / inline data | 首屏在 HTML 里,深层再走 API |
| 完全空 / 只有静态资源 | C. JSONP / `<script src>` 驱动 | 老金融行情站常见 |
| 有 API 但 401/403/签名错 | D. Token / CSRF 鉴权型 | 在 A 基础上加鉴权 |
Content-Type: text/event-stream / WebSocket 握手 | E. 流式 | 行情 tick / chat |
分不清时参考下面五节的其他信号。
数据是 SPA / 异步加载时,`wait time 2` 可能不够。改用 opencli browser wait xhr '/api/path-fragment' 直接等具体接口到场,比盲 wait time 5 更稳。
---
Pattern A — SPA / JSON XHR
代表:xueqiu、linear、notion、大多数现代 SaaS
信号:
- URL 一访问就是
/,后续数据都在 network tab document.querySelector('main').childElementCount一开始为 0,后被 JS 填充window.React / window.Vue / window.__REACT_DEVTOOLS_GLOBAL_HOOK__存在
下一步:api-discovery.md §1(network 精读)
注意 — Pattern A 命中不等于 strategy 选 `PAGE_FETCH`:
- 先看
opencli browser analyze输出的api_candidates[]:verdict=likely_data的条目才是真候选;verdict=noise(analytics / beacon / personalization)不能算 API 信号 - booking #1680 反例:17 个 JSON XHR 看起来像 Pattern A,但全是 analytics side-channel,最终走
DOM_STATE/UI_SELECTOR - replay 候选 endpoint 后,按
strategy-selection.md的契约模型选 strategy;PUBLIC_API/COOKIE_API都不通才考虑PAGE_FETCH
---
Pattern B — SSR / inline data
代表:bilibili 个人主页、小红书、微博、部分 Next.js / Nuxt 页
信号:
- 第一个请求(
document)返回的 HTML 里已经含目标数据(curl <url> | grep <某数字>) window.__INITIAL_STATE__/window.__NEXT_DATA__/window.__NUXT__存在- 关 JS 仍能看到首屏数据
下一步:api-discovery.md §2(state 抽取) + §1(深层数据回到 network)
---
Pattern C — JSONP / <script src> 驱动
代表:eastmoney、tonghuashun、老一代金融站
信号:
network空或只有 css/font- 页面上肯定有数据(价格、成交量等)
document.querySelectorAll('script[src]')里有指向push / api / data域名的 src- 响应是
jQuery123({...})这种回调包裹(JSONP)
下一步:api-discovery.md §3(bundle / script src 搜索)
---
Pattern D — Token / CSRF / Bearer
代表:Twitter/X、部分企业 SaaS
信号:
- 已经是 Pattern A,但
fetch(url, {credentials:'include'})返回 401/403 - network 里请求头有
X-Csrf-Token / Authorization: Bearer / X-Client-Id / X-Workspace-Id等自定义字段 - 401 响应体带
{"code":"AUTH_REQUIRED","csrf":"..."}类提示
下一步:api-discovery.md §4(token 来源排查) + §5(store action / intercept 降级)
---
Pattern E — 流式
代表:行情 tick、LLM chat
信号:
network里有101 Switching Protocols(WebSocket 握手)- Response headers 含
Content-Type: text/event-stream - 请求一直 pending 不结束
下一步:先找同数据的 HTTP 轮询接口(90% 概率有)。真没有再走 intercept 收 N 条。
---
识别失败怎么办
诊断信号互相矛盾(比如 network 非空但目标数据不在里面),按优先级硬走:
1. 先当 A,试 api-discovery.md §1 2. 不行当 B,试 §2 3. 还不行当 C,试 §3 4. 401 出现了切 D,试 §4 5. 所有手段都试过,启动 intercept(§5)
不要纠结分类。分类是帮忙定第一步,没命中就按顺序降级。
Strategy Selection
SKILL.md 顶层已给出 strategy gate 的 enum、表格和必填字段。本文件展开为什么这套 ladder 是按"契约"而不是"接口高度"组织的,以及具体怎么用 opencli browser analyze 的 api_candidates 证据填 strategy note。
进入条件:你已经按 site-recon.md 跑过 opencli browser analyze、按 api-discovery.md 抓过候选 endpoint。本文件是写 note 之前的最后一站。
---
1. 核心模型:契约 vs 无契约
普遍假设 "API > DOM" — 数据不支持。
837 个内置 adapter 在 30 天观察窗(2026-04-20 → 2026-05-20)按 6 档 strategy 分类后的实测 fix 频率:
| Strategy | 契约级别 | fixes/adapter-year | 解读 |
|---|---|---|---|
PUBLIC_API | stable | 1.18 | 一方文档化 API,最稳 |
COOKIE_API | stable | 2.01 | 官方 web 接口 + 用户 cookie |
UI_SELECTOR | visible-ui | 1.92 | DOM 的 a11y / semantic 约定也是契约 |
DOM_STATE | visible-ui | 0.91 (N=11, 小样本) | hydration JSON 半契约 |
PAGE_FETCH | internal-unstable | 8.41 | 站内未文档化 endpoint,最易漂 |
INTERCEPT | internal-unstable | 8.69 | 拦截内部 XHR,签名/字段 silent drift |
含义:
- 选
PAGE_FETCH/INTERCEPT的 adapter 平均维护成本是PUBLIC_API的 ~7-8 倍 UI_SELECTOR在 1.92/year,跟COOKIE_API同档 — 不是"漂得最快"DOM_STATE在 0.91/year 但 N=11 小样本,按UI_SELECTOR的近邻处理
Selection bias caveat:PAGE_FETCH / INTERCEPT 高 fix 率部分来自 selection bias — 用这俩的本身就是难站(Twitter GraphQL、xhs signed URL)。但这不改变 practical implication:能用契约层就用契约层,别把稳定的 UI/DOM 实现盲目迁到无契约 endpoint。
数据观察窗局限:30 天窗口是近似不是长尾;PAGE_FETCH/INTERCEPT/DOM_STATE 样本量小(N=32/9/11),二期数据足时会单独评估 DOM_STATE。
---
2. Ladder 心智模型
契约层(首选,互相平级,按 surface 适配):
PUBLIC_API ─┬─ COOKIE_API ─┬─ UI_SELECTOR ≈ DOM_STATE
(read) (write/click/upload)
无契约层(被迫才用,必须正向论证 8x 维护成本):
PAGE_FETCH ──── INTERCEPT注意:ladder 不是从上往下降级。UI_SELECTOR 不是 PUBLIC_API 失败后的"惩罚选项"。如果数据/操作本来就是 UI 表面的事(publish、click、upload、表单),UI_SELECTOR 是首选,不需要为"为什么不是 API"过度辩护。
---
3. 怎么把 api_candidates 转化为 strategy note 证据
opencli browser analyze <url> 的输出里 api_candidates[] 字段,每条带:
{
"url": "https://example.com/api/list",
"status": 200,
"contentType": "application/json",
"real_data_score": 0.82,
"verdict": "likely_data",
"reasons": ["json content-type", "non-empty top-level array", "3 business-like keys"],
"sample_paths": ["$.data.items:array(20)", "$.data.items[0].title:string"]
}按 verdict 决策:
| Verdict | 含义 | strategy 信号 |
|---|---|---|
likely_data (score ≥ 0.65) | 看起来是业务数据 | 优先 replay 这条做 PUBLIC_API / COOKIE_API 候选 |
maybe_data (score 0.35-0.65) | 可能业务数据但有 telemetry / 空字段嫌疑 | replay 必须人工核对字段是不是目标数据 |
noise | analytics / beacon / personalization | 不是 API 候选;Pattern A 不能基于这类条目成立 |
blocked (401/403) | auth-gated | 先排 cookie / token / CSRF,不要直接退到 UI_SELECTOR |
关键:real_data_score 是证据,不是 strategy。你最终在 strategy note 里仍要写 replay 出来的 status / content-type / sample shape,不是把 score 直接当结论。
反例:booking #1680
Site: booking.com (酒店搜索)
analyze 输出:17 个 JSON XHR,原 Pattern A
但 api_candidates 全部 verdict=noise(analytics + personalization + experiment)按 1.0.17 前的旧判定,agent 会按 Pattern A 写 PAGE_FETCH adapter,replay 拿到 noise data → adapter silent-fail。新判定:real_data_candidates = 0 → Pattern 落到 C → 提示 SSR HTML scrape → 正确的 strategy 是 DOM_STATE / UI_SELECTOR。
browser analyze 的 recommended_next_step 也已更新为 "Inspect api_candidates, then replay the best endpoint" — 不再按 XHR count 推 API。
---
4. Strategy note 的关键字段填法
Contract 字段
不是直接从 strategy enum 抄,而是反映"这个 source 有多稳":
stable:一方文档化 API、官方 web 接口(PUBLIC_API、COOKIE_API)visible-ui:用户可见的 DOM、a11y / semantic 标记(UI_SELECTOR、DOM_STATE)internal-unstable:站内未文档化 endpoint、签名 / queryId 漂移、字段 silent rename(PAGE_FETCH、INTERCEPT)
Evidence 三行
每行都是事实,不是猜测:
- observed request/state: GET /api/v2/list (sample_paths: $.data.items:array(20), $.data.items[0].title:string)
- auth source: browser cookie (sessionid),无 CSRF
- replay result: 200 / application/json / 20 items / 非空observed request/state 在 DOM_STATE 时写 state global key(window.__INITIAL_STATE__.feed.items);在 UI_SELECTOR 时写 selector path 或 a11y locator(role=list[name="Trending"] > listitem)。
If PAGE_FETCH or INTERCEPT 三行论证
Why PUBLIC_API / COOKIE_API are unavailable: <因为 a_bogus signature 必须 page runtime 生成 / 公开 API 缺少 since 字段 / 接口仅在登录态曝露但 cookie 透传会触发 anti-bot>
Why UI_SELECTOR / DOM_STATE are not safer: <因为数据是无限滚动 + 增量加载,DOM 一次只能拿 1 屏 / 因为目标是 write action,UI 无对应操作>
Why the maintenance cost is acceptable: <因为业务需求要 raw timeline cursor / 因为已经接受漂时 autofix 流程兜底>反模式:
- ❌ "因为 API 比 DOM 高级" — 不是论证,是假设
- ❌ "因为 selector 不可靠" — 数据不支持(UI_SELECTOR 跟 COOKIE_API 同档)
- ❌ "因为我看到 17 XHR" — 不是论证,是 booking #1680 反例
正确论证须基于:endpoint 的真实不可达 / 操作语义本质 / 维护成本承担方有明确接收方。
If UI_SELECTOR / DOM_STATE
- semantic anchor: <a11y role / data-testid / framework-stable class>
- typed error path: <selector 失配时抛 EmptyResultError / CommandExecutionError>不需要"why not API"过度辩护。如果你能简短说一句"目标是 publish,没有公开 write API"或"数据在 SSR HTML 直接 inline 了"就够了。
---
5. 与其他 reference 的关系
| 文件 | 关系 |
|---|---|
| `api-discovery.md` | §1-5 是 endpoint 发现的具体方法。本文件指它,但本文件管"用 endpoint 证据填 strategy note",那边管"怎么先找到 endpoint" |
| `site-recon.md` | Pattern A-E 是 site classification。Pattern A 命中 ≠ PAGE_FETCH 必然合适 — 还要看 api_candidates 是不是 likely_data |
| `coverage-matrix.md` | 鉴权列已对齐 6 档 strategy enum |
| `adapter-template.md` | 写代码模板。strategy note 应该在打开 template 之前已经定好 |
| `success-rate-pitfalls.md` | 11 种 silent failure 模式 — 多数发生在 strategy 选错时(比如把 noise endpoint 当业务数据) |
---
6. 反例案例库
booking #1680 — Pattern A 误判
旧判定按 XHR count 推 Pattern A,实际 17 XHR 全是 analytics / personalization side channel。新 verdict 系统能识别为 noise,落到 Pattern C → SSR HTML scrape。
Twitter GraphQL — PAGE_FETCH 高维护成本的典型
queryId 每隔 1-2 月漂一次,字段名 silent rename(legacy.user_screen_name → core.user.screen_name)。30 天 9 个 fix PR。Why the maintenance cost is acceptable 的合理论证:业务需要 raw timeline cursor、autofix 流程已接住、fixed 时间窗口可控。
xiaohongshu signed URL — INTERCEPT 必要场景
a_bogus signature 由 page runtime 即时生成,无法在 Node 端复现也不能拷贝 cookie 跨 origin replay。合理 strategy 是 INTERCEPT:触发 UI 让页面自己发请求,从 response 取数据。
weread-official — PUBLIC_API 首选
WeRead 官方 Agent Gateway 有 Bearer auth + 文档化 schema。一方契约 + 不依赖 cookie / 不依赖浏览器 — 最理想的 strategy。维护成本最低。
Success-Rate Pitfalls
11 个静默失败(adapter 看起来能跑、verify 能过,但数据是错的)的坑。每条给:现象 → 根因 → 防御手段。
不是风格建议。每条都对应过一次真实翻车。
---
1. fixture pattern 被放松以过 verify
现象:verify 报 pattern "url" does not match /^https?:\/\/.*\.com\/bbs\/thread-/。agent 的"修法"是把 fixture 里的 pattern 改宽(^https?://),verify 一下就通过了。
根因:adapter 丢了 URL 前缀 / 拼错了路径 / 吃到了相对路径。pattern 失败不是 fixture 太严,是 adapter 输出真的破了。
防御:
autofixskill 现有纪律:verify pattern 失败 = 收紧 adapter,不是收紧 fixture(opencli-autofixSKILL.md §Rules for Patching 第 6 条)- 要改 fixture 的唯一合法理由:站点本身换了格式(例如 URL 规范迁移)。这种情况下在
~/.opencli/sites/<site>/notes.md顶部写一段说明
---
2. 字段内容污染但 notEmpty / columns 都过
现象:description 字段不为空,verify 通过。肉眼看输出发现描述里混了 "address: 上海 category: IT" 之类明显不属于描述的片段——兄弟 DOM 节点或父节点文字被一起 textContent 了。
根因:.container 的 textContent 包括所有后代文字。调 innerText 仅好一点;用 querySelector('.desc').textContent 时,如果 .desc 里嵌套了 .tag.address,一样吃进去。
防御:
- fixture 用
mustNotContain:{ "description": ["address:", "category:", "工作年限:"] }把已踩过的污染词列出来 - adapter 侧:定位更精确的 selector,或抓到后
.replace(/address:[^\n]*/g, '').trim() - 别信
textContent.trim()就完事
---
3. 字段语义分歧(两个字段看起来都对)
现象:51job 列表有 updatedate 和 publishDate,eastmoney 债券有 f10(发行日)和 f26(上市日)。adapter 随便选一个,verify 通过,用户一对照发现时间错位 1 个月。
根因:两个字段都是合法日期,format 也对,只是含义不同。notEmpty / types 都挡不住。
防御:
- Step 7 字段解码必须和网页肉眼对至少一条已知记录("这条债券首页写的上市日是 2025-02-14",看 adapter 输出对不对得上)
- 字段写进
field-map.json时meaning要精确到"上市日"而不是"日期"
---
4. 字段单位混淆(数值量级错)
现象:eastmoney 返回 totalMarketCap: 128(单位:亿元),adapter 直接写进 marketCap,用户对照 K 线页看是 128 元。常见错位:
| 接口返回 | 网页显示 | 实际单位 | 错误写法 |
|---|---|---|---|
0.025 | 2.5% | 小数 | adapter 再 × 100 → 显示 250% |
12800 | 1.28 万 | 元 | adapter 不除 10000 → 显示 12800 万 |
f152 = 2 | 9.27 | 价格 = 原值 ÷ 10^f152 | adapter 忽略 f152 → 显示 927 |
防御:
- fixture 加
mustBeTruthy挡|| 0/|| falsesilent fallback(数值列应该有值,不是 0) field-map.json的meaning写单位:"premium pct (0-1 fraction, NOT already × 100)"- Step 11 肉眼比对不能只比"有没有数字",要比数量级
---
5. JSON-in-attribute vs 渲染后 innerText
现象:51job 把完整 JSON 塞在 <div data-sensorsdata='{"job_title":"..."}'> 里,用 innerText 取的是渲染后的截断显示文字("Lead... ↩ 上海..."),字段边界丢了。
根因:现代站点常把结构化数据放在 data-* 属性里,渲染层只挑部分显示。取 innerText 相当于丢掉了结构。
防御:
- 看到
data-sensorsdata/data-ng-state/data-page-props/data-track类属性先读属性,不读 innerText - 先在
browser eval里检查:document.querySelector('.item').dataset看有没有 JSON 串 - 搜 bundle 时也搜
JSON.parse(el.dataset.*)模式看 vendor 把数据塞哪了
---
6. cookie 域 / origin 不一致的隐式假设
现象:在 jobs.51job.com 页面调 cupid.51job.com 的接口,headless 里带了 cookie 也跨不过去——credentials:'include' 只管带 cookie,不管 CORS。
根因:浏览器 CORS 预检默认关闭跨 subdomain 请求。credentials:'include' 不是万能药。
防御:参见 api-discovery.md §0.2。判断:fetch(target).catch(e=>'cors:'+e.message) 看是不是 TypeError。降级路径:改用 same-origin endpoint / 改在目标 subdomain 上打开页面 / 走 §5 intercept。
---
7. 等不够就抓导致空 DOM / 空 network
现象:open url && wait time 2 && network 看到 0 条业务 API。agent 以为是 Pattern C(静态),去 bundle 里找 baseURL,找不到就卡住。真相:SPA 3.5 秒才发出第一个 API。
根因:wait time N 是盲等。不同站点 JS 执行速度差很多。
防御:
- 数据是异步加载时不用 `wait time`,用
opencli browser wait xhr '/api/path-fragment',等具体 XHR 到场再network - 不确定 endpoint 路径时:先
wait time 2 && network,看到候选路径再转wait xhr确认 - 首诊断用
opencli browser analyze <url>一步拿json_responses数量——=0 时才真的是 Pattern C
---
8. adapter 里的 falsy || 0 兜底静默
现象:likes: data.likes || 0。接口偶尔返回 likes: null(可能因字段名改了、权限问题等),adapter 写成 0,verify types: {likes: 'number'} 通过,用户看到的是"所有帖子 0 赞"。
根因:|| 兜底把"没抓到"变成"是 0"。notEmpty 挡不住 0,types 也不挡。
防御:
- fixture 用
mustBeTruthy: ["likes", "count", ...]——业务数值列必须 truthy - adapter 侧 prefer
?.而不是||;真的想兜底就兜undefined,让 verify 能看见 - 全部
|| 0要过一遍眼:这个 0 是合法值还是漏抓 fallback
---
9. 跨 session cookie 污染 / 登录态漂移
现象:本地开发时用自己的登录态验 endpoint 能通,PR 一合 verify fixture 跑在 CI 环境里立刻 401——顺手把样本数据也固化进了 fixture,看起来"一切正常"。
根因:fixture 样本是带登录态跑出来的。存 ~/.opencli/sites/<site>/fixtures/*.json 没脱敏,把 cookie / token / 自己的 uid / 昵称存了进去。
防御:
site-memory.md的脱敏规则:存 fixtures 前去掉 cookie / token / 用户私有字段(手机号 / 邮箱 / 昵称 / uid)- 需要登录态的接口:adapter 用
Strategy.COOKIE,adapter 代码里不写任何具体 cookie 值,只声明"我需要 domain X 的 cookie" - verify 样本里看到
Bearer ...或 32 位 hex token → 先删再存
---
10. adapter 默认 timeout 不统一
现象:同一个站两个 adapter,一个默认 15s timeout,另一个默认 60s。慢接口在一个命令里 ok,在另一个一样的慢接口却 timeout 了。
根因:模板没统一 timeout;agent 依赖"最像的邻居"复制,复制到的邻居选了短 timeout。
防御:
- 邻居 adapter 的
requestTimeoutMs/browser.wait配置不能盲抄。每个 adapter 应该结合自己的接口特性设一个 - 真实接口延迟:Step 5 endpoint 验证时用
time curl(或performance.now()包 fetch)量一下 p50 / p95,timeout 设 p95 × 2 比较安全 - 出现偶发 timeout 别 retry 掩盖;记到
notes.md,下次就知道这接口 p95 偏高
---
11. aria-label / placeholder / title 是 locale-dependent 文本
现象:你本地英文 Chrome 测 button[aria-label="Submit"] 一切正常,verify fixture 也是英文环境抓的。用户把 chrome://settings/languages 切中文,同一个按钮变 aria-label="提交",adapter silent 0 匹配——退化成 notEmpty / types 都没法 fire 的"adapter 跑完返 0 行"。
根因:aria-label / title / placeholder / alt / textContent 都是页面的用户可见文本,被站点 i18n 框架翻译。用它们当 selector anchor 等于 "select by visible text",locale 一动整个选择器就废。
防御:
- 优先用 locale-stable 标识:
data-testid/data-*/ 稳定id/class。先确认不是 hash / A-B test 产物 role不按 locale 翻译,但通常不唯一;只能当 semantic / scope filter,不能用裸[role="button"]当 primary- 站点只暴露
aria-label(典型如 ChatGPT web 某些 control)时,写 fallback list,至少 en + zh-CN:'[aria-label="Send"], [aria-label="发送"]' - commit 前 grep
aria-label=/placeholder=/title=的硬编码字符串,确认每条都有兜底 locale - 找不到 control 要 typed fail-fast(例如
CommandExecutionError/ send-failed),不要把 selector miss 变成空 rows 或假成功 - 详细 framework + 活例见
adapter-template.md §Selector 稳定性
不要去给 framework 加 --i18n flag 自动展开——多一层 indirection 还要维护翻译字典,纯 over-engineering。
---
总结:静默失败的共同特征
1. verify 绿 ≠ 数据对。verify 只能证"结构没坏",证不出"值对不对"。Step 11 肉眼比对是必须的。 2. "字段有值"是个比"字段为空"更危险的失败态。空你会去查,有值你会 fallthrough。 3. fixture 四件套一起上:patterns + notEmpty + mustNotContain + mustBeTruthy——每件挡一类问题,缺一个就漏。
回写 notes.md 时把你踩的新坑写进去。下次就有第 12 条了。
Related skills
How it compares
Pick opencli-adapter-author when you need registry-compliant adapter scaffolding; use generic CLI tutorials when OpenCLI registry integration is not required.
FAQ
Who is opencli-adapter-author for?
Agents and developers authoring OpenCLI adapters with the official browser init and verify toolchain.
When should I use opencli-adapter-author?
When creating a new site adapter, choosing fetch strategy, or closing the loop to browser verify.
Is opencli-adapter-author safe to install?
Review the Security Audits panel on this page before installing in production.