Skill: liberalchang/opencli-adapter-author (version 1.0.0)

--- 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:*), Read, Edit, Write, Grep --- # opencli-adapter-author 你是要给一个站点写 adapter 的 agent。这份 skill 目标：**从零到通过 `opencli browser verify` 的 30 分钟内闭环**。 全程用现有工具：`opencli browser *` / `opencli doctor` / `opencli browser init` / `opencli browser verify`。没有新命令。 调试浏览器型 adapter 时，优先直接带上 `--live --focus`。这样命令跑完后 automation window 还在，而且在前台，方便你核对最终页面状态，而不是猜是抓数错了还是页面走偏了。 --- ## 前置：看你落在哪 先拿 `coverage-matrix.md` 快速自测。三个问题： 1. 数据在浏览器里看得到吗？（否 → 先解决鉴权） 2. 数据是 HTTP/JSON/HTML 吗？（否 → 不在 skill 范围） 3. 需要实时推送吗？（是 → 找同数据 HTTP 接口；没有就放弃） 三个都 yes 继续。 --- ## 顶层决策树 ``` START │ ▼ ┌──────────────────────────┐ │ opencli doctor 通？ │── no ──→ 修桥接（doctor 输出里的提示） └──────────────────────────┘ │ yes ▼ ┌────────────────────────────────────────────────────┐ │ 读站点记忆： │ │ 1. ~/.opencli/sites/<site>/endpoints.json │ │ 2. ~/.opencli/sites/<site>/notes.md │ │ 3. references/site-memory/<site>.md │ └────────────────────────────────────────────────────┘ │ 命中 endpoint + 字段 → 直接跳到【endpoint 验证】（不跳写 adapter！memory 可能过期） │ 没命中 → 继续 ▼ ┌──────────────────────────┐ │ 站点侦察（site-recon） │ → Pattern A/B/C/D/E └──────────────────────────┘ │ ▼ ┌──────────────────────────┐ │ API 发现（api-discovery）│ §1 network → §2 state → §3 bundle → §4 token → §5 intercept └──────────────────────────┘ │ 拿到候选 endpoint ▼ ┌────────────────────────────────────────────┐ │ 直接 fetch 验证 endpoint（memory 命中也要跑）│── 401/403 ──→ 回到 §4 排 token │ 数据非空 + 200 │── 空/HTML ──→ 回到 site-recon 换 Pattern │ memory 里的值还活着吗？ │── 站点换版 ──→ 标记旧 endpoint，回 api-discovery └────────────────────────────────────────────┘ │ OK ▼ ┌───────────────────────────────────────┐ │ 字段解码（memory 里的 field-map 也要抽查）│ 自解释 → 直接 / 已知代号 → field-conventions / 未知 → decode-playbook │ 比一条已知字段和网页肉眼值，确认没错位 │ └───────────────────────────────────────┘ │ ▼ ┌──────────────────────────┐ │ 设计 columns (output) │ 对照 output-design.md 的命名 / 类型 / 顺序 └──────────────────────────┘ │ ▼ ┌──────────────────────────┐ │ opencli browser init │ 生成 ~/.opencli/clis/<site>/<name>.js 骨架 │ 复制最像的邻居 adapter │ │ 改 name / URL / 映射三处 │ └──────────────────────────┘ │ ▼ ┌──────────────────────────┐ │ opencli browser verify │── 失败 ──→ autofix skill，回对应步骤 └──────────────────────────┘ │ 成功 ▼ ┌──────────────────────────┐ │ 字段 vs 网页肉眼对一遍 │── 数值不对 ──→ 回字段解码 └──────────────────────────┘ │ 对得上 ▼ ┌──────────────────────────┐ │ 回写 ~/.opencli/sites/ │ endpoints / field-map / notes / fixtures └──────────────────────────┘ │ ▼ DONE ``` --- ## Runbook（一步一步勾选） ``` [ ] 1. opencli doctor 返回 "Everything looks good" [ ] 2. 读站点记忆： [ ] ~/.opencli/sites/<site>/endpoints.json 存在？里面有想要的 endpoint？ [ ] references/site-memory/<site>.md 存在？看"已知 endpoint"节 [ ] 命中后：**跳到第 5（endpoint 验证） + 第 7（字段核对）**，不能直接跳第 9 写 adapter [ ] memory 写入超过 30 天（看 `verified_at`）→ 当作过期，按冷启动走 Step 3 → 4 [ ] 3. 侦察（site-recon.md）： [ ] **首选**：`opencli browser analyze <url>` 一步拿 pattern + 反爬 + 最近 adapter + next step [ ] `analyze` 结论模糊时再手跑：`open` → `wait time 2` (或 `wait xhr <regex>`) → `network` [ ] 定 Pattern（A / B / C / D / E） [ ] 4. API 发现（api-discovery.md）按 Pattern 选 §： [ ] Pattern A → §1 network 精读 [ ] Pattern B → §2 state 抽取 + §1 深层数据 [ ] Pattern C → §3 bundle / script src 搜索 [ ] Pattern D → §4 token 来源 + 降级 §5 [ ] Pattern E → 找 HTTP 轮询接口；找不到才 §5 [ ] 5. 直接 fetch 候选 endpoint 验证： [ ] 返回 200 [ ] 响应含目标数据（不是 HTML / 广告） [ ] 6. 定鉴权策略：裸 fetch 通 → PUBLIC；要 cookie → COOKIE；要 header → HEADER；拿不到签名 → INTERCEPT [ ] 7. 字段解码： [ ] 自解释 → 直接用 key [ ] 已知代号 → field-conventions.md 查表 [ ] 未知代号 → field-decode-playbook.md（排序键对比 / 结构差分 / 常量排查） [ ] 8. 设计 columns（output-design.md）： [ ] 命名 camelCase 且对齐邻居 adapter [ ] 类型 / 单位 / 百分比格式清楚 [ ] 顺序：识别列 → 业务数字 → metadata [ ] 9. 写 adapter（adapter-template.md）： [ ] opencli browser init <site>/<name> [ ] 找同站点或同类型最像的 adapter，cp 过来 [ ] 改 name / URL / 字段映射 [ ] 10. opencli browser verify <site>/<name> [ ] 首轮通过后立刻 `--write-fixture` 生成 `~/.opencli/sites/<site>/verify/<cmd>.json` 种子 [ ] 手改种子：加 `patterns`（URL / 日期 / ID 格式）+ `notEmpty`（核心字段）+ 收紧 `rowCount` [ ] 再跑一次 `opencli browser verify <site>/<name>`，确认 ✓ matches fixture [ ] 11. 字段值 vs 网页肉眼比对（别只看 "Adapter works!"） [ ] 12. 回写站点记忆（**verify 通过 + 肉眼比对对得上之后**，schema 见 `references/site-memory.md`）： [ ] `endpoints.json`：以 endpoint 的短名为 key，value = `{url, method, params.{required,optional}, response, verified_at: YYYY-MM-DD, notes}` [ ] `field-map.json`：只追加新代号。key = 字段代号，value = `{meaning, verified_at: YYYY-MM-DD, source}`；**已存在的 key 不要覆盖**，有冲突先和网页肉眼值对齐再写 [ ] `notes.md`：顶部追加一段 `## YYYY-MM-DD by <agent/user>`，写本次写 adapter 时遇到的新坑 / 新结论 [ ] `verify/<cmd>.json`：**必填。** `opencli browser verify` 的期望值（args / rowCount / columns / types / patterns / notEmpty），Step 10 已经让你生成了，这里只是 checklist [ ] `fixtures/<cmd>-<YYYYMMDDHHMM>.json`：存一份该 endpoint 的完整响应样本（去掉 cookie / token / 用户私有字段再存），给后续字段对比 / 离线 replay 用 [ ] 调试过程中如果在 repo / adapter 目录 dump 过临时文件（`.dbg-*.html` / `raw-*.json` / 等），**在 commit 前清干净**——这些本来就该落在 `~/.opencli/sites/<site>/fixtures/` 或 `/tmp/` ``` --- ## 降级路径（某步卡住跳到哪） | 卡在 | 现象 | 跳去 | |------|------|-----| | Step 4 API 发现 | `network` 空，`__INITIAL_STATE__` 也空 | §3 bundle 搜 baseURL | | | bundle 搜不到 baseURL | §5 intercept | | Step 5 endpoint 验证 | 401 / 403 | §4 token 排查 | | | 200 但响应是 HTML | 回 Step 3 换 Pattern 判断 | | | 200 但 `data: []` 空 | 参数传错 / 接口换版，回 §1 看 network 里真实请求头 | | Step 7 字段解码 | 排序键对比推不出 | field-decode-playbook.md §3 结构差分 | | | 还推不出 | 先输出 raw，adapter 跑起来再迭代 | | Step 10 verify 失败 | `fltt` 漏了 / 字段映射错 | autofix skill | | | 某列永远是 `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/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 踩坑前翻：10 种"verify 能过但数据是错的"静默失败 | --- ## 关键约定 - adapter 只引 `@jackwener/opencli/registry` + `@jackwener/opencli/errors`，不用第三方 - `columns` 数组和 `func` 返回对象 keys 完全对齐（含顺序） - 已知失败抛 `CliError('CODE', 'msg')` 或 `AuthRequiredError(domain)`，不要 silent `return []` - 写私人 adapter 用 `~/.opencli/clis/<site>/<name>.js`（免 build）；要提 PR 才 copy 到 `clis/<site>/<name>.js` - 站点记忆每轮回写：没记忆 → 用 skill → 产生记忆 → 下次变 5 分钟 - **调试过程中的原始 dump / 抓包 / HTML 样本只能落在 `~/.opencli/sites/<site>/fixtures/` 或 `/tmp/`。严禁在 repo 根目录、`clis/<site>/` 或当前工作目录留 `.dbg-*.html / raw-*.json / sample.*` 这类临时文件**（PR diff 会带上去，别人 review 时很烦）。 --- ## 卡住了 - 诊断类：`opencli doctor` → 看 `notes.md` → 搜 autofix skill - 字段解码类：`field-decode-playbook.md` 全三节走完 → 先输出 raw 迭代 - endpoint 找不到：api-discovery §5 intercept 兜底 不要猜。猜错了 verify 能通过但数据是错的，用户看到乱码才发现。

