references/adapter-template.md
# 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`](../../../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(...)` 一起抄过去。
```javascript
// 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 — 标头
```javascript
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
```javascript
args: [
{ name: 'sort', type: 'string', default: 'turnover', help: '...' },
{ name: 'limit', type: 'int', default: 20, help: '...' },
],
columns: ['rank', 'bondCode', 'bondName', /* ... */ ],
```
**规则**:
- `type`: `string` / `int` / `float` / `bool`
- `default` 必填(缺失的命令会拒绝启动)
- `columns` 数组必须跟 `func` 返回的 object keys 完全对上,顺序也一致(决定表格列顺序)
- 列名 camelCase,跟 `cli({...})` 其他 adapter 保持统一
- **中间解析对象 key 不能跟 columns 任一项重叠** —— 否则 `silent-column-drop` audit 会把它当 row 候选误判。`{pid, html, start}` 这类中间结构改成 `{postId, body, offset}`,最后在 push row 时再 destructure aliasing 回 column 命名。背景:PR #1329 R1 codex-mini0 catch 的([before](https://github.com/jackwener/OpenCLI/blob/384bcd6fdd93f3075bd2c835e82689c42bfe4b2f/clis/1point3acres/thread.js#L50-L63) → [after](../../../clis/1point3acres/thread.js#L50-L65))
### 3. func — 主体
```javascript
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](./typed-errors.md) 和 [`clis/1point3acres/utils.js`](../../../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**,不会收到 `page`
- `browser: true`:`func: async (page, args, debug?) => { ... }` —— **双参 (page, args)**,第一参是浏览器上下文
- `args`:所有 `args[]` 声明的参数解析后的 object
**错误处理**:用 typed error 5-classification(参见 [`typed-errors.md`](./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`。
```javascript
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:
```javascript
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`](./typed-errors.md) 映射到 `CommandExecutionError` / `AuthRequiredError` / `EmptyResultError`。
### 页面内 DOM 逻辑用 `page.evaluate(fn, ...args)`
新 adapter 优先写函数形式,外部变量通过参数传入:
```javascript
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:
```javascript
// 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 域的双查
```javascript
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。现在的契约:
```javascript
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](./typed-errors.md)。
---
## 同类型 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:
```js
// 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:
```json
{
"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` 类)可以直接让工具生成种子:
```bash
# 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/hot
```
positional adapter 目前 `--write-fixture` 没法表达主语,**首份 fixture 要手写**:
```bash
# 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/`。
references/api-discovery.md
# API Discovery
**Layer 2:这个站的目标数据 endpoint 是什么?** 已经分完类(`site-recon.md`)再进来。
五种手段。按优先级降级用;命中只代表“进入验证”,不代表可以直接写 adapter。复杂/私有/写入候选还要过 [`deep-recon.md`](./deep-recon.md) 的 contract gate。
---
## §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`。
判断:
```bash
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 命中率最高)
### 拿候选
```bash
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`,也会识别 `text/x-component` 与明确的 `/rsc-action/` React Server Component 流。如果你确定浏览器 DevTools 里有目标请求但这里缺失,用 `--all` 查一遍是否被其他 content-type 或 URL 噪音过滤挡掉。capture queue 是破坏性读取;Core 会先缓存本批原始条目再做展示过滤,所以紧接着的空 `--all` 仍可复用该 session 的 raw cache,而不是永久丢掉被隐藏的条目。
如果是冷启动,先看 `opencli browser analyze <url>` 里的 `api_candidates`:
- `verdict: "likely_data"`:优先 replay 这条,拿 status / content-type / sample shape 填 strategy note
- `verdict: "maybe_data"`:可以试,但必须人工核对字段是否是目标业务数据
- `verdict: "noise"`:多半是 analytics / beacon / personalization,不要因为 XHR 数量多就判 Pattern A
- `verdict: "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:
```bash
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 变):
```bash
opencli browser network --detail <key>
```
capture 会持久化到 `~/.opencli/cache/browser-network/<session>.json`(默认 TTL 24h),所以 `--detail` 即使跨多条其他命令也还在。
`--detail` 还会在 capture provider 支持时返回 `request`:method 仍在顶层;headers 中 cookie、Authorization、CSRF/XSRF、token/key/secret/session 等值会替换为 `<redacted>`;可安全识别的 JSON object / URL-encoded form 会保留结构,位置数组、opaque 或截断 body 只保留 kind、shape、full size、truncated/omitted 状态。不要因为 body 被安全省略就拿 URL 单独 replay——这说明请求合同仍不完整。
这也意味着私有页面的 response 可能落在本地 cache。侦察结束要删除相关 session capture 并释放 browser session;不要依赖 24h TTL 代替清理。
### 关键 request headers
先用 `browser network --detail <key>` 看脱敏后的 request headers / body shape;不要打印或复制 credential 原值。旧 capture provider 若没有返回 `request`,再去 DevTools Network 面板核字段名,或用页面自然动作重新 capture,不能用 `browser eval` 猜造一份缺 header/body 的 URL-only 请求:
| 看到 | 含义 | 对应策略 |
|------|------|---------|
| 只有 `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 里。需要:
```bash
# 滚到底(虚拟列表)
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)
首屏数据常挂在这几个全局变量上:
```bash
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 |
取数据:
```bash
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
```bash
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
```bash
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。
### 用 jsluice 扩大候选面(可选)
手工搜 `baseURL` 只适合小 bundle。站点脚本多、压缩重或 endpoint 通过 `fetch` / XHR / 字符串拼接生成时,可以把**已经加载的脚本文本**通过 stdin 交给本机可选的 [jsluice](https://github.com/BishopFox/jsluice) 做语法感知扫描。
```bash
# bundle 只短暂落 /tmp;扫描后删除
jsluice urls < /tmp/example-bundle.js
```
边界:jsluice 输出是 candidate,不是 contract。`EXPR` 表示动态值未知;扫描无法证明 token、签名、CORS、权限、分页、字段语义或副作用。不要把命中 URL 直接写进 adapter,更不要把扫描到的疑似 secret 原值保存到 trace/site memory。
每个候选至少记录:来源 bundle + 代码位置、method/path、触发它的可见动作、动态 network 是否发生、replay status/content-type/shape、选择或拒绝原因。复杂站直接转 [`deep-recon.md`](./deep-recon.md) 的 evidence ledger。
### 直接试候选 endpoint
像 eastmoney 这种经验 endpoint 可以直接喂:
```bash
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 只是 transport 成功。至少换一个输入再试,并核 content-type、目标 identity、非空 shape、分页和可见页面值;写入或复杂私有协议转 `deep-recon.md`,不能“数据看起来像”就认。
### URL 后缀探测
有些站直接在 URL 加 `.json` 就是 REST:
- `https://www.reddit.com/r/rust.json` — Reddit 全覆盖
- `https://xueqiu.com/S/SH600000.json` — 雪球部分页
```bash
# 当前页加 .json 试
opencli browser eval "fetch(location.pathname.replace(/\\/$/,'')+'.json').then(r=>r.ok?r.json():'no')"
```
---
## §4 Token / CSRF 来源排查(Pattern D)
已经在 network 里看到请求带自定义头,怎么拿到那个值:
### Cookie 里
```bash
opencli browser eval "document.cookie.split('; ').map(x=>x.slice(0,x.indexOf('='))).filter(Boolean)"
```
常见 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 里
```bash
opencli browser eval "Object.keys(localStorage).filter(k=>/token|auth|jwt|bearer|csrf/i.test(k))"
```
先只列 key 名,找 `token / auth / jwt / bearer / csrf`。只有选定 production auth source 后才在页面内使用对应值;不要把值打印进聊天、trace、shell history 或 site memory。
### Bundle 硬编码
有些站的 Bearer 是全站一个常量(Twitter 的匿名 Bearer)。在 bundle 里搜:
```bash
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.matchAll(/Bearer\\s+[\\w-]{20,}/g)];return {count:m.length,positions:m.slice(0,3).map(x=>x.index)}})()"
```
只返回数量/位置,不返回 token 原值。即使 bundle 中是公共匿名 Bearer,也先确认它是否是预期公开合同;不要复制未知 credential-shaped string。
### 调用页面 runtime 让站点自己生成请求(只读、最后手段)
Vue + Pinia / Redux / React Context 有时能调用页面自己的只读 store method,让站点 runtime 自己生成签名和请求:
```bash
# Pinia
opencli browser eval "typeof __pinia !== 'undefined' ? Object.keys(__pinia.state.value) : 'no pinia'"
# 只调用已证明是 read-only 的 store action(每个站点具体 action 名要查)
opencli browser eval "window.__pinia.state.value.someStore.someMethod({...})"
```
这不是“绕签名”,也不是 direct API contract:它仍依赖页面 controller/runtime,production strategy 通常是 `INTERCEPT`。只有动作语义被可见 UI 和动态请求证明为 read-only 才能在侦察中调用。未知 effect 或 write action 禁止自动调用;写入只观察用户明确授权的一次自然操作,按 `deep-recon.md` 处理。
---
## §5 让页面自然发请求并截获 response(最后降级)
所有手段都试过还拿不到请求签名时,让页面自己自然发请求,adapter 用现有 Browser Bridge capture/interceptor 读取响应。优先 CDP network capture;只有已存在站点实现依赖 XHR interceptor 时才复用它,不要再写页面内 fetch/XHR monkey patch。
```javascript
// func 里:capture 必须先于触发动作安装,并先 drain stale entries
await page.startNetworkCapture('/api/foo');
await page.readNetworkCapture();
await page.goto('https://xxx.com/trigger-page');
// 等页面自己发请求,再读取所有相关完整 response
const entries = await page.readNetworkCapture();
```
capture queue 可能是破坏性 drain:过滤 relevant URL 后,只要看到 bodyless/truncated relevant entry 就拒绝 partial;多 response 要按业务 identity 合并,不能只取最后一个。分页、缓存和 no-partial 规则见 `deep-recon.md`。
代价是要等页面真的触发请求,慢且依赖内部合同。只在 §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 形式存在。这种放弃或换源。
references/coverage-matrix.md
# 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 这条回路。
references/deep-recon.md
# Deep Recon: evidence-first discovery for undocumented sites
Use this when a site has no documented API, the DOM is lossy, a command needs more than one page, writes are requested, or bundle/network/UI evidence conflicts. The goal is the smallest reproducible contract—not the largest endpoint inventory and not a demo that only works once.
## 1. Freeze the command surface and mutation boundary
Before browsing, make an intent matrix:
| Command | User intent | Read/write | Completeness | Exact target | Allowed live action |
|---|---|---|---|---|---|
| `search` | find matching entities | read | exact limit or upstream exhaustion | query | safe replay after proof |
| `send` | create external side effect | write | one confirmed mutation | recipient + content | only with explicit authorization |
Group aliases that share one proven query primitive; do not create many commands by cloning scripts. A “rich” CLI is broad in verified user goals, not broad in unproven endpoints.
Write the mutation boundary in one sentence. Passive observation and clearly read-only actions are normally safe. Any send/delete/publish/follow/payment action needs explicit authorization that names the target and permitted count. Authorization to observe one write is not authorization to replay it repeatedly.
## 2. Build an evidence ledger, not a traffic dump
Track one row per candidate:
| Intent | Visible action | Source | Method/path | Effect | Auth/signing | Response shape | Replay result | Decision |
|---|---|---|---|---|---|---|---|---|---|
`Source` is one of `dynamic`, `state`, `bundle`, or `memory`. `Effect` is `read`, `write`, or `uncertain`; HTTP method does not decide it. `Decision` is `chosen`, `rejected`, or `blocked`, with the exact reason and lift condition.
Never paste credentials or response bodies into the ledger. Store structural facts: status, content type, arity/paths, row counts, cursor behavior, and source location.
## 3. Triangulate three evidence planes
1. **Visible truth**: page rows, counts, URLs, identity, and semantic controls.
2. **Dynamic truth**: DevTools requests caused by one controlled action.
3. **Static candidates**: loaded bundles scanned manually or with syntax-aware tools such as jsluice.
Dynamic evidence proves that a request occurred. Static scanning expands recall to lazy pagination, detail, search, and routes that this session did not trigger. Neither alone proves a production contract.
Do not equate “structured” with JSON. React Server Components (`text/x-component`), streamed HTML fragments, protobuf-like payloads, and positional arrays may carry the authoritative data. Preserve their content type, request context, truncation state, and structural shape even when the default network view would normally hide them.
jsluice is optional and stays outside adapter runtime. Feed it script text through stdin, keep source locations, and treat `EXPR` as unknown. Do not persist suspected secret values. A candidate becomes useful only after dynamic occurrence or a safe replay verifies its shape and semantics.
## 4. Attribute requests with causal diffs
For each intent:
1. establish a network/state baseline;
2. perform exactly one visible action;
3. inspect only newly added requests/state;
4. repeat with one changed input;
5. run a negative control when ambiguity remains.
The changed-input run should reveal which field controls query, filter, cursor, or target identity. If several requests arrive, do not select the largest response or last URL by guess—compare payload identity against visible results.
For writes, the user performs or authorizes exactly one natural action. Capture before and after state, confirmation UI, and any request/response pair. Do not auto-replay the captured write during discovery or tests.
## 5. Rank candidates before decoding
Prefer a candidate that:
- contains the user-visible target data or mutation identity;
- works across two inputs without copying ephemeral values;
- has a safe, explainable auth source;
- paginates to the requested completeness;
- returns typed, distinguishable auth/HTTP/upstream failures;
- is cheaper to maintain than the strongest UI/DOM alternative.
Penalize opaque signatures, rotating query IDs, positional writes, one-time tokens, page-only controllers, responses unrelated to the visible action, and candidates whose only evidence is a bundle string.
## 6. Pass the contract gate
A read contract must prove all of these:
1. **Occurrence**: the page naturally sends it, or it is documented/public.
2. **Reproducibility**: a safe replay works across two non-empty inputs; if replay is impossible, `INTERCEPT` preserves the page-owned request.
3. **Identity**: returned rows match visible target identity, not adjacent recommendations/ads/sidebars.
4. **Completeness**: pagination reaches exact limit or proven upstream exhaustion.
5. **Auth boundary**: cookies/CSRF/origin/runtime requirements are explicit and do not leak secrets.
6. **Failure semantics**: auth, HTTP, malformed/truncated body, repeated cursor/page, timeout, and partial data fail typed.
Replay the complete request contract, not a URL-shaped fragment. A captured URL returning 4xx/5xx does not reject the underlying endpoint when headers, body, cookies, runtime action identifiers, or page-owned signing were omitted. Record the missing context and use `INTERCEPT` until it can be reproduced safely; never guess absent request fields from a bundle string.
A direct API-backed write contract additionally must prove:
1. target identity is deterministically bound in the request;
2. auth/CSRF/signing can be reproduced without fabricating or exfiltrating runtime secrets;
3. idempotency or duplicate-risk semantics are known;
4. pre-write failure is distinguishable from post-write uncertainty;
5. success is verified independently of the edited control/request echo;
6. retries are disabled after an uncertain write unless the user first checks state.
“The browser sent it once” is not enough. A page-generated one-time anti-abuse token, an opaque positional write, or a controller method that clicks/dispatches UI means there is no direct URL/API contract yet. If the requested surface requires direct API and this gate fails, do not substitute UI automation: record the blocker and lift condition (for example, official OAuth scopes).
## 7. Handle capture, pagination, and cache explicitly
Browser capture queues may be destructive drains. Before relying on them:
- install capture before the action and drain stale entries;
- cache the raw selected capture before applying display-only MIME, static-resource, or shape filters;
- allow in-flight responses to settle;
- treat bodyless or truncated relevant entries as possible data loss;
- inspect non-JSON structured streams with request method, safely redacted headers, body shape, and size/truncation metadata;
- merge all relevant completed responses in the action window;
- identify pages/cursors by content, not arrival order alone;
- deduplicate by stable entity ID;
- reject repeated pages/cursors and page-cap exhaustion rather than return accumulated partial rows.
Never copy authorization, cookies, CSRF/XSRF values, API keys, session identifiers, or token-bearing request bodies into output, ledgers, fixtures, or site memory. Redact keyed values; if a positional or opaque body cannot be sanitized confidently, preserve only its kind, shape, full size, and truncation/omission state.
A cached page may render without a fresh request. A DOM fallback is valid only when it is strictly scoped to the target container, preserves the public columns, and can distinguish empty state from structure drift. Do not silently switch to a weaker page-wide selector.
## 8. Choose strategy per command
Use the repository strategy ladder after the contract gate. One site may legitimately mix strategies:
- `PUBLIC_API` / `COOKIE_API` for reproducible contracts;
- `PAGE_FETCH` for a verified same-origin read contract that must execute in page context;
- `DOM_STATE` for stable hydration or cached state;
- `INTERCEPT` when page-owned signing is unavoidable for reads;
- `UI_SELECTOR` for user-visible writes only when that surface is allowed and safer;
- no command when the requested contract cannot be proved.
Preserve page-owned auth/signing rather than reimplementing adversarial logic. “API-first” means prefer a verified structured contract for completeness and maintainability; it does not mean force every command onto an internal endpoint.
## 9. Decode and test against invariants
For positional payloads, freeze only verified indexes, assert outer arity and required identifiers, and fail typed on drift. For object payloads, validate the minimum required keys and distinguish legitimate null from missing/malformed.
Validation set:
- two non-empty inputs plus one true empty result;
- more than one page when pagination exists;
- exact limit and upstream exhaustion;
- cache/repeat behavior;
- auth, HTTP, JSON/shape, truncated/bodyless capture, timeout, repeated cursor/page, and page cap;
- every public column and target identity.
Tests must exercise the production navigation/capture/fetch path, not only a parser helper. Use sanitized synthetic fixtures; high-sensitivity live responses never enter the repository. When fixing a silent bug, reverse-validate that the new regression test fails against the old implementation.
## 10. Deliver a clean, reviewable artifact set
Leave durable outputs at the right layer:
- **adapter**: executable contract, shared helpers, typed errors;
- **tests**: production-path behavior and structural invariants;
- **docs**: command surface, limits, auth, uncertainty, examples;
- **site memory/sitemap**: verified routes, triggers, fallbacks, rejected strategies, pitfalls, and verification date;
- **recon conclusion**: evidence ledger without secrets or private bodies.
Before PR:
1. regenerate manifest and validate the site;
2. run focused/site tests, typecheck, build, docs coverage, typed-error and silent-column gates;
3. scan the diff for live addresses, account IDs, tokens, message IDs, bodies, attachments, raw captures, and temporary dumps;
4. delete raw capture/cache artifacts and release browser sessions;
5. obtain independent exact-head review for writes, auth/signing, positional schemas, or no-partial pagination.
Record rejected approaches with a concrete lift condition. A well-proven “no safe command yet” is a successful Deep Recon outcome; it prevents the next author from repeating unsafe dead ends.
references/field-conventions.md
# 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, turnoverRate
```
### Market 前缀(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 分钟能推一条。
推完一个代号补回本文件,下次直接查。
references/field-decode-playbook.md
# Field Decode Playbook
响应里出现 `f237 / zdf / oc5 / x4` 这种看不懂的代号时走这套流程。目标:10 分钟内搞定一条未知字段,不靠猜。
适用于 `field-conventions.md` 没收录的站点、没收录的代号、或者收录了但怀疑对不上的情况。
---
## 决策树
```
拿到一条响应,有字段看不懂?
├── 字段值是字符串 / 时间 / URL → 走 §1 「肉眼对网页」
├── 字段值是数字 → 走 §2 「排序键对比法」
├── 字段值是数组 / 对象 → 走 §3 「结构差分法」
└── 改参数响应不动 → 走 §4 「常量 / 精度位排查」
```
---
## §1 肉眼对网页(字符串/时间/URL 类)
最快。打开对应网页,响应的这条记录在哪行,把响应值跟页面上的文字一一对照。
```bash
# 举例: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 字段的判别**:
```bash
# 如果是相对路径,拼 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`(涨跌幅):
```bash
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. **用第三个参数交叉验证**
```bash
opencli browser eval "fetch('<url>&fid=f6&po=1').then(r=>r.json()).then(d=>d.data.diff.slice(0,3))" # 成交额
```
对照网页上"成交额排行"的前三名。对得上就认。
### 实例:推 f237(可转债溢价率)
```bash
# 按价格排,拿一条观察
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. **先数一次嵌套路径**
```bash
opencli browser eval "fetch('<url>').then(r=>r.json()).then(j=>({keys:Object.keys(j), type:Array.isArray(j)?'array':'object'}))"
```
2. **一层一层剥**
```bash
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. **同一字段在不同条目间是否变化**
```bash
# 取前三条的 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,命令仍可用,下一版再语义化
不要猜。猜错了后面验证不到、线上跑出来用户看到乱码也会误导决策。
references/jsdom-fixture-pattern.md
# 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:
```bash
opencli browser open https://www.example.com/<page>
# In another shell, dump page.content():
opencli browser eval 'document.documentElement.outerHTML' \
> /tmp/raw-<command>.html
```
2. **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:
```bash
awk 'NF>0' /tmp/raw-<command>.html > clis/<site>/__fixtures__/<command>.html
```
JSDOM'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
```js
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.document` swapped
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):
```js
// 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 structure
- `references/output-design.md` — column naming for the post-extract mapping
- `references/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
references/output-design.md
# 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 怎么命名:
```bash
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 / size`
- `sort` 而不是 `sort_by / order_by / sortKey`
- `market` 而不是 `exchange / platform / type`
- `symbol` / `code` / `query` 根据业务选,保持和邻居 adapter 一致
- 布尔参数用 `enableX / includeX`,默认 false 免得用户改变认知负担
`help` 文案给**所有合法值**,别让用户猜:
```javascript
{ name: 'sort', type: 'string', default: 'turnover', help: '排序:turnover / change / drop / price / premium' }
```
---
## 示例对比
**差的**:
```javascript
columns: ['股票代码', 'name', 'PRICE', 'change%', 'vol', 'time']
```
问题:中英混、大小写乱、百分号字符串、缩写不统一。
**好的**:
```javascript
columns: ['rank', 'stockCode', 'stockName', 'price', 'changePct', 'volume', 'updateTime']
```
识别列在前,metadata 在后,命名统一 camelCase。
references/site-memory.md
# Site Memory
站点记忆分两层:**in-repo 种子**(skill 自带的已知站点公共知识)+ **本地工作目录**(每台机器跑过的站点累积产物)。
---
## 两层结构
```
skills/opencli-adapter-author/references/site-memory/<site>.md
— 公共种子。手写 + PR 审核进入。多 agent 共享的第一批起点。
— 已铺:eastmoney / xueqiu / bilibili / tonghuashun / gmail
~/.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`,结构固定:
```markdown
# <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;高敏私人响应不落盘)
<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 不会读;高敏私人响应只用合成 fixture。
### `endpoints.json` 格式(schema 锁死)
key = endpoint 的短名(`clist` / `kline` / `search` 等),不要用全 URL 当 key。
```json
{
"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`,都归 `params`
- `params.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 三件套:
```json
{
"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-DD`
- `source`:怎么推出来的,让下次能复查(`field-decode-playbook sort-key` / `网页标签对照` / `bundle 搜索 var pricePct =`)
- **已存在的 key 不要默默覆盖**。有冲突时先用 `fixtures/` 里的真实样本 + 网页肉眼值再确认一遍
### `verify/<cmd>.json` 格式(schema 锁死)
每个 adapter 一份,`opencli browser verify <site>/<cmd>` 会自动读。**没有这份 = verify 只能证"能跑",证不出数据对**——所以是必填产物。
```json
{
"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` 格式
```markdown
## 2026-04-20 by opencli-user
写 `convertible.js` 时遇到:
- f237 推断是溢价率(排序对比法,页面对照)
- `fltt=2` 不加的话价格是整数 × 10^f152
- `fs=b:MK0354` 过滤可转债
```
顶部追加新段落,老的不删。每段有日期 + 写入人。
### `fixtures/<cmd>-<YYYYMMDDHHMM>.json` 格式
一份该 endpoint 的**可安全持久化**响应样本。优先级:合成 fixture > 公开响应 > 可证明完成脱敏的真实响应。用途:
- 未来字段代号再变时,拿样本和 `field-map.json` 做 regression 对比
- 站点换版时,新响应和旧 fixture 做 diff 看哪个字段结构变了
**存之前做数据分级**:
- 公开列表/行情等无账户私有内容:可保存,仍需移除 cookie/token/header。
- 低敏账号数据:只有完成字段级脱敏且不会从正文、snippet、URL、附件名反推个人时才保存。
- 邮箱、私信、通讯录、支付、健康、草稿、上传文件等高敏内容:不要保存真实 response,即使“删了邮箱地址”也不够;用保持 shape/arity 的合成 fixture。
原始 capture 只在 `/tmp/` 或受控本地 cache 中短暂存在,任务结束删除;不要把 raw private response 当成“完整 fixture”长期积累。
---
## 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 — 不要保存任何鉴权凭据
- 用户私有数据:高敏内容一律不存;低敏内容只有完成字段级脱敏才可存
- 过期超过 30 天的 last-probe.log(自动清)
## 不要写进 **repo / adapter 目录** 的东西
调试过程里的临时 dump(`.dbg-*.html` / `raw-*.json` / `sample-*` / `trace-*.txt`)**只能**落在系统 `/tmp/`;只有通过上面数据分级、准备长期保留的安全样本才进入 `~/.opencli/sites/<site>/fixtures/`。PR diff 会把 repo 根目录和 `clis/<site>/` 下的文件一起带走;任务结束还要删除原始 capture/cache。
---
## 没有 site-memory 时
新站点没对应 `.md`,也没本地目录 → 完整走 recon + discovery,跑完直接写 `~/.opencli/sites/<site>/`,后面就有了。
references/site-memory/bilibili.md
# 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,部分写接口要带到 header `Referer + 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 key
- `GET 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'`,不要重写。
references/site-memory/eastmoney.md
# 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 参数 / 字段映射三处。
references/site-memory/gmail.md
# gmail
## Domain and auth
- Desktop UI: `mail.google.com/mail/u/<account>/`.
- Requires Google session cookies and a signed-in Gmail surface.
- The official Gmail API requires separate OAuth; browser cookies are not an official API credential.
## Verified read paths
- `POST /sync/u/<account>/i/bv`: natural search/list response. Positional array, arity 19. Labels may appear at `[1]`, threads at `[2]`, counts at `[6]`.
- `POST /sync/u/<account>/i/fd`: natural thread/message detail response. Cached threads may render without a fresh request.
- `POST /sync/u/<account>/i/s`: incremental synchronization; not needed by the initial adapter.
- Legacy `POST /mail/u/<account>/?ui=2` still appears during bootstrap. Do not assume a single transport variant.
## Positional fields verified in 2026-08
- Thread: subject `[0]`, snippet `[1]`, timestamp `[2]`, sync id `[3]`, summaries `[4]`.
- Summary: sender card `[1]`, label ids `[10]`.
- FD thread: id `[0]`, message wrappers `[2]`; wrapper is `[messageId, record]`.
- Message: to `[0]`, cc `[1]`, subject `[4]`, body `[5]`, snippet `[6]`, sender `[10]`, attachments `[13]`, date `[16]`, legacy id `[34]`.
- Sender: display name `[14]`, address `[16]`.
- Attachment node: id `[1]`, data `[3]`; data contains name `[2]`, MIME `[3]`, size `[4]`.
## Strategy
- Reads: `INTERCEPT`. Let Gmail submit the visible search/open action, then parse the natural `bv/fd` response. Never reconstruct private XSRF/BTAI request bodies.
- Cached thread fallback: rendered message containers (`data-message-id`, `.a3s`, scoped attachment cards).
- Writes: no supported browser-session API contract is currently available. Gmail's private `/sync` operations require page-runtime state, and the official Gmail API requires separate OAuth credentials and scopes.
## Durable pitfalls
1. Synthetic `.value` + synthetic key events do not reliably submit Gmail search. Use native insertion and raw CDP Enter; verify the exact field value before submission.
2. `thread-f:<decimal>` converts to legacy hex with `BigInt(decimal).toString(16)`; never use Number.
3. `bv` search responses may omit label definitions. The complete visible fallback is `#settings/labels`.
4. A new `/fd` response is not guaranteed for cached messages. Treat rendered content as a legitimate visible-ui fallback.
5. Responses are anti-XSSI-prefixed positional arrays. Enforce arity/required-field guards and fail typed on drift.
6. Network capture is a destructive drain; a relevant bodyless/truncated entry means possible data loss, not an empty/short page.
7. Never store live subjects, addresses, message bodies, attachment names, cookies, or account-specific ids in fixtures/site memory.
8. Do not replay legacy `?ui=2` write actions or fabricate `/sync` write payloads. Current send commits include a page-generated WAA anti-abuse token; supported API-backed writes require Google OAuth.
references/site-memory/tonghuashun.md
# 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 版本。
references/site-memory/xueqiu.md
# 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,直接用别重写。
references/site-recon.md
# Site Recon
**Layer 1:这是哪种站?** 分类完直接进 `api-discovery.md` 找 endpoint。
本文件**只做分类**,不讲 endpoint 怎么找。
---
## 一步诊断(推荐)
```bash
opencli browser analyze <url>
```
返回一份 JSON:
```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 给不出明确结论时)
```bash
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)
不要纠结分类。分类是帮忙定第一步,没命中就按顺序降级。
references/strategy-selection.md
# 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[]` 字段,每条带:
```json
{
"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` 三行
每行都是事实,不是猜测:
```md
- 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` 三行论证
```md
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`
```md
- 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`](./api-discovery.md) | §1-5 是 endpoint 发现的具体方法。本文件指它,但本文件管"用 endpoint 证据填 strategy note",那边管"怎么先找到 endpoint" |
| [`deep-recon.md`](./deep-recon.md) | 无文档私有协议、写入、分页/缓存或证据冲突时,先做动作归因和 contract gate;只有通过后才进入本文件选 strategy |
| [`site-recon.md`](./site-recon.md) | Pattern A-E 是 site classification。Pattern A 命中 ≠ `PAGE_FETCH` 必然合适 — 还要看 `api_candidates` 是不是 `likely_data` |
| [`coverage-matrix.md`](./coverage-matrix.md) | 鉴权列已对齐 6 档 strategy enum |
| [`adapter-template.md`](./adapter-template.md) | 写代码模板。strategy note 应该在打开 template 之前已经定好 |
| [`success-rate-pitfalls.md`](./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。维护成本最低。
references/success-rate-pitfalls.md
# 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 输出真的破了。
**防御**:
- `autofix` skill 现有纪律:**verify pattern 失败 = 收紧 adapter,不是收紧 fixture**(`opencli-autofix` SKILL.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` / `|| false` silent 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 条了。
references/typed-errors.md
# Typed Error Conventions
OpenCLI 用 5 类 typed error 让 agent 能从 exit code 直接分辨"参数错 / 没数据 / 接口挂 / 要登录 / 超时"。silent `return []` / silent `return [{sentinel}]` / scalar sentinel (`'-'`) / `Math.max/min` silent clamp / `CliError('HTTP_ERROR')` 这些"绿但错"的写法都被 audit gate 抓得越来越紧([`scripts/check-typed-error-lint.mjs`](../../../scripts/check-typed-error-lint.mjs) 的 baseline JSON 只能减不能加,新违例必须立刻收掉)。
每条 rule 都挂了真实 anti-pattern 反例(PR #1329 三轮迭代是主要素材库)。
---
## 1. 5-classification 落点表
| 场景 | 抛 | code | exit |
|------|-----|------|------|
| 参数不合法(包括越界、格式错、缺主语 positional) | `ArgumentError(message)` | `ARGUMENT` | 2 |
| 业务无数据 / 资源不存在 | `EmptyResultError(command, hint?)` | `EMPTY_RESULT` | 66 |
| HTTP 非 2xx / fetch throw / JSON parse 错 / 接口业务报错 | `CommandExecutionError(message)` | `COMMAND_EXEC` | 1 |
| 需要登录(cookie 缺 / 401 / 302 → /login / "请登录" 页) | `AuthRequiredError(domain, message?)` | `AUTH_REQUIRED` | 77 |
| 等响应超时(CDP / page.evaluate / 流式回复) | `TimeoutError(label, seconds)` | `TIMEOUT` | 75 |
类签名定义见 [`src/errors.ts`](../../../src/errors.ts)。
**禁止泛用 `CliError('HTTP_ERROR' / 'NO_DATA' / 'USER_NOT_FOUND' / 'FETCH_ERROR' / 'INVALID_ARGUMENT' / 'API_ERROR' / 'THREAD_NOT_FOUND')`** —— 这些都对应到 5 类里其中一个,没有不能映射的场景(PR #1329 R3 commit `c40daf7` 把 7 处 `CliError(...)` 全部替换成了上面 5 类)。`CliError` 基类只用作子类的实现细节,adapter 代码不应直接 new。
---
## 2. Anti-pattern A:silent-clamp 不止 limit
**反例**(PR #1329 R3 codex-mini0 直接修,commit `c40daf7`;pre-fix lines: [`thread page/limit/contentLimit`](https://github.com/jackwener/OpenCLI/blob/2b8609b82fccaf98505c5b1b1859e3ffdaa1a55c/clis/1point3acres/thread.js#L37-L39), [`notifications limit`](https://github.com/jackwener/OpenCLI/blob/2b8609b82fccaf98505c5b1b1859e3ffdaa1a55c/clis/1point3acres/notifications.js#L41), [`qwen ask timeout`](https://github.com/jackwener/OpenCLI/blob/2b8609b82fccaf98505c5b1b1859e3ffdaa1a55c/clis/qwen/ask.js#L42), [`qwen image timeout`](https://github.com/jackwener/OpenCLI/blob/2b8609b82fccaf98505c5b1b1859e3ffdaa1a55c/clis/qwen/image.js#L119), [`qwen history API page_size`](https://github.com/jackwener/OpenCLI/blob/2b8609b82fccaf98505c5b1b1859e3ffdaa1a55c/clis/qwen/utils.js#L311)):
```js
// ❌ silent clamp — 200 当 100,0/-1 当 1,timeout=5 当 15
const limit = Math.max(1, Math.min(Number(args.limit) || 20, 100));
const timeout = Math.max(15, parseInt(kwargs.timeout, 10) || 120);
const contentLimit = Math.max(50, Number(args.contentLimit) || 400);
const page = Math.max(1, Number(args.page) || 1);
```
**修法**([`clis/1point3acres/utils.js`](../../../clis/1point3acres/utils.js) 的 `normalizePositiveInteger` / `normalizeLimit`):
```js
import { ArgumentError } from '@jackwener/opencli/errors';
/** Validate a positive integer arg without silently flooring/clamping. */
export function normalizePositiveInteger(value, defaultValue, label = 'value', { min = 1 } = {}) {
const raw = value ?? defaultValue;
const n = Number(raw);
if (!Number.isInteger(n) || n <= 0) throw new ArgumentError(`${label} must be a positive integer`);
if (n < min) throw new ArgumentError(`${label} must be >= ${min}`);
return n;
}
/** With both lower bound and an explicit ceiling. */
export function normalizeLimit(value, defaultValue, maxValue, label = 'limit') {
const n = normalizePositiveInteger(value, defaultValue, label);
if (n > maxValue) throw new ArgumentError(`${label} must be <= ${maxValue}`);
return n;
}
```
**用法**([`clis/1point3acres/thread.js#L37-L39`](../../../clis/1point3acres/thread.js)):
```js
const page = normalizePositiveInteger(args.page, 1, 'page');
const limit = normalizePositiveInteger(args.limit, 10, 'limit');
const contentLimit = normalizePositiveInteger(args.contentLimit, 400, 'contentLimit', { min: 50 });
```
**注意**:当前 silent-clamp 检测的 regex(在 [`src/convention-audit.ts`](../../../src/convention-audit.ts) 的 `auditTypedErrorPatterns` 节,由 [`scripts/check-typed-error-lint.mjs`](../../../scripts/check-typed-error-lint.mjs) 调)只抓 `Math.min(...limit...)`。`Math.max(1, ...)` 单边 floor 和非-`limit` 命名(`page` / `timeout` / `contentLimit`)都不在 regex 里,所以 #1329 R3 时被 F-P-0 review 而不是 gate 挡下来 → 必须靠**人审 checklist** 兜:**任何外部参数(args.\* / kwargs.\*)做 Math.max / Math.min / `|| N` 当兜底都是 silent-clamp**。
### 何时抽 site-level helper
≥ 2 个同站 adapter 都要做同样的 limit / page 校验时,把 `normalizeLimit` / `normalizePositiveInteger` 抽到 `clis/<site>/utils.js`。1 个 adapter 用就直接 inline,别为 1 处单点抽 helper。
---
## 3. Anti-pattern B:sentinel row / scalar sentinel 吞 empty / unknown / failure
**反例 1**(PR #1329 R3 修掉的,[`clis/1point3acres/notifications.js` before](https://github.com/jackwener/OpenCLI/blob/2b8609b82fccaf98505c5b1b1859e3ffdaa1a55c/clis/1point3acres/notifications.js#L35-L37)):
```js
// ❌ "暂时没有提醒内容" 是 empty result,不是一行业务数据
if (/暂时没有提醒内容/.test(html)) {
return [{ index: 0, from: '', summary: '暂时没有提醒内容', time: '', threadUrl: '' }];
}
```
**反例 2**([`clis/1point3acres/search.js` before](https://github.com/jackwener/OpenCLI/blob/2b8609b82fccaf98505c5b1b1859e3ffdaa1a55c/clis/1point3acres/search.js#L52-L60)):
```js
// ❌ "抱歉" hint 是搜索无结果,不是 rank=0 的伪结果行
if (items.length === 0) {
const hint = html.match(/<p>([^<]*?抱歉[^<]*?)<\/p>/);
if (hint) {
return [{ rank: 0, tid: '', title: hint[1].trim(), forum: '', author: '', replies: 0, views: 0, postTime: '', url: '' }];
}
return [];
}
```
**反例 3**([`clis/qwen/history.js` before](https://github.com/jackwener/OpenCLI/blob/2b8609b82fccaf98505c5b1b1859e3ffdaa1a55c/clis/qwen/history.js#L44-L51)):
```js
// ❌ API failure 不是 conversation 的一行
if (!result.ok && !result.sessions.length) {
return [{ Index: 0, Title: `API failed (status=${result.status})`, Updated: '', Url: '' }];
}
```
**反例 4**([`clis/qwen/image.js` before](https://github.com/jackwener/OpenCLI/blob/2b8609b82fccaf98505c5b1b1859e3ffdaa1a55c/clis/qwen/image.js#L160-L164)):
```js
// ❌ 单张图片 fetch 失败,伪装成 "⚠️ fetch-failed" 状态行继续
if (!asset?.ok) {
results.push({ Status: `⚠️ fetch-failed(${asset?.status || '?'})`, File: '-', Link: link });
continue;
}
```
**修法**:
```js
import { EmptyResultError, CommandExecutionError } from '@jackwener/opencli/errors';
if (/暂时没有提醒内容/.test(html)) {
throw new EmptyResultError('1point3acres notifications', '暂时没有提醒内容');
}
if (items.length === 0) {
throw new EmptyResultError('1point3acres search', `No results for "${query}"`);
}
if (!result.ok && !result.sessions.length) {
throw new CommandExecutionError(`Qianwen history API failed (status=${result.status})`);
}
if (!asset?.ok) {
throw new CommandExecutionError(`Failed to fetch image: status=${asset?.status || '?'}`);
}
```
**反例 5:scalar sentinel**(PR #1329 R2 修掉的,[`clis/qwen/status.js` before](https://github.com/jackwener/OpenCLI/blob/42e5303c792d9f71d9a30dde2e391405e03661e7/clis/qwen/status.js#L24-L29)):
```js
// ❌ '-' 会被下游当成真实 model/session id 字符串
return [{
Status: 'Connected',
Login: loggedIn ? 'Yes' : 'No (guest mode)',
Model: model || '-',
SessionId: sessionId || '-',
}];
// ✅ unknown 用 null,agent 可以 if (row.Model === null) 干净分支
return [{
Status: 'Connected',
Login: loggedIn ? 'Yes' : 'No (guest mode)',
Model: model ? model : null,
SessionId: sessionId ? sessionId : null,
}];
```
**根因**:success row 是"业务数据"的合同。把 empty / failure 塞进 row 会破坏:
1. **round-trip pipeline**:listing → detail 类下游 adapter 拿到 `tid: ''` 会去查 `thread `(""),白跑一轮
2. **exit code semantics**:empty 应该 exit 66、API fail 应该 exit 1,混进 row 后两者都 exit 0,agent 没法 branch
3. **fixture rowCount.min=1**:sentinel row 让 verify 永远过 → 真的接口挂了也看不见
**对比 #1290 twitter trending**:当时也踩过 silent N/A row 的坑,drop 掉那行比留个 silent-wrong 字段诚实——这是 2026-05-04 的早期教训。typed-errors 把"drop"升级成"throw EmptyResultError",下游 agent 能直接从 exit code 66 分辨 empty vs 真实数据错位。
---
## 4. Anti-pattern C:`return []` 当 silent fallback
`adapter` 跑完什么都没拿到不能 `return []`:
```js
// ❌
if (items.length === 0) return [];
// ✅
if (items.length === 0) throw new EmptyResultError('site command', `optional context`);
```
`EmptyResultError` 的 hint 默认是 `'The page structure may have changed, or you may need to log in'`(见 [`src/errors.ts#L134`](../../../src/errors.ts)),自己传 message 比默认更有用:写**为什么空了**("No results for '<query>'" / "uid=<X> 在该论坛没有发过帖子")。
---
## 5. Anti-pattern D:generic `CliError('CODE')` 隐藏分类
**反例**(PR #1329 R3 修掉的,[`clis/coingecko/top.js` before](https://github.com/jackwener/OpenCLI/blob/2b8609b82fccaf98505c5b1b1859e3ffdaa1a55c/clis/coingecko/top.js#L36-L38), [`clis/1point3acres/thread.js` before](https://github.com/jackwener/OpenCLI/blob/2b8609b82fccaf98505c5b1b1859e3ffdaa1a55c/clis/1point3acres/thread.js#L45-L46), [`clis/1point3acres/user.js` before](https://github.com/jackwener/OpenCLI/blob/2b8609b82fccaf98505c5b1b1859e3ffdaa1a55c/clis/1point3acres/user.js#L35-L36)):
```js
// ❌ 都是 exit 1,agent 无法区分服务失败 vs 空结果 vs bad args
if (!resp.ok) throw new CliError('HTTP_ERROR', `HTTP ${resp.status}`);
if (!Array.isArray(data) || data.length === 0) throw new CliError('NO_DATA', 'no data');
if (!/id="postlist"/.test(html)) throw new CliError('THREAD_NOT_FOUND', `帖子 ${tid} 不存在`);
```
**修法**:
```js
// HTTP / fetch / JSON / in-band API error → runtime failure
if (!resp.ok) throw new CommandExecutionError(`request failed: HTTP ${resp.status}`);
if (!Array.isArray(data)) throw new CommandExecutionError('unexpected response shape');
// valid empty / resource missing → empty result
if (data.length === 0) throw new EmptyResultError('coingecko top', 'no market data');
if (!/id="postlist"/.test(html)) throw new EmptyResultError('1point3acres thread', `帖子 ${tid} 不存在`);
```
`CliError` is still the base class underneath every typed error, and core runtime primitives may throw it internally. Adapter code should not directly `new CliError(...)`; pick one of the five classes in §1 so scripts and agents can branch on stable exit codes.
---
## 6. Verify fixture 怎么挡这三类
新写 fixture 时(`~/.opencli/sites/<site>/verify/<cmd>.json`):
- **rowCount.min ≥ 1**:保证 sentinel-row 不能伪装通过
- **patterns**:核心 id 列加 `^\d+$` 类正则,挡 `tid: ''` / `pid: ''` 之类空字符串污染
- **mustBeTruthy**:业务数值列(`replies` / `views` / `count`)必须 truthy,挡 `|| 0` silent fallback
- **预期 EmptyResultError 是合法返回时**(例如某条 fixture 故意搜不出来)用 `expect.exitCode: 66` 让"空态合法"和"adapter 崩了"在 fixture 层就分开(参见 [`site-memory.md`](./site-memory.md) verify schema)
---
## 7. 已经 grandfathered 的旧 adapter
repo 里仍有相当数量的 `CliError('HTTP_ERROR')` / `Math.max(1, Math.min(...))` 式的旧写法,被 [`scripts/typed-error-lint-baseline.json`](../../../scripts/typed-error-lint-baseline.json) 圈住(baseline 只允许减、不允许加)。**新写 adapter 必须按本文档**;旧 adapter 不强制立刻迁移,但碰到时顺手收一条是欢迎的——清掉一条 baseline 自然下降一条,gate 不会卡。
---
## 8. 自查清单(PR push 前)
- [ ] adapter 没有 `Math.max(1, Math.min(...))` / `Math.max(N, ...)` clamp 在外部参数上
- [ ] adapter 没有 `return []` / `return [{...sentinel...}]` 当 empty / failure 兜底
- [ ] adapter 没有 `'-'` / `'N/A'` 这类 scalar sentinel 冒充 real value;语义可空就返回 `null`
- [ ] adapter 抛的不是 `CliError('XXX')`,而是 5 类 typed error 之一
- [ ] verify fixture 的 `rowCount.min ≥ 1`,sentinel row 过不了
- [ ] `npm run check:typed-error-lint` 跑过 → baseline 没增加
SKILL.md
---
name: opencli-adapter-author
description: 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.
allowed-tools: Bash(opencli:*), Bash(jsluice:*), Read, Edit, Write, Grep
---
# opencli-adapter-author
你是要给一个站点写 adapter 的 agent。这份 skill 目标:简单站点争取 **30 分钟内从零到通过 `opencli browser verify`**;复杂、私有协议或写操作站点以证据完整和安全为先,不为了时限猜接口。
全程用现有工具:`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 实现盲目迁到无契约内部接口。
```md
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`](./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
▼
┌────────────────────────────────────────────┐
│ 需要 Deep Recon? │ 无文档私有 API / DOM 丢数据 / 写操作 / 证据冲突
│ → references/deep-recon.md │ intent matrix → 因果 diff → 候选账本 → contract gate
└────────────────────────────────────────────┘
│ 候选通过合同证明;不通过则记录拒绝与 lift condition
▼
┌────────────────────────────────────────────┐
│ 验证候选合同(memory 命中也要跑) │── 401/403 ──→ 回到 §4 排 token
│ safe replay;不可 replay 的 read 用自然截获 │── 空/HTML ──→ 回到 site-recon 换 Pattern
│ 数据非空、identity 对、分页/错误语义完整 │── 站点换版 ──→ 标记旧 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
[ ] 无文档 API / DOM 丢数据 / 写操作 / bundle 与 network 冲突 → `deep-recon.md`
[ ] 写 intent matrix 和明确的 mutation boundary
[ ] baseline → 单一动作 → 新请求 diff;至少一组 changed-input 对照
[ ] jsluice 只扩大候选面;候选必须进入 evidence ledger
[ ] read 候选过 occurrence/replay/completeness/auth/pagination/failure gate
[ ] write 候选有明确授权、目标绑定、幂等/不确定性与不可自动重试语义
[ ] 5. 候选合同验证(memory 命中也要重跑):
[ ] `PUBLIC_API / COOKIE_API / PAGE_FETCH`:safe replay 跨两个输入返回成功
[ ] `INTERCEPT`:两次自然页面动作都截到属于目标 identity 的完整响应
[ ] 响应含目标数据(不是 HTML / 广告 / 推荐侧栏),字段与网页对得上
[ ] 分页达到 exact limit 或证明 upstream exhaustion;失败不返回 partial
[ ] write 不自动 replay,必须过 `deep-recon.md` 的额外合同门禁
[ ] 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`:仅保存公开数据或可证明完成脱敏的样本;私人邮箱/消息/账号等高敏响应改用合成 fixture,不落盘
[ ] 原始 dump/capture 只短暂落 `/tmp/` 或受控 cache;安全分级后的长期样本才进 `fixtures/`,任务结束清理原始文件
[ ] 13. repo 贡献收口(私人 adapter 可跳过):
[ ] production-path tests,不只测 parser/helper
[ ] `npm run typecheck` + focused/site tests + `npm run build`
[ ] `node dist/src/main.js validate <site>`
[ ] `npm run check:typed-error-lint` + `npm run check:silent-column-drop`
[ ] adapter 文档;若 sitemap/site memory 有稳定新知识则同步
[ ] `git diff --check` + 敏感数据扫描 + 删除 raw capture/cache + 释放 browser session
[ ] 写操作或私有协议请独立 review exact head 后再合入
```
---
## 降级路径(某步卡住跳到哪)
| 卡在 | 现象 | 跳去 |
|------|------|-----|
| 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\|null` 只有在语义真的可空时用 |
| 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/deep-recon.md` | 复杂无文档站:动作归因、jsluice 候选扩展、合同证明、读写安全与交付净账 |
| `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`](./references/typed-errors.md) 5-classification 抛对应 typed error;**不要** silent `return []`,**不要** silent `return [{sentinel}]`,**不要** `Math.max/min` silent clamp 外部参数
- 写私人 adapter 用 `~/.opencli/clis/<site>/<name>.js`(免 build);要提 PR 才 copy 到 `clis/<site>/<name>.js`
- 站点记忆每轮回写:没记忆 → 用 skill → 产生记忆 → 下次变 5 分钟
- **“真实发生过”不等于“可作为 production contract 重放”**。私有写请求、一次性风控 token、页面 runtime controller 都必须过 `deep-recon.md` 的 contract gate;过不了就记录 blocker/lift condition,不生成伪 API 命令。
- **调试过程中的原始 dump / 抓包 / HTML 样本只能短暂落在系统 `/tmp/` 或受控 cache,任务结束删除。只有通过 `site-memory.md` 数据分级、准备长期保留的公开/合成/已脱敏样本才进入 `~/.opencli/sites/<site>/fixtures/`。严禁在 repo 根目录、`clis/<site>/` 或当前工作目录留 `.dbg-*.html / raw-*.json / sample.*`。**
- **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 真能挂。
---
## 卡住了
- 诊断类:`opencli doctor` → 看 `notes.md` → 搜 autofix skill
- 字段解码类:`field-decode-playbook.md` 全三节走完 → 先输出 raw 迭代
- endpoint 找不到:api-discovery §5 intercept 兜底
不要猜。猜错了 verify 能通过但数据是错的,用户看到乱码才发现。