path: references/adapter-template.md
content: # Adapter Template

一份 adapter 就是一次 `cli({...})` 调用。文件结构固定，三段：declaration、args、func。

拿 `clis/eastmoney/convertible.js` 当活例子，对照拆解。

---

## 活例子：convertible.js

```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 (_page, 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 / HEADER / INTERCEPT / UI
  browser: false,             // PUBLIC 几乎总是 false；COOKIE/HEADER 一律 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 保持统一

### 3. func — 主体

```javascript
func: async (_page, args) => {
  // 1. 解析参数
  const limit = Math.max(1, Math.min(Number(args.limit) || 20, 100));

  // 2. 构造 URL / 请求
  const url = new URL(...);
  url.searchParams.set(...);

  // 3. 发请求
  const resp = await fetch(url, { headers: {...} });
  if (!resp.ok) throw new CliError('HTTP_ERROR', `... HTTP ${resp.status}`);

  // 4. 解析 + 业务校验
  const data = await resp.json();
  const diff = Array.isArray(data?.data?.diff) ? data.data.diff : [];
  if (diff.length === 0) throw new CliError('NO_DATA', '...');

  // 5. map 到 columns 同名 keys
  return diff.slice(0, limit).map((it, i) => ({
    rank: i + 1,
    bondCode: it.f12,
    // ...
  }));
},
```

**参数形态**：

- `page` — 仅当 `browser: true` 时有用；`PUBLIC` 模式传一个 no-op 占位
- `args` — 所有 `args[]` 声明的参数解析后的 object

**错误处理**：

| 场景 | 写法 |
|------|------|
| 参数不合法 | `throw new CliError('INVALID_ARGUMENT', '...')` |
| HTTP 非 2xx | `throw new CliError('HTTP_ERROR', 'HTTP <status>')` |
| 业务返回空 | `throw new CliError('NO_DATA', '...')` 或 `'EMPTY_RESULT'` |
| 需要登录 | `throw new AuthRequiredError(domain)`（从 `@jackwener/opencli/errors` 引） |
| 接口约束失败 | `throw new CliError('API_ERROR', '...')` |

不要 `return []` 了事。autofix skill 靠 CliError 的 code 决定要不要重试。

---

## 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 { AuthRequiredError, CliError } 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 = {} } = {}) {
    const 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',
    });
    if (!resp.ok) throw new CliError('HTTP_ERROR', `HTTP ${resp.status}`);
    const buf = await resp.arrayBuffer();
    return new TextDecoder(encoding).decode(buf);
}

cli({
    site: 'example',
    name: 'me',
    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 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
        return rows.slice(0, Math.max(1, Number(args.limit) || 20));
    },
});
```

### 为什么不走 `page.evaluate(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 侧干净。

**规则**：HTML 型 COOKIE adapter 一律 Node 侧 `fetch`，浏览器只当 cookie jar 用。

### 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。

### 明确的空态要返回哨兵行，不要 `[]`

空态（"暂时没有提醒内容" / "暂无通知" / "No results"）返回 `[]`，下游 agent 会当成"接口挂了"而重试。正确做法是返回**一行明确写着当前状态的数据**：

```javascript
if (/暂时没有提醒内容/.test(html)) {
    return [{ index: 0, from: '', summary: '暂时没有提醒内容', time: '', threadUrl: '' }];
}
```

---

## 同类型 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/`。


path: references/api-discovery.md
content: # API Discovery

**Layer 2：这个站的目标数据 endpoint 是什么？** 已经分完类（`site-recon.md`）再进来。

五种手段。按优先级降级用，命中即走。

---

## §0 进入 §1 之前：先看两条红线

这两条不看清楚，后面的 endpoint 验证会一直在错的前提下兜圈子。

### 0.1 反爬厂商 → 决定 fetch 能不能从 Node 走

`opencli browser analyze <url>` 的 `anti_bot` 字段给答案；手查看 cookies 也行：

| cookie / body 信号 | 厂商 | 裸 Node fetch / curl 结果 | 策略 |
|------------------|------|-----|-----|
| `acw_sc__v2` / `acw_tc` / `ssxmod_itna`；body 含 `arg1 = '32-HEX'` 或 `/ntc_captcha/` | **Aliyun WAF** | 返回 slider HTML，不是真数据 | 先在浏览器上下文里验证 endpoint；HTML 型 COOKIE adapter 最终仍走 Node-side fetch + `page.getCookies()` |
| `__cf_bm` / `cf_clearance` / `__cfduid`；body 含 `Cloudflare Ray ID` / `Checking your browser` | **Cloudflare** | TLS 指纹被标记，失败 | 同上：先 browser-context probe，最终 adapter 仍按模板选 fetch 路线 |
| `_abck` / `bm_sz` / `bm_sv` | **Akamai** | 即使带 cookie 也常被挡 | 同上 |
| body 含 `geetest` / `gt_captcha` | **Geetest** | 滑块/拼图，程序无解 | 超出 skill 范围，放弃或 UI 策略 |

**规则**：看到上面四种任一个，先不要拿**裸** Node fetch 做 endpoint 验证。先用 browser-context probe 或目标 origin 页面确认接口能通；最终 adapter 的 fetch 路线仍按 `adapter-template.md` 选，HTML 型 COOKIE adapter 继续走 Node-side fetch + `page.getCookies()`。

### 0.2 跨 subdomain = CORS 默认关

`jobs.51job.com` 页面 fetch `cupid.51job.com` 的 API，默认会被浏览器 CORS 预检挡住——除非目标接口回了 `Access-Control-Allow-Origin`。

判断：

```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`

静态资源 / 埋点 / 追踪默认已过滤；需要全量看用 `--all`。

### 按 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/<workspace>.json`（默认 TTL 24h），所以 `--detail` 即使跨多条其他命令也还在。

### 关键 request headers

`browser network` 当前只抓响应（body + status + ct），抓不到请求头。要看请求头就在 DevTools Network 面板里点这条 request，或用 `browser eval` 手动 `fetch(url)` 复现一次观察浏览器发出去的头：

| 看到 | 含义 | 对应策略 |
|------|------|---------|
| 只有 `Cookie` | 登录态靠 cookie | `Strategy.COOKIE` |
| `Authorization: Bearer xxx` | token 鉴权 | 先找 token 来源（localStorage / cookie / bundle 硬编码） |
| `X-Csrf-Token: xxx` 同时存在 cookie 里 | CSRF 防护 | `Strategy.HEADER`，从 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。

### 直接试候选 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 且数据对得上就认。

### 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('; ').reduce((o,x)=>{const[k,v]=x.split('=');o[k]=v;return o},{})"
```

常见 token cookie 名：`ct0`（Twitter CSRF）、`xq_a_token`（雪球）、`SESSDATA`（B 站）、`_csrf / csrfToken`（通用）。

**`document.cookie` 只能看到 non-HttpOnly 的 cookie。** 上面那条命令侦察阶段够用，真写 adapter 时 auth 经常是 HttpOnly，一定要用 `page.getCookies(...)` 从 CDP cookie jar 拿——见 `adapter-template.md` 的 "COOKIE adapter 骨架"。

论坛 / BBS 引擎（Discuz!X / phpBB / vBulletin）还多一坑：auth cookie 设在**根域** `.example.com`（不是 `www.example.com`），且 HttpOnly。要查 `{ domain: '.<root>' }` **和** `{ domain: 'www.<root>' }` 两次，否则 adapter 在有 cookie 的前提下仍然 401。

### localStorage / sessionStorage 里

```bash
opencli browser eval "Object.keys(localStorage).map(k=>k+' => '+localStorage.getItem(k).slice(0,50))"
```

找 `token / auth / jwt / bearer` 关键字。

### 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.match(/Bearer\\s+[\\w-]{20,}/g);return m?.slice(0,3)||'not found'})()"
```

### Store action 绕签名

Vue + Pinia / Redux / React Context 有时直接调 store method 能绕过签名逻辑（method 内部会自己拿签名再发请求）：

```bash
# Pinia
opencli browser eval "typeof __pinia !== 'undefined' ? Object.keys(__pinia.state.value) : 'no pinia'"

# 直接调 store action（每个站点具体 action 名要查）
opencli browser eval "window.__pinia.state.value.someStore.someMethod({...})"
```

---

## §5 installInterceptor（最后降级）

所有手段都试过还拿不到请求签名时，让页面自己发请求，adapter 做 MITM 拦截响应：

```javascript
// func 里
await page.evaluate(installInterceptorCode, { domain: 'api.xxx.com', path: '/foo' });
await page.goto('https://xxx.com/trigger-page');
// 等页面自己发那条请求
const intercepted = await page.evaluate('window.__opencli_intercepted');
return intercepted.response;
```

代价是要等页面真的触发请求，慢、不稳。只在 §1-4 都不行时用。

---

## 诊断不出来怎么办

按这个顺序试到命中：

```
§1 network ──→ 命中？yes → 走
      │        no
      ↓
§2 state  ──→ 命中？yes → 走
      │        no
      ↓
§3 bundle ──→ 命中？yes → 走
      │        no
      ↓
§4 token  ──→ 401 解除？yes → 走
      │        no
      ↓
§5 intercept → 让页面自己发
```

**四条都命不中的站（罕见）**：多半是视觉化渲染（canvas / webgl），数据不以 HTTP/JSON 形式存在。这种放弃或换源。


path: references/coverage-matrix.md
content: # 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 |
| 鉴权 | 裸 `fetch()` 拿到（PUBLIC） | ✅ | `Strategy.PUBLIC + browser:false`（coingecko dry run 验证） |
| | cookie | ✅ | `Strategy.COOKIE + browser:true` + `credentials:'include'`（xueqiu / bilibili 已覆盖） |
| | Bearer + X-Csrf-Token | 🟡 | `Strategy.HEADER` + 在 `page.evaluate` 拼 header |
| | 页面能发但独立 fetch 不行 | 🟡 | `Strategy.INTERCEPT` |
| 字段形态 | 自解释（`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 这条回路。


path: references/field-conventions.md
content: # 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 接口用 `fetchJson(page, 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 分钟能推一条。

推完一个代号补回本文件，下次直接查。


path: references/field-decode-playbook.md
content: # 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，命令仍可用，下一版再语义化

不要猜。猜错了后面验证不到、线上跑出来用户看到乱码也会误导决策。


path: references/output-design.md
content: # 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。


path: references/site-memory.md
content: # Site Memory

站点记忆分两层：**in-repo 种子**（skill 自带的已知站点公共知识）+ **本地工作目录**（每台机器跑过的站点累积产物）。

---

## 两层结构

```
skills/opencli-adapter-author/references/site-memory/<site>.md
    — 公共种子。手写 + PR 审核进入。多 agent 共享的第一批起点。
    — 已铺：eastmoney / xueqiu / bilibili / tonghuashun

~/.opencli/sites/<site>/
    — 本地累积。agent 跑 adapter 过程里自动写入，跨 session 复用。
    — 不进 git，不进 PR。
```

用法：开头先读本地，命中 **不跳写 adapter**，仍要跑 Step 5 endpoint 验证 + Step 7 字段抽查（memory 可能过期或站点换版）；没命中读 in-repo；都没有走完整 recon。

---

## Layer 1 — In-repo 种子（`references/site-memory/<site>.md`）

每个覆盖站点一个 `.md`，结构固定：

```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；**调试时的原始 dump 也只能落在这里或 /tmp/**）
    <cmd>-<ts>.json
  last-probe.log         — 最近一次侦察输出（下次接着用）
```

`verify/` vs `fixtures/` 别混：
- `verify/<cmd>.json` 是**结构期望**（rowCount / columns / types / patterns / notEmpty），每 adapter 一份、会被 verify 读。
- `fixtures/<cmd>-<ts>.json` 是**原始响应样本**，给人 / 下一个 agent 做字段比对用，verify 不会读。

### `endpoints.json` 格式（schema 锁死）

key = endpoint 的短名（`clist` / `kline` / `search` 等），不要用全 URL 当 key。

```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 的**完整**响应样本。用途：

- 未来字段代号再变时，拿样本和 `field-map.json` 做 regression 对比
- 站点换版时，新响应和旧 fixture 做 diff 看哪个字段结构变了

**存之前脱敏**：去掉 cookie / token / 登录态相关 header、去掉用户自己的 uid / 用户名 / 手机号 / 邮箱。

---

## runbook 里的读/写时机

```
Step 2 开始前 → 读  ~/.opencli/sites/<site>/
                → 读  references/site-memory/<site>.md
                命中后 → 不跳写 adapter，仍要跑 Step 5 (endpoint 验证) + Step 7 (字段抽查)
                        verified_at 超 30 天 → 当作过期，按冷启动走 Step 3 → 4

Step 10 verify 首轮通过后 → 写 ~/.opencli/sites/<site>/verify/<cmd>.json
                            - 先 `--write-fixture` 拿种子，再手改 patterns / notEmpty / rowCount
                            - 没这份后续 verify 挡不住数据错位，**必填**

Step 11 肉眼对比通过后 → 写 ~/.opencli/sites/<site>/
                        - endpoints.json：按 schema 追加或更新 verified_at
                        - field-map.json：只追加新 key，已有的不默默覆盖
                        - notes.md：顶部追加一段
                        - fixtures/：脱敏后存一份完整响应样本（区别于 verify/）
```

**回写是 commit，不是 stash**：不过 Step 10 verify + Step 11 肉眼对比不写，防止把错的映射喂给下一轮。

---

## 不要写进 `~/.opencli/sites/` 的东西

- 真实账户 cookie / token — 不要保存任何鉴权凭据
- 用户私有数据（返回体里有个人敏感字段的 → 脱敏再存 fixtures）
- 过期超过 30 天的 last-probe.log（自动清）

## 不要写进 **repo / adapter 目录** 的东西

调试过程里的临时 dump（`.dbg-*.html` / `raw-*.json` / `sample-*` / `trace-*.txt`）**只能**落在 `~/.opencli/sites/<site>/fixtures/` 或系统 `/tmp/`。PR diff 会把 repo 根目录和 `clis/<site>/` 下的文件一起带走——别人 review 时看到一堆调试副产物会很烦。

---

## 没有 site-memory 时

新站点没对应 `.md`，也没本地目录 → 完整走 recon + discovery，跑完直接写 `~/.opencli/sites/<site>/`，后面就有了。


path: references/site-recon.md
content: # 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 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）

不要纠结分类。分类是帮忙定第一步，没命中就按顺序降级。


path: references/success-rate-pitfalls.md
content: # Success-Rate Pitfalls

10 个**静默失败**（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 偏高

---

## 总结：静默失败的共同特征

1. **verify 绿 ≠ 数据对**。verify 只能证"结构没坏"，证不出"值对不对"。Step 11 肉眼比对是必须的。
2. **"字段有值"是个比"字段为空"更危险的失败态**。空你会去查，有值你会 fallthrough。
3. **fixture 四件套一起上**：`patterns` + `notEmpty` + `mustNotContain` + `mustBeTruthy`——每件挡一类问题，缺一个就漏。

回写 `notes.md` 时把你踩的新坑写进去。下次就有第 11 条了。


path: references/site-memory/bilibili.md
content: # 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 接口用 `fetchJson(page, 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'`，不要重写。


path: references/site-memory/eastmoney.md
content: # 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 参数 / 字段映射三处。


path: references/site-memory/tonghuashun.md
content: # 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 版本。


path: references/site-memory/xueqiu.md
content: # 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，直接用别重写。


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)