{"owner":"jackwener","repo":"OpenCLI","hasSkills":true,"totalSkillsCount":32,"totalTokensCount":56995,"categories":["root-instruction","anthropic-skill"],"hasMcp":false,"mcpConfig":null,"found":["llms.txt","skills/opencli-adapter-author/SKILL.md","skills/opencli-adapter-author/references/adapter-template.md","skills/opencli-adapter-author/references/api-discovery.md","skills/opencli-adapter-author/references/coverage-matrix.md","skills/opencli-adapter-author/references/deep-recon.md","skills/opencli-adapter-author/references/field-conventions.md","skills/opencli-adapter-author/references/field-decode-playbook.md","skills/opencli-adapter-author/references/jsdom-fixture-pattern.md","skills/opencli-adapter-author/references/output-design.md","skills/opencli-adapter-author/references/site-memory.md","skills/opencli-adapter-author/references/site-memory/bilibili.md","skills/opencli-adapter-author/references/site-memory/eastmoney.md","skills/opencli-adapter-author/references/site-memory/gmail.md","skills/opencli-adapter-author/references/site-memory/tonghuashun.md","skills/opencli-adapter-author/references/site-memory/xueqiu.md","skills/opencli-adapter-author/references/site-recon.md","skills/opencli-adapter-author/references/strategy-selection.md","skills/opencli-adapter-author/references/success-rate-pitfalls.md","skills/opencli-adapter-author/references/typed-errors.md","skills/opencli-autofix/SKILL.md","skills/opencli-browser/SKILL.md","skills/opencli-usage/SKILL.md","skills/smart-search/SKILL.md","skills/smart-search/references/sources-ai.md","skills/smart-search/references/sources-info.md","skills/smart-search/references/sources-media.md","skills/smart-search/references/sources-other.md","skills/smart-search/references/sources-shopping.md","skills/smart-search/references/sources-social.md","skills/smart-search/references/sources-tech.md","skills/smart-search/references/sources-travel.md"],"skills":{"llms.txt":"# OpenCLI\n\n> General-purpose browser automation for AI agents, with reusable site commands.\n> An agent-browser alternative: navigate, inspect, click, fill, extract, and screenshot without a site adapter.\n\n## What OpenCLI does\n\nOpenCLI gives you one surface for three kinds of automation:\n\n1. **Built-in adapters** — 100+ sites pre-built with deterministic commands (Twitter/X, Xiaohongshu, Bilibili, Zhihu, LinkedIn, YouTube, Reddit, HackerNews, and more)\n2. **Browser automation for AI agents** — AI agents (Claude Code, Cursor, etc.) can navigate, click, type, fill, extract, and screenshot any page via `opencli browser` primitives and your logged-in Chrome session\n3. **Adapter authoring** — Write new site adapters end-to-end using `opencli-adapter-author` skill + `opencli browser recon/init/verify` workflow\n\n\n## OpenCLI as an agent-browser alternative\n\nOpenCLI supports general-purpose browser workflows without pre-built site adapters. The browser loop is: `opencli browser work open https://example.com`, `opencli browser work state`, then choose actions such as `click`, `fill`, or `extract`. Inspect state again after page changes; release the session with `opencli browser work close`.\n\nUse the same named session across calls. Element references, CLI arguments, outputs, and session lifecycle differ from agent-browser, so migrate and verify existing scripts rather than treating the CLIs as syntax-compatible. Adapters are optional shortcuts for recurring site tasks, not a prerequisite for browsing.\n\n- [OpenCLI vs agent-browser and migration](https://opencli.info/docs/guide/agent-browser-alternative.html)\n- [中文迁移指南](https://opencli.info/docs/zh/guide/agent-browser-alternative.html)\n- [Browser Bridge setup](https://opencli.info/docs/guide/browser-bridge.html)\n\n## Install\n\n```bash\nnpm install -g @jackwener/opencli\n# Install Browser Bridge extension from Chrome Web Store\n# Run: opencli doctor\n```\n\nRequires Node.js >= 20.\n\n## Key capabilities\n\n- **No built-in LLM dependency** — commands do not invoke an LLM; an AI agent orchestrating actions has its own model costs\n- **Account safe** — reuses Chrome's logged-in state, credentials never leave the browser\n- **Deterministic adapters** — site commands provide structured output schemas. Pipeable, CI-friendly\n- **Plugin system** — `opencli plugin install github:user/repo`\n- **Multiple output formats** — JSON, CSV, Markdown, YAML, table\n\n## Skills for AI agents\n\nInstall with: `npx skills add jackwener/opencli`\n\n- `opencli-browser` — give AI agents direct browser control (click, type, extract, screenshot)\n- `opencli-adapter-author` — write new adapters from recon to verification\n- `opencli-autofix` — repair broken adapters\n- `opencli-usage` — command and site reference\n\n## Built-in sites (selected)\n\nTwitter/X, Xiaohongshu, Bilibili, Zhihu, LinkedIn, YouTube, Reddit, HackerNews, Weibo, Amazon, Bloomberg, V2EX, Boss直聘, Douyin, Spotify, WeChat, Xiaoyuzhou, NotebookLM, Claude, Gemini, GeoGebra, xueqiu, zlibrary, upwork, 1688, pixiv, and 80+ more.\n\nFull list: https://github.com/jackwener/opencli/blob/main/docs/adapters/index.md\n\n## Configuration\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `OPENCLI_BROWSER_COMMAND_TIMEOUT` | `60` | Seconds to wait for a browser command |\n| `OPENCLI_PROFILE` | — | Browser Bridge profile alias for multi-profile setups |\n| `OPENCLI_VERBOSE` | `false` | Enable verbose logging |\n\n## Exit codes\n\n`0` success · `66` empty result · `69` Browser Bridge down · `75` timeout · `77` auth required · `78` config error · `130` Ctrl-C\n\n## Key links\n\n- Website: https://opencli.info\n- GitHub: https://github.com/jackwener/opencli\n- npm: https://www.npmjs.com/package/@jackwener/opencli\n- Chrome Extension: https://chromewebstore.google.com/detail/opencli/ildkmabpimmkaediidaifkhjpohdnifk\n- Docs: https://opencli.info\n- Plugins: https://opencli.info/plugins\n\n## Author\n\njackwener — https://github.com/jackwener\n\n## Related resources\n\n- Website llms.txt: https://opencli.info/llms.txt\n- Full adapter docs: https://github.com/jackwener/opencli/blob/main/docs/adapters/index.md\n- Extending guide: https://github.com/jackwener/opencli/blob/main/docs/guide/extending-opencli.md\n- Plugin guide: https://github.com/jackwener/opencli/blob/main/docs/guide/plugins.md\n","skills/opencli-adapter-author/SKILL.md":"---\nname: opencli-adapter-author\ndescription: Build or improve an OpenCLI JS adapter using observed site behavior, existing adapters and browser API evidence. For ad-hoc browser interaction, see opencli-browser.\nallowed-tools: Bash(opencli:*), Bash(jsluice:*), Read, Edit, Write, Grep\n---\n\n# Adapter authoring\n\nChoose the simplest implementation that reliably performs the requested task.\nUse an existing site adapter when it already covers the capability:\n\n```sh\nopencli list search --site example\nopencli example search --help -f json\n```\n\n`list` returns `{ok:true,data:{commands,total,nextOffset?}}`; use `--offset` to\ncontinue, `--limit` to adjust page size. Detailed arguments belong in command help.\n\n## Evidence available to you\n\n```sh\nopencli browser open https://example.com --session research\nopencli browser state --diagnostics --session research\nopencli browser network --session research\nopencli browser network --static --session research\nopencli browser network --detail RequestKey --session research\n```\n\nNetwork captures describe observed requests. Static analysis adds candidates\nfrom loaded scripts without executing them. Inspect source locations, unresolved\nexpressions and capture provenance. Replay read requests when useful and compare\nbusiness data with the page. Write commands have real effects, including when run\nthrough `adapter test`; use a suitable test account or a supported dry-run.\n\nPrefer public APIs with a stable contract when available. Browser fetch, captured\nresponses, page state and semantic UI interactions are also valid choices. Select\nbased on the task and observed reliability. Do not guess endpoint parameters or\nhide failed operations behind empty success results.\n\n## JS interface\n\nAdapters are ordinary files under `~/.opencli/clis/<site>/<command>.js` or repository\n`clis/<site>/`. There is no draft directory, promotion step or required memory file.\n\n```sh\nopencli adapter init example/search\n```\n\nThe existing `cli({...})` interface is unchanged: `func(args)` for Node execution,\n`func(page, args)` with `browser: true`. Reuse neighboring adapters and shared helpers.\nReturn business data; the CLI adds the JSON success envelope. Throw a typed error\nwith an actionable hint for failures. Use `access: 'read'|'write'` accurately.\n`columns` controls human table exports; JSON preserves nested results.\n\n```js\nimport { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n  site: 'example', name: 'search', description: 'Search example items',\n  access: 'read', strategy: Strategy.PUBLIC, browser: false,\n  args: [{ name: 'query', positional: true, required: true }],\n  func: async (args) => {\n    const response = await fetch(`https://example.com/api/search?q=${encodeURIComponent(args.query)}`);\n    if (!response.ok) throw new Error(`Search failed: HTTP ${response.status}`);\n    return response.json();\n  },\n});\n```\n\n## Check and exercise\n\n```sh\nopencli adapter check example/search\nopencli example search 'query'\nopencli adapter test example/search --args '{\"query\":\"query\"}'\n```\n\n`check` validates definitions without executing adapters. `test` executes the normal\nCLI and optionally compares an existing fixture. `--write-fixture`, `--update-fixture`\nand `--no-fixture` control optional expectations; no row-shape or site-memory gate\nis imposed. `--trace retain-on-failure` helps diagnose browser execution.\n\nFor repository changes, cover the core behavior and failure paths with focused\nproduction-path tests, typecheck and build. Verify IDs, fields, units, pagination\nand empty/error semantics relevant to the task. Do not automatically replay a\nwrite with an unknown outcome; inspect state first.\n\n## Optional references\n\nRead only what is useful for the current task: `references/adapter-template.md`\nfor adapter patterns, `api-discovery.md` for request investigation,\n`output-design.md` for result fields, `field-decode-playbook.md` for opaque fields,\nand `deep-recon.md` for difficult private protocols. Site notes and fixtures are\noptional knowledge aids; older reference checklists are not mandatory workflows.\n","skills/opencli-adapter-author/references/adapter-template.md":"# Adapter Template\n\n一份 adapter 就是一次 `cli({...})` 调用。文件结构固定，三段：declaration、args、func。\n\n拿 `clis/eastmoney/convertible.js` 当活例子，对照拆解。\n\n---\n\n## 活例子：convertible.js\n\n> **注意（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(...)` 一起抄过去。\n\n```javascript\n// eastmoney convertible — on-market convertible bond listing.\nimport { cli, Strategy } from '@jackwener/opencli/registry';\nimport { CliError } from '@jackwener/opencli/errors';\n\nconst SORTS = {\n  change:   { fid: 'f3',   order: 'desc' },\n  drop:     { fid: 'f3',   order: 'asc' },\n  turnover: { fid: 'f6',   order: 'desc' },\n  price:    { fid: 'f2',   order: 'desc' },\n  premium:  { fid: 'f237', order: 'desc' },\n  value:    { fid: 'f236', order: 'desc' },\n  ytm:      { fid: 'f239', order: 'desc' },\n};\n\ncli({\n  site: 'eastmoney',\n  name: 'convertible',\n  description: '可转债行情列表（默认按成交额排序）',\n  domain: 'push2.eastmoney.com',\n  strategy: Strategy.PUBLIC,\n  browser: false,\n  args: [\n    { name: 'sort',  type: 'string', default: 'turnover', help: '排序：turnover / change / drop / price / premium' },\n    { name: 'limit', type: 'int',    default: 20,         help: '返回数量 (max 100)' },\n  ],\n  columns: ['rank', 'bondCode', 'bondName', 'bondPrice', 'bondChangePct',\n            'stockCode', 'stockName', 'stockPrice', 'stockChangePct',\n            'convPrice', 'convValue', 'convPremiumPct', 'remainingYears', 'ytm', 'listDate'],\n  func: async (args) => {\n    const sortKey = String(args.sort ?? 'turnover').toLowerCase();\n    const sort = SORTS[sortKey];\n    if (!sort) throw new CliError('INVALID_ARGUMENT', `Unknown sort \"${sortKey}\". Valid: ${Object.keys(SORTS).join(', ')}`);\n    const limit = Math.max(1, Math.min(Number(args.limit) || 20, 100));\n\n    const url = new URL('https://push2.eastmoney.com/api/qt/clist/get');\n    url.searchParams.set('pn', '1');\n    url.searchParams.set('pz', String(limit));\n    url.searchParams.set('po', sort.order === 'desc' ? '1' : '0');\n    url.searchParams.set('np', '1');\n    url.searchParams.set('fltt', '2');\n    url.searchParams.set('invt', '2');\n    url.searchParams.set('fid', sort.fid);\n    url.searchParams.set('fs', 'b:MK0354');\n    url.searchParams.set('fields', 'f12,f14,f2,f3,f6,f229,f230,f232,f234,f235,f236,f237,f238,f239,f243');\n    url.searchParams.set('ut', 'bd1d9ddb04089700cf9c27f6f7426281');\n\n    const resp = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0' } });\n    if (!resp.ok) throw new CliError('HTTP_ERROR', `convertible failed: HTTP ${resp.status}`);\n    const data = await resp.json();\n    const diff = Array.isArray(data?.data?.diff) ? data.data.diff : [];\n    if (diff.length === 0) throw new CliError('NO_DATA', 'eastmoney returned no convertible data');\n\n    return diff.slice(0, limit).map((it, i) => ({\n      rank: i + 1,\n      bondCode: it.f12,\n      bondName: it.f14,\n      bondPrice: it.f2,\n      bondChangePct: it.f3,\n      stockCode: it.f232,\n      stockName: it.f234,\n      stockPrice: it.f229,\n      stockChangePct: it.f230,\n      convPrice: it.f235,\n      convValue: it.f236,\n      convPremiumPct: it.f237,\n      remainingYears: it.f238,\n      ytm: it.f239,\n      listDate: String(it.f243 ?? ''),\n    }));\n  },\n});\n```\n\n---\n\n## 三段解剖\n\n### 1. Declaration — 标头\n\n```javascript\ncli({\n  site: 'eastmoney',          // 第一级命名空间，目录名一致\n  name: 'convertible',        // 第二级，CLI 上的子命令\n  description: '...',         // 一句话，出现在 `opencli list` 和 `opencli <site> -h`\n  domain: 'push2.eastmoney.com',  // 主要请求域名（诊断面板用）\n  strategy: Strategy.PUBLIC,  // PUBLIC / COOKIE / INTERCEPT / UI\n  browser: false,             // PUBLIC 几乎总是 false；COOKIE/INTERCEPT/UI 一律 true\n  ...\n});\n```\n\n### 2. Args & Columns\n\n```javascript\nargs: [\n  { name: 'sort',  type: 'string', default: 'turnover', help: '...' },\n  { name: 'limit', type: 'int',    default: 20,         help: '...' },\n],\ncolumns: ['rank', 'bondCode', 'bondName', /* ... */ ],\n```\n\n**规则**：\n\n- `type`: `string` / `int` / `float` / `bool`\n- `default` 必填（缺失的命令会拒绝启动）\n- `columns` 数组必须跟 `func` 返回的 object keys 完全对上，顺序也一致（决定表格列顺序）\n- 列名 camelCase，跟 `cli({...})` 其他 adapter 保持统一\n- **中间解析对象 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)）\n\n### 3. func — 主体\n\n```javascript\nimport { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';\n\nfunc: async (args) => {\n  // 1. 解析参数 — 越界一律抛，不要 silent clamp\n  const n = Number(args.limit ?? 20);\n  if (!Number.isInteger(n) || n <= 0) throw new ArgumentError('limit must be a positive integer');\n  if (n > 100) throw new ArgumentError('limit must be <= 100');\n  const limit = n;\n\n  // 2. 构造 URL / 请求\n  const url = new URL(...);\n  url.searchParams.set(...);\n\n  // 3. 发请求 — fetch 抛 / HTTP 非 2xx 都归 CommandExecutionError\n  let resp;\n  try {\n    resp = await fetch(url, { headers: { /* ... */ } });\n  } catch (error) {\n    throw new CommandExecutionError(`request failed: ${error?.message || error}`);\n  }\n  if (!resp.ok) throw new CommandExecutionError(`request failed: HTTP ${resp.status}`);\n\n  // 4. 解析 + 业务校验 — 业务空 → EmptyResultError，不要 sentinel row 也不要 return []\n  const data = await resp.json();\n  const diff = Array.isArray(data?.data?.diff) ? data.data.diff : [];\n  if (diff.length === 0) throw new EmptyResultError('site command', 'API returned no rows');\n\n  // 5. map 到 columns 同名 keys\n  return diff.slice(0, limit).map((it, i) => ({\n    rank: i + 1,\n    bondCode: it.f12,\n    // ...\n  }));\n},\n```\n\n**站点级 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。\n\n**参数形态**（**踩过最多次的坑**：搞反签名后 `args` 实际是 `debug` flag，所有 `args.foo` 静默 undefined → fallback 到 default。#1329 upstream 之前 8 个 non-browser adapter 写错过签名，全部 silently fallback 到默认参数）：\n\n- `browser: false`：`func: async (args, debug?) => { ... }` —— **单参 args**，不会收到 `page`\n- `browser: true`：`func: async (page, args, debug?) => { ... }` —— **双参 (page, args)**，第一参是浏览器上下文\n- `args`：所有 `args[]` 声明的参数解析后的 object\n\n**错误处理**：用 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）决定要不要重试。\n\n---\n\n## COOKIE adapter 骨架（需要登录态）\n\nPUBLIC 模式不够（接口 401 / 302 到 login / 响应是\"请登录\"页）就走这里。要点三条：\n\n1. 读 cookie 走 `page.getCookies(...)`，**不要读 `document.cookie`**。\n2. 拿 HTML 走 Node 端 `fetch` + 手动解码，**不要塞进 `page.evaluate` 里**。\n3. Declaration 加 `browser: true`；不需要真的打开目标页时 `navigateBefore: false`。\n\n```javascript\nimport { cli, Strategy } from '@jackwener/opencli/registry';\nimport { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';\n\nconst BASE = 'https://www.example.com';\nconst HOST = 'www.example.com';\nconst ROOT = '.example.com';  // 根域（auth 常在这里）\n\nasync function readCookie(page) {\n    const seen = new Map();\n    for (const opts of [{ domain: HOST }, { domain: ROOT }]) {\n        try {\n            const cookies = await page.getCookies(opts);\n            for (const c of cookies || []) {\n                if (!seen.has(c.name)) seen.set(c.name, c.value);\n            }\n        } catch { /* try next domain */ }\n    }\n    return [...seen].map(([k, v]) => `${k}=${v}`).join('; ');\n}\n\nasync function fetchHtml(url, { cookie, encoding = 'utf-8', headers = {} } = {}) {\n    let resp;\n    try {\n        resp = await fetch(url, {\n            headers: {\n                'User-Agent': 'Mozilla/5.0',\n                'Accept-Language': 'zh-CN,zh;q=0.9',\n                Referer: `${BASE}/`,\n                ...(cookie ? { Cookie: cookie } : {}),\n                ...headers,\n            },\n            redirect: 'follow',\n        });\n    } catch (error) {\n        throw new CommandExecutionError(`example request failed: ${error?.message || error}`);\n    }\n    if (!resp.ok) throw new CommandExecutionError(`example request failed: HTTP ${resp.status}`);\n    const buf = await resp.arrayBuffer();\n    return new TextDecoder(encoding).decode(buf);\n}\n\ncli({\n    site: 'example',\n    name: 'me',\n    access: 'read',\n    description: '示例：需要登录的私有页面',\n    domain: HOST,\n    strategy: Strategy.COOKIE,\n    browser: true,\n    navigateBefore: false,    // 本命令不需要先开目标页\n    args: [{ name: 'limit', type: 'int', default: 20, help: '返回条数' }],\n    columns: ['index', 'title', 'time'],\n    func: async (page, args) => {\n        const limit = Number(args.limit ?? 20);\n        if (!Number.isInteger(limit) || limit <= 0) throw new ArgumentError('limit must be a positive integer');\n\n        const cookie = await readCookie(page);\n        const html = await fetchHtml(`${BASE}/inbox`, { cookie, encoding: 'gbk' });\n\n        if (/请登录|需要登录|<title>Login/i.test(html)) {\n            throw new AuthRequiredError(HOST);\n        }\n\n        // parse html → rows\n        if (!rows.length) throw new EmptyResultError('example me', 'inbox is empty');\n        return rows.slice(0, limit);\n    },\n});\n```\n\n### JSON API 用 `page.fetchJson()`，不要手写 `page.evaluate(fetch(...))`\n\n如果接口必须在浏览器上下文里请求（依赖当前页面 cookie / CORS / origin），用内置 primitive：\n\n```javascript\nconst data = await page.fetchJson(`${BASE}/api/list`, {\n  method: 'POST',\n  headers: { 'X-Requested-With': 'XMLHttpRequest' },\n  body: { page: 1, size: limit },\n});\n```\n\n它固定 `credentials: 'include'`，带 timeout，HTTP 非 2xx / 非 JSON 会抛统一 runtime error。adapter 里不用再手写 `page.evaluate(fetch(...))`；如果你需要额外包一层业务语义，按 [`typed-errors.md`](./typed-errors.md) 映射到 `CommandExecutionError` / `AuthRequiredError` / `EmptyResultError`。\n\n### 页面内 DOM 逻辑用 `page.evaluate(fn, ...args)`\n\n新 adapter 优先写函数形式，外部变量通过参数传入：\n\n```javascript\nconst href = await page.evaluate((selector) => {\n  const link = document.querySelector(selector);\n  return link ? link.getAttribute('href') : null;\n}, 'a[data-testid=\"profile\"]');\n```\n\n`fn` 在浏览器页面上下文执行，不能读取 Node 侧闭包变量；参数必须能被 `JSON.stringify` 序列化。字符串形式 `page.evaluate('document.title')` 仍可用于简单表达式和既有代码，但不要再写依赖隐式 auto-IIFE 的模板字符串函数。\n\n### HTML 不走 browser fetch\n\n三个坑，踩一个就重写：\n\n- **HttpOnly cookie 看不见**：绝大多数登录站点把 auth cookie 标 `HttpOnly`，`document.cookie` 永远读不到它，只能通过 CDP 的 cookie jar 拿（`page.getCookies`）。塞到 `page.evaluate` 里就等于回到 `document.cookie` 那条路，必挂。\n- **`navigateBefore: false` 时当前 tab 不在目标站**：页面 origin 可能是 `about:blank` 或上一条命令留下的别处，从那儿发 fetch 到目标域就是 cross-origin，浏览器 CORS 一挡就是 \"Failed to fetch\"。\n- **非 UTF-8 编码解码麻烦**：GBK / Big5 / Shift-JIS 的站（Discuz / phpBB 老版 / 日站）在 `page.evaluate` 里用 `response.text()` 拿到的是乱码，`TextDecoder('gbk').decode(buf)` 的写法只在 Node 侧干净。\n\n**规则**：JSON 型浏览器接口用 `page.fetchJson()`；HTML 型 COOKIE adapter 一律 Node 侧 `fetch`，浏览器只当 cookie jar 用。\n\n### Selector 稳定性 — 不要 select 用户可见文本\n\nissue #1474 触发：同一个发送按钮在英文 Chrome 是 `aria-label=\"Submit\"`，在中文 Chrome（`chrome://settings/languages` 设中文）变 `aria-label=\"提交\"`，CSS 选择器 `button[aria-label=\"Submit\"]` 在中文环境下直接 0 匹配，silent empty result。\n\n根因不是 i18n bug，是**选择器的 anchor 选错了**。把页面 DOM 属性按 \"locale-stable vs locale-dependent\" 分两类：\n\n| 类 | 例子 | locale 切换会变吗 | 用作 primary selector？ |\n|----|------|----------------|---------------------|\n| **locale-stable 标识** | `data-testid`、`data-*`、稳定 `id` / `class` | 通常不变（开发者内部 ID） | ✅ 首选，但要先确认不是 hash / A-B test |\n| **semantic / scope anchor** | `role`、结构关系、邻近稳定容器 | 不按 locale 翻译，但常常不唯一 | ⚠️ 只作 scope/filter；不要单独用 `button[role=\"button\"]` |\n| **locale-dependent 文本** | `aria-label`、`title`、`placeholder`、`alt`、`textContent` | 变（被 i18n 框架翻译） | ❌ 仅当 stable 选择器全都不存在时的兜底 |\n\nChatGPT 的 web 端就是反例驱动的：有些 controls 暴露稳定 `data-testid`，有些 surfaces 只暴露 `aria-label` / `placeholder`。这种站必须先用 stable selector，再用多语言 fallback list：\n\n```javascript\n// clis/chatgpt/utils.js（简化活例）\nconst COMPOSER_SELECTORS = [\n  '#prompt-textarea',\n  '[data-testid=\"composer\"] [contenteditable=\"true\"]',\n  '[aria-label=\"Chat with ChatGPT\"]',   // en\n  '[aria-label=\"与 ChatGPT 聊天\"]',      // zh-CN\n  '[placeholder=\"Ask anything\"]',\n  '[placeholder=\"有问题，尽管问\"]',       // zh-CN\n];\nconst SEND_BUTTON_SELECTORS = [\n  'button[data-testid=\"send-button\"]:not([disabled])',\n  'button[aria-label=\"Send prompt\"]:not([disabled])',\n  'button[aria-label=\"发送提示\"]:not([disabled])',\n];\n```\n\n写 fallback list 的纪律：\n\n1. **stable selector 放最前**（`#prompt-textarea` / `[data-testid=\"send-button\"]`），locale-dependent 的放后面当兜底\n2. **`role` 只能当 semantic / scope filter**：`dialog [role=\"textbox\"]` 可以；裸 `button` / `[role=\"button\"]` 不够，因为同页可能有多个按钮\n3. **每种 locale 至少列一条**（en + zh-CN 是底线；扩到 ja / ko / ar 看站点用户分布）\n4. **commit 前 grep `aria-label=` / `placeholder=` / `title=` 看是不是漏了 fallback locale**——见 success-rate-pitfalls.md §11\n5. **失败要 typed fail-fast**：找不到 control 应该 `CommandExecutionError` / send-failed，不要返回空 rows 或假成功\n6. **不要给 framework 加 `--i18n \"zh:提交,ja:送信\"` 这种 flag** —— 等于把 fallback list 从 adapter 挪到 CLI，多一层 indirection 还要维护翻译字典。这是 over-engineering，已经在评审时被否\n\n为什么不在 daemon 端固定 Chrome locale？因为 opencli **不启动 Chrome**——daemon 是连用户已经在跑的 Chrome（CDP via extension），用户可能就是中文 UI / 中文资料检索需求。强制 en-US 会破坏用户的正当工作流。\n\n### Cookie 域的双查\n\n```javascript\nfor (const opts of [{ domain: HOST }, { domain: ROOT }]) { ... }\n```\n\n不是所有站都这么玄学，但下面这几类踩坑最多：\n\n| 站点类型 | 坑 |\n|---------|----|\n| Discuz!X / phpBB / vBulletin 论坛 | Auth cookie 设在 `.<root>.com`，HttpOnly；业务页在 `www.<root>.com`。只查 `www.` 会漏 |\n| 多子域账户体系（`account.x.com` vs `api.x.com`） | 登录时写在 account 域，API 域读取时拿不到 |\n| 新版 Chrome SameSite=Lax 默认 | 某些 cookie 查 `url:` 才给返，查 `domain:` 不给 |\n\n双查成本很低，不确定就两个都查，用 Map 去重第一次出现的 name。\n\n### 空态抛 `EmptyResultError`，**不**塞 sentinel 行\n\n历史上这里写的是\"返回一行说明 row 比 `return []` 安全\"。**这条已经反过来了**——见 PR #1329 R3 的 four anti-pattern fixes。现在的契约：\n\n```javascript\nimport { EmptyResultError } from '@jackwener/opencli/errors';\n\n// ❌ 老写法：sentinel 行污染 row 合同，让 listing→detail round-trip 拿到 tid='' 白跑\nif (/暂时没有提醒内容/.test(html)) {\n    return [{ index: 0, from: '', summary: '暂时没有提醒内容', time: '', threadUrl: '' }];\n}\n\n// ✅ 新写法：empty 是合法状态，但不是 row。exit code 66 让 agent 直接 branch\nif (/暂时没有提醒内容/.test(html)) {\n    throw new EmptyResultError('1point3acres notifications', '暂时没有提醒内容');\n}\n```\n\n更多反例和详细 routing 见 [`typed-errors.md` §3](./typed-errors.md)。\n\n---\n\n## 同类型 adapter 对照\n\n| 类型 | 代表 | 参考 |\n|------|------|-----|\n| clist 分页排行 | `convertible.js` / `rank.js` / `etf.js` / `sectors.js` | 都共享 `fs` + `fid` + `po` 结构 |\n| ulist 批量报价 | `quote.js` | `secids` 逗号拼接 |\n| K 线历史 | `kline.js` | `fields1 / fields2` 控列，CSV 解析 |\n| 报表（datacenter-web） | `longhu.js` / `holders.js` | `reportName` 驱动 |\n| 7x24 新闻 | `kuaixun.js` | `np-listapi` 栏目 id |\n| 公司公告 | `announcement.js` | `np-anotice-stock` |\n| 指数/北上 | `index-board.js` / `northbound.js` | push2 专用端点 |\n\n新写一条时，选最像的那类，复制后改 `name` / URL / fields / column 映射三处。\n\n---\n\n## Verify fixture（每个 adapter 配一份 `~/.opencli/sites/<site>/verify/<name>.json`）\n\nverify fixture 是\"adapter 产出长什么样\"的结构锚点。没有它，`opencli adapter test` 只能证\"adapter 能跑完不抛\"，证不出数据没错位。**必写**。\n\n详细 schema 见 `site-memory.md` 的 `verify/<cmd>.json` 节。这里只讲两个容易踩的地方：\n\n### args 形态：object vs array\n\n`args` 字段决定 verify 怎么调你的 adapter：\n\n- **对象形态** `{ \"limit\": 3 }` → 展开成 `--limit 3`，标准 named-flag adapter 用这个\n- **数组形态** `[\"123\", \"--limit\", \"3\"]` → 原样 append 到命令后，**positional 主语型** adapter 必须用这个\n\nrepo 约定\"主语优先 positional\"——thread 详情型、url 解析型、关键词搜索型都用 positional：\n\n```js\n// clis/1point3acres/thread.js — 接收 <tid> 作为主语\ncli({\n  site: '1point3acres',\n  name: 'thread',\n  args: [\n    { name: 'tid',   type: 'string', required: true, positional: true },\n    { name: 'limit', type: 'int',    default: 20 },\n  ],\n  // ...\n});\n```\n\n对应 fixture：\n\n```json\n{\n  \"args\": [\"1234567\", \"--limit\", \"3\"],\n  \"expect\": { \"rowCount\": { \"min\": 1, \"max\": 3 }, \"...\": \"...\" }\n}\n```\n\n**不要写成** `{ \"tid\": \"1234567\", \"limit\": 3 }`——这会被展开成 `--tid 1234567 --limit 3`，commander 把 `--tid` 当未知 flag 报错，或者 adapter 根本不认。\n\n### 种子 → 手改\n\nnamed-flag adapter（`hot` / `latest` 类）可以直接让工具生成种子：\n\n```bash\n# 1. 让 verify 先跑一遍，--write-fixture 生成种子（默认追加 --limit 3）\nopencli adapter test 1point3acres/hot --write-fixture\n\n# 2. 手改 ~/.opencli/sites/1point3acres/verify/hot.json\n#    - patterns: 加 URL / 日期 / ID 正则\n#    - notEmpty: 加核心字段（title / author / url）\n#    - rowCount: 收紧到业务合理区间\n\n# 3. 再跑 verify，fixture 吃得动就 OK\nopencli adapter test 1point3acres/hot\n```\n\npositional adapter 目前 `--write-fixture` 没法表达主语，**首份 fixture 要手写**：\n\n```bash\n# 1. 先直跑 adapter 看输出长啥样\nopencli 1point3acres thread 1173710 --limit 2 --format json | head\n\n# 2. 照着响应手写 ~/.opencli/sites/1point3acres/verify/thread.json\n#    （args 一定用数组: [\"1173710\", \"--limit\", \"2\"]）\n\n# 3. 跑 verify 核对\nopencli adapter test 1point3acres/thread\n```\n\n机器生成的种子只有 rowCount.min=1 / columns / types，挡不住字段值错位。**patterns + notEmpty 无论哪种情形都是肉写的**。\n\n---\n\n## 私人 adapter vs repo 贡献\n\n```\n~/.opencli/clis/<site>/<name>.js    # 私人\nclis/<site>/<name>.js               # repo 贡献\n```\n\n**两者在 `cli({...})` 层面完全一样**。差别只在运行入口：\n\n- 私人：写完立即可跑（`opencli <site> <name>`）\n- repo：要 `npm run build` 才被注册\n\n先在 `~/.opencli/clis/` 调通再拷贝到 `clis/`。\n","skills/opencli-adapter-author/references/api-discovery.md":"# API Discovery\n\n**寻找目标数据 endpoint。** 按当前证据选择需要的调查手段。\n\n下面提供五种调查手段。候选需要结合真实业务结果判断；复杂协议可以参考 [`deep-recon.md`](./deep-recon.md)，无需固定流程。\n\n---\n\n## Evidence from loaded scripts and network requests\n\n```bash\nopencli browser --session research open https://example.com\nopencli browser --session research network --static\nopencli browser --session research network --detail '<returned request key>'\n```\n\n`network --static` combines captured requests with AST candidates from `fetch`, axios, XHR and related calls. It reports method, URL, parameter names, script/line and request key. `network+static` means both sources support the same route; `static` alone is an unexecuted candidate. `EXPR` marks unresolved expressions. Check `scripts[].error`, `omittedScripts`, and cache provenance (`networkSource`, `networkSavedAt`). The bounded scan covers current document scripts, without following imports or proving authentication/pagination behavior.\n\nUse the evidence to choose the next investigation. Write or edit the normal JS adapter in place and run it through the existing CLI to inspect real business results; no draft directory or activation step is required.\n\n## §0 进入 §1 之前：先看两条红线\n\n这两条不看清楚，后面的 endpoint 验证会一直在错的前提下兜圈子。\n\n### 0.1 反爬厂商 → 决定 fetch 能不能从 Node 走\n\n`opencli browser state --diagnostics --session research` 的 `anti_bot` 字段给答案；手查看 cookies 也行：\n\n| cookie / body 信号 | 厂商 | 裸 Node fetch / curl 结果 | 策略 |\n|------------------|------|-----|-----|\n| `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()` |\n| `__cf_bm` / `cf_clearance` / `__cfduid`；body 含 `Cloudflare Ray ID` / `Checking your browser` | **Cloudflare** | TLS 指纹被标记，失败 | 同上：先 browser-context probe，最终 adapter 仍按模板选 fetch 路线 |\n| `_abck` / `bm_sz` / `bm_sv` | **Akamai** | 即使带 cookie 也常被挡 | 同上 |\n| body 含 `geetest` / `gt_captcha` | **Geetest** | 滑块/拼图，程序无解 | 超出 skill 范围，放弃或 UI 策略 |\n\n**规则**：看到上面四种任一个，先不要拿**裸** Node fetch 做 endpoint 验证。先用 browser-context probe 或目标 origin 页面确认接口能通；最终 adapter 的 fetch 路线仍按 `adapter-template.md` 选，HTML 型 COOKIE adapter 继续走 Node-side fetch + `page.getCookies()`。\n\n### 0.2 跨 subdomain = CORS 默认关\n\n`jobs.51job.com` 页面 fetch `cupid.51job.com` 的 API，默认会被浏览器 CORS 预检挡住——除非目标接口回了 `Access-Control-Allow-Origin`。\n\n判断：\n\n```bash\nopencli browser --session research eval \"fetch('https://<target-subdomain>/api/...', {credentials:'include'}).then(r=>r.status).catch(e=>'cors:'+e.message)\"\n```\n\n- 返回 status 数字 → CORS 通，继续\n- 返回 `cors:...` 或 `TypeError: Failed to fetch` → 挡住了\n\n**挡住时**：不要把 `credentials:'include'` 当万能药——这只解决\"带 cookie\"，不解决\"跨 origin\"。降级路径：\n\n1. 换同 origin 的 endpoint（同一个 subdomain 下的 API 往往更宽松）\n2. 用 `opencli browser --session research open https://<target-subdomain>/`，让页面在目标 subdomain 本身打开，再 fetch 相对路径\n3. 真跨域且无替代 → 走 `§5 intercept`，从页面自身发的请求里抓响应\n\n---\n\n## §1 network 精读（首选，Pattern A / D 命中率最高）\n\n### 拿候选\n\n```bash\nopencli browser --session research network\n```\n\n默认输出是 JSON，每个候选都带：\n- `key` — 稳定引用（GraphQL 的 `operationName` 或 `METHOD host+pathname`）\n- `shape` — response body 的路径→类型映射（不含原 body，省 token）\n- `status / url / method / ct / size`\n\n静态资源 / 埋点 / 追踪默认已过滤。默认会保留 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，而不是永久丢掉被隐藏的条目。\n\n如果是冷启动，先看 `opencli browser state --diagnostics --session research` 里的 `api_candidates`：\n\n- `verdict: \"likely_data\"`：优先 replay 这条，拿 status / content-type / sample shape 填 strategy note\n- `verdict: \"maybe_data\"`：可以试，但必须人工核对字段是否是目标业务数据\n- `verdict: \"noise\"`：多半是 analytics / beacon / personalization，不要因为 XHR 数量多就判 Pattern A\n- `verdict: \"blocked\"`：401/403；先排 cookie / token / CSRF，别直接退到 selector\n\n`real_data_score` 是证据，不是自动 strategy。最终仍要在 strategy note 里写 replay 结果和降级理由。\n\n### 按 shape 初筛\n\n挑 `key` 里含业务词（`list / detail / Timeline / User / Tweets / Quote`）的优先看 `shape`：\n\n- `$.data` 是 `object` 且下面出现 `array(N)` / `total` / `page` → 基本是它\n- 路径里出现 `nickname / avatar / title / price / tweets / items` → 就是它\n- shape 只有 `$: string` 或全是 HTML 噪音 → 下一条\n\n### 按期望字段反查（`--filter`）\n\n已经知道目标 body 该含哪些字段就直接让 CLI 把列表筛到只剩候选，不用自己 scroll 翻 shape：\n\n```bash\nopencli browser --session research network --filter author,text,likes\n```\n\n- 字段以英文逗号分隔；AND 语义，必须每个字段都作为 shape 路径的**任意一段**出现才保留（`$.data.items[0].author` 命中 `author`、`items`、`data` 都算）\n- 区分大小写（JSON key 本来就 case-sensitive）\n- 输出 envelope 新增 `filter` / `filter_dropped`，`count` 是过滤后数量\n- 0 命中不是 error，返回 `entries: []`；说明字段组合不对，换一组或去掉约束再试\n- 不要跟 `--detail` 一起用——`--detail` 按 key 取单条、`--filter` 是列表缩窄，组合会报 `invalid_args`\n- 空值 / `,,,` → `invalid_filter` 结构化错误\n- capture 依然按全量持久化，后续 `--detail <key>` 能找到被过滤掉的条目\n\n### 拉完整 body\n\n候选定了再拉完整 body（by key，不是 index — 数组顺序会随每次 capture 变）：\n\n```bash\nopencli browser --session research network --detail <key>\n```\n\ncapture 会持久化到 `~/.opencli/cache/browser-network/<session>.json`（默认 TTL 24h），所以 `--detail` 即使跨多条其他命令也还在。\n\n`--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——这说明请求合同仍不完整。\n\n这也意味着私有页面的 response 可能落在本地 cache。侦察结束要删除相关 session capture 并释放 browser session；不要依赖 24h TTL 代替清理。\n\n### 关键 request headers\n\n先用 `browser network --detail <key>` 看脱敏后的 request headers / body shape；不要打印或复制 credential 原值。旧 capture provider 若没有返回 `request`，再去 DevTools Network 面板核字段名，或用页面自然动作重新 capture，不能用 `browser eval` 猜造一份缺 header/body 的 URL-only 请求：\n\n| 看到 | 含义 | 对应策略 |\n|------|------|---------|\n| 只有 `Cookie` | 登录态靠 cookie | `Strategy.COOKIE` |\n| `Authorization: Bearer xxx` | token 鉴权 | 先找 token 来源（localStorage / cookie / bundle 硬编码） |\n| `X-Csrf-Token: xxx` 同时存在 cookie 里 | CSRF 防护 | `Strategy.COOKIE`，从 cookie 读 ct0 类字段拼头 |\n| `X-Workspace-Id / X-Tenant-Id` | 多租户业务头 | 先调 `/workspaces` 拿 ID，缓存下来 |\n| 啥自定义头都没有 | 匿名接口 | `Strategy.PUBLIC` |\n\n### 触发懒加载接口\n\n默认页加载完后滚动 / 点击才会出的接口不在首屏 network 里。需要：\n\n```bash\n# 滚到底（虚拟列表）\nopencli browser --session research eval \"window.scrollTo(0, document.body.scrollHeight)\"\nopencli browser --session research wait time 2\nopencli browser --session research network\n\n# 点某个按钮\nopencli browser --session research click <N>\nopencli browser --session research wait time 2\nopencli browser --session research network\n```\n\n---\n\n## §2 `__INITIAL_STATE__` / inline HTML（Pattern B）\n\n首屏数据常挂在这几个全局变量上：\n\n```bash\nopencli browser --session research eval \"Object.keys(window).filter(k=>k.startsWith('__'))\"\n```\n\n命中的常见名：\n\n| 全局 | 框架 |\n|------|-----|\n| `__NEXT_DATA__` | Next.js |\n| `__NUXT__` | Nuxt.js |\n| `__INITIAL_STATE__` | 自定义 Vue / React SSR |\n| `__PRELOADED_STATE__` | Redux SSR |\n| `__REMIX_CONTEXT__` | Remix |\n\n取数据：\n\n```bash\nopencli browser --session research eval \"JSON.stringify(window.__NEXT_DATA__).slice(0, 3000)\"\n```\n\n**关键**：inline state 只覆盖首屏的一部分（通常是 SEO 相关字段）。分页 / 评论 / 懒加载还是得回 §1 抓 API。\n\n把首屏 state 当作 adapter 的兜底数据源：公开访问时 state 里有 → 直接 parse；数据更新快 / 分页 → 回到 API。\n\n---\n\n## §3 JS bundle / script src 搜索（Pattern C，也是 A/D 的降级）\n\n### 扫 script src\n\n```bash\nopencli browser --session research 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))\"\n```\n\n看结果里的 hostname：\n\n- 明显像 API 的域名（`api.xxx / push.xxx / data.xxx / gateway.xxx`）→ 直接去试\n- 主 bundle（`main.js / app.js / index.xxx.js`）→ 继续下一步下载 bundle 搜 baseURL\n\n### 搜 bundle 里的 baseURL\n\n```bash\nopencli browser --session research 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})()\"\n```\n\n命中 `baseURL:\"https://api.foo.com\"` 直接拿 host 拼 endpoint。\n\n### 用 jsluice 扩大候选面（可选）\n\n手工搜 `baseURL` 只适合小 bundle。站点脚本多、压缩重或 endpoint 通过 `fetch` / XHR / 字符串拼接生成时，可以把**已经加载的脚本文本**通过 stdin 交给本机可选的 [jsluice](https://github.com/BishopFox/jsluice) 做语法感知扫描。\n\n```bash\n# bundle 只短暂落 /tmp；扫描后删除\njsluice urls < /tmp/example-bundle.js\n```\n\n边界：jsluice 输出是 candidate，不是 contract。`EXPR` 表示动态值未知；扫描无法证明 token、签名、CORS、权限、分页、字段语义或副作用。不要把命中 URL 直接写进 adapter，更不要把扫描到的疑似 secret 原值保存到 trace/site memory。\n\n每个候选至少记录：来源 bundle + 代码位置、method/path、触发它的可见动作、动态 network 是否发生、replay status/content-type/shape、选择或拒绝原因。复杂站直接转 [`deep-recon.md`](./deep-recon.md) 的 evidence ledger。\n\n### 直接试候选 endpoint\n\n像 eastmoney 这种经验 endpoint 可以直接喂：\n\n```bash\nopencli browser --session research 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())\"\n```\n\n200 只是 transport 成功。至少换一个输入再试，并核 content-type、目标 identity、非空 shape、分页和可见页面值；写入或复杂私有协议转 `deep-recon.md`，不能“数据看起来像”就认。\n\n### URL 后缀探测\n\n有些站直接在 URL 加 `.json` 就是 REST：\n\n- `https://www.reddit.com/r/rust.json` — Reddit 全覆盖\n- `https://xueqiu.com/S/SH600000.json` — 雪球部分页\n\n```bash\n# 当前页加 .json 试\nopencli browser --session research eval \"fetch(location.pathname.replace(/\\\\/$/,'')+'.json').then(r=>r.ok?r.json():'no')\"\n```\n\n---\n\n## §4 Token / CSRF 来源排查（Pattern D）\n\n已经在 network 里看到请求带自定义头，怎么拿到那个值：\n\n### Cookie 里\n\n```bash\nopencli browser --session research eval \"document.cookie.split('; ').map(x=>x.slice(0,x.indexOf('='))).filter(Boolean)\"\n```\n\n常见 token cookie 名：`ct0`（Twitter CSRF）、`xq_a_token`（雪球）、`SESSDATA`（B 站）、`_csrf / csrfToken`（通用）。\n\n**`document.cookie` 只能看到 non-HttpOnly 的 cookie。** 上面那条命令侦察阶段够用，真写 adapter 时 auth 经常是 HttpOnly，一定要用 `page.getCookies(...)` 从 CDP cookie jar 拿——见 `adapter-template.md` 的 \"COOKIE adapter 骨架\"。\n\n论坛 / BBS 引擎（Discuz!X / phpBB / vBulletin）还多一坑：auth cookie 设在**根域** `.example.com`（不是 `www.example.com`），且 HttpOnly。要查 `{ domain: '.<root>' }` **和** `{ domain: 'www.<root>' }` 两次，否则 adapter 在有 cookie 的前提下仍然 401。\n\n### localStorage / sessionStorage 里\n\n```bash\nopencli browser --session research eval \"Object.keys(localStorage).filter(k=>/token|auth|jwt|bearer|csrf/i.test(k))\"\n```\n\n先只列 key 名，找 `token / auth / jwt / bearer / csrf`。只有选定 production auth source 后才在页面内使用对应值；不要把值打印进聊天、trace、shell history 或 site memory。\n\n### Bundle 硬编码\n\n有些站的 Bearer 是全站一个常量（Twitter 的匿名 Bearer）。在 bundle 里搜：\n\n```bash\nopencli browser --session research 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)}})()\"\n```\n\n只返回数量/位置，不返回 token 原值。即使 bundle 中是公共匿名 Bearer，也先确认它是否是预期公开合同；不要复制未知 credential-shaped string。\n\n### 调用页面 runtime 让站点自己生成请求（只读、最后手段）\n\nVue + Pinia / Redux / React Context 有时能调用页面自己的只读 store method，让站点 runtime 自己生成签名和请求：\n\n```bash\n# Pinia\nopencli browser --session research eval \"typeof __pinia !== 'undefined' ? Object.keys(__pinia.state.value) : 'no pinia'\"\n\n# 只调用已证明是 read-only 的 store action（每个站点具体 action 名要查）\nopencli browser --session research eval \"window.__pinia.state.value.someStore.someMethod({...})\"\n```\n\n这不是“绕签名”，也不是 direct API contract：它仍依赖页面 controller/runtime，production strategy 通常是 `INTERCEPT`。只有动作语义被可见 UI 和动态请求证明为 read-only 才能在侦察中调用。未知 effect 或 write action 禁止自动调用；写入只观察用户明确授权的一次自然操作，按 `deep-recon.md` 处理。\n\n---\n\n## §5 让页面自然发请求并截获 response（最后降级）\n\n所有手段都试过还拿不到请求签名时，让页面自己自然发请求，adapter 用现有 Browser Bridge capture/interceptor 读取响应。优先 CDP network capture；只有已存在站点实现依赖 XHR interceptor 时才复用它，不要再写页面内 fetch/XHR monkey patch。\n\n```javascript\n// func 里：capture 必须先于触发动作安装，并先 drain stale entries\nawait page.startNetworkCapture('/api/foo');\nawait page.readNetworkCapture();\nawait page.goto('https://xxx.com/trigger-page');\n// 等页面自己发请求，再读取所有相关完整 response\nconst entries = await page.readNetworkCapture();\n```\n\ncapture queue 可能是破坏性 drain：过滤 relevant URL 后，只要看到 bodyless/truncated relevant entry 就拒绝 partial；多 response 要按业务 identity 合并，不能只取最后一个。分页、缓存和 no-partial 规则见 `deep-recon.md`。\n\n代价是要等页面真的触发请求，慢且依赖内部合同。只在 §1-4 都不行时用。\n\n---\n\n## 诊断不出来怎么办\n\n按这个顺序试到命中：\n\n```\n§1 network ──→ 命中？yes → 走\n      │        no\n      ↓\n§2 state  ──→ 命中？yes → 走\n      │        no\n      ↓\n§3 bundle ──→ 命中？yes → 走\n      │        no\n      ↓\n§4 token  ──→ 401 解除？yes → 走\n      │        no\n      ↓\n§5 intercept → 让页面自己发\n```\n\n**四条都命不中的站（罕见）**：多半是视觉化渲染（canvas / webgl），数据不以 HTTP/JSON 形式存在。这种放弃或换源。\n","skills/opencli-adapter-author/references/coverage-matrix.md":"# Coverage Matrix\n\nskill 明确承诺能搞定什么、搞不定什么。动手前先看一眼这张表，判断目标站落在哪一格。\n\n**状态标记**：\n- ✅ **已验证**：有可跑通的真实 adapter / dry-run 证据\n- 🟡 **已列招但未硬跑**：文档把方法写全了，但这一版没拿真实站点跑过；第一次遇到时按文档走，踩坑回来补 `site-memory`\n- ❌ **不支持**：skill 明确不碰，走绕开方案\n\n---\n\n## 支持（skill 里有对应的招）\n\n| 维度 | 支持 | 状态 | 走哪节 |\n|------|------|------|-------|\n| 页面形态 | 列表页 / 排行页 | ✅ | `adapter-template.md`（convertible.js / rank.js 类） |\n| | 详情页（单对象） | ✅ | `adapter-template.md`（stock.js / holders.js 类） |\n| | 时间序列（K 线 / 分钟线） | ✅ | `adapter-template.md`（kline.js） |\n| | 嵌套列表（列表里含列表） | 🟡 | `adapter-template.md` + `output-design.md` 打平规则 |\n| 站点类型 | SPA（React/Vue，JSON XHR） | ✅ | `site-recon.md` Pattern A + `api-discovery.md` §network |\n| | SSR（HTML with inline data） | 🟡 | `site-recon.md` Pattern B + `api-discovery.md` §state |\n| | JSONP / push/script[src] | ✅ | `site-recon.md` Pattern C + `api-discovery.md` §bundle（eastmoney / tonghuashun 已覆盖） |\n| | SPA + 独立 BFF domain | 🟡 | `api-discovery.md` §bundle §suffix |\n| Strategy（详见 `strategy-selection.md`） | 裸 `fetch()` 拿到 | ✅ | `PUBLIC_API`（一方文档化接口，最稳：fixes/adapter-year=1.18） |\n| | cookie 透传 | ✅ | `COOKIE_API`（官方 web 接口 + 用户登录态，fixes/adapter-year=2.01） |\n| | publish / upload / click / 表单 | ✅ | `UI_SELECTOR`（DOM 的 a11y / semantic 也是契约，fixes/adapter-year=1.92） |\n| | hydration state / inline JSON | 🟡 | `DOM_STATE`（fixes/adapter-year=0.91 小样本 N=11，按 UI_SELECTOR 同档） |\n| | page-context fetch（CORS / same-origin runtime） | 🟡 | `PAGE_FETCH`（无契约内部 endpoint，fixes/adapter-year=8.41，必须正向论证） |\n| | 触发 UI 拦截响应 | 🟡 | `INTERCEPT`（无契约，fixes/adapter-year=8.69，必须正向论证） |\n| 字段形态 | 自解释（`title / price / current`） | ✅ | 直接映射 |\n| | 已登记代号 | ✅ | `field-conventions.md` 查表 |\n| | 未登记代号 | 🟡 | `field-decode-playbook.md` 排序键对比法 |\n| | 嵌套路径 `data.diff[].f2` | ✅ | `field-decode-playbook.md` §3 结构差分 |\n| 分页 | `page` / `pn` / `pageNum` | ✅ | `adapter-template.md` 例子 |\n| | `cursor` / `next_cursor` | ✅ | adapter 里 while 循环，收集到 limit |\n| | `offset` / `start` | ✅ | 同上 |\n| 响应格式 | JSON | ✅ | 默认 |\n| | JSONP（`?callback=`） | ✅ | 去掉 callback 参数直接请求，返回仍是 JSON 字符串包裹 |\n| | CSV 字符串（eastmoney kline） | ✅ | `response.split(',')` 按列序解 |\n| | HTML 表格（tonghuashun） | 🟡 | `page.evaluate` 里用 `querySelectorAll` 拿 |\n\n🟡 的维度意思：方法在文档里，但这一版没拿真实站点跑过端到端。第一次遇到时按文档走，遇到和文档不一致的地方记到 `~/.opencli/sites/<site>/notes.md`，下一次再打开就是 ✅。\n\n---\n\n## 不支持（承认搞不定，skill 不教）\n\n| 场景 | 原因 | 绕开方案 |\n|------|------|---------|\n| 首次登录获取 token | 需要用户真实输入账密 | 让用户先在 browser session 里手动登录，adapter 拿 cookie 就行 |\n| 复杂 anti-bot（captcha） | 反爬拒流量 | 放弃，换同数据的其他站点 |\n| 加密字段（客户端 crypto） | 要破解 bundle 逆向 | 换 endpoint；实在不行发请求到 intercept 让页面自己解 |\n| WebSocket 流式数据 | 状态管理复杂 | 退回 HTTP 轮询版本（多数站都有） |\n| 私有 binary 协议 | 非 HTTP/WS | 不在 skill 范围 |\n| 视觉化图表（只有 canvas） | 数据埋在渲染层 | 找对应 API；找不到就放弃 |\n| 签名算法涉及静态密钥 | 需要长期跟踪 bundle 变更 | 走 `Strategy.INTERCEPT`，让页面自己发带签名的请求 |\n| 频控 / rate-limit 严格 | 多发几次就 429 | adapter 层控并发 + 加退避；但 skill 不解决 |\n\n---\n\n## 决定用不用 skill 的快速自测\n\n三个问题：\n\n1. **数据能在浏览器里看到吗？** 看不到（登录墙 / 付费墙）→ 先解决鉴权，再回来\n2. **数据来源是 HTTP/JSON/HTML 之一吗？** 不是（binary / 加密）→ 不在 skill 范围\n3. **需不需要每秒推送？** 需要 → 找同数据 HTTP 接口；没有就放弃\n\n三个都 yes 再往下走。\n\n---\n\n## 本轮硬验证 / 当前证据\n\n| 类型 | 证据 adapter | 覆盖维度 |\n|------|-------------|---------|\n| PUBLIC + 自解释字段 + SPA | `~/.opencli/clis/coingecko/top.js`（本轮 dry run） | `Strategy.PUBLIC` + REST JSON + 自解释字段 + 列表页 |\n| COOKIE + 代号字段 + JSONP | `clis/eastmoney/*.js` × 13（PR #1091 merged） | `Strategy.PUBLIC`（匿名 `ut=`）+ JSONP + f-代号 + 列表/详情/K 线 |\n| COOKIE + SPA | `clis/bilibili/*.js` × 10+（已存在） | `Strategy.COOKIE + browser:true` + wbi 签名 |\n\n**本 PR 新增的 skill 还未硬验证的维度**：🟡 行，尤其 SSR Pattern B + Bearer/CSRF + 未登记代号解码。这些放到第一批真实用户 adapter 写作中打磨，skill 文档先落，踩坑回来补 `site-memory`。合 PR 之前先拿 coingecko 跑第二轮（带着第一轮写出的 `~/.opencli/sites/coingecko/`）验证 memory 命中 → endpoint re-verify → 字段抽查 → 写 adapter 这条回路。\n","skills/opencli-adapter-author/references/deep-recon.md":"# Deep Recon: evidence-first discovery for undocumented sites\n\nUse 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.\n\n## 1. Freeze the command surface and mutation boundary\n\nBefore browsing, make an intent matrix:\n\n| Command | User intent | Read/write | Completeness | Exact target | Allowed live action |\n|---|---|---|---|---|---|\n| `search` | find matching entities | read | exact limit or upstream exhaustion | query | safe replay after proof |\n| `send` | create external side effect | write | one confirmed mutation | recipient + content | only with explicit authorization |\n\nGroup 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.\n\nWrite 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.\n\n## 2. Build an evidence ledger, not a traffic dump\n\nTrack one row per candidate:\n\n| Intent | Visible action | Source | Method/path | Effect | Auth/signing | Response shape | Replay result | Decision |\n|---|---|---|---|---|---|---|---|---|---|\n\n`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.\n\nNever paste credentials or response bodies into the ledger. Store structural facts: status, content type, arity/paths, row counts, cursor behavior, and source location.\n\n## 3. Triangulate three evidence planes\n\n1. **Visible truth**: page rows, counts, URLs, identity, and semantic controls.\n2. **Dynamic truth**: DevTools requests caused by one controlled action.\n3. **Static candidates**: loaded bundles scanned manually or with syntax-aware tools such as jsluice.\n\nDynamic 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.\n\nDo 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.\n\njsluice 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.\n\n## 4. Attribute requests with causal diffs\n\nFor each intent:\n\n1. establish a network/state baseline;\n2. perform exactly one visible action;\n3. inspect only newly added requests/state;\n4. repeat with one changed input;\n5. run a negative control when ambiguity remains.\n\nThe 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.\n\nFor 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.\n\n## 5. Rank candidates before decoding\n\nPrefer a candidate that:\n\n- contains the user-visible target data or mutation identity;\n- works across two inputs without copying ephemeral values;\n- has a safe, explainable auth source;\n- paginates to the requested completeness;\n- returns typed, distinguishable auth/HTTP/upstream failures;\n- is cheaper to maintain than the strongest UI/DOM alternative.\n\nPenalize 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.\n\n## 6. Pass the contract gate\n\nA read contract must prove all of these:\n\n1. **Occurrence**: the page naturally sends it, or it is documented/public.\n2. **Reproducibility**: a safe replay works across two non-empty inputs; if replay is impossible, `INTERCEPT` preserves the page-owned request.\n3. **Identity**: returned rows match visible target identity, not adjacent recommendations/ads/sidebars.\n4. **Completeness**: pagination reaches exact limit or proven upstream exhaustion.\n5. **Auth boundary**: cookies/CSRF/origin/runtime requirements are explicit and do not leak secrets.\n6. **Failure semantics**: auth, HTTP, malformed/truncated body, repeated cursor/page, timeout, and partial data fail typed.\n\nReplay 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.\n\nA direct API-backed write contract additionally must prove:\n\n1. target identity is deterministically bound in the request;\n2. auth/CSRF/signing can be reproduced without fabricating or exfiltrating runtime secrets;\n3. idempotency or duplicate-risk semantics are known;\n4. pre-write failure is distinguishable from post-write uncertainty;\n5. success is verified independently of the edited control/request echo;\n6. retries are disabled after an uncertain write unless the user first checks state.\n\n“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).\n\n## 7. Handle capture, pagination, and cache explicitly\n\nBrowser capture queues may be destructive drains. Before relying on them:\n\n- install capture before the action and drain stale entries;\n- cache the raw selected capture before applying display-only MIME, static-resource, or shape filters;\n- allow in-flight responses to settle;\n- treat bodyless or truncated relevant entries as possible data loss;\n- inspect non-JSON structured streams with request method, safely redacted headers, body shape, and size/truncation metadata;\n- merge all relevant completed responses in the action window;\n- identify pages/cursors by content, not arrival order alone;\n- deduplicate by stable entity ID;\n- reject repeated pages/cursors and page-cap exhaustion rather than return accumulated partial rows.\n\nNever 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.\n\nA 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.\n\n## 8. Choose strategy per command\n\nUse the repository strategy ladder after the contract gate. One site may legitimately mix strategies:\n\n- `PUBLIC_API` / `COOKIE_API` for reproducible contracts;\n- `PAGE_FETCH` for a verified same-origin read contract that must execute in page context;\n- `DOM_STATE` for stable hydration or cached state;\n- `INTERCEPT` when page-owned signing is unavoidable for reads;\n- `UI_SELECTOR` for user-visible writes only when that surface is allowed and safer;\n- no command when the requested contract cannot be proved.\n\nPreserve 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.\n\n## 9. Decode and test against invariants\n\nFor 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.\n\nValidation set:\n\n- two non-empty inputs plus one true empty result;\n- more than one page when pagination exists;\n- exact limit and upstream exhaustion;\n- cache/repeat behavior;\n- auth, HTTP, JSON/shape, truncated/bodyless capture, timeout, repeated cursor/page, and page cap;\n- every public column and target identity.\n\nTests 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.\n\n## 10. Deliver a clean, reviewable artifact set\n\nLeave durable outputs at the right layer:\n\n- **adapter**: executable contract, shared helpers, typed errors;\n- **tests**: production-path behavior and structural invariants;\n- **docs**: command surface, limits, auth, uncertainty, examples;\n- **site memory**: verified routes, triggers, fallbacks, rejected strategies, pitfalls, and verification date;\n- **recon conclusion**: evidence ledger without secrets or private bodies.\n\nBefore PR:\n\n1. regenerate manifest and validate the site;\n2. run focused/site tests, typecheck, build, docs coverage, typed-error and silent-column gates;\n3. scan the diff for live addresses, account IDs, tokens, message IDs, bodies, attachments, raw captures, and temporary dumps;\n4. delete raw capture/cache artifacts and release browser sessions;\n5. obtain independent exact-head review for writes, auth/signing, positional schemas, or no-partial pagination.\n\nRecord 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.\n","skills/opencli-adapter-author/references/field-conventions.md":"# Field Conventions\n\n响应字段代号在主要站点上的解码表。写 adapter 前先查一遍，查不到再用 SKILL.md 里 Step 2 的实测法推出来，推完补到本表。\n\n---\n\n## eastmoney（东方财富）\n\n域名：`push2.eastmoney.com` / `push2his.eastmoney.com` / `datacenter-web.eastmoney.com` / `np-listapi.eastmoney.com` / `np-anotice-stock.eastmoney.com`\n\n### 通用行情字段（`push2` clist/ulist/stock）\n\n| 代号 | 含义 | 备注 |\n|------|------|------|\n| `f1`  | 精度位数 | `fltt=2` 后可忽略 |\n| `f2`  | 最新价 | 要 `fltt=2` 才是格式化浮点 |\n| `f3`  | 涨跌幅 % | 同上 |\n| `f4`  | 涨跌额 | 同上 |\n| `f5`  | 成交量（手） | |\n| `f6`  | 成交额（元） | 接口默认是\"元\"，个别接口是\"万元\"，看 `f152` |\n| `f7`  | 振幅 % | |\n| `f8`  | 换手率 % | |\n| `f9`  | 市盈率（动态） | |\n| `f10` | 量比 | |\n| `f12` | 代码 | 股票/债券/ETF/指数统一 |\n| `f13` | market | 数字市场代号，见下表 |\n| `f14` | 名称 | |\n| `f15` | 最高 | |\n| `f16` | 最低 | |\n| `f17` | 今开 | |\n| `f18` | 昨收 | |\n| `f20` | 总市值 | |\n| `f21` | 流通市值 | |\n| `f23` | 市净率 | |\n| `f62` | 主力净流入 | **单位是\"元\"**，但有的接口返回\"万元\"，要核对 |\n| `f66` | 超大单净流入 | 同上 |\n| `f72` | 大单净流入 | |\n| `f78` | 中单净流入 | |\n| `f84` | 小单净流入 | |\n| `f100` | 所属板块 | |\n| `f152` | 精度（当 `fltt` 不传时要除以 10^f152） | 所以一律 `fltt=2` |\n\n### Convertible bond（可转债）专属\n\n| 代号 | 含义 |\n|------|------|\n| `f229` | 正股价 |\n| `f230` | 正股涨跌幅 |\n| `f232` | 正股代码 |\n| `f234` | 正股名称 |\n| `f235` | 转股价 |\n| `f236` | 转股价值 |\n| `f237` | 转股溢价率 |\n| `f238` | 剩余年限 |\n| `f239` | 到期收益率 (YTM) |\n| `f243` | 上市日期 (YYYYMMDD int) |\n\n### K-line `push2his` stock/kline/get\n\n返回形如 `data.klines: [\"2024-01-02,10.5,10.8,10.9,10.4,12345,...\"]` 的 CSV 字符串数组。用 `fields1 / fields2` 控制列。典型列序：\n\n```\ndate, open, close, high, low, volume, turnover, amplitude, changePct, changeAmt, turnoverRate\n```\n\n### Market 前缀（secid 格式 `<market>.<code>`）\n\n| 前缀 | 市场 |\n|------|------|\n| `1.`   | Shanghai (SSE) |\n| `0.`   | Shenzhen (SZSE) + Beijing (BSE) |\n| `116.` | Hong Kong (HKEX) |\n| `105.` | NASDAQ |\n| `106.` | NYSE |\n| `107.` | AMEX |\n| `100.` | 指数（HSI / SPX / DJIA / 各板块指数） |\n\n判断 symbol 是否已经是 secid 时，只在数字前缀属于上表时才认，否则视作普通 code（防止 `00700.HK` 这种 `<digits>.<alpha>` 误判）。\n\n### fs 市场/板块过滤码\n\n| 代码 | 含义 |\n|------|------|\n| `m:0+t:6,m:0+t:80,m:1+t:2,m:1+t:23,m:0+t:81+s:2048` | 沪深 A 股全集 |\n| `m:1+t:2,m:1+t:23` | 沪 A |\n| `m:0+t:6,m:0+t:80` | 深 A |\n| `m:0+t:81+s:2048` | 北证 A |\n| `m:0+t:80` | 创业板 |\n| `m:1+t:23` | 科创板 |\n| `m:116+t:3,m:116+t:4,m:116+t:1,m:116+t:2` | 港股 |\n| `m:105,m:106,m:107` | 美股 |\n| `b:MK0021` | ETF |\n| `b:MK0354` | 可转债 |\n| `m:90+t:2` | 行业板块 |\n| `m:90+t:3` | 概念板块 |\n| `m:90+t:1` | 地域板块 |\n\n### datacenter-web 报表 `reportName`\n\n| 名称 | 内容 |\n|------|------|\n| `RPT_DAILYBILLBOARD_DETAILS` | 龙虎榜 |\n| `RPT_F10_EH_FREEHOLDERS`     | 十大流通股东 |\n\n---\n\n## xueqiu（雪球）\n\n域名：`stock.xueqiu.com` / `xueqiu.com`\n\n行情 API 返回字段是人类可读，不需要词典：\n\n| 字段 | 含义 |\n|------|------|\n| `symbol` | SH600000 / 00700 / AAPL 样式 |\n| `name` | 名称 |\n| `current` | 最新价 |\n| `chg` | 涨跌额 |\n| `percent` | 涨跌幅 % |\n| `volume` | 成交量 |\n| `amount` | 成交额 |\n| `high52w / low52w` | 52 周高低 |\n| `market_capital` | 总市值 |\n| `pe_ttm` | 市盈率 TTM |\n| `pb` | 市净率 |\n\n鉴权：需要 `xq_a_token` cookie。走 `Strategy.COOKIE + browser: true`。\n\n---\n\n## bilibili\n\n域名：`api.bilibili.com` / `space.bilibili.com` / `passport.bilibili.com`\n\nB 站接口字段也大多人类可读。关键坑是 **wbi 签名**：\n\n- 凡 URL 含 `/wbi/` 的接口都需要 `w_rid + wts` 签名\n- 签名算法依赖每日轮换的 `img_key + sub_key`（从 `nav` 接口拿）\n- 平台 SDK 在 `clis/bilibili/utils.js`：`apiGet(page, path, { signed: true, params })` 自动签\n- 普通 cookie JSON 接口优先用 `page.fetchJson(url)`\n\n| 字段 | 含义 |\n|------|------|\n| `mid` | 用户 UID |\n| `aid / bvid` | 视频 ID（av 号 / bv 号） |\n| `cid` | 视频分 P ID |\n| `uname / upname` | up 主昵称 |\n| `view / danmaku / reply / favorite / coin / share / like` | 各类计数 |\n| `pubdate / ctime` | 发布/创建时间（秒级 unix） |\n\n---\n\n## tonghuashun（同花顺）\n\n域名：`q.10jqka.com.cn` / `d.10jqka.com.cn` / `data.10jqka.com.cn`\n\n同花顺多数接口返回 HTML 表格，需要 DOM 解析。JSONP 接口要设 `Referer: http://q.10jqka.com.cn/`，否则返回空。\n\n| 字段（API） | 含义 |\n|------|------|\n| `openPrice / closePrice` | 开/收盘 |\n| `zdf` | 涨跌幅 % |\n| `hsl` | 换手率 |\n| `zf`  | 振幅 |\n\n---\n\n## 字段不在词典时\n\n看 `field-decode-playbook.md`。里面是标准 SOP（排序键对比、结构差分、精度排查），10 分钟能推一条。\n\n推完一个代号补回本文件，下次直接查。\n","skills/opencli-adapter-author/references/field-decode-playbook.md":"# Field Decode Playbook\n\n响应里出现 `f237 / zdf / oc5 / x4` 这种看不懂的代号时走这套流程。目标：10 分钟内搞定一条未知字段，不靠猜。\n\n适用于 `field-conventions.md` 没收录的站点、没收录的代号、或者收录了但怀疑对不上的情况。\n\n---\n\n## 决策树\n\n```\n拿到一条响应，有字段看不懂？\n├── 字段值是字符串 / 时间 / URL → 走 §1 「肉眼对网页」\n├── 字段值是数字 → 走 §2 「排序键对比法」\n├── 字段值是数组 / 对象 → 走 §3 「结构差分法」\n└── 改参数响应不动 → 走 §4 「常量 / 精度位排查」\n```\n\n---\n\n## §1 肉眼对网页（字符串/时间/URL 类）\n\n最快。打开对应网页，响应的这条记录在哪行，把响应值跟页面上的文字一一对照。\n\n```bash\n# 举例：eastmoney convertible，响应里有 f243=20231215，页面上这支债写着\"上市日期 2023-12-15\"\n# 直接判断 f243 是上市日期（YYYYMMDD int）\n```\n\n**时间字段的判别**：\n\n| 观察到的形态 | 大概率是 |\n|-------------|---------|\n| 10 位整数 `1712345678` | unix 秒 |\n| 13 位整数 `1712345678000` | unix 毫秒 |\n| 8 位整数 `20240101` | YYYYMMDD int |\n| 6 位整数 `240101` | YYMMDD int |\n| 字符串 `2024-01-01` / `2024/01/01` | ISO 日期 |\n| 字符串 `01/01/2024` | 美式日期，小心月日顺序 |\n\n**URL 字段的判别**：\n\n```bash\n# 如果是相对路径，拼 domain 回浏览器验证\nopencli browser --session research eval \"window.location.origin + '/<path>'\"\n```\n\n---\n\n## §2 排序键对比法（数字类核心手段）\n\n数字字段是最容易踩坑的一类——看起来都是小数，但可能是涨跌幅、换手率、振幅、溢价率、市盈率……单位和量级也可能带陷阱。\n\n### 流程\n\n1. **找到一个能改变排序的参数**\n\n   | 站点 | 排序参数 | 值域 |\n   |------|---------|------|\n   | eastmoney (push2 clist) | `fid` + `po` | `fid` 是字段代号，`po` 是 `0=asc / 1=desc` |\n   | xueqiu | `order_by` | `percent / volume / amount / ...` |\n   | bilibili | `order` | `pubdate / click / stow / ...` |\n   | tonghuashun | `sort` | 数字代号 |\n   | 通用 | 页面点击表头切排序，抓包看新参数 | — |\n\n2. **用两个已知含义的值各抓一份响应**\n\n   比如 eastmoney，`fid=f2`（最新价）和 `fid=f3`（涨跌幅）：\n\n   ```bash\n   opencli browser --session research eval \"fetch('<url>&fid=f2&po=1').then(r=>r.json()).then(d=>d.data.diff.slice(0,3))\"\n   opencli browser --session research eval \"fetch('<url>&fid=f3&po=1').then(r=>r.json()).then(d=>d.data.diff.slice(0,3))\"\n   ```\n\n3. **对比两组数据**：\n\n   - 第一条记录的 `symbol/name` 变了吗？没变说明这个参数无效或者白名单窄\n   - 新的第一条里，哪个字段数量级 / 正负号 / 小数位数变化最大？那个字段就是和 `fid` 对应的业务语义\n   - 单调性：按 `desc` 取前 3 条，看新旧两组里目标字段是不是都单调下降\n\n4. **用第三个参数交叉验证**\n\n   ```bash\n   opencli browser --session research eval \"fetch('<url>&fid=f6&po=1').then(r=>r.json()).then(d=>d.data.diff.slice(0,3))\"  # 成交额\n   ```\n\n   对照网页上\"成交额排行\"的前三名。对得上就认。\n\n### 实例：推 f237（可转债溢价率）\n\n```bash\n# 按价格排，拿一条观察\nopencli browser --session research 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])\"\n# 返回 {f12:'123456', f14:'XX转债', f2:180.5, f3:2.1, f236:98.5, f237:83.2, f239:-4.1}\n\n# 按 f237 排\nopencli browser --session research eval \"fetch('...&fid=f237&po=1...').then(...)\"\n# 第一条变了，f237 值变成 400+\n\n# 打开 eastmoney 可转债页，切\"溢价率\"排序，第一条的溢价率确实是 400+ —— f237 就是溢价率 %\n```\n\n---\n\n## §3 结构差分法（数组 / 嵌套对象类）\n\n响应顶层是 `{data: {diff: [...]}}` 还是 `{list: [{...}, {...}]}` 还是 `{rows: [{k:v}, ...]}`，不同接口差别大。\n\n### 流程\n\n1. **先数一次嵌套路径**\n\n   ```bash\n   opencli browser --session research eval \"fetch('<url>').then(r=>r.json()).then(j=>({keys:Object.keys(j), type:Array.isArray(j)?'array':'object'}))\"\n   ```\n\n2. **一层一层剥**\n\n   ```bash\n   opencli browser --session research eval \"fetch('<url>').then(r=>r.json()).then(j=>{const d=j.data; return {keys:Object.keys(d), sample: d[Object.keys(d)[0]]}})\"\n   ```\n\n3. **数数组长度对照 pz / pageSize**\n\n   如果请求 `pz=20` 拿回的数组正好 20，那就是结果数组；如果是 1 那可能是 pagination meta。\n\n4. **同一字段在不同条目间是否变化**\n\n   ```bash\n   # 取前三条的 keys，看哪些 key 的值在变（业务数据），哪些不变（常量/配置）\n   opencli browser --session research 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})))\"\n   ```\n\n---\n\n## §4 常量 / 精度位排查\n\n有的字段每条都一样，是精度指示器或类型标记。\n\n| 典型现象 | 通常含义 | 处理 |\n|---------|---------|------|\n| 每条值都是 `2` 或 `3` | 精度位数（小数几位） | 除以 `10^n` 还原浮点 |\n| 每条值都是 `0` 或 `1` | 涨跌方向 / 停牌标记 | 枚举，查同类 adapter |\n| 每条值都是 `6` / `8` / `80` | 市场代号 / 分类 ID | 查 `field-conventions.md` 市场表 |\n| 每条值都是空字符串 | 接口不返回但字段有占位 | 请求里去掉 / 用别的字段 |\n\n**eastmoney 精度坑**：不传 `fltt=2` 时，价格字段是 `int * 10^f152`。所以一律加 `fltt=2`，避免所有除法问题。\n\n---\n\n## §5 写完要做的事\n\n推出一个新代号后：\n\n1. **补进 `references/field-conventions.md`**：找到对应站点的表格加一行。下次直接查。\n2. **在 adapter 代码里留一条注释**：如果是实测推出来的不常见代号，写一行 `// f237 = convertible premium rate (verified 2026-04-20 against page)` 方便复核。\n3. **通过 `opencli adapter test` 验一次**：字段值能对上网页上眼见的数字。\n\n---\n\n## §6 通用坑\n\n| 坑 | 症状 | 解 |\n|----|------|-----|\n| 单位\"万\"当\"元\"用 | 成交额少 4 个零 | eastmoney `f6` 是元、`f62 / f64` 等净流入是万，核对每条接口 |\n| 百分比没 × 100 | 涨跌幅显示 0.0XX | 响应已经乘过 100 或者站点用小数 —— 看一眼网页 |\n| 股票代码被截 | `600000` 变 `60000` | 某些接口返回 int，前导 0 掉了。永远用 `String(code).padStart(6, '0')` 处理 A 股代码 |\n| 时间区无前导 0 | `2024-1-1` 排序坏 | parse 后用 ISO 回写 |\n| JSON 里数字是字符串 | 无法 `.toFixed()` | `Number(x)` 显式转 |\n\n---\n\n## §7 真推不出来\n\n三条兜底：\n\n1. **找同站点已有的 adapter** — `ls clis/<site>/` 翻文件，别站点邻居的 adapter 通常共享字段命名\n2. **找开源实现** — GitHub 搜 `<域名> fields` 或 `<域名> api`，往往有 Python / Go 库已经解码过\n3. **灰度**：把这条字段先输出成 raw，命令仍可用，下一版再语义化\n\n不要猜。猜错了后面验证不到、线上跑出来用户看到乱码也会误导决策。\n","skills/opencli-adapter-author/references/jsdom-fixture-pattern.md":"# JSDOM-against-frozen-fixture pattern (for in-browser DOM extractors)\n\n## When this pattern applies\n\nYou're writing an adapter where the data extraction happens **inside the\nlive browser** via `page.evaluate(...)` — not in Node-side post-processing.\nTypical signal: the adapter has a function literal stringified into\n`page.evaluate('(' + fn.toString() + ')()')`, walking `document.querySelector`\nand other DOM APIs.\n\nThese extractors are invisible to mocked `page.evaluate` unit tests — those\ntests feed pre-baked results to the func, so the real DOM walk never runs.\nPR #1312 found two such silent in-browser bugs in dianping that only\nsurfaced on live verify:\n\n1. shop title fallback split on ASCII `[]` while the page renders\n   full-width `【】`, so `name` was always empty.\n2. `headText.replace(/\\s+/g, ' ')` collapsed rating \"4.8\" with reviews\n   \"21241条\", and a head-wide `/\\d+条/` regex captured `4.821241` → 5.\n\nIf either category looks plausible for your site, freeze a representative\nHTML snapshot and replay it through JSDOM in a unit test.\n\n## File layout\n\n- Test file: `clis/<site>/<site>.test.js` (alongside the adapter file)\n- Fixture file: `clis/<site>/__fixtures__/<command>.html`\n\nReference implementation: `clis/dianping/__fixtures__/{shop,search}.html`\n(see PR #1313 for the original test + PR #1318 for the whitespace-strip\nfollow-up that this doc grew out of).\n\n## Creating the HTML fixture\n\nThe whole point is to commit a **representative snapshot of the live\npage's DOM** — so the JSDOM unit test exercises the real selector paths\nthe live extractor walks.\n\n### Mandatory steps (in order)\n\n1. **Capture** the page's HTML from a live verify run:\n   ```bash\n   opencli browser --session research open https://www.example.com/<page>\n   # In another shell, dump page.content():\n   opencli browser --session research eval 'document.documentElement.outerHTML' \\\n     > /tmp/raw-<command>.html\n   ```\n\n2. **Strip noise blocks** that JSDOM doesn't need and that change every\n   page load (so committed fixtures wouldn't survive a re-capture diff\n   anyway):\n   - All `<script>...</script>` content\n   - All `<style>...</style>` content\n   - All `<iframe>...</iframe>` content\n   - All `<!-- ... -->` HTML comments\n   - All `<link rel=\"preload\" ...>` / tracking pixels\n\n3. **Replace `<img src=\"...\">`** with a placeholder\n   (`src=\"placeholder.png\"`) — real CDN URLs leak account-scoped tokens\n   and are noise.\n\n4. **Trim to the minimum subtree** that exercises the extractor and\n   triggers the bug you're guarding against. For dianping shop, that\n   was `.shop-head` + `.desc-info` + `.review-title`; for search,\n   3 of 15 result `<li>` cards (rank 1, 2, 3).\n\n5. **MANDATORY whitespace normalization step** — strip all\n   whitespace-only lines:\n   ```bash\n   awk 'NF>0' /tmp/raw-<command>.html > clis/<site>/__fixtures__/<command>.html\n   ```\n   JSDOM's HTML parser is whitespace-tolerant; blank lines have zero\n   semantic effect on the test, but they bloat the committed diff and\n   obscure the meaningful DOM subtree from reviewers.\n   **Skipping this step is the most common silent quality regression\n   in fixture creation.** PR #1318 cleaned 239 leftover blank lines\n   from dianping's two fixtures (84.6% / 54.8% of file content) and\n   the JSDOM tests still passed unchanged.\n\n6. **Commit** at `clis/<site>/__fixtures__/<command>.html`.\n\n### Anti-patterns to avoid\n\n- ❌ \"Strip script/style content\" but leave the surrounding newlines.\n  Removing inline script body without collapsing the now-blank line is\n  the source of the noise — Step 5 exists specifically to clean this up.\n- ❌ Trim to minimum subtree, skip Step 5. The fixture works for the\n  test but reviewers see hundreds of blank lines.\n- ❌ Pretty-print the **mega-line** (e.g. `<div>...</div>` collapsed\n  onto one giant line by the source page). Some bugs depend on\n  text-node adjacency without intervening whitespace (e.g. `4.8` and\n  `21241条` immediately adjacent → `headText` fusion bug). Pretty-print\n  inserts whitespace that masks the very condition you're testing for.\n  Step 5 only deletes empty lines — never re-flow content.\n- ❌ Re-capture from live and overwrite the committed fixture without\n  re-running Steps 2-5. The fixture is a **frozen** snapshot; if the\n  page layout changes, that's a separate decision (update test\n  expectations + re-trim + re-strip).\n\n## Writing the JSDOM unit test\n\n```js\nimport { describe, it, expect, beforeEach, afterEach } from 'vitest';\nimport { JSDOM } from 'jsdom';\nimport { readFileSync } from 'node:fs';\nimport { join, dirname } from 'node:path';\nimport { fileURLToPath } from 'node:url';\nimport { extractShopFields } from './shop.js';\n\nconst __dirname = dirname(fileURLToPath(import.meta.url));\nconst SHOP_FIXTURE = readFileSync(join(__dirname, '__fixtures__/shop.html'), 'utf8');\n\ndescribe('shop adapter — extractor against frozen HTML fixture', () => {\n    let originalDocument;\n    let originalLocation;\n\n    beforeEach(() => {\n        originalDocument = globalThis.document;\n        originalLocation = globalThis.location;\n    });\n\n    afterEach(() => {\n        globalThis.document = originalDocument;\n        globalThis.location = originalLocation;\n    });\n\n    function loadFixture(html, url) {\n        const dom = new JSDOM(html, { url });\n        globalThis.document = dom.window.document;\n        globalThis.location = dom.window.location;\n        return dom;\n    }\n\n    it('extracts the canonical fields and avoids known silent bugs', () => {\n        loadFixture(SHOP_FIXTURE, 'https://www.example.com/shop/123');\n\n        const data = extractShopFields();\n\n        expect(data.ok).toBe(true);\n        expect(data.name).toBe('...');\n        // Add explicit regression guards for each known silent bug.\n        expect(data.reviewsRaw).toBe('...');  // not the fused \"<rating><reviews>\" form\n    });\n});\n```\n\nFor this to work, the adapter's extractor must be a **top-level\nfunction** that uses bare `document` / `location` (not `window.document`),\nso the same code is exercised by:\n\n- live browser: injected via `${extractFn.toString()}` into\n  `page.evaluate`\n- JSDOM unit test: with `globalThis.document` swapped\n\nIf your adapter currently has the extractor as an IIFE inside a\ntemplate literal, refactor to a top-level `export function` first.\nReference: `clis/dianping/{shop,search}.js` extracts `extractShopFields()`\nand `extractSearchRows()` with bare `document`/`location`.\n\n## Reverse-validation (mandatory before claiming the test catches the bug)\n\nA test that \"passes 18/18\" doesn't prove it would have caught the original\nbug — only that it agrees with the current implementation. Before\ntrusting a regression guard:\n\n1. Make a backup of the adapter source.\n2. Reintroduce the buggy variant of the relevant extractor.\n3. Run the test. It MUST fail with an assertion that points at the\n   silent bug.\n4. Restore from backup.\n\nFor dianping bug #2 (rating/reviews fusion):\n```js\n// BUGGY VARIANT — replaces the .reviews selector path\nconst buggyMatch = headText.match(/(\\d+)条/);\nlet reviewsRaw = buggyMatch ? buggyMatch[0] : '';\n```\n\nIf after Step 5 of fixture creation the test still fails on this buggy\nvariant with `expected '21241条' to be '21241条'` actually receiving\n`'821241条'` (the fused digits), the regression guard is intact. If the\ntest still passes with the buggy variant, the fixture is too stripped /\nnormalization went too far / the assertion is too loose — go back and\ntighten.\n\nThis is the same discipline as `--write-fixture` Step 10 in the main\nrunbook (verify fixture catches what it should), applied to the JSDOM\nHTML fixture instead of the response JSON fixture.\n\n## See also\n\n- `references/adapter-template.md` — basic adapter file structure\n- `references/output-design.md` — column naming for the post-extract mapping\n- `references/success-rate-pitfalls.md` — broader \"verify can pass while\n  data is silently wrong\" catalog; the mocked-page.evaluate gap that\n  motivates this whole pattern is one entry there\n","skills/opencli-adapter-author/references/output-design.md":"# Output Design\n\nadapter 的 `columns` 不是随便列。要让下游（用户、其他 adapter 合并、agent 后续分析）都能直接读。\n\n---\n\n## 核心约定\n\n### 1. 命名\n\n**camelCase，全英文**：\n\n| 好 | 差 |\n|----|---|\n| `marketCap` | `market_cap` / `市值` / `MarketCap` |\n| `change24hPct` | `change_percentage_24h` / `涨跌幅24h` / `changePct_24h` |\n| `bondCode` | `bond_code` / `BOND_CODE` |\n| `pubTime` | `publish_time` / `pubdate` |\n\n原则：\n- 缩写：`pct`（百分比）/ `pe`（市盈率）/ `pb`（市净率）/ `ytm`（到期收益率）/ `id`\n- 时间后缀：`Time`（具体时刻）/ `Date`（日期）/ `Ts`（unix 秒）\n- 百分比一律 `Pct` 结尾，数值是\"已乘 100\"形式（`2.5` 表示 2.5%，不是 `0.025`）\n- 数量后缀：`Count`（整数计数）/ `Total`（累计值）\n\n### 2. 类型\n\n| 字段类 | JS 类型 | 格式 |\n|--------|---------|------|\n| 价格 / 金额 | `number` | 原始小数，别除 1000 别取整 |\n| 百分比 | `number` | 已 × 100（`2.5` = 2.5%） |\n| 计数 / rank | `number` | 正整数 |\n| 代码 / id / symbol | `string` | 股票代码 `'600000'` 保留前导 0 |\n| 名称 / 标题 | `string` | 去首尾空白 |\n| 时间 | `string` ISO（`'2024-01-15T10:30:00Z'`）或 `number` unix 秒 | 不要本地字符串 `'2024/1/15'` |\n| 布尔 | `boolean` | 不用 `0/1` |\n| URL | `string` | 绝对路径，相对路径要拼 host |\n\n**特殊**：\n- 缺失用 `null`，不用 `0` / `''`（0 和空字符串有业务含义，别混）\n- 枚举 → string，不要 int 代号（`'listed'` 比 `0` 清楚）\n\n### 3. 顺序\n\n固定三段：\n\n```\n[识别列 ...] [业务数字 ...] [metadata ...]\n```\n\n**识别列**（前 1-3 列）：`rank / symbol / code / bondCode / name / title / id`\n\n**业务数字**（中间）：价格、涨跌幅、成交量、市值等业务语义\n\n**metadata**（最后 1-3 列）：`pubTime / updateTime / source / url`\n\n### 4. 必有列（按类型）\n\n| adapter 类型 | 必须包含 |\n|-------------|---------|\n| 排行 / 列表 | `rank` + 识别列 + 业务数字 |\n| 时序 / K 线 | `date`（或 `ts`）+ 数值 |\n| 详情（单对象） | 识别列 + 业务字段 |\n| 新闻 / 公告 | `title` + `pubTime` + `url` |\n\n### 5. 控量\n\n**单条 ≤ 15 列**。超了就得考虑：\n- 拆成多个 adapter（列表版 + 详情版）\n- 次要字段合进 `extras: {...}` 对象\n- 只有少数用户关心的字段默认隐藏（靠参数开关）\n\n---\n\n## 对齐邻居 adapter\n\n写新 adapter 前先看同站点现有 adapter 怎么命名：\n\n```bash\ngrep -h \"columns:\" clis/<site>/*.js\n```\n\n复用同类列名。比如 `clis/eastmoney/convertible.js` 用 `bondCode / bondName / stockCode / stockName`，新写 eastmoney 某个涉及股票代码的 adapter 就沿用 `stockCode / stockName`，不要发明 `securityId / securityName`。\n\n---\n\n## 常见错误\n\n| 错 | 对 |\n|----|---|\n| `columns: ['id', 'name', 'data.price']`（点路径） | 把 `data.price` 在 func 里打平成 `price` |\n| `{date: '2024-01-15 10:30'}`（空格 + 非 ISO） | `'2024-01-15T10:30:00Z'` 或 `Date.toISOString()` |\n| `{pct: '2.5%'}`（字符串 + 单位） | `{changePct: 2.5}` 纯数字 |\n| `{volume: '1.2万'}` | `{volume: 12000}` |\n| `{code: 600000}`（整数丢前导 0） | `{code: '600000'}` string，或 `String(code).padStart(6, '0')` |\n| columns 和 func 返回的 keys 对不上 | 列出的每个 key 必须在返回对象里，顺序也一致 |\n\n---\n\n## description 字段\n\nadapter `description` 是用户第一眼看到的，写清楚：\n\n1. **数据是什么**：`\"A 股涨幅排行\"` vs `\"大盘指数分时\"`\n2. **默认行为**：`\"默认按涨幅排序，前 20 条\"`\n3. **重要参数**：`\"支持 market 参数切换沪深/北证\"`\n\n不要：\n- `\"get data\"` — 废话\n- `\"查询 xxx 数据\"` — 也是废话\n- 塞完整 URL / 字段代号列表 — 留给 help\n\n一行 30 字左右够了。\n\n---\n\n## args 命名\n\n- `limit` 而不是 `count / num / n / size`\n- `sort` 而不是 `sort_by / order_by / sortKey`\n- `market` 而不是 `exchange / platform / type`\n- `symbol` / `code` / `query` 根据业务选，保持和邻居 adapter 一致\n- 布尔参数用 `enableX / includeX`，默认 false 免得用户改变认知负担\n\n`help` 文案给**所有合法值**，别让用户猜：\n\n```javascript\n{ name: 'sort', type: 'string', default: 'turnover', help: '排序：turnover / change / drop / price / premium' }\n```\n\n---\n\n## 示例对比\n\n**差的**：\n\n```javascript\ncolumns: ['股票代码', 'name', 'PRICE', 'change%', 'vol', 'time']\n```\n\n问题：中英混、大小写乱、百分号字符串、缩写不统一。\n\n**好的**：\n\n```javascript\ncolumns: ['rank', 'stockCode', 'stockName', 'price', 'changePct', 'volume', 'updateTime']\n```\n\n识别列在前，metadata 在后，命名统一 camelCase。\n","skills/opencli-adapter-author/references/site-memory.md":"# Site Memory\n\n站点记忆分两层：**in-repo 种子**（skill 自带的已知站点公共知识）+ **本地工作目录**（每台机器跑过的站点累积产物）。\n\n---\n\n## 两层结构\n\n```\nskills/opencli-adapter-author/references/site-memory/<site>.md\n    — 公共种子。手写 + PR 审核进入。多 agent 共享的第一批起点。\n    — 已铺：eastmoney / xueqiu / bilibili / tonghuashun / gmail\n\n~/.opencli/sites/<site>/\n    — 本地累积。agent 跑 adapter 过程里自动写入，跨 session 复用。\n    — 不进 git，不进 PR。\n```\n\n用法：开头先读本地，命中 **不跳写 adapter**，仍要跑 Step 5 endpoint 验证 + Step 7 字段抽查（memory 可能过期或站点换版）；没命中读 in-repo；都没有走完整 recon。\n\n---\n\n## Layer 1 — In-repo 种子（`references/site-memory/<site>.md`）\n\n每个覆盖站点一个 `.md`，结构固定：\n\n```markdown\n# <site>\n\n## 域名\n主 API / 备 API / 登录 / 静态资源\n\n## 默认鉴权\n`Strategy.XXX` + 必需 cookie/header + 获取方式\n\n## 已知 endpoint（选最常用的 5-10 条）\n- `GET <url>` — 返回 X，分页参数 Y\n- ...\n\n## 字段（指向 `field-conventions.md` 的某一节）\n\n## 坑 / 陷阱\n- fltt=2 必传\n- 单位是\"万\"不是\"元\"\n- ...\n\n## 可参考的 adapter\n`clis/<site>/<name>.js` × N\n```\n\n审核门槛高，里面写的东西必须是\"多数人都会踩到\"的共识。一次性试错、站点局部怪癖放 Layer 2。\n\n---\n\n## Layer 2 — 本地工作目录（`~/.opencli/sites/<site>/`）\n\nagent 每跑一次相关 adapter 就可以自动写/读：\n\n```\n~/.opencli/sites/<site>/\n  notes.md               — 累积笔记（时间戳 + 写入人 + 发现）\n  endpoints.json         — 已验证的 endpoint 目录\n  field-map.json         — 字段代号 → 含义（key 为字段代号，value 为 {meaning, verified_at, source}）\n  verify/                — `opencli adapter test` 期望值（值级校验锚点，每个 adapter 一份）\n    <cmd>.json\n  fixtures/              — 公开/合成/已完成脱敏的响应样本（给字段对比 / 离线 replay；高敏私人响应不落盘）\n    <cmd>-<ts>.json\n  last-probe.log         — 最近一次侦察输出（下次接着用）\n```\n\n`verify/` vs `fixtures/` 别混：\n- `verify/<cmd>.json` 是**结构期望**（rowCount / columns / types / patterns / notEmpty），每 adapter 一份、会被 verify 读。\n- `fixtures/<cmd>-<ts>.json` 是**可安全持久化的响应样本**，给人 / 下一个 agent 做字段比对用，verify 不会读；高敏私人响应只用合成 fixture。\n\n### `endpoints.json` 格式（schema 锁死）\n\nkey = endpoint 的短名（`clist` / `kline` / `search` 等），不要用全 URL 当 key。\n\n```json\n{\n  \"clist\": {\n    \"url\": \"https://push2.eastmoney.com/api/qt/clist/get\",\n    \"method\": \"GET\",\n    \"params\": {\n      \"required\": [\"fs\", \"fields\"],\n      \"optional\": [\"pn\", \"pz\", \"fid\", \"po\", \"fltt\"]\n    },\n    \"response\": \"data.diff[] 数组\",\n    \"verified_at\": \"2026-04-20\",\n    \"notes\": \"fltt=2 必传\"\n  }\n}\n```\n\n字段说明：\n\n- `url` / `method`：原样存，query string 不入 `url`，都归 `params`\n- `params.required` / `params.optional`：参数名列表。**不存具体值**（值会变，记例子放 `notes`）\n- `response`：一句话写清响应形状入口（`data.diff[] 数组` / `result.data.items` / `纯数组`），而不是把整个响应贴进来\n- `verified_at`：`YYYY-MM-DD`。超过 30 天下次读到当作过期重验\n- `notes`：一两句关键坑（`fltt=2 必传` / `ms 单位 begin` 之类），不要写长文\n\n### `field-map.json` 格式（schema 锁死）\n\nkey = 字段代号（`f237` / `f152`），value 三件套：\n\n```json\n{\n  \"f237\": {\n    \"meaning\": \"convertible premium rate (%)\",\n    \"verified_at\": \"2026-04-20\",\n    \"source\": \"field-decode-playbook sort-key comparison vs page\"\n  }\n}\n```\n\n- `meaning`：人话 + 单位/精度（`%` / `元` / `万元` / `× 10^f152` 等）\n- `verified_at`：`YYYY-MM-DD`\n- `source`：怎么推出来的，让下次能复查（`field-decode-playbook sort-key` / `网页标签对照` / `bundle 搜索 var pricePct =`）\n- **已存在的 key 不要默默覆盖**。有冲突时先用 `fixtures/` 里的真实样本 + 网页肉眼值再确认一遍\n\n### `verify/<cmd>.json` 格式（schema 锁死）\n\n每个 adapter 一份，`opencli adapter test <site>/<cmd>` 会自动读。**没有这份 = verify 只能证\"能跑\"，证不出数据对**——可按任务需要维护 fixture。\n\n```json\n{\n  \"args\": { \"limit\": 3 },\n  \"expect\": {\n    \"rowCount\": { \"min\": 1, \"max\": 3 },\n    \"columns\": [\"rank\", \"tid\", \"title\", \"url\"],\n    \"types\": {\n      \"rank\": \"number\",\n      \"tid\": \"string|number\",\n      \"title\": \"string\",\n      \"url\": \"string\"\n    },\n    \"patterns\": {\n      \"url\": \"^https://www\\\\.1point3acres\\\\.com/bbs/thread-\"\n    },\n    \"notEmpty\": [\"title\", \"url\"]\n  }\n}\n```\n\n字段说明：\n\n- `args`：verify 调 adapter 时要带的参数。支持两种形态：\n  - **对象**：`{ \"limit\": 3 }` → 展开成 `--limit 3`，用于标准 named flag 适配器\n  - **数组**：`[\"123\", \"--limit\", \"3\"]` → **原样**追加到命令后，用于 positional 主语型适配器（`<tid>` / `<url>` / `<query>`）。repo 约定\"主语优先 positional\"，所以这类适配器只能用数组形态\n- `expect.rowCount.{min,max}`：包含边界。稳定列表接口收紧到 `[min, max]`，动态接口给一个宽区间\n- `expect.columns`：每行必须都有这些 key（严格要求——漏了就 fail）\n- `expect.types`：支持 `|` union（`string|null`）和 `any` 通配。写多少列类型看列的稳定性；波动大的列直接 `any` 比频繁改 fixture 好\n- `expect.patterns`：正则表达式 **字符串**（注意 `\\\\` 转义）。`null` / `undefined` 会被跳过，不要用正则校验可空字段\n- `expect.notEmpty`：trim 后不能为空的列。这是\"adapter 没吃掉核心业务字段\"的最后一道保险\n- `expect.mustNotContain`：`Record<col, string[]>`。列值里不允许出现这些子串。用来挡\"字段内容污染\"——比如 `description` 里混进了 `address:` / `category:` 的邻居节点文字、`title` 前面粘了面包屑前缀。`notEmpty` 挡不住这种软污染\n- `expect.mustBeTruthy`：列数组。列值必须是 JS truthy。用来挡\"silent `|| 0` / `|| false` 兜底\"——数值列返回 0 / 空字符串 / false 都会被 `notEmpty` 放过，但业务上通常是\"没抓到\"\n\n### 什么时候手写 vs `--write-fixture` 自动生成\n\n- `--write-fixture` 只是种子：生成 `rowCount.min=1` / `columns` / `types`，**没有** `patterns` / `notEmpty` / `mustNotContain` / `mustBeTruthy`——纯类型 fixture 挡不住数值错位 / 字段污染 / silent fallback。\n- 拿到种子后**必须手改**，四件套一起上：\n  - `patterns`：URL / 日期 / ID 等格式列\n  - `notEmpty`：核心业务字段\n  - `mustNotContain`：描述类文本列容易被兄弟节点污染时，把禁词（`address:` / `category:` 等）列出来\n  - `mustBeTruthy`：数值 / 布尔业务列，挡 `|| 0` / `|| false`\n- adapter 是 positional 主语型（`<tid>` / `<url>` / `<query>`）时，`--write-fixture` 的 `args` 要手写成数组形态。工具不会替你决定形态。\n- 站点换版导致 fixture 过时：`--update-fixture` 覆盖。改之前**先用肉眼核对一次网页值**，别闭着眼把错的响应固化下来。\n- **规避反模式：不要为了让 verify 通过去放松 pattern**。失败的 pattern 说明 adapter 输出有问题，要收紧 adapter，不是收紧 fixture。放松 fixture 等于默认把错数据接受下来。\n\n### `notes.md` 格式\n\n```markdown\n## 2026-04-20 by opencli-user\n写 `convertible.js` 时遇到：\n- f237 推断是溢价率（排序对比法，页面对照）\n- `fltt=2` 不加的话价格是整数 × 10^f152\n- `fs=b:MK0354` 过滤可转债\n```\n\n顶部追加新段落，老的不删。每段有日期 + 写入人。\n\n### `fixtures/<cmd>-<YYYYMMDDHHMM>.json` 格式\n\n一份该 endpoint 的**可安全持久化**响应样本。优先级：合成 fixture > 公开响应 > 可证明完成脱敏的真实响应。用途：\n\n- 未来字段代号再变时，拿样本和 `field-map.json` 做 regression 对比\n- 站点换版时，新响应和旧 fixture 做 diff 看哪个字段结构变了\n\n**存之前做数据分级**：\n\n- 公开列表/行情等无账户私有内容：可保存，仍需移除 cookie/token/header。\n- 低敏账号数据：只有完成字段级脱敏且不会从正文、snippet、URL、附件名反推个人时才保存。\n- 邮箱、私信、通讯录、支付、健康、草稿、上传文件等高敏内容：不要保存真实 response，即使“删了邮箱地址”也不够；用保持 shape/arity 的合成 fixture。\n\n原始 capture 只在 `/tmp/` 或受控本地 cache 中短暂存在，任务结束删除；不要把 raw private response 当成“完整 fixture”长期积累。\n\n---\n\n## runbook 里的读/写时机\n\n```\nStep 2 开始前 → 读  ~/.opencli/sites/<site>/\n                → 读  references/site-memory/<site>.md\n                命中后 → 不跳写 adapter，仍要跑 Step 5 (endpoint 验证) + Step 7 (字段抽查)\n                        verified_at 超 30 天 → 当作过期，按冷启动走 Step 3 → 4\n\nStep 10 verify 首轮通过后 → 写 ~/.opencli/sites/<site>/verify/<cmd>.json\n                            - 先 `--write-fixture` 拿种子，再手改 patterns / notEmpty / rowCount\n                            - 没这份后续 verify 挡不住数据错位，**必填**\n\nStep 11 肉眼对比通过后 → 写 ~/.opencli/sites/<site>/\n                        - endpoints.json：按 schema 追加或更新 verified_at\n                        - field-map.json：只追加新 key，已有的不默默覆盖\n                        - notes.md：顶部追加一段\n                        - fixtures/：按数据分级保存公开/合成/已脱敏样本（区别于 verify/）\n```\n\n**回写是 commit，不是 stash**：不过 Step 10 verify + Step 11 肉眼对比不写，防止把错的映射喂给下一轮。\n\n---\n\n## 不要写进 `~/.opencli/sites/` 的东西\n\n- 真实账户 cookie / token — 不要保存任何鉴权凭据\n- 用户私有数据：高敏内容一律不存；低敏内容只有完成字段级脱敏才可存\n- 过期超过 30 天的 last-probe.log（自动清）\n\n## 不要写进 **repo / adapter 目录** 的东西\n\n调试过程里的临时 dump（`.dbg-*.html` / `raw-*.json` / `sample-*` / `trace-*.txt`）**只能**落在系统 `/tmp/`；只有通过上面数据分级、准备长期保留的安全样本才进入 `~/.opencli/sites/<site>/fixtures/`。PR diff 会把 repo 根目录和 `clis/<site>/` 下的文件一起带走；任务结束还要删除原始 capture/cache。\n\n---\n\n## 没有 site-memory 时\n\n新站点没对应 `.md`，也没本地目录 → 完整走 recon + discovery，跑完直接写 `~/.opencli/sites/<site>/`，后面就有了。\n","skills/opencli-adapter-author/references/site-memory/bilibili.md":"# bilibili（B 站）\n\n## 域名\n\n| 用途 | 域名 |\n|------|------|\n| 主 API | `api.bilibili.com` |\n| 个人主页 / 空间 | `space.bilibili.com` |\n| 登录 / 鉴权 | `passport.bilibili.com` |\n| 动态 | `t.bilibili.com` / `api.vc.bilibili.com` |\n| 直播 | `api.live.bilibili.com` |\n\n## 默认鉴权\n\n- `Strategy.COOKIE + browser: true`\n- 核心 cookie：`SESSDATA`（登录态）、`bili_jct`（CSRF token，部分写接口要带到 header `Referer + csrf`）\n- **未登录也能调多数读接口**，但有 wbi 签名要求\n\n## 关键：wbi 签名\n\n- 凡 URL 含 `/wbi/` 的接口都要 `w_rid + wts` 签名\n- 签名算法依赖每日轮换的 `img_key / sub_key`，从 `api.bilibili.com/x/web-interface/nav` 的 `wbi_img` 字段拿\n- **不要自己重新实现**：`clis/bilibili/utils.js` 里的 `apiGet(page, path, { signed: true, params })` 已经封装好\n- 普通 cookie JSON 接口优先用 `page.fetchJson(url)`；站点级签名逻辑仍复用 `utils.js`\n\n## 已知 endpoint\n\n- `GET api.bilibili.com/x/web-interface/nav` — 登录态 + 拿 wbi key\n- `GET api.bilibili.com/x/space/wbi/arc/search?mid=<uid>` — 用户视频（需 wbi 签）\n- `GET api.bilibili.com/x/space/acc/info?mid=<uid>` — 用户资料\n- `GET api.bilibili.com/x/web-interface/view?bvid=BV...` — 视频详情\n- `GET api.bilibili.com/x/web-interface/popular?ps=20&pn=1` — 热门\n- `GET api.bilibili.com/x/web-interface/ranking/v2?rid=0` — 排行\n- `GET api.bilibili.com/x/v2/reply/wbi/main?type=1&oid=<aid>&mode=3` — 评论（需 wbi）\n- `GET api.bilibili.com/x/web-interface/search/all/v2?keyword=<q>` — 综合搜索\n- `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`）\n- `GET api.bilibili.com/x/v2/history/cursor` — 观看历史（需登录）\n- `GET api.bilibili.com/x/v3/fav/folder/created/list-all` — 收藏夹列表（需登录）\n\n## 字段\n\n字段基本人类可读，见 `../field-conventions.md` 的 bilibili 节（`mid / aid / bvid / cid / view / danmaku / reply / favorite / coin / share / like / pubdate / ctime`）。\n\n## 坑 / 陷阱\n\n1. **wbi 签名缓存 `img_key / sub_key`**：24 小时内有效，每次请求都重新 fetch `nav` 会被限频。utils.js 内部做了缓存\n2. **`mid` 和 `uid` 一回事**：接口不统一，`mid=` 居多，个别接口要 `host_mid=`\n3. **视频 ID 两种**：`aid`（老数字）和 `bvid`（BV1xxx），视频详情要用 `bvid`\n4. **`ps=` 最大 50**（popular/ranking），超过了多发几页拼\n5. **动态接口换版**：老的 `dynamic_svr` 已经废，新的走 `x/polymer/web-dynamic/v1/feed/space`，写新 adapter 直接用新接口\n6. **评论分页靠 `next` 游标**，不是页号\n7. **B 站限频 `-352 风控`**：短时间高频调会拦截，adapter 层加 500ms 间隔\n8. **搜索要先 \"cold start\"**：空 cookie 的 session 第一次搜会 412，先访问首页拿 `buvid3` cookie\n9. **直播接口在 `api.live.bilibili.com`**，不是主域名\n\n## 可参考的 adapter\n\n| 模板类型 | 参考文件 |\n|---------|---------|\n| 用户资料 / 视频列表 | `clis/bilibili/user-videos.js` / `me.js` |\n| 视频详情 / 字幕 | `clis/bilibili/download.js` / `subtitle.js` |\n| 评论 | `clis/bilibili/comments.js` |\n| 搜索 | `clis/bilibili/search.js` |\n| 热门 / 排行 | `clis/bilibili/hot.js` / `ranking.js` |\n| 动态 | `clis/bilibili/dynamic.js` |\n| 关注 | `clis/bilibili/following.js` |\n| 收藏 | `clis/bilibili/favorite.js` |\n| 观看历史 | `clis/bilibili/history.js` |\n\n通用工具：`clis/bilibili/utils.js`。新 adapter 先 `import { apiGet, fetchJson } from './utils.js'`，不要重写。\n","skills/opencli-adapter-author/references/site-memory/eastmoney.md":"# eastmoney（东方财富）\n\n## 域名\n\n| 用途 | 域名 |\n|------|------|\n| 行情列表 / 批量报价 | `push2.eastmoney.com` |\n| K 线历史 | `push2his.eastmoney.com` |\n| 报表类（龙虎榜 / 十大股东） | `datacenter-web.eastmoney.com` |\n| 7x24 快讯 | `np-listapi.eastmoney.com` |\n| 公司公告 | `np-anotice-stock.eastmoney.com` |\n| 静态页（网页端入口） | `quote.eastmoney.com` / `data.eastmoney.com` |\n\n## 默认鉴权\n\n- `Strategy.PUBLIC + browser: false`\n- 统一带 `ut=bd1d9ddb04089700cf9c27f6f7426281`（push2 系列的公共 token）\n- User-Agent 随意给个 `Mozilla/5.0` 就行，不加会偶发被拦\n\n## 已知 endpoint\n\n- `GET push2.eastmoney.com/api/qt/clist/get` — 列表 / 排行\n  - 必需：`fs`（市场/板块过滤码）、`fields`（字段清单）\n  - 可选：`pn`（页码，1-based）、`pz`（每页数量）、`fid`（排序字段）、`po`（0=asc / 1=desc）、`fltt`（2=浮点格式化）、`invt`（2=固定）、`np`（1=固定）\n  - 返回：`data.diff[]` 数组\n- `GET push2.eastmoney.com/api/qt/ulist.np/get` — 批量报价（给定 secids）\n  - 必需：`secids`（逗号拼接的 `<market>.<code>`）、`fields`\n  - 返回：`data.diff[]` 数组\n- `GET push2.eastmoney.com/api/qt/stock/get` — 单只详情\n  - 必需：`secid`、`fields`\n- `GET push2his.eastmoney.com/api/qt/stock/kline/get` — K 线历史\n  - 必需：`secid`、`klt`（周期：1 分 / 5 / 15 / 30 / 60 / 101 日 / 102 周 / 103 月）、`fqt`（0=不复权 / 1=前复权 / 2=后复权）、`fields1`、`fields2`\n  - 返回：`data.klines[]` — CSV 字符串数组\n- `GET datacenter-web.eastmoney.com/api/data/v1/get` — 报表类\n  - 必需：`reportName`（如 `RPT_DAILYBILLBOARD_DETAILS`）、`columns`、`pageSize`、`pageNumber`、`sortColumns`、`sortTypes`\n  - 返回：`result.data[]`\n- `GET np-listapi.eastmoney.com/nlist/api/list/get` — 7x24 快讯\n  - 必需：`client=web`、`column_id`、`limit`、`last_time`\n- `GET np-anotice-stock.eastmoney.com/api/security/ann` — 公司公告\n  - 必需：`sr=-1`、`page_size`、`page_index`、`ann_type`、`stock_list`\n\n## 字段\n\n字段代号词典见 `../field-conventions.md` 的 eastmoney 节（`f2 / f3 / f12 / f14 / f62 / f229-f243` 等全集）。\n\n市场前缀、fs 过滤码、secid 格式也都在那里。\n\n## 坑 / 陷阱\n\n1. **`fltt=2` 必传**：否则价格字段是 `int × 10^f152` 的整数表示，要自己除\n2. **资金流单位混乱**：`f6` 成交额是元，但 `f62 / f66 / f72 / f78 / f84` 等净流入大部分接口返回\"万元\"，核对单条接口后再乘\n3. **secid vs code**：`0.000001 / 1.600000` 是 secid（market + code），只有纯 code 时要先判断所属市场前缀再拼。用 `clis/eastmoney/_secid.js` 导出的 `resolveSecid(input)`（单股）或 `splitSymbols(s)`（批量参数拆分），不要自己硬拼前缀\n4. **港股代码 `00700.HK` 不是 secid**：只有前缀属于 `{0, 1, 105, 106, 107, 116, 100, 90}` 才当 secid\n5. **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`\n6. **datacenter-web 字段名大写**：`SECURITY_CODE` 不是 `security_code`\n\n## 可参考的 adapter\n\n| 模板类型 | 参考文件 |\n|---------|---------|\n| clist 分页排行 | `clis/eastmoney/convertible.js` / `rank.js` / `etf.js` / `sectors.js` |\n| ulist 批量报价 | `clis/eastmoney/quote.js` |\n| K 线历史 | `clis/eastmoney/kline.js` |\n| 报表类 | `clis/eastmoney/longhu.js` / `holders.js` |\n| 7x24 快讯 | `clis/eastmoney/kuaixun.js` |\n| 公司公告 | `clis/eastmoney/announcement.js` |\n| 指数 / 北上 | `clis/eastmoney/index-board.js` / `northbound.js` |\n| 资金流 | `clis/eastmoney/money-flow.js` |\n\n新写 eastmoney adapter 时，照最像的那条 copy + 改 `name` / URL 参数 / 字段映射三处。\n","skills/opencli-adapter-author/references/site-memory/gmail.md":"# gmail\n\n## Domain and auth\n\n- Desktop UI: `mail.google.com/mail/u/<account>/`.\n- Requires Google session cookies and a signed-in Gmail surface.\n- The official Gmail API requires separate OAuth; browser cookies are not an official API credential.\n\n## Verified read paths\n\n- `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]`.\n- `POST /sync/u/<account>/i/fd`: natural thread/message detail response. Cached threads may render without a fresh request.\n- `POST /sync/u/<account>/i/s`: incremental synchronization; not needed by the initial adapter.\n- Legacy `POST /mail/u/<account>/?ui=2` still appears during bootstrap. Do not assume a single transport variant.\n\n## Positional fields verified in 2026-08\n\n- Thread: subject `[0]`, snippet `[1]`, timestamp `[2]`, sync id `[3]`, summaries `[4]`.\n- Summary: sender card `[1]`, label ids `[10]`.\n- FD thread: id `[0]`, message wrappers `[2]`; wrapper is `[messageId, record]`.\n- Message: to `[0]`, cc `[1]`, subject `[4]`, body `[5]`, snippet `[6]`, sender `[10]`, attachments `[13]`, date `[16]`, legacy id `[34]`.\n- Sender: display name `[14]`, address `[16]`.\n- Attachment node: id `[1]`, data `[3]`; data contains name `[2]`, MIME `[3]`, size `[4]`.\n\n## Strategy\n\n- Reads: `INTERCEPT`. Let Gmail submit the visible search/open action, then parse the natural `bv/fd` response. Never reconstruct private XSRF/BTAI request bodies.\n- Cached thread fallback: rendered message containers (`data-message-id`, `.a3s`, scoped attachment cards).\n- 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.\n\n## Durable pitfalls\n\n1. 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.\n2. `thread-f:<decimal>` converts to legacy hex with `BigInt(decimal).toString(16)`; never use Number.\n3. `bv` search responses may omit label definitions. The complete visible fallback is `#settings/labels`.\n4. A new `/fd` response is not guaranteed for cached messages. Treat rendered content as a legitimate visible-ui fallback.\n5. Responses are anti-XSSI-prefixed positional arrays. Enforce arity/required-field guards and fail typed on drift.\n6. Network capture is a destructive drain; a relevant bodyless/truncated entry means possible data loss, not an empty/short page.\n7. Never store live subjects, addresses, message bodies, attachment names, cookies, or account-specific ids in fixtures/site memory.\n8. 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.\n","skills/opencli-adapter-author/references/site-memory/tonghuashun.md":"# tonghuashun（同花顺 / ths）\n\n## 域名\n\n| 用途 | 域名 |\n|------|------|\n| 行情页 / 热榜 | `q.10jqka.com.cn` |\n| 数据中心（多维筛选） | `data.10jqka.com.cn` |\n| 行情推送 | `d.10jqka.com.cn` / `dq.10jqka.com.cn` |\n| 基本面 / F10 | `basic.10jqka.com.cn` |\n| 资讯 / 新闻 | `news.10jqka.com.cn` |\n\n## 默认鉴权\n\n- `Strategy.COOKIE + browser: true`（多数接口有 cookie 风控）\n- `Referer: http://q.10jqka.com.cn/` 是关键 header，很多接口缺它直接返回空\n- 有些 JSONP 接口没 cookie 要求，但 Referer 还是要带\n- 不支持登录态扩展（加密/签名客户端逻辑复杂）\n\n## 已知 endpoint\n\n- `GET d.10jqka.com.cn/v6/line/hs_<code>/01/last1200.js` — K 线（JSONP，返回 `quotebridge_v6_line_...({...})`）\n- `GET q.10jqka.com.cn/thsq/quote/v6/<code>` — 实时行情快照（页面内 XHR）\n- `GET q.10jqka.com.cn/stock/attention/` — 热度榜（HTML，抽表）\n- `GET data.10jqka.com.cn/funds/ggzjl/field/zdf/order/desc/page/1/ajax/1/` — 资金流排行（HTML 表格 + JSONP 混合）\n- `GET news.10jqka.com.cn/tapp/news/push/stock/?tag=&page=1&limit=20` — 快讯\n- `GET data.10jqka.com.cn/ifinance/hotNews/` — 热点新闻\n\n## 字段\n\n见 `../field-conventions.md` 的 tonghuashun 节（`openPrice / closePrice / zdf / hsl / zf` 等）。\n\n数值字段多数是字符串（带百分号或\"万\"单位），解析时 `parseFloat` 并处理单位。\n\n## 坑 / 陷阱\n\n1. **`Referer` 必须带**，否则 302 到错误页\n2. **多数 JSONP 接口回调名固定**：`quotebridge_v6_...`，adapter 里直接 `.replace(/^[\\w_]+\\((.*)\\)$/, '$1')` 剥壳再 JSON.parse\n3. **HTML 表格接口**（如资金流）：`page.evaluate` 里 `document.querySelectorAll('table tr')` 抽，每列顺序在同类 adapter 之间复用\n4. **`<code>` 格式 `hs_600000`**：hs 是上海，sz 是深圳；港美股前缀不同\n5. **数据中心接口有日期快照切换**：`date=YYYYMMDD` 参数默认当天，历史日期要显式传\n6. **限频特别严**：连续 10 次会跳风控页，adapter 层 1s 间隔起\n7. **响应 gzip 强制**：某些接口不带 Accept-Encoding 会 406，`fetch` 默认 OK，node 原生 http 要显式加\n\n## 可参考的 adapter\n\n| 模板类型 | 参考文件 |\n|---------|---------|\n| 热度榜 | `clis/ths/hot-rank.js`（目前唯一一个，其他待补） |\n\nths 覆盖度低，新写 adapter 参考 eastmoney 同类型 adapter 的结构，把 URL / 解析逻辑换成 ths 版本。\n","skills/opencli-adapter-author/references/site-memory/xueqiu.md":"# xueqiu（雪球）\n\n## 域名\n\n| 用途 | 域名 |\n|------|------|\n| 行情 / 搜索 / 关注列表 | `stock.xueqiu.com` / `xueqiu.com` |\n| 动态 / 评论 / 热帖 | `xueqiu.com` |\n| 基金（蛋卷） | `danjuanfunds.com` |\n\n## 默认鉴权\n\n- `Strategy.COOKIE + browser: true`\n- 核心 cookie：`xq_a_token`（匿名也有，登录后含用户身份）\n- `page.evaluate` 里 `fetch(url, { credentials: 'include' })` 带 cookie\n- 浏览器先访问一次 `xueqiu.com` 触发 cookie 下发，再去调接口（否则 `xq_a_token` 不存在 → 400）\n\n## 已知 endpoint\n\n- `GET stock.xueqiu.com/v5/stock/quote.json?symbol=SH600000` — 单股详情\n- `GET stock.xueqiu.com/v5/stock/batch/quote.json?symbol=SH600000,SZ000001` — 批量报价\n- `GET stock.xueqiu.com/v5/stock/chart/kline.json?symbol=SH600000&begin=<ts>&period=day&type=before&count=-100` — K 线\n- `GET stock.xueqiu.com/v5/stock/screener/quote/list.json?market=CN&type=sh_sz` — 排行\n  - 可选：`order_by=percent`（涨幅）/ `volume`（成交量）/ `amount`（成交额）/ `market_capital`\n- `GET stock.xueqiu.com/v5/stock/search.json?keyword=<q>` — 搜索\n- `GET xueqiu.com/statuses/hot/list.json?since_id=-1&max_id=-1&size=20&type=stock` — 热门帖\n- `GET xueqiu.com/statuses/search.json?source=all&q=<q>` — 动态搜索\n- `GET xueqiu.com/service/v5/stock/portfolio/list.json` — 关注组合（需登录）\n- `GET xueqiu.com/v4/statuses/user_timeline.json?user_id=<uid>` — 用户动态\n\n## 字段\n\n字段基本人类可读（`symbol / name / current / chg / percent / volume / amount / market_capital / pe_ttm / pb`），详见 `../field-conventions.md` 的 xueqiu 节。\n\n## 坑 / 陷阱\n\n1. **必须先访问首页**：冷启动直接调 API 会 400 `cookie is invalid`，先 `page.goto('https://xueqiu.com/')` 再 fetch\n2. **`symbol` 前缀硬编码**：SH/SZ/HK/US —— 港股 `00700` 是 `'HK00700'`，美股 `AAPL` 是 `'AAPL'`（无前缀）\n3. **kline 的 `begin` 是毫秒 unix**，不是秒\n4. **screener 的 `type`**：`sh_sz / hk / us` 必传，漏了拿空数组\n5. **评论接口 `comments.json` 要传 `statusId`**，不是 `status_id`\n6. **限频明显**：同 symbol 短时间反复调会 403，adapter 层建议加 300ms 间隔\n7. **蛋卷基金（`danjuanfunds.com`）是独立子品牌**：cookie 域不同，要单独获取\n\n## 可参考的 adapter\n\n| 模板类型 | 参考文件 |\n|---------|---------|\n| 单股 / 批量报价 | `clis/xueqiu/stock.js` |\n| K 线 | `clis/xueqiu/kline.js` |\n| 排行 | `clis/xueqiu/hot-stock.js` |\n| 热帖 / feed | `clis/xueqiu/hot.js` / `feed.js` |\n| 关注 | `clis/xueqiu/watchlist.js` |\n| 搜索 | `clis/xueqiu/search.js` |\n| 评论 | `clis/xueqiu/comments.js` |\n| 基金持仓（蛋卷） | `clis/xueqiu/fund-holdings.js` |\n\n工具：`clis/xueqiu/utils.js` 有 `fetchWithRetry` + symbol normalize，直接用别重写。\n","skills/opencli-adapter-author/references/site-recon.md":"# Site Recon\n\n**Layer 1：这是哪种站？** 分类完直接进 `api-discovery.md` 找 endpoint。\n\n本文件**只做分类**，不讲 endpoint 怎么找。\n\n---\n\n## 页面与请求事实\n\n```bash\nopencli browser open <url> --session research\nopencli browser state --diagnostics --session research\nopencli browser network --static --session research\n```\n\n读取 success envelope 的 `data`。`state --diagnostics` 返回页面与可用 state globals；\n`network --static` 返回捕获请求和脚本候选。由 agent 判断最适合的数据源，CLI 不再给出\nPattern 分类或 `recommended_next_step`。下面的分类仅作为分析参考。\n\n看 `network` 输出判：\n\n| `network` 看到什么 | 站点类型 | 特征 |\n|------------------|---------|------|\n| 大量 `/api/...` JSON 请求，包含目标数据 | **A. SPA / JSON XHR** | React/Vue，数据走 fetch |\n| 有请求但都是广告 / 埋点，无目标数据 | **B. SSR / inline data** | 首屏在 HTML 里，深层再走 API |\n| 完全空 / 只有静态资源 | **C. JSONP / `<script src>` 驱动** | 老金融行情站常见 |\n| 有 API 但 401/403/签名错 | **D. Token / CSRF 鉴权型** | 在 A 基础上加鉴权 |\n| `Content-Type: text/event-stream` / WebSocket 握手 | **E. 流式** | 行情 tick / chat |\n\n分不清时参考下面五节的其他信号。\n\n**数据是 SPA / 异步加载时，`wait time 2` 可能不够**。改用 `opencli browser --session research wait xhr '/api/path-fragment'` 直接等具体接口到场，比盲 `wait time 5` 更稳。\n\n---\n\n## Pattern A — SPA / JSON XHR\n\n**代表**：xueqiu、linear、notion、大多数现代 SaaS\n\n**信号**：\n- URL 一访问就是 `/`，后续数据都在 network tab\n- `document.querySelector('main').childElementCount` 一开始为 0，后被 JS 填充\n- `window.React / window.Vue / window.__REACT_DEVTOOLS_GLOBAL_HOOK__` 存在\n\n**下一步**：`api-discovery.md` §1（network 精读）\n\n**注意 — Pattern A 命中不等于 strategy 选 `PAGE_FETCH`**：\n- 检查 network response shape 与业务目标是否一致；analytics / beacon / personalization 不能单独证明目标 API 可用。\n- booking #1680 反例：17 个 JSON XHR 看起来像 Pattern A，但全是 analytics side-channel，最终走 `DOM_STATE` / `UI_SELECTOR`\n- replay 候选 endpoint 后，按 `strategy-selection.md` 的契约模型选 strategy；`PUBLIC_API` / `COOKIE_API` 都不通才考虑 `PAGE_FETCH`\n\n---\n\n## Pattern B — SSR / inline data\n\n**代表**：bilibili 个人主页、小红书、微博、部分 Next.js / Nuxt 页\n\n**信号**：\n- 第一个请求（`document`）返回的 HTML 里已经含目标数据（`curl <url> | grep <某数字>`）\n- `window.__INITIAL_STATE__` / `window.__NEXT_DATA__` / `window.__NUXT__` 存在\n- 关 JS 仍能看到首屏数据\n\n**下一步**：`api-discovery.md` §2（state 抽取） + §1（深层数据回到 network）\n\n---\n\n## Pattern C — JSONP / `<script src>` 驱动\n\n**代表**：eastmoney、tonghuashun、老一代金融站\n\n**信号**：\n- `network` 空或只有 css/font\n- 页面上肯定有数据（价格、成交量等）\n- `document.querySelectorAll('script[src]')` 里有指向 `push / api / data` 域名的 src\n- 响应是 `jQuery123({...})` 这种回调包裹（JSONP）\n\n**下一步**：`api-discovery.md` §3（bundle / script src 搜索）\n\n---\n\n## Pattern D — Token / CSRF / Bearer\n\n**代表**：Twitter/X、部分企业 SaaS\n\n**信号**：\n- 已经是 Pattern A，但 `fetch(url, {credentials:'include'})` 返回 401/403\n- network 里请求头有 `X-Csrf-Token / Authorization: Bearer / X-Client-Id / X-Workspace-Id` 等自定义字段\n- 401 响应体带 `{\"code\":\"AUTH_REQUIRED\",\"csrf\":\"...\"}` 类提示\n\n**下一步**：`api-discovery.md` §4（token 来源排查） + §5（store action / intercept 降级）\n\n---\n\n## Pattern E — 流式\n\n**代表**：行情 tick、LLM chat\n\n**信号**：\n- `network` 里有 `101 Switching Protocols`（WebSocket 握手）\n- Response headers 含 `Content-Type: text/event-stream`\n- 请求一直 pending 不结束\n\n**下一步**：先找**同数据的 HTTP 轮询接口**（90% 概率有）。真没有再走 intercept 收 N 条。\n\n---\n\n## 识别失败怎么办\n\n诊断信号互相矛盾（比如 network 非空但目标数据不在里面），按优先级硬走：\n\n1. 先当 A，试 `api-discovery.md` §1\n2. 不行当 B，试 §2\n3. 还不行当 C，试 §3\n4. 401 出现了切 D，试 §4\n5. 所有手段都试过，启动 intercept（§5）\n\n不要纠结分类。分类是帮忙定第一步，没命中就按顺序降级。\n","skills/opencli-adapter-author/references/strategy-selection.md":"# Choosing an adapter strategy\n\nUse the site's available contract and the task's needs to choose an implementation.\nThis reference is optional; no strategy note or classification artifact is required.\n\n| Source | Useful when | Check |\n| --- | --- | --- |\n| Public API | A documented endpoint covers the task | Inputs, authentication, pagination and error semantics |\n| Cookie-authenticated API | The endpoint works with the current account | Cookie/CSRF scope and real returned identity |\n| Page state / SSR | The page already contains the required data | Field meaning, completeness and hydration timing |\n| Semantic UI | The visible controls express the operation reliably | Stable selectors, target identity and observable result |\n| Browser fetch | The request needs page origin or runtime context | Actual request parameters and response meaning |\n| Network interception | The page produces a request that is difficult to reproduce | The captured response belongs to the intended action and input |\n\nThere is no universal API-over-DOM rule. A stable visible control can be easier to\nmaintain than an undocumented internal endpoint. Reuse an existing adapter or\nshared helper when it already models the same operation.\n\n## Gather facts as needed\n\n```sh\nopencli browser state --diagnostics --session research\nopencli browser network --static --session research\nopencli browser network --detail RequestKey --session research\n```\n\nRead `data` from the success envelope. Page diagnostics expose title, URL and\ninitial-state globals. Network evidence provides captured request keys, response\nshapes and static source locations. `static` is a candidate; `network+static`\nmeans both sources support the route. Neither proves business correctness.\nThe CLI does not assign a strategy score or prescribe the next action.\n\nCheck representative values against the visible page. For lists, distinguish a\ntrue end of pagination from a failed or partial response. For writes, bind the\noperation to the requested target and inspect its result; do not replay an action\nwhose previous outcome is unknown.\n\nIf an endpoint needs a runtime-generated signature, consider observing the page's\nown request or using semantic UI. Use `references/api-discovery.md` for techniques\nand `references/deep-recon.md` when the protocol requires further investigation.\n","skills/opencli-adapter-author/references/success-rate-pitfalls.md":"# Success-Rate Pitfalls\n\n11 个**静默失败**（adapter 看起来能跑、verify 能过，但数据是错的）的坑。每条给：现象 → 根因 → 防御手段。\n\n不是风格建议。每条都对应过一次真实翻车。\n\n---\n\n## 1. fixture pattern 被放松以过 verify\n\n**现象**：`verify` 报 `pattern \"url\" does not match /^https?:\\/\\/.*\\.com\\/bbs\\/thread-/`。agent 的\"修法\"是把 fixture 里的 pattern 改宽（`^https?://`），verify 一下就通过了。\n\n**根因**：adapter 丢了 URL 前缀 / 拼错了路径 / 吃到了相对路径。pattern 失败不是 fixture 太严，是 adapter 输出真的破了。\n\n**防御**：\n- `autofix` skill 现有纪律：**verify pattern 失败 = 收紧 adapter，不是收紧 fixture**（`opencli-autofix` SKILL.md §Rules for Patching 第 6 条）\n- 要改 fixture 的唯一合法理由：**站点本身换了格式**（例如 URL 规范迁移）。这种情况下在 `~/.opencli/sites/<site>/notes.md` 顶部写一段说明\n\n---\n\n## 2. 字段内容污染但 `notEmpty` / `columns` 都过\n\n**现象**：`description` 字段不为空，`verify` 通过。肉眼看输出发现描述里混了 `\"address: 上海 category: IT\"` 之类明显不属于描述的片段——兄弟 DOM 节点或父节点文字被一起 `textContent` 了。\n\n**根因**：`.container` 的 `textContent` 包括所有后代文字。调 `innerText` 仅好一点；用 `querySelector('.desc').textContent` 时，如果 `.desc` 里嵌套了 `.tag.address`，一样吃进去。\n\n**防御**：\n- fixture 用 `mustNotContain`：`{ \"description\": [\"address:\", \"category:\", \"工作年限:\"] }` 把已踩过的污染词列出来\n- adapter 侧：定位更精确的 selector，或抓到后 `.replace(/address:[^\\n]*/g, '').trim()`\n- 别信 `textContent.trim()` 就完事\n\n---\n\n## 3. 字段语义分歧（两个字段看起来都对）\n\n**现象**：51job 列表有 `updatedate` 和 `publishDate`，eastmoney 债券有 `f10`（发行日）和 `f26`（上市日）。adapter 随便选一个，verify 通过，用户一对照发现时间错位 1 个月。\n\n**根因**：两个字段都是合法日期，format 也对，只是含义不同。`notEmpty` / `types` 都挡不住。\n\n**防御**：\n- Step 7 字段解码**必须和网页肉眼对至少一条已知记录**（\"这条债券首页写的上市日是 2025-02-14\"，看 adapter 输出对不对得上）\n- 字段写进 `field-map.json` 时 `meaning` 要精确到\"上市日\"而不是\"日期\"\n\n---\n\n## 4. 字段单位混淆（数值量级错）\n\n**现象**：eastmoney 返回 `totalMarketCap: 128`（单位：亿元），adapter 直接写进 `marketCap`，用户对照 K 线页看是 128 元。常见错位：\n\n| 接口返回 | 网页显示 | 实际单位 | 错误写法 |\n|---------|---------|----------|---------|\n| `0.025` | `2.5%` | 小数 | adapter 再 × 100 → 显示 250% |\n| `12800` | `1.28 万` | 元 | adapter 不除 10000 → 显示 12800 万 |\n| `f152 = 2` | `9.27` | 价格 = 原值 ÷ 10^f152 | adapter 忽略 f152 → 显示 927 |\n\n**防御**：\n- fixture 加 `mustBeTruthy` 挡 `|| 0` / `|| false` silent fallback（数值列应该有值，不是 0）\n- `field-map.json` 的 `meaning` 写单位：`\"premium pct (0-1 fraction, NOT already × 100)\"`\n- Step 11 肉眼比对不能只比\"有没有数字\"，要比数量级\n\n---\n\n## 5. JSON-in-attribute vs 渲染后 innerText\n\n**现象**：51job 把完整 JSON 塞在 `<div data-sensorsdata='{\"job_title\":\"...\"}'>` 里，用 innerText 取的是渲染后的截断显示文字（\"Lead... ↩ 上海...\"），字段边界丢了。\n\n**根因**：现代站点常把结构化数据放在 `data-*` 属性里，渲染层只挑部分显示。取 innerText 相当于丢掉了结构。\n\n**防御**：\n- 看到 `data-sensorsdata` / `data-ng-state` / `data-page-props` / `data-track` 类属性先读属性，不读 innerText\n- 先在 `browser eval` 里检查：`document.querySelector('.item').dataset` 看有没有 JSON 串\n- 搜 bundle 时也搜 `JSON.parse(el.dataset.*)` 模式看 vendor 把数据塞哪了\n\n---\n\n## 6. cookie 域 / origin 不一致的隐式假设\n\n**现象**：在 `jobs.51job.com` 页面调 `cupid.51job.com` 的接口，headless 里带了 cookie 也跨不过去——`credentials:'include'` 只管带 cookie，不管 CORS。\n\n**根因**：浏览器 CORS 预检默认关闭跨 subdomain 请求。`credentials:'include'` 不是万能药。\n\n**防御**：参见 `api-discovery.md §0.2`。判断：`fetch(target).catch(e=>'cors:'+e.message)` 看是不是 TypeError。降级路径：改用 same-origin endpoint / 改在目标 subdomain 上打开页面 / 走 `§5 intercept`。\n\n---\n\n## 7. 等不够就抓导致空 DOM / 空 network\n\n**现象**：`open url && wait time 2 && network` 看到 0 条业务 API。agent 以为是 Pattern C（静态），去 bundle 里找 baseURL，找不到就卡住。真相：SPA 3.5 秒才发出第一个 API。\n\n**根因**：`wait time N` 是盲等。不同站点 JS 执行速度差很多。\n\n**防御**：\n- 数据是异步加载时**不用 `wait time`**，用 `opencli browser --session research wait xhr '/api/path-fragment'`，等具体 XHR 到场再 `network`\n- 不确定 endpoint 路径时：先 `wait time 2 && network`，看到候选路径再转 `wait xhr` 确认\n\n---\n\n## 8. adapter 里的 falsy `|| 0` 兜底静默\n\n**现象**：`likes: data.likes || 0`。接口偶尔返回 `likes: null`（可能因字段名改了、权限问题等），adapter 写成 0，verify `types: {likes: 'number'}` 通过，用户看到的是\"所有帖子 0 赞\"。\n\n**根因**：`||` 兜底把\"没抓到\"变成\"是 0\"。`notEmpty` 挡不住 0，`types` 也不挡。\n\n**防御**：\n- fixture 用 `mustBeTruthy: [\"likes\", \"count\", ...]`——业务数值列必须 truthy\n- adapter 侧 prefer `?.` 而不是 `||`；真的想兜底就兜 `undefined`，让 verify 能看见\n- 全部 `|| 0` 要过一遍眼：这个 0 是合法值还是漏抓 fallback\n\n---\n\n## 9. 跨 session cookie 污染 / 登录态漂移\n\n**现象**：本地开发时用自己的登录态验 endpoint 能通，PR 一合 verify fixture 跑在 CI 环境里立刻 401——顺手把样本数据也固化进了 fixture，看起来\"一切正常\"。\n\n**根因**：fixture 样本是带登录态跑出来的。存 `~/.opencli/sites/<site>/fixtures/*.json` 没脱敏，把 cookie / token / 自己的 uid / 昵称存了进去。\n\n**防御**：\n- `site-memory.md` 的脱敏规则：存 fixtures 前去掉 cookie / token / 用户私有字段（手机号 / 邮箱 / 昵称 / uid）\n- 需要登录态的接口：adapter 用 `Strategy.COOKIE`，adapter 代码里**不写任何具体 cookie 值**，只声明\"我需要 domain X 的 cookie\"\n- verify 样本里看到 `Bearer ...` 或 32 位 hex token → 先删再存\n\n---\n\n## 10. adapter 默认 timeout 不统一\n\n**现象**：同一个站两个 adapter，一个默认 15s timeout，另一个默认 60s。慢接口在一个命令里 ok，在另一个一样的慢接口却 timeout 了。\n\n**根因**：模板没统一 timeout；agent 依赖\"最像的邻居\"复制，复制到的邻居选了短 timeout。\n\n**防御**：\n- 邻居 adapter 的 `requestTimeoutMs` / `browser.wait` 配置**不能盲抄**。每个 adapter 应该结合自己的接口特性设一个\n- 真实接口延迟：Step 5 endpoint 验证时用 `time curl`（或 `performance.now()` 包 fetch）量一下 p50 / p95，timeout 设 p95 × 2 比较安全\n- 出现偶发 timeout 别 retry 掩盖；记到 `notes.md`，下次就知道这接口 p95 偏高\n\n---\n\n## 11. `aria-label` / `placeholder` / `title` 是 locale-dependent 文本\n\n**现象**：你本地英文 Chrome 测 `button[aria-label=\"Submit\"]` 一切正常，verify fixture 也是英文环境抓的。用户把 `chrome://settings/languages` 切中文，同一个按钮变 `aria-label=\"提交\"`，adapter silent 0 匹配——退化成 `notEmpty` / `types` 都没法 fire 的\"adapter 跑完返 0 行\"。\n\n**根因**：`aria-label` / `title` / `placeholder` / `alt` / `textContent` 都是页面的**用户可见文本**，被站点 i18n 框架翻译。用它们当 selector anchor 等于 \"select by visible text\"，locale 一动整个选择器就废。\n\n**防御**：\n- 优先用 locale-stable 标识：`data-testid` / `data-*` / 稳定 `id` / `class`。先确认不是 hash / A-B test 产物\n- `role` 不按 locale 翻译，但通常不唯一；只能当 semantic / scope filter，不能用裸 `[role=\"button\"]` 当 primary\n- 站点只暴露 `aria-label`（典型如 ChatGPT web 某些 control）时，写 fallback list，至少 en + zh-CN：`'[aria-label=\"Send\"], [aria-label=\"发送\"]'`\n- commit 前 grep `aria-label=` / `placeholder=` / `title=` 的硬编码字符串，确认每条都有兜底 locale\n- 找不到 control 要 typed fail-fast（例如 `CommandExecutionError` / send-failed），不要把 selector miss 变成空 rows 或假成功\n- 详细 framework + 活例见 `adapter-template.md §Selector 稳定性`\n\n不要去给 framework 加 `--i18n` flag 自动展开——多一层 indirection 还要维护翻译字典，纯 over-engineering。\n\n---\n\n## 总结：静默失败的共同特征\n\n1. **verify 绿 ≠ 数据对**。verify 只能证\"结构没坏\"，证不出\"值对不对\"。Step 11 肉眼比对是必须的。\n2. **\"字段有值\"是个比\"字段为空\"更危险的失败态**。空你会去查，有值你会 fallthrough。\n3. **fixture 四件套一起上**：`patterns` + `notEmpty` + `mustNotContain` + `mustBeTruthy`——每件挡一类问题，缺一个就漏。\n\n回写 `notes.md` 时把你踩的新坑写进去。下次就有第 12 条了。\n","skills/opencli-adapter-author/references/typed-errors.md":"# Typed Error Conventions\n\nOpenCLI 用 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 只能减不能加，新违例必须立刻收掉）。\n\n每条 rule 都挂了真实 anti-pattern 反例（PR #1329 三轮迭代是主要素材库）。\n\n---\n\n## 1. 5-classification 落点表\n\n| 场景 | 抛 | code | exit |\n|------|-----|------|------|\n| 参数不合法（包括越界、格式错、缺主语 positional） | `ArgumentError(message)` | `ARGUMENT` | 2 |\n| 业务无数据 / 资源不存在 | `EmptyResultError(command, hint?)` | `EMPTY_RESULT` | 66 |\n| HTTP 非 2xx / fetch throw / JSON parse 错 / 接口业务报错 | `CommandExecutionError(message)` | `COMMAND_EXEC` | 1 |\n| 需要登录（cookie 缺 / 401 / 302 → /login / \"请登录\" 页） | `AuthRequiredError(domain, message?)` | `AUTH_REQUIRED` | 77 |\n| 等响应超时（CDP / page.evaluate / 流式回复） | `TimeoutError(label, seconds)` | `TIMEOUT` | 75 |\n\n类签名定义见 [`src/errors.ts`](../../../src/errors.ts)。\n\n**禁止泛用 `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。\n\n---\n\n## 2. Anti-pattern A：silent-clamp 不止 limit\n\n**反例**（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)）：\n\n```js\n// ❌ silent clamp — 200 当 100，0/-1 当 1，timeout=5 当 15\nconst limit = Math.max(1, Math.min(Number(args.limit) || 20, 100));\nconst timeout = Math.max(15, parseInt(kwargs.timeout, 10) || 120);\nconst contentLimit = Math.max(50, Number(args.contentLimit) || 400);\nconst page = Math.max(1, Number(args.page) || 1);\n```\n\n**修法**（[`clis/1point3acres/utils.js`](../../../clis/1point3acres/utils.js) 的 `normalizePositiveInteger` / `normalizeLimit`）：\n\n```js\nimport { ArgumentError } from '@jackwener/opencli/errors';\n\n/** Validate a positive integer arg without silently flooring/clamping. */\nexport function normalizePositiveInteger(value, defaultValue, label = 'value', { min = 1 } = {}) {\n    const raw = value ?? defaultValue;\n    const n = Number(raw);\n    if (!Number.isInteger(n) || n <= 0) throw new ArgumentError(`${label} must be a positive integer`);\n    if (n < min) throw new ArgumentError(`${label} must be >= ${min}`);\n    return n;\n}\n\n/** With both lower bound and an explicit ceiling. */\nexport function normalizeLimit(value, defaultValue, maxValue, label = 'limit') {\n    const n = normalizePositiveInteger(value, defaultValue, label);\n    if (n > maxValue) throw new ArgumentError(`${label} must be <= ${maxValue}`);\n    return n;\n}\n```\n\n**用法**（[`clis/1point3acres/thread.js#L37-L39`](../../../clis/1point3acres/thread.js)）：\n\n```js\nconst page = normalizePositiveInteger(args.page, 1, 'page');\nconst limit = normalizePositiveInteger(args.limit, 10, 'limit');\nconst contentLimit = normalizePositiveInteger(args.contentLimit, 400, 'contentLimit', { min: 50 });\n```\n\n**注意**：当前 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**。\n\n### 何时抽 site-level helper\n\n≥ 2 个同站 adapter 都要做同样的 limit / page 校验时，把 `normalizeLimit` / `normalizePositiveInteger` 抽到 `clis/<site>/utils.js`。1 个 adapter 用就直接 inline，别为 1 处单点抽 helper。\n\n---\n\n## 3. Anti-pattern B：sentinel row / scalar sentinel 吞 empty / unknown / failure\n\n**反例 1**（PR #1329 R3 修掉的，[`clis/1point3acres/notifications.js` before](https://github.com/jackwener/OpenCLI/blob/2b8609b82fccaf98505c5b1b1859e3ffdaa1a55c/clis/1point3acres/notifications.js#L35-L37)）：\n\n```js\n// ❌ \"暂时没有提醒内容\" 是 empty result，不是一行业务数据\nif (/暂时没有提醒内容/.test(html)) {\n    return [{ index: 0, from: '', summary: '暂时没有提醒内容', time: '', threadUrl: '' }];\n}\n```\n\n**反例 2**（[`clis/1point3acres/search.js` before](https://github.com/jackwener/OpenCLI/blob/2b8609b82fccaf98505c5b1b1859e3ffdaa1a55c/clis/1point3acres/search.js#L52-L60)）：\n\n```js\n// ❌ \"抱歉\" hint 是搜索无结果，不是 rank=0 的伪结果行\nif (items.length === 0) {\n    const hint = html.match(/<p>([^<]*?抱歉[^<]*?)<\\/p>/);\n    if (hint) {\n        return [{ rank: 0, tid: '', title: hint[1].trim(), forum: '', author: '', replies: 0, views: 0, postTime: '', url: '' }];\n    }\n    return [];\n}\n```\n\n**反例 3**（[`clis/qwen/history.js` before](https://github.com/jackwener/OpenCLI/blob/2b8609b82fccaf98505c5b1b1859e3ffdaa1a55c/clis/qwen/history.js#L44-L51)）：\n\n```js\n// ❌ API failure 不是 conversation 的一行\nif (!result.ok && !result.sessions.length) {\n    return [{ Index: 0, Title: `API failed (status=${result.status})`, Updated: '', Url: '' }];\n}\n```\n\n**反例 4**（[`clis/qwen/image.js` before](https://github.com/jackwener/OpenCLI/blob/2b8609b82fccaf98505c5b1b1859e3ffdaa1a55c/clis/qwen/image.js#L160-L164)）：\n\n```js\n// ❌ 单张图片 fetch 失败，伪装成 \"⚠️ fetch-failed\" 状态行继续\nif (!asset?.ok) {\n    results.push({ Status: `⚠️ fetch-failed(${asset?.status || '?'})`, File: '-', Link: link });\n    continue;\n}\n```\n\n**修法**：\n\n```js\nimport { EmptyResultError, CommandExecutionError } from '@jackwener/opencli/errors';\n\nif (/暂时没有提醒内容/.test(html)) {\n    throw new EmptyResultError('1point3acres notifications', '暂时没有提醒内容');\n}\nif (items.length === 0) {\n    throw new EmptyResultError('1point3acres search', `No results for \"${query}\"`);\n}\nif (!result.ok && !result.sessions.length) {\n    throw new CommandExecutionError(`Qianwen history API failed (status=${result.status})`);\n}\nif (!asset?.ok) {\n    throw new CommandExecutionError(`Failed to fetch image: status=${asset?.status || '?'}`);\n}\n```\n\n**反例 5：scalar sentinel**（PR #1329 R2 修掉的，[`clis/qwen/status.js` before](https://github.com/jackwener/OpenCLI/blob/42e5303c792d9f71d9a30dde2e391405e03661e7/clis/qwen/status.js#L24-L29)）：\n\n```js\n// ❌ '-' 会被下游当成真实 model/session id 字符串\nreturn [{\n    Status: 'Connected',\n    Login: loggedIn ? 'Yes' : 'No (guest mode)',\n    Model: model || '-',\n    SessionId: sessionId || '-',\n}];\n\n// ✅ unknown 用 null，agent 可以 if (row.Model === null) 干净分支\nreturn [{\n    Status: 'Connected',\n    Login: loggedIn ? 'Yes' : 'No (guest mode)',\n    Model: model ? model : null,\n    SessionId: sessionId ? sessionId : null,\n}];\n```\n\n**根因**：success row 是\"业务数据\"的合同。把 empty / failure 塞进 row 会破坏：\n\n1. **round-trip pipeline**：listing → detail 类下游 adapter 拿到 `tid: ''` 会去查 `thread `（\"\"），白跑一轮\n2. **exit code semantics**：empty 应该 exit 66、API fail 应该 exit 1，混进 row 后两者都 exit 0，agent 没法 branch\n3. **fixture rowCount.min=1**：sentinel row 让 verify 永远过 → 真的接口挂了也看不见\n\n**对比 #1290 twitter trending**：当时也踩过 silent N/A row 的坑，drop 掉那行比留个 silent-wrong 字段诚实——这是 2026-05-04 的早期教训。typed-errors 把\"drop\"升级成\"throw EmptyResultError\"，下游 agent 能直接从 exit code 66 分辨 empty vs 真实数据错位。\n\n---\n\n## 4. Anti-pattern C：`return []` 当 silent fallback\n\n`adapter` 跑完什么都没拿到不能 `return []`：\n\n```js\n// ❌\nif (items.length === 0) return [];\n\n// ✅\nif (items.length === 0) throw new EmptyResultError('site command', `optional context`);\n```\n\n`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> 在该论坛没有发过帖子\"）。\n\n---\n\n## 5. Anti-pattern D：generic `CliError('CODE')` 隐藏分类\n\n**反例**（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)）：\n\n```js\n// ❌ 都是 exit 1，agent 无法区分服务失败 vs 空结果 vs bad args\nif (!resp.ok) throw new CliError('HTTP_ERROR', `HTTP ${resp.status}`);\nif (!Array.isArray(data) || data.length === 0) throw new CliError('NO_DATA', 'no data');\nif (!/id=\"postlist\"/.test(html)) throw new CliError('THREAD_NOT_FOUND', `帖子 ${tid} 不存在`);\n```\n\n**修法**：\n\n```js\n// HTTP / fetch / JSON / in-band API error → runtime failure\nif (!resp.ok) throw new CommandExecutionError(`request failed: HTTP ${resp.status}`);\nif (!Array.isArray(data)) throw new CommandExecutionError('unexpected response shape');\n\n// valid empty / resource missing → empty result\nif (data.length === 0) throw new EmptyResultError('coingecko top', 'no market data');\nif (!/id=\"postlist\"/.test(html)) throw new EmptyResultError('1point3acres thread', `帖子 ${tid} 不存在`);\n```\n\n`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.\n\n---\n\n## 6. Verify fixture 怎么挡这三类\n\n新写 fixture 时（`~/.opencli/sites/<site>/verify/<cmd>.json`）：\n\n- **rowCount.min ≥ 1**：保证 sentinel-row 不能伪装通过\n- **patterns**：核心 id 列加 `^\\d+$` 类正则，挡 `tid: ''` / `pid: ''` 之类空字符串污染\n- **mustBeTruthy**：业务数值列（`replies` / `views` / `count`）必须 truthy，挡 `|| 0` silent fallback\n- **预期 EmptyResultError 是合法返回时**（例如某条 fixture 故意搜不出来）用 `expect.exitCode: 66` 让\"空态合法\"和\"adapter 崩了\"在 fixture 层就分开（参见 [`site-memory.md`](./site-memory.md) verify schema）\n\n---\n\n## 7. 已经 grandfathered 的旧 adapter\n\nrepo 里仍有相当数量的 `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 不会卡。\n\n---\n\n## 8. 自查清单（PR push 前）\n\n- [ ] adapter 没有 `Math.max(1, Math.min(...))` / `Math.max(N, ...)` clamp 在外部参数上\n- [ ] adapter 没有 `return []` / `return [{...sentinel...}]` 当 empty / failure 兜底\n- [ ] adapter 没有 `'-'` / `'N/A'` 这类 scalar sentinel 冒充 real value；语义可空就返回 `null`\n- [ ] adapter 抛的不是 `CliError('XXX')`，而是 5 类 typed error 之一\n- [ ] verify fixture 的 `rowCount.min ≥ 1`，sentinel row 过不了\n- [ ] `npm run check:typed-error-lint` 跑过 → baseline 没增加\n","skills/opencli-autofix/SKILL.md":"---\nname: opencli-autofix\ndescription: Automatically fix broken OpenCLI adapters when commands fail. Load this skill when an opencli command fails — it guides you through collecting a trace artifact, patching the adapter, retrying, and filing an upstream GitHub issue after a verified fix. Works with any AI agent.\nallowed-tools: Bash(opencli:*), Bash(gh:*), Read, Edit, Write\n---\n\n# OpenCLI AutoFix — Automatic Adapter Self-Repair\n\nWhen an `opencli` command fails because a website changed its DOM, API, or response schema, **automatically diagnose, fix the adapter, and retry** — don't just report the error.\n\n## Safety Boundaries\n\n**Before starting any repair, check these hard stops:**\n\n- **`AUTH_REQUIRED`** (exit code 77) — **STOP.** Do not modify code. Tell the user to log into the site in Chrome.\n- **`BROWSER_CONNECT`** (exit code 69) — **STOP.** Do not modify code. Tell the user to run `opencli doctor`.\n- **CAPTCHA / rate limiting** — **STOP.** Not an adapter issue.\n\n**Scope constraint:**\n- **Only modify the file at `adapterSourcePath` in the trace `summary.md` front matter** — this is the authoritative adapter location (may be `clis/<site>/` in repo or `~/.opencli/clis/<site>/` for npm installs)\n- **Never modify** `src/`, `extension/`, `tests/`, `package.json`, or `tsconfig.json`\n\n**Retry budget:** Max **3 repair rounds** per failure. If 3 rounds of diagnose → fix → retry don't resolve it, stop and report what was tried.\n\n## Prerequisites\n\n```bash\nopencli doctor    # Verify extension + daemon connectivity\n```\n\n## When to Use This Skill\n\nUse when `opencli <site> <command>` fails with repairable errors:\n- **SELECTOR** — element not found (DOM changed)\n- **EMPTY_RESULT** — no data returned (API response changed)\n- **API_ERROR** / **NETWORK** — endpoint moved or broke\n- **PAGE_CHANGED** — page structure no longer matches\n- **COMMAND_EXEC** — runtime error in adapter logic\n- **TIMEOUT** — page loads differently, adapter waits for wrong thing\n\n## Before Entering Repair: \"Empty\" ≠ \"Broken\"\n\n`EMPTY_RESULT` — and sometimes a structurally-valid `SELECTOR` that returns nothing — is often **not an adapter bug**. Platforms actively degrade results under anti-scrape heuristics, and a \"not found\" response from the site doesn't mean the content is actually missing. Rule this out **before** committing to a repair round:\n\n- **Retry with an alternative query or entry point.** If `opencli xiaohongshu search \"X\"` returns 0 but `opencli xiaohongshu search \"X 攻略\"` returns 20, the adapter is fine — the platform was shaping results for the first query.\n- **Spot-check in a normal Chrome tab.** If the data is visible in the user's own browser but the adapter comes back empty, the issue is usually authentication state, rate limiting, or a soft block — not a code bug. The fix is `opencli doctor` / re-login, not editing source.\n- **Look for soft 404s.** Sites like xiaohongshu / weibo / douyin return HTTP 200 with an empty payload instead of a real 404 when an item is hidden or deleted. The snapshot will look structurally correct. A retry 2-3 seconds later often distinguishes \"temporarily hidden\" from \"actually gone\".\n- **\"0 results\" from a search is an answer.** If the adapter successfully reached the search endpoint, got an HTTP 200, and the platform returned `results: []`, that is a valid answer — report it to the user as \"no matches for this query\" rather than patching the adapter.\n\nOnly proceed to Step 1 if the empty/selector-missing result is **reproducible across retries and alternative entry points**. Otherwise you're patching a working adapter to chase noise, and the patched version will break the next working path.\n\n## Step 1: Collect Trace Context\n\nRun the failing command with failure-retained trace enabled:\n\n```bash\nopencli <site> <command> [args...] --trace retain-on-failure 2>trace-error.yaml\n```\n\nOn failure, stderr contains the normal error envelope plus a small `trace` block:\n\n```yaml\nok: false\nerror:\n  code: SELECTOR\n  message: \"Could not find element: .old-selector\"\ntrace:\n  schemaVersion: 1\n  opencliVersion: \"...\"\n  traceId: \"...\"\n  dir: \"/path/to/.opencli/profiles/default/traces/...\"\n  summaryPath: \"/path/to/.opencli/profiles/default/traces/.../summary.md\"\n  receiptPath: \"/path/to/.opencli/profiles/default/traces/.../receipt.json\"\n```\n\nRead `summaryPath` first. It is the LLM-oriented entry point and includes front matter:\n\n```yaml\n---\nschemaVersion: 1\nopencliVersion: \"...\"\ntraceId: \"...\"\nstatus: failure\nsite: \"example\"\ncommand: \"example/search\"\nadapterSourcePath: \"/path/to/clis/example/search.js\"\nerrorCode: \"SELECTOR\"\nerrorMessage: \"Could not find element: .old-selector\"\n---\n```\n\nThe artifact directory contains:\n\n```text\nsummary.md      # start here\nreceipt.json    # machine-readable trace receipt\ntrace.jsonl     # full redacted timeline\nnetwork.jsonl   # redacted network events\nconsole.jsonl   # redacted console events\nstate/          # final snapshots when available\nscreenshots/    # final screenshots when available\n```\n\nIf you redirected stderr to a file, read that file and copy `trace.summaryPath`.\n\nDo not ask the user to rerun with legacy diagnostic env vars. Trace is the repair evidence path.\n\n## Step 2: Analyze the Failure\n\nRead the trace summary and the adapter source. Classify the root cause:\n\n| Error Code | Likely Cause | Repair Strategy |\n|-----------|-------------|-----------------|\n| SELECTOR | DOM restructured, class/id renamed | Explore current DOM → find new selector |\n| EMPTY_RESULT | API response schema changed, or data moved | Check network → find new response path |\n| API_ERROR | Endpoint URL changed, new params required | Discover new API via network intercept |\n| AUTH_REQUIRED | Login flow changed, cookies expired | **STOP** — tell user to log in, do not modify code |\n| TIMEOUT | Page loads differently, spinner/lazy-load | Add/update wait conditions |\n| PAGE_CHANGED | Major redesign | May need full adapter rewrite |\n\n**Key questions to answer:**\n1. What is the adapter trying to do? (Read the file at `adapterSourcePath`)\n2. What did the page look like when it failed? (Read `summary.md`, then `state/` if needed)\n3. What network requests happened? (Read `Failed Network` in `summary.md`, then `network.jsonl` if needed)\n4. What's the gap between what the adapter expects and what the page provides?\n\n## Step 3: Explore the Current Website\n\nUse `opencli browser` to inspect the live website. **Never use the broken adapter** — it will just fail again.\n\n### DOM changed (SELECTOR errors)\n\n```bash\n# Open the page and inspect current DOM\nopencli browser open https://example.com/target-page && opencli browser --session research state\n\n# Look for elements that match the adapter's intent\n# Compare the snapshot with what the adapter expects\n```\n\n### API changed (API_ERROR, EMPTY_RESULT)\n\n```bash\n# Open page with network interceptor, then trigger the action manually\nopencli browser open https://example.com/target-page && opencli browser --session research state\n\n# Interact to trigger API calls\nopencli browser click <N> && opencli browser --session research network\n\n# Narrow to the request you care about by the fields its body should have\nopencli browser --session research network --filter author,text,likes\n\n# Inspect specific API response (key is the `key` field from the default JSON output)\nopencli browser --session research network --detail <key>\n```\n\n## Step 4: Patch the Adapter\n\nRead the adapter source file at `adapterSourcePath` from the trace summary front matter and make targeted fixes. This path is authoritative — it may be in the repo (`clis/`) or user-local (`~/.opencli/clis/`).\n\nUse the `Read` tool on the exact path from summary.md front matter.\n\n### Common Fixes\n\n**Selector update:**\n```typescript\n// Before: page.evaluate('document.querySelector(\".old-class\")...')\n// After:  page.evaluate('document.querySelector(\".new-class\")...')\n```\n\n**API endpoint change:**\n```typescript\n// Before: const resp = await page.evaluate(`fetch('/api/v1/old-endpoint')...`)\n// After:  const resp = await page.evaluate(`fetch('/api/v2/new-endpoint')...`)\n```\n\n**Response schema change:**\n```typescript\n// Before: const items = data.results\n// After:  const items = data.data.items  // API now nests under \"data\"\n```\n\n**Wait condition update:**\n```typescript\n// Before: await page.wait({ selector: '.loading-spinner', hidden: true })\n// After:  await page.wait({ selector: '[data-loaded=\"true\"]' })\n```\n\n### Rules for Patching\n\n1. **Make minimal changes** — fix only what's broken, don't refactor\n2. **Keep the same output structure** — `columns` and return format must stay compatible\n3. **Prefer API over DOM scraping** — if you discover a JSON API during exploration, switch to it\n4. **Use `@jackwener/opencli/*` imports only** — never add third-party package imports\n5. **Test after patching** — run the command again to verify\n6. **Never relax `verify/<cmd>.json` fixtures to silence a failure.** A failing `patterns` / `notEmpty` / `mustNotContain` / `mustBeTruthy` rule means the adapter's output is broken. Tighten the adapter so it produces correct values; do not loosen the fixture to accept the broken values. The one legitimate reason to edit a fixture during repair is when the **site itself** changed shape (e.g. URL format migration) — in that case update the fixture and note the change in `~/.opencli/sites/<site>/notes.md`. Otherwise editing the fixture is covering up a silent correctness regression.\n\n## Step 5: Verify the Fix\n\n```bash\n# Run the command normally\nopencli <site> <command> [args...]\n```\n\nIf it still fails, go back to Step 1 and collect a fresh trace. You have a budget of **3 repair rounds** (trace → fix → retry). If the same error persists after a fix, try a different approach. After 3 rounds, stop and report what was tried.\n\n## Step 6: File an Upstream Issue\n\nIf the retry **passes**, the local adapter has drifted from upstream. File a GitHub issue so the fix flows back to `jackwener/OpenCLI`.\n\n**Do NOT file for:**\n- `AUTH_REQUIRED`, `BROWSER_CONNECT`, `ARGUMENT`, `CONFIG` — environment/usage issues, not adapter bugs\n- CAPTCHA or rate limiting — not fixable upstream\n- Failures you couldn't actually fix (3 rounds exhausted)\n\n**Only file after a verified local fix** — the retry must pass first.\n\n**Procedure:**\n\n1. Prepare the issue content from the trace summary you already have:\n   - **Title:** `[autofix] <site>/<command>: <error_code>` (e.g. `[autofix] zhihu/hot: SELECTOR`)\n   - **Body** (use this template):\n\n```markdown\n## Summary\nOpenCLI autofix repaired this adapter locally, and the retry passed.\n\n## Adapter\n- Site: `<site>`\n- Command: `<command>`\n- OpenCLI version: `<version from opencli --version>`\n\n## Original failure\n- Error code: `<error_code>`\n\n~~~\n<error_message>\n~~~\n\n## Local fix summary\n\n~~~\n<1-2 sentence description of what you changed and why>\n~~~\n\n_Issue filed by OpenCLI autofix after a verified local repair._\n```\n\n2. **Ask the user before filing.** Show them the draft title and body. Only proceed if they confirm.\n\n3. If the user approves and `gh auth status` succeeds:\n\n```bash\ngh issue create --repo jackwener/OpenCLI \\\n  --title \"[autofix] <site>/<command>: <error_code>\" \\\n  --body \"<the body above>\"\n```\n\nIf `gh` is not installed or not authenticated, tell the user and skip — do not error out.\n\n## When to Stop\n\n**Hard stops (do not modify code):**\n- **AUTH_REQUIRED / BROWSER_CONNECT** — environment issue, not adapter bug\n- **Site requires CAPTCHA** — can't automate this\n- **Rate limited / IP blocked** — not an adapter issue\n\n**Soft stops (report after attempting):**\n- **3 repair rounds exhausted** — stop, report what was tried and what failed\n- **Feature completely removed** — the data no longer exists\n- **Major redesign** — needs full adapter rewrite via `opencli-adapter-author` skill\n\nIn all stop cases, clearly communicate the situation to the user rather than making futile patches.\n\n## Example Repair Session\n\n```\n1. User runs: opencli zhihu hot\n   → Fails: SELECTOR \"Could not find element: .HotList-item\"\n\n2. AI runs: opencli zhihu hot --trace retain-on-failure 2>trace-error.yaml\n   → Gets trace summary with final state and failed action evidence\n\n3. AI reads summary/state: page loaded but uses \".HotItem\" instead of \".HotList-item\"\n\n4. AI explores: opencli browser open https://www.zhihu.com/hot && opencli browser --session research state\n   → Confirms new class name \".HotItem\" with child \".HotItem-content\"\n\n5. AI patches: Edit adapter at `adapterSourcePath` — replace \".HotList-item\" with \".HotItem\"\n\n6. AI verifies: opencli zhihu hot\n   → Success: returns hot topics\n\n7. AI prepares upstream issue draft, shows it to the user\n\n8. User approves → AI runs: gh issue create --repo jackwener/OpenCLI --title \"[autofix] zhihu/hot: SELECTOR\" --body \"...\"\n```\n","skills/opencli-browser/SKILL.md":"---\nname: opencli-browser\ndescription: Drive Chrome with OpenCLI to inspect pages, interact with logged-in sites, capture API evidence, and manage native tabs, groups and windows. For reusable adapters, see opencli-adapter-author.\nallowed-tools: Bash(opencli:*), Read, Edit, Write\n---\n\n# Browser tools for agents\n\nUse the capability that fits the task. Discover commands with `opencli list <query>`;\nread exact arguments with `opencli browser <command> --help -f json`.\nRun `opencli doctor` when diagnosing a connection problem.\n\n## Scope and results\n\nPage commands require `--session <name>`. Reuse that name across a task; different\nsessions isolate page work. `--profile <name>` selects a Chrome profile when needed.\nOptions can follow command arguments:\n\n```sh\nopencli browser open https://example.com --session research\nopencli browser state --session research\nopencli browser click --role button --name Search --session research\nopencli browser close --session research\n```\n\nDefault output is one JSON value: `{ \"ok\": true, \"data\": ... }`.\nFailures return `{ \"ok\": false, \"error\": { \"code\": \"...\", \"message\": \"...\", \"hint\": \"...\" } }`\nand a nonzero exit code. Diagnostic logs go to stderr. `network --follow` and\n`console --follow` stream one envelope per line. Help, version and shell completion\nare inspection/export surfaces; structured help uses `--help -f json`.\n\nRead the returned data and error fields. When `error.outcome` is `unknown`, inspect\nthe affected state before retrying a write.\n\n## Page targets and interaction\n\n- `state` returns URL and interactive element refs. `--diagnostics` also reports\n  page title and available initial-state globals; `--source ax` selects the AX tree.\n- `find` locates elements. Interaction targets accept a ref, CSS selector, or\n  semantic flags such as `--role`, `--name`, `--label`, `--text`, `--testid`.\n- Use `--nth` to resolve deliberate multiple matches. On stale refs, read fresh\n  state or locate the element again; refs describe the observed page.\n- `fill` replaces a field and verifies its value; `type` types text. Other actions\n  include `click`, `select`, `check`, `upload`, `drag`, `keys` and `dialog`.\n- `get text|value|attributes|html`, `extract`, `screenshot` and `eval` expose page data.\n  `eval` executes JavaScript in the page and can mutate state.\n- `wait xhr|selector|text|download` observes an expected condition. Consult help for\n  timeouts and matching rules.\n- `tab list|new|select|close` manages session tabs. `--tab` accepts the page identity\n  returned by these commands; these are different from numeric native Chrome IDs.\n- `frames` lists frame targets; `eval --frame` evaluates in a selected frame.\n\n`bind --session research` attaches the current user tab. Bound sessions permit\nnavigation but block tab creation, selection and closure. `unbind` releases the\nbinding without closing the user tab. Owned sessions keep leased tabs until\n`close` or idle expiry. `--window foreground|background` controls owned windows.\n\n## Network and scripts\n\n```sh\nopencli browser network --session research\nopencli browser network --detail RequestKey --session research\nopencli browser network --static --session research\n```\n\n`network` returns captured request keys and response shapes. `--detail` reads a\ncached request; `--filter` selects shapes; `--raw` exposes response bodies.\n`--static` adds Acorn AST candidates from loaded scripts, with source locations,\nmethods, query/body parameter names and observed request evidence. `EXPR` means\nan unresolved expression. Static candidates are not executed or proven APIs.\nCheck `analysisComplete`, script errors, omitted scripts and `networkSource`.\nAn empty live capture can reuse a recent cache; reported provenance distinguishes it.\n\n## Native Chrome management\n\nNative calls need no session and do not attach a page:\n\n```sh\nopencli browser chrome describe\nopencli browser chrome describe tabs.group\nopencli browser chrome call tabs.query '[{}]'\nopencli browser chrome call tabs.group '[{\"tabIds\":[123,456],\"createProperties\":{\"windowId\":10}}]'\nopencli browser chrome call tabGroups.update '[789,{\"title\":\"Research\",\"color\":\"blue\"}]'\n```\n\nUse numeric IDs returned by Chrome, replacing the example IDs. `describe` lists\nsupported methods, signatures, live permissions and official documentation.\nNative-created tabs/windows persist. Mutations touching active OpenCLI session\ntabs or managed groups return `managed_resource_conflict`; ordinary groups can be\ncreated, renamed, recolored, moved and dissolved with `tabs.ungroup`.\n","skills/opencli-usage/SKILL.md":"---\nname: opencli-usage\ndescription: Discover and invoke OpenCLI capabilities for websites and Chrome. Find commands, inspect schemas, interpret results and choose browser or adapter tools.\nallowed-tools: Bash(opencli:*), Read\n---\n\n# OpenCLI\n\nOpenCLI exposes reusable website adapters and direct Chrome tools to agents.\n\n```sh\nopencli list search\nopencli list --site github --limit 20\nopencli github search --help -f json\n```\n\n`list [query]` searches command names and descriptions across built-ins and adapters.\nResults are compact and paginated: `{ok:true,data:{commands,total,nextOffset?}}`.\nPass `--offset <nextOffset>` to continue. Inspect exact inputs using command help.\nDo not hard-code the adapter catalog or dump all commands into context.\n\n## Results\n\nCommands default to JSON: `{ok:true,data:...}`. Failures produce a nonzero exit\ncode and `{ok:false,error:{code,message,hint?,...}}` on stdout. Logs use stderr.\nExplicit `-f table|yaml|csv|md|plain` exports are available where command help lists\nthem. Help, version, completion and explicit plain exports retain their own formats.\nBrowser follow modes stream one JSON envelope per line.\n\nUse error codes and supplied candidates/keys to recover. Unknown write outcomes\nrequire inspecting state before repeating an action. An empty successful result\nis distinct from an execution failure.\n\n## Browser\n\n```sh\nopencli browser open https://example.com --session research\nopencli browser state --session research\nopencli browser close --session research\nopencli browser chrome describe\n```\n\nPage commands use `--session`; native Chrome methods need no session. `--profile`\nselects a connected Chrome profile. `profile list` discovers profiles and\n`profile use <name>` selects the default. Use `doctor` for connection diagnosis.\nSee `opencli-browser` for interaction, API evidence and tab/group management.\n\n## Adapters and installation\n\n```sh\nopencli adapter init example/search\nopencli adapter check example/search\nopencli adapter test example/search --args '{\"query\":\"hello\"}'\nopencli adapter status\n```\n\nSee `opencli-adapter-author` for implementation. `adapter eject` makes an official\nsite editable locally; `adapter reset` removes that local override. Adapter tests\nexecute real commands and can perform writes.\n\n`plugin list|install|update|uninstall|create` manages plugin packages. `auth status`\nreports website login facts; `auth refresh` touches sessions. `daemon status|restart|stop`\nmanages the bridge process. Read help for the selected capability.\n\nBundled skill content is available through `skills list` and\n`skills read opencli-browser -f plain` (omit `-f plain` for JSON).\n","skills/smart-search/SKILL.md":"---\nname: smart-search\ndescription: 基于 opencli 命令的智能搜索路由器。当用户想要使用 OpenCLI、CLI 或 API 搜索、查询、查找或研究信息时，尤其是涉及指定网站、社交媒体、技术资料、新闻、购物、旅游、求职、金融或中文内容时，务必使用此 skill\n---\n\n# 智能搜索路由器\n\n根据话题和场景，将查询路由到最佳的 opencli 搜索源。此 skill 的核心目标不是记忆命令，而是先定位数据源，再让 Agent 通过 `opencli` 自己读取实时帮助，避免文档漂移。\n\n## 强制预检\n\n每次使用前，必须先做下面两步：\n\n- 运行 `opencli list -f yaml`\n- 用 live registry 确认候选站点是否存在，并检查 `strategy`、`browser`、`domain`\n\n选定站点后，必须再做下面两步：\n\n- 运行 `opencli <site> -h` 查看该站点有哪些子命令\n- 若已锁定某个子命令，再运行 `opencli <site> <command> -h` 查看参数、输出列、策略\n\n不要在 skill 文档里硬编码参数或假设命令签名；以 `opencli ... -h` 的实时输出为准。\n\n## 主路由规则\n\n只使用这一条规则，不再维护多套优先级：\n\n1. 当用户明确指定网站、平台或数据源时，直接使用对应网站。\n2. 当用户没有指定网站时，优先只选择一个 AI 源：`grok`、`doubao`、`gemini` 三选一。\n3. 当 AI 返回内容不足、缺少原始数据、需要权威佐证或需要垂直结果时，再补充 1-2 个专用源。\n\n## 单题预算与频率限制\n\n把“单个用户问题”理解为同一意图链路下的一次问题求解；同一轮追问、澄清、补充条件，若核心问题未变，仍算同一题。\n\n先建立一份站点调用台账。每次真正执行搜索命令后，立刻更新：\n\n- `site`\n- `query`\n- `count`\n- `status`\n\n计数规则：\n\n- `opencli list -f yaml`、`opencli <site> -h`、`opencli <site> <command> -h` 属于预检与帮助，不计入搜索次数\n- 一次真正的 `opencli <site> ...` 搜索/查询执行，计为该站点 1 次调用\n- 同站点因为报错、超时、验证码、反爬、登录态异常而失败，也算 1 次调用，不要无限重试\n\n频率上限：\n\n- AI 站点硬限制：同一题内，每个 AI 站点最多调用 1 次\n- 默认策略仍然是只选 1 个 AI 站点，不要把多个 AI 站点串成常规流程\n- 只有当用户明确要求比较多个 AI 站点时，才可以额外调用其他 AI 站点；但每个被点名的 AI 站点仍然最多 1 次\n- 非 AI 站点默认最多调用 2 次\n- 非 AI 站点第 2 次调用必须有明确理由，例如第一次结果过宽，需要加时间、地区、类别、排序或关键词限定\n- 非 AI 站点不要进行第 3 次调用；若信息仍不足，停止扩搜并明确说明缺口\n\n触发限频后的处理：\n\n- 记录：「已跳过：<site> 达到频率上限」\n- 优先改用其他同类站点\n- 若没有合适替代源，则直接基于已收集信息回答，并说明覆盖范围与缺口\n\n## 查询结束汇报\n\n每次查询结束后，回答末尾必须追加一段简短的“搜索摘要”，至少包含下面三项：\n\n- 使用了什么网站搜索\n- 每个网站搜了什么词\n- 每个网站搜了几次\n\n如果有被限频跳过的站点，也要明确写出。\n\n建议使用下面的固定格式：\n\n```md\n搜索摘要\n- 网站：<site1> | 查询词：<term1> | 次数：<n>\n- 网站：<site2> | 查询词：<term2>；<term3> | 次数：<n>\n- 已跳过：<site3>，原因：达到频率上限\n```\n\n## AI 源选择\n\n- `grok`\n  适合实时讨论、英文互联网舆论、Twitter/X 语境、热点追踪。\n- `doubao`\n  适合中文语境、字节抖音生态、生活方式内容、中文热点与泛中文问答。\n- `gemini`\n  适合全球网页、英文资料、通用信息检索、背景综述。\n\n如果用户没有指定网站，默认先判断语言和语境，再从这三个里只选一个。\n\n一旦某个 AI 站点已经执行过一次真实查询，就不要在同一题里改写关键词后再次调用该 AI 站点。若答案不足，优先补专用源，不要反复追打同一个 AI 站点。\n\n## AI 查询词建议\n\n当使用 AI 源时，不要只丢一个过短关键词。优先构造成“主题 + 目标 + 限定条件”的查询。\n\n- 主题\n  用户真正要查的对象、事件、产品、人物、公司、技术名词。\n- 目标\n  想要什么结果，例如总结、对比、原因、趋势、推荐、原始线索。\n- 限定条件\n  语言、地区、时间范围、平台范围、受众、价格带、岗位地点、是否要引用原始来源。\n\n优先使用下面这种表达方式：\n\n- `<主题> + <你要回答的问题>`\n- `<主题> + <时间范围/地区/语言>`\n- `<主题> + <平台或来源范围>`\n- `<主题> + <输出要求>`\n\n避免只输入：\n\n- 单个名词\n- 没有时间范围的热点问题\n- 没有地区限制的购物、求职、旅游问题\n- 没有平台限制的社交媒体问题\n\n## 专用源补充时机\n\n当出现以下任一情况时，再补充专用源：\n\n- AI 给出的是摘要，但你需要原始帖子、原始视频、原始商品或原始职位结果\n- AI 覆盖面不足，漏掉垂直站点信息\n- 需要更高权威性或更强领域相关性\n- 用户明确要求“从某个平台找”\n\n单次查询通常控制在 1 个 AI 源 + 1 到 2 个专用源，避免结果过载。\n\n## 处理不可用的源\n\n当站点不可用时：\n\n- 不要因为单个源失败而中止整个搜索\n- 记录：「已跳过：<site> 不可用」\n- 回退到同类其他站点，或回退到一个 AI 源\n- 始终以 `opencli list -f yaml` 与 `opencli <site> -h` 的实际结果为准\n\n不要假设任何站点“绝对可用”。即使是公开站点，也以当前环境中的 live help 和执行结果为准。\n\n## 参考文件\n\n根据需要读取对应文件：\n\n- **`references/sources-ai.md`** — AI 默认源\n- **`references/sources-tech.md`** — 技术 / 学术\n- **`references/sources-social.md`** — 社交媒体\n- **`references/sources-media.md`** — 媒体 / 娱乐\n- **`references/sources-info.md`** — 资讯 / 知识\n- **`references/sources-shopping.md`** — 购物\n- **`references/sources-travel.md`** — 旅游\n- **`references/sources-other.md`** — 其他垂直源\n\n只读与当前查询相关的文件，无需全部加载。\n","skills/smart-search/references/sources-ai.md":"# AI 默认源\n\n当用户没有明确指定网站时，先在 `grok`、`doubao`、`gemini` 中选择一个，不要一开始并行跑多个 AI 源。\n\n## 使用规则\n\n1. 先运行 `opencli list -f yaml`\n2. 确认 `grok`、`doubao`、`gemini` 哪些在当前 registry 中可用\n3. 运行 `opencli <site> -h`\n4. 锁定具体子命令后，再运行 `opencli <site> <command> -h`\n\n## 路由建议\n\n### grok\n\n- 适用：实时热点、Twitter/X 语境、英文互联网讨论、舆论与趋势\n- 补充源常见去向：`twitter`、`reddit`、`reuters`、`google`\n- 搜索词建议：\n  - 加上时间范围，例如“today / this week”\n  - 加上平台范围，例如“on X”, “from social posts”\n  - 加上目标，例如“latest reactions”, “main viewpoints”, “key claims”\n  - 示例：\n    - `OpenAI latest reactions on X this week`\n    - `TSLA earnings main viewpoints on social media April 2026`\n    - `Nintendo Switch 2 rumors latest discussion on X`\n\n### doubao\n\n- 适用：中文语境、国内热点、生活方式、字节生态、中文泛问答\n- 补充源常见去向：`xiaohongshu`、`weibo`、`zhihu`、`bilibili`、`36kr`\n- 搜索词建议：\n  - 加上中文场景限定，例如“中文讨论”“国内用户”“小红书/微博语境”\n  - 加上需求目标，例如“帮我总结”“给我对比”“提取推荐理由”\n  - 加上人群或用途，例如“适合新手”“预算 500 元内”“上海求职”\n  - 示例：\n    - `2026年MacBook Air值得买吗 中文讨论里主要观点是什么`\n    - `上海 AI 产品经理招聘趋势 近一个月中文信息总结`\n    - `敏感肌防晒推荐 中文用户常提到的优缺点`\n\n### gemini\n\n- 适用：全球网页、英文资料、背景综述、通用检索\n- 补充源常见去向：`google`、`wikipedia`、`arxiv`、`stackoverflow`\n- 搜索词建议：\n  - 加上主题类型，例如“overview”, “comparison”, “background”, “best sources”\n  - 加上范围限定，例如地区、时间、语言、行业\n  - 加上结果形式，例如“with sources”, “compare pros and cons”, “official guidance”\n  - 示例：\n    - `MCP overview and official guidance with sources`\n    - `best budget travel destinations in Japan April 2026 compare pros and cons`\n    - `TypeScript decorators current status official sources`\n\n## 补充原则\n\n- 先用一个 AI 源拿到初步答案\n- 若答案缺少原始数据、垂直结果或权威来源，再补 1-2 个专用源\n- 不要把 AI 默认源当成命令签名的事实来源；命令细节始终以 `opencli ... -h` 为准\n\n## 通用写法模板\n\n可直接按下面模板构造 AI 查询：\n\n- 热点/新闻：\n  `<事件> + 最新进展 + <时间范围> + <地区/平台>`\n- 对比/推荐：\n  `<对象A> vs <对象B> + <关注维度> + <人群/预算/用途>`\n- 中文社区：\n  `<主题> + 中文讨论里主要观点 + <时间范围>`\n- 全球资料：\n  `<主题> + overview/background + with sources`\n- 求职：\n  `<岗位> + <城市/国家> + market trends/hiring + <时间范围>`\n- 购物：\n  `<商品> + reviews/price/value + <地区/预算>`\n","skills/smart-search/references/sources-info.md":"# 资讯 / 知识\n\n用于新闻、百科、通用知识、中文科技资讯。\n\n## 站点\n\n### google\n\n- 适用：通用网页搜索、跨站点兜底\n- 使用前先运行：`opencli google -h`\n\n### wikipedia\n\n- 适用：名词解释、背景知识、历史事实\n- 使用前先运行：`opencli wikipedia -h`\n\n### reuters\n\n- 适用：国际新闻、事实性报道\n- 使用前先运行：`opencli reuters -h`\n\n### weibo\n\n- 适用：微博热点、话题、中文舆论\n- 使用前先运行：`opencli weibo -h`\n\n### 36kr\n\n- 适用：中文科技、创业、融资资讯\n- 使用前先运行：`opencli 36kr -h`\n\n### substack\n\n- 适用：newsletter、作者订阅内容、长文\n- 使用前先运行：`opencli substack -h`\n\n## 路由提示\n\n- 背景知识与定义，优先 `wikipedia`\n- 通用网页与跨站信息，优先 `google`\n- 国际新闻优先 `reuters`\n- 中文科技资讯优先 `36kr`\n- 当 AI 回答过于泛化时，用这些源补充事实和链接\n","skills/smart-search/references/sources-media.md":"# 媒体 / 娱乐\n\n用于视频、电影、剧集、音乐、播客、内容平台搜索。\n\n## 站点\n\n### bilibili\n\n- 适用：中文视频、教程、评测、UP 主内容\n- 使用前先运行：`opencli bilibili -h`\n\n### youtube\n\n- 适用：英文视频、教程、评测、访谈\n- 使用前先运行：`opencli youtube -h`\n\n### tiktok\n\n- 适用：短视频、热点、娱乐内容\n- 使用前先运行：`opencli tiktok -h`\n\n### imdb\n\n- 适用：电影、剧集、演员、评分\n- 使用前先运行：`opencli imdb -h`\n\n### douban\n\n- 适用：中文电影、书籍、音乐口碑与评分\n- 使用前先运行：`opencli douban -h`\n\n### spotify\n\n- 适用：歌曲、歌手、专辑\n- 使用前先运行：`opencli spotify -h`\n\n### apple-podcasts\n\n- 适用：播客节目、播客搜索\n- 使用前先运行：`opencli apple-podcasts -h`\n\n### medium\n\n- 适用：长文、技术博客、设计与产品文章\n- 使用前先运行：`opencli medium -h`\n\n## 路由提示\n\n- 用户只说“找视频/影评/播客/文章”，先按内容形态选站点\n- 用户没有指定站点时，可先用 `doubao`、`grok` 或 `gemini` 做一轮粗检索，再补媒体站点拿原始结果\n","skills/smart-search/references/sources-other.md":"# 其他垂直源\n\n用于求职、金融、书籍、词典等不适合归入前述分类的场景。\n\n## 站点\n\n### linkedin\n\n- 适用：全球职位、英文岗位、跨国公司招聘\n- 使用前先运行：`opencli linkedin -h`\n\n### boss\n\n- 适用：国内职位搜索、招聘与岗位信息\n- 使用前先运行：`opencli boss -h`\n\n### xueqiu\n\n- 适用：股票、金融讨论、行情相关线索\n- 使用前先运行：`opencli xueqiu -h`\n\n### weread\n\n- 适用：中文书籍搜索与书单线索\n- 使用前先运行：`opencli weread -h`\n\n### dictionary\n\n- 适用：英文词义、基础词典查询\n- 使用前先运行：`opencli dictionary -h`\n\n### sinablog\n\n- 适用：较旧的中文博客内容\n- 使用前先运行：`opencli sinablog -h`\n\n## 路由提示\n\n- 求职：全球优先 `linkedin`，国内优先 `boss`\n- 金融：优先 `xueqiu`\n- 书籍：中文优先 `weread`\n- 英文单词定义：优先 `dictionary`\n","skills/smart-search/references/sources-shopping.md":"# 购物\n\n用于商品搜索、价格、好价、口碑与区域电商结果。\n\n## 站点\n\n### amazon\n\n- 适用：全球商品搜索、价格参考、英文电商\n- 使用前先运行：`opencli amazon -h`\n\n### smzdm\n\n- 适用：国内好价、优惠、导购、商品讨论\n- 使用前先运行：`opencli smzdm -h`\n\n### coupang\n\n- 适用：韩国电商商品搜索\n- 使用前先运行：`opencli coupang -h`\n\n### douban\n\n- 适用：图书、影视、音乐类口碑补充，不是标准电商，但可用于消费决策补充\n- 使用前先运行：`opencli douban -h`\n\n## 路由提示\n\n- 用户指定平台时直接使用该平台\n- 未指定平台时，全球商品优先 `amazon`，国内好价优先 `smzdm`\n- 若先用 AI 做产品调研，后续可补电商站点拿实际商品结果\n","skills/smart-search/references/sources-social.md":"# 社交媒体\n\n用于需要原始帖子、原始用户结果、中文或英文社区讨论时。\n\n## 站点\n\n### twitter\n\n- 适用：Twitter/X 原始帖子、作者、实时讨论\n- 使用前先运行：`opencli twitter -h`\n\n### weibo\n\n- 适用：微博热点、话题、中文舆论\n- 使用前先运行：`opencli weibo -h`\n\n### xiaohongshu\n\n- 适用：生活方式、穿搭、美妆、旅行、真实体验\n- 使用前先运行：`opencli xiaohongshu -h`\n\n### zhihu\n\n- 适用：中文深度问答、专业解释、行业经验\n- 使用前先运行：`opencli zhihu -h`\n\n### tieba\n\n- 适用：兴趣圈子、历史帖子、粉丝社区\n- 使用前先运行：`opencli tieba -h`\n\n### instagram\n\n- 适用：账号搜索、图片社交线索\n- 使用前先运行：`opencli instagram -h`\n\n### facebook\n\n- 适用：主页、人物、帖子线索\n- 使用前先运行：`opencli facebook -h`\n\n### bluesky\n\n- 适用：Bluesky 用户与账号搜索\n- 使用前先运行：`opencli bluesky -h`\n\n## 路由提示\n\n- 用户明确指定某个平台时，直接用该平台\n- 用户只说“社交媒体上怎么看”时，可先选 `grok` 或 `doubao`\n- AI 只给摘要而没有原始帖子时，再切到对应社交站点\n","skills/smart-search/references/sources-tech.md":"# 技术 / 学术\n\n用于技术问题、研究论文、开发者讨论、开源社区信息。\n\n## 站点\n\n### arxiv\n\n- 适用：论文、研究、模型、算法、学术背景\n- 使用前先运行：`opencli arxiv -h`\n\n### stackoverflow\n\n- 适用：具体报错、API 用法、代码模式、实现细节\n- 使用前先运行：`opencli stackoverflow -h`\n\n### hackernews\n\n- 适用：技术社区讨论、开发者观点、创业和产品话题\n- 使用前先运行：`opencli hackernews -h`\n\n### reddit\n\n- 适用：英文社区问答、经验贴、推荐、对比讨论\n- 使用前先运行：`opencli reddit -h`\n\n### linux-do\n\n- 适用：人工智能、开源工具、中文技术社区\n- 使用前先运行：`opencli linux-do -h`\n\n### v2ex\n\n- 适用：中文技术社区\n- 使用前先运行：`opencli linux-do \n\n## 路由提示\n\n- 用户明确说“论文”“研究”时，优先 `arxiv`\n- 用户明确说“报错”“API 怎么用”时，优先 `stackoverflow`\n- 用户要“社区讨论”“开发者观点”时，优先 `hackernews` 或 `reddit`\n- 若用户没有明确指定站点，可先选 `gemini` 或 `grok`，内容不足时再补这些专用源\n","skills/smart-search/references/sources-travel.md":"# 旅游\n\n用于目的地、景区、酒店联想和旅行线索。\n\n## 站点\n\n### ctrip\n\n- 适用：目的地、景区、酒店、旅行联想搜索\n- 使用前先运行：`opencli ctrip -h`\n\n### xiaohongshu\n\n- 适用：生活方式、穿搭、美妆、旅行、真实体验\n- 使用前先运行：`opencli xiaohongshu -h`\n\n## 路由提示\n\n- 用户明确说“携程”时直接使用 `ctrip`\n- 用户只说“旅行/目的地/景区”时，可先选 `doubao` 做中文语境粗检索，再补 `ctrip`\n"},"files":{"llms.txt":"# OpenCLI\n\n> General-purpose browser automation for AI agents, with reusable site commands.\n> An agent-browser alternative: navigate, inspect, click, fill, extract, and screenshot without a site adapter.\n\n## What OpenCLI does\n\nOpenCLI gives you one surface for three kinds of automation:\n\n1. **Built-in adapters** — 100+ sites pre-built with deterministic commands (Twitter/X, Xiaohongshu, Bilibili, Zhihu, LinkedIn, YouTube, Reddit, HackerNews, and more)\n2. **Browser automation for AI agents** — AI agents (Claude Code, Cursor, etc.) can navigate, click, type, fill, extract, and screenshot any page via `opencli browser` primitives and your logged-in Chrome session\n3. **Adapter authoring** — Write new site adapters end-to-end using `opencli-adapter-author` skill + `opencli browser recon/init/verify` workflow\n\n\n## OpenCLI as an agent-browser alternative\n\nOpenCLI supports general-purpose browser workflows without pre-built site adapters. The browser loop is: `opencli browser work open https://example.com`, `opencli browser work state`, then choose actions such as `click`, `fill`, or `extract`. Inspect state again after page changes; release the session with `opencli browser work close`.\n\nUse the same named session across calls. Element references, CLI arguments, outputs, and session lifecycle differ from agent-browser, so migrate and verify existing scripts rather than treating the CLIs as syntax-compatible. Adapters are optional shortcuts for recurring site tasks, not a prerequisite for browsing.\n\n- [OpenCLI vs agent-browser and migration](https://opencli.info/docs/guide/agent-browser-alternative.html)\n- [中文迁移指南](https://opencli.info/docs/zh/guide/agent-browser-alternative.html)\n- [Browser Bridge setup](https://opencli.info/docs/guide/browser-bridge.html)\n\n## Install\n\n```bash\nnpm install -g @jackwener/opencli\n# Install Browser Bridge extension from Chrome Web Store\n# Run: opencli doctor\n```\n\nRequires Node.js >= 20.\n\n## Key capabilities\n\n- **No built-in LLM dependency** — commands do not invoke an LLM; an AI agent orchestrating actions has its own model costs\n- **Account safe** — reuses Chrome's logged-in state, credentials never leave the browser\n- **Deterministic adapters** — site commands provide structured output schemas. Pipeable, CI-friendly\n- **Plugin system** — `opencli plugin install github:user/repo`\n- **Multiple output formats** — JSON, CSV, Markdown, YAML, table\n\n## Skills for AI agents\n\nInstall with: `npx skills add jackwener/opencli`\n\n- `opencli-browser` — give AI agents direct browser control (click, type, extract, screenshot)\n- `opencli-adapter-author` — write new adapters from recon to verification\n- `opencli-autofix` — repair broken adapters\n- `opencli-usage` — command and site reference\n\n## Built-in sites (selected)\n\nTwitter/X, Xiaohongshu, Bilibili, Zhihu, LinkedIn, YouTube, Reddit, HackerNews, Weibo, Amazon, Bloomberg, V2EX, Boss直聘, Douyin, Spotify, WeChat, Xiaoyuzhou, NotebookLM, Claude, Gemini, GeoGebra, xueqiu, zlibrary, upwork, 1688, pixiv, and 80+ more.\n\nFull list: https://github.com/jackwener/opencli/blob/main/docs/adapters/index.md\n\n## Configuration\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `OPENCLI_BROWSER_COMMAND_TIMEOUT` | `60` | Seconds to wait for a browser command |\n| `OPENCLI_PROFILE` | — | Browser Bridge profile alias for multi-profile setups |\n| `OPENCLI_VERBOSE` | `false` | Enable verbose logging |\n\n## Exit codes\n\n`0` success · `66` empty result · `69` Browser Bridge down · `75` timeout · `77` auth required · `78` config error · `130` Ctrl-C\n\n## Key links\n\n- Website: https://opencli.info\n- GitHub: https://github.com/jackwener/opencli\n- npm: https://www.npmjs.com/package/@jackwener/opencli\n- Chrome Extension: https://chromewebstore.google.com/detail/opencli/ildkmabpimmkaediidaifkhjpohdnifk\n- Docs: https://opencli.info\n- Plugins: https://opencli.info/plugins\n\n## Author\n\njackwener — https://github.com/jackwener\n\n## Related resources\n\n- Website llms.txt: https://opencli.info/llms.txt\n- Full adapter docs: https://github.com/jackwener/opencli/blob/main/docs/adapters/index.md\n- Extending guide: https://github.com/jackwener/opencli/blob/main/docs/guide/extending-opencli.md\n- Plugin guide: https://github.com/jackwener/opencli/blob/main/docs/guide/plugins.md\n","skills/opencli-adapter-author/SKILL.md":"---\nname: opencli-adapter-author\ndescription: Build or improve an OpenCLI JS adapter using observed site behavior, existing adapters and browser API evidence. For ad-hoc browser interaction, see opencli-browser.\nallowed-tools: Bash(opencli:*), Bash(jsluice:*), Read, Edit, Write, Grep\n---\n\n# Adapter authoring\n\nChoose the simplest implementation that reliably performs the requested task.\nUse an existing site adapter when it already covers the capability:\n\n```sh\nopencli list search --site example\nopencli example search --help -f json\n```\n\n`list` returns `{ok:true,data:{commands,total,nextOffset?}}`; use `--offset` to\ncontinue, `--limit` to adjust page size. Detailed arguments belong in command help.\n\n## Evidence available to you\n\n```sh\nopencli browser open https://example.com --session research\nopencli browser state --diagnostics --session research\nopencli browser network --session research\nopencli browser network --static --session research\nopencli browser network --detail RequestKey --session research\n```\n\nNetwork captures describe observed requests. Static analysis adds candidates\nfrom loaded scripts without executing them. Inspect source locations, unresolved\nexpressions and capture provenance. Replay read requests when useful and compare\nbusiness data with the page. Write commands have real effects, including when run\nthrough `adapter test`; use a suitable test account or a supported dry-run.\n\nPrefer public APIs with a stable contract when available. Browser fetch, captured\nresponses, page state and semantic UI interactions are also valid choices. Select\nbased on the task and observed reliability. Do not guess endpoint parameters or\nhide failed operations behind empty success results.\n\n## JS interface\n\nAdapters are ordinary files under `~/.opencli/clis/<site>/<command>.js` or repository\n`clis/<site>/`. There is no draft directory, promotion step or required memory file.\n\n```sh\nopencli adapter init example/search\n```\n\nThe existing `cli({...})` interface is unchanged: `func(args)` for Node execution,\n`func(page, args)` with `browser: true`. Reuse neighboring adapters and shared helpers.\nReturn business data; the CLI adds the JSON success envelope. Throw a typed error\nwith an actionable hint for failures. Use `access: 'read'|'write'` accurately.\n`columns` controls human table exports; JSON preserves nested results.\n\n```js\nimport { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n  site: 'example', name: 'search', description: 'Search example items',\n  access: 'read', strategy: Strategy.PUBLIC, browser: false,\n  args: [{ name: 'query', positional: true, required: true }],\n  func: async (args) => {\n    const response = await fetch(`https://example.com/api/search?q=${encodeURIComponent(args.query)}`);\n    if (!response.ok) throw new Error(`Search failed: HTTP ${response.status}`);\n    return response.json();\n  },\n});\n```\n\n## Check and exercise\n\n```sh\nopencli adapter check example/search\nopencli example search 'query'\nopencli adapter test example/search --args '{\"query\":\"query\"}'\n```\n\n`check` validates definitions without executing adapters. `test` executes the normal\nCLI and optionally compares an existing fixture. `--write-fixture`, `--update-fixture`\nand `--no-fixture` control optional expectations; no row-shape or site-memory gate\nis imposed. `--trace retain-on-failure` helps diagnose browser execution.\n\nFor repository changes, cover the core behavior and failure paths with focused\nproduction-path tests, typecheck and build. Verify IDs, fields, units, pagination\nand empty/error semantics relevant to the task. Do not automatically replay a\nwrite with an unknown outcome; inspect state first.\n\n## Optional references\n\nRead only what is useful for the current task: `references/adapter-template.md`\nfor adapter patterns, `api-discovery.md` for request investigation,\n`output-design.md` for result fields, `field-decode-playbook.md` for opaque fields,\nand `deep-recon.md` for difficult private protocols. Site notes and fixtures are\noptional knowledge aids; older reference checklists are not mandatory workflows.\n","skills/opencli-adapter-author/references/adapter-template.md":"# Adapter Template\n\n一份 adapter 就是一次 `cli({...})` 调用。文件结构固定，三段：declaration、args、func。\n\n拿 `clis/eastmoney/convertible.js` 当活例子，对照拆解。\n\n---\n\n## 活例子：convertible.js\n\n> **注意（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(...)` 一起抄过去。\n\n```javascript\n// eastmoney convertible — on-market convertible bond listing.\nimport { cli, Strategy } from '@jackwener/opencli/registry';\nimport { CliError } from '@jackwener/opencli/errors';\n\nconst SORTS = {\n  change:   { fid: 'f3',   order: 'desc' },\n  drop:     { fid: 'f3',   order: 'asc' },\n  turnover: { fid: 'f6',   order: 'desc' },\n  price:    { fid: 'f2',   order: 'desc' },\n  premium:  { fid: 'f237', order: 'desc' },\n  value:    { fid: 'f236', order: 'desc' },\n  ytm:      { fid: 'f239', order: 'desc' },\n};\n\ncli({\n  site: 'eastmoney',\n  name: 'convertible',\n  description: '可转债行情列表（默认按成交额排序）',\n  domain: 'push2.eastmoney.com',\n  strategy: Strategy.PUBLIC,\n  browser: false,\n  args: [\n    { name: 'sort',  type: 'string', default: 'turnover', help: '排序：turnover / change / drop / price / premium' },\n    { name: 'limit', type: 'int',    default: 20,         help: '返回数量 (max 100)' },\n  ],\n  columns: ['rank', 'bondCode', 'bondName', 'bondPrice', 'bondChangePct',\n            'stockCode', 'stockName', 'stockPrice', 'stockChangePct',\n            'convPrice', 'convValue', 'convPremiumPct', 'remainingYears', 'ytm', 'listDate'],\n  func: async (args) => {\n    const sortKey = String(args.sort ?? 'turnover').toLowerCase();\n    const sort = SORTS[sortKey];\n    if (!sort) throw new CliError('INVALID_ARGUMENT', `Unknown sort \"${sortKey}\". Valid: ${Object.keys(SORTS).join(', ')}`);\n    const limit = Math.max(1, Math.min(Number(args.limit) || 20, 100));\n\n    const url = new URL('https://push2.eastmoney.com/api/qt/clist/get');\n    url.searchParams.set('pn', '1');\n    url.searchParams.set('pz', String(limit));\n    url.searchParams.set('po', sort.order === 'desc' ? '1' : '0');\n    url.searchParams.set('np', '1');\n    url.searchParams.set('fltt', '2');\n    url.searchParams.set('invt', '2');\n    url.searchParams.set('fid', sort.fid);\n    url.searchParams.set('fs', 'b:MK0354');\n    url.searchParams.set('fields', 'f12,f14,f2,f3,f6,f229,f230,f232,f234,f235,f236,f237,f238,f239,f243');\n    url.searchParams.set('ut', 'bd1d9ddb04089700cf9c27f6f7426281');\n\n    const resp = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0' } });\n    if (!resp.ok) throw new CliError('HTTP_ERROR', `convertible failed: HTTP ${resp.status}`);\n    const data = await resp.json();\n    const diff = Array.isArray(data?.data?.diff) ? data.data.diff : [];\n    if (diff.length === 0) throw new CliError('NO_DATA', 'eastmoney returned no convertible data');\n\n    return diff.slice(0, limit).map((it, i) => ({\n      rank: i + 1,\n      bondCode: it.f12,\n      bondName: it.f14,\n      bondPrice: it.f2,\n      bondChangePct: it.f3,\n      stockCode: it.f232,\n      stockName: it.f234,\n      stockPrice: it.f229,\n      stockChangePct: it.f230,\n      convPrice: it.f235,\n      convValue: it.f236,\n      convPremiumPct: it.f237,\n      remainingYears: it.f238,\n      ytm: it.f239,\n      listDate: String(it.f243 ?? ''),\n    }));\n  },\n});\n```\n\n---\n\n## 三段解剖\n\n### 1. Declaration — 标头\n\n```javascript\ncli({\n  site: 'eastmoney',          // 第一级命名空间，目录名一致\n  name: 'convertible',        // 第二级，CLI 上的子命令\n  description: '...',         // 一句话，出现在 `opencli list` 和 `opencli <site> -h`\n  domain: 'push2.eastmoney.com',  // 主要请求域名（诊断面板用）\n  strategy: Strategy.PUBLIC,  // PUBLIC / COOKIE / INTERCEPT / UI\n  browser: false,             // PUBLIC 几乎总是 false；COOKIE/INTERCEPT/UI 一律 true\n  ...\n});\n```\n\n### 2. Args & Columns\n\n```javascript\nargs: [\n  { name: 'sort',  type: 'string', default: 'turnover', help: '...' },\n  { name: 'limit', type: 'int',    default: 20,         help: '...' },\n],\ncolumns: ['rank', 'bondCode', 'bondName', /* ... */ ],\n```\n\n**规则**：\n\n- `type`: `string` / `int` / `float` / `bool`\n- `default` 必填（缺失的命令会拒绝启动）\n- `columns` 数组必须跟 `func` 返回的 object keys 完全对上，顺序也一致（决定表格列顺序）\n- 列名 camelCase，跟 `cli({...})` 其他 adapter 保持统一\n- **中间解析对象 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)）\n\n### 3. func — 主体\n\n```javascript\nimport { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';\n\nfunc: async (args) => {\n  // 1. 解析参数 — 越界一律抛，不要 silent clamp\n  const n = Number(args.limit ?? 20);\n  if (!Number.isInteger(n) || n <= 0) throw new ArgumentError('limit must be a positive integer');\n  if (n > 100) throw new ArgumentError('limit must be <= 100');\n  const limit = n;\n\n  // 2. 构造 URL / 请求\n  const url = new URL(...);\n  url.searchParams.set(...);\n\n  // 3. 发请求 — fetch 抛 / HTTP 非 2xx 都归 CommandExecutionError\n  let resp;\n  try {\n    resp = await fetch(url, { headers: { /* ... */ } });\n  } catch (error) {\n    throw new CommandExecutionError(`request failed: ${error?.message || error}`);\n  }\n  if (!resp.ok) throw new CommandExecutionError(`request failed: HTTP ${resp.status}`);\n\n  // 4. 解析 + 业务校验 — 业务空 → EmptyResultError，不要 sentinel row 也不要 return []\n  const data = await resp.json();\n  const diff = Array.isArray(data?.data?.diff) ? data.data.diff : [];\n  if (diff.length === 0) throw new EmptyResultError('site command', 'API returned no rows');\n\n  // 5. map 到 columns 同名 keys\n  return diff.slice(0, limit).map((it, i) => ({\n    rank: i + 1,\n    bondCode: it.f12,\n    // ...\n  }));\n},\n```\n\n**站点级 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。\n\n**参数形态**（**踩过最多次的坑**：搞反签名后 `args` 实际是 `debug` flag，所有 `args.foo` 静默 undefined → fallback 到 default。#1329 upstream 之前 8 个 non-browser adapter 写错过签名，全部 silently fallback 到默认参数）：\n\n- `browser: false`：`func: async (args, debug?) => { ... }` —— **单参 args**，不会收到 `page`\n- `browser: true`：`func: async (page, args, debug?) => { ... }` —— **双参 (page, args)**，第一参是浏览器上下文\n- `args`：所有 `args[]` 声明的参数解析后的 object\n\n**错误处理**：用 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）决定要不要重试。\n\n---\n\n## COOKIE adapter 骨架（需要登录态）\n\nPUBLIC 模式不够（接口 401 / 302 到 login / 响应是\"请登录\"页）就走这里。要点三条：\n\n1. 读 cookie 走 `page.getCookies(...)`，**不要读 `document.cookie`**。\n2. 拿 HTML 走 Node 端 `fetch` + 手动解码，**不要塞进 `page.evaluate` 里**。\n3. Declaration 加 `browser: true`；不需要真的打开目标页时 `navigateBefore: false`。\n\n```javascript\nimport { cli, Strategy } from '@jackwener/opencli/registry';\nimport { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';\n\nconst BASE = 'https://www.example.com';\nconst HOST = 'www.example.com';\nconst ROOT = '.example.com';  // 根域（auth 常在这里）\n\nasync function readCookie(page) {\n    const seen = new Map();\n    for (const opts of [{ domain: HOST }, { domain: ROOT }]) {\n        try {\n            const cookies = await page.getCookies(opts);\n            for (const c of cookies || []) {\n                if (!seen.has(c.name)) seen.set(c.name, c.value);\n            }\n        } catch { /* try next domain */ }\n    }\n    return [...seen].map(([k, v]) => `${k}=${v}`).join('; ');\n}\n\nasync function fetchHtml(url, { cookie, encoding = 'utf-8', headers = {} } = {}) {\n    let resp;\n    try {\n        resp = await fetch(url, {\n            headers: {\n                'User-Agent': 'Mozilla/5.0',\n                'Accept-Language': 'zh-CN,zh;q=0.9',\n                Referer: `${BASE}/`,\n                ...(cookie ? { Cookie: cookie } : {}),\n                ...headers,\n            },\n            redirect: 'follow',\n        });\n    } catch (error) {\n        throw new CommandExecutionError(`example request failed: ${error?.message || error}`);\n    }\n    if (!resp.ok) throw new CommandExecutionError(`example request failed: HTTP ${resp.status}`);\n    const buf = await resp.arrayBuffer();\n    return new TextDecoder(encoding).decode(buf);\n}\n\ncli({\n    site: 'example',\n    name: 'me',\n    access: 'read',\n    description: '示例：需要登录的私有页面',\n    domain: HOST,\n    strategy: Strategy.COOKIE,\n    browser: true,\n    navigateBefore: false,    // 本命令不需要先开目标页\n    args: [{ name: 'limit', type: 'int', default: 20, help: '返回条数' }],\n    columns: ['index', 'title', 'time'],\n    func: async (page, args) => {\n        const limit = Number(args.limit ?? 20);\n        if (!Number.isInteger(limit) || limit <= 0) throw new ArgumentError('limit must be a positive integer');\n\n        const cookie = await readCookie(page);\n        const html = await fetchHtml(`${BASE}/inbox`, { cookie, encoding: 'gbk' });\n\n        if (/请登录|需要登录|<title>Login/i.test(html)) {\n            throw new AuthRequiredError(HOST);\n        }\n\n        // parse html → rows\n        if (!rows.length) throw new EmptyResultError('example me', 'inbox is empty');\n        return rows.slice(0, limit);\n    },\n});\n```\n\n### JSON API 用 `page.fetchJson()`，不要手写 `page.evaluate(fetch(...))`\n\n如果接口必须在浏览器上下文里请求（依赖当前页面 cookie / CORS / origin），用内置 primitive：\n\n```javascript\nconst data = await page.fetchJson(`${BASE}/api/list`, {\n  method: 'POST',\n  headers: { 'X-Requested-With': 'XMLHttpRequest' },\n  body: { page: 1, size: limit },\n});\n```\n\n它固定 `credentials: 'include'`，带 timeout，HTTP 非 2xx / 非 JSON 会抛统一 runtime error。adapter 里不用再手写 `page.evaluate(fetch(...))`；如果你需要额外包一层业务语义，按 [`typed-errors.md`](./typed-errors.md) 映射到 `CommandExecutionError` / `AuthRequiredError` / `EmptyResultError`。\n\n### 页面内 DOM 逻辑用 `page.evaluate(fn, ...args)`\n\n新 adapter 优先写函数形式，外部变量通过参数传入：\n\n```javascript\nconst href = await page.evaluate((selector) => {\n  const link = document.querySelector(selector);\n  return link ? link.getAttribute('href') : null;\n}, 'a[data-testid=\"profile\"]');\n```\n\n`fn` 在浏览器页面上下文执行，不能读取 Node 侧闭包变量；参数必须能被 `JSON.stringify` 序列化。字符串形式 `page.evaluate('document.title')` 仍可用于简单表达式和既有代码，但不要再写依赖隐式 auto-IIFE 的模板字符串函数。\n\n### HTML 不走 browser fetch\n\n三个坑，踩一个就重写：\n\n- **HttpOnly cookie 看不见**：绝大多数登录站点把 auth cookie 标 `HttpOnly`，`document.cookie` 永远读不到它，只能通过 CDP 的 cookie jar 拿（`page.getCookies`）。塞到 `page.evaluate` 里就等于回到 `document.cookie` 那条路，必挂。\n- **`navigateBefore: false` 时当前 tab 不在目标站**：页面 origin 可能是 `about:blank` 或上一条命令留下的别处，从那儿发 fetch 到目标域就是 cross-origin，浏览器 CORS 一挡就是 \"Failed to fetch\"。\n- **非 UTF-8 编码解码麻烦**：GBK / Big5 / Shift-JIS 的站（Discuz / phpBB 老版 / 日站）在 `page.evaluate` 里用 `response.text()` 拿到的是乱码，`TextDecoder('gbk').decode(buf)` 的写法只在 Node 侧干净。\n\n**规则**：JSON 型浏览器接口用 `page.fetchJson()`；HTML 型 COOKIE adapter 一律 Node 侧 `fetch`，浏览器只当 cookie jar 用。\n\n### Selector 稳定性 — 不要 select 用户可见文本\n\nissue #1474 触发：同一个发送按钮在英文 Chrome 是 `aria-label=\"Submit\"`，在中文 Chrome（`chrome://settings/languages` 设中文）变 `aria-label=\"提交\"`，CSS 选择器 `button[aria-label=\"Submit\"]` 在中文环境下直接 0 匹配，silent empty result。\n\n根因不是 i18n bug，是**选择器的 anchor 选错了**。把页面 DOM 属性按 \"locale-stable vs locale-dependent\" 分两类：\n\n| 类 | 例子 | locale 切换会变吗 | 用作 primary selector？ |\n|----|------|----------------|---------------------|\n| **locale-stable 标识** | `data-testid`、`data-*`、稳定 `id` / `class` | 通常不变（开发者内部 ID） | ✅ 首选，但要先确认不是 hash / A-B test |\n| **semantic / scope anchor** | `role`、结构关系、邻近稳定容器 | 不按 locale 翻译，但常常不唯一 | ⚠️ 只作 scope/filter；不要单独用 `button[role=\"button\"]` |\n| **locale-dependent 文本** | `aria-label`、`title`、`placeholder`、`alt`、`textContent` | 变（被 i18n 框架翻译） | ❌ 仅当 stable 选择器全都不存在时的兜底 |\n\nChatGPT 的 web 端就是反例驱动的：有些 controls 暴露稳定 `data-testid`，有些 surfaces 只暴露 `aria-label` / `placeholder`。这种站必须先用 stable selector，再用多语言 fallback list：\n\n```javascript\n// clis/chatgpt/utils.js（简化活例）\nconst COMPOSER_SELECTORS = [\n  '#prompt-textarea',\n  '[data-testid=\"composer\"] [contenteditable=\"true\"]',\n  '[aria-label=\"Chat with ChatGPT\"]',   // en\n  '[aria-label=\"与 ChatGPT 聊天\"]',      // zh-CN\n  '[placeholder=\"Ask anything\"]',\n  '[placeholder=\"有问题，尽管问\"]',       // zh-CN\n];\nconst SEND_BUTTON_SELECTORS = [\n  'button[data-testid=\"send-button\"]:not([disabled])',\n  'button[aria-label=\"Send prompt\"]:not([disabled])',\n  'button[aria-label=\"发送提示\"]:not([disabled])',\n];\n```\n\n写 fallback list 的纪律：\n\n1. **stable selector 放最前**（`#prompt-textarea` / `[data-testid=\"send-button\"]`），locale-dependent 的放后面当兜底\n2. **`role` 只能当 semantic / scope filter**：`dialog [role=\"textbox\"]` 可以；裸 `button` / `[role=\"button\"]` 不够，因为同页可能有多个按钮\n3. **每种 locale 至少列一条**（en + zh-CN 是底线；扩到 ja / ko / ar 看站点用户分布）\n4. **commit 前 grep `aria-label=` / `placeholder=` / `title=` 看是不是漏了 fallback locale**——见 success-rate-pitfalls.md §11\n5. **失败要 typed fail-fast**：找不到 control 应该 `CommandExecutionError` / send-failed，不要返回空 rows 或假成功\n6. **不要给 framework 加 `--i18n \"zh:提交,ja:送信\"` 这种 flag** —— 等于把 fallback list 从 adapter 挪到 CLI，多一层 indirection 还要维护翻译字典。这是 over-engineering，已经在评审时被否\n\n为什么不在 daemon 端固定 Chrome locale？因为 opencli **不启动 Chrome**——daemon 是连用户已经在跑的 Chrome（CDP via extension），用户可能就是中文 UI / 中文资料检索需求。强制 en-US 会破坏用户的正当工作流。\n\n### Cookie 域的双查\n\n```javascript\nfor (const opts of [{ domain: HOST }, { domain: ROOT }]) { ... }\n```\n\n不是所有站都这么玄学，但下面这几类踩坑最多：\n\n| 站点类型 | 坑 |\n|---------|----|\n| Discuz!X / phpBB / vBulletin 论坛 | Auth cookie 设在 `.<root>.com`，HttpOnly；业务页在 `www.<root>.com`。只查 `www.` 会漏 |\n| 多子域账户体系（`account.x.com` vs `api.x.com`） | 登录时写在 account 域，API 域读取时拿不到 |\n| 新版 Chrome SameSite=Lax 默认 | 某些 cookie 查 `url:` 才给返，查 `domain:` 不给 |\n\n双查成本很低，不确定就两个都查，用 Map 去重第一次出现的 name。\n\n### 空态抛 `EmptyResultError`，**不**塞 sentinel 行\n\n历史上这里写的是\"返回一行说明 row 比 `return []` 安全\"。**这条已经反过来了**——见 PR #1329 R3 的 four anti-pattern fixes。现在的契约：\n\n```javascript\nimport { EmptyResultError } from '@jackwener/opencli/errors';\n\n// ❌ 老写法：sentinel 行污染 row 合同，让 listing→detail round-trip 拿到 tid='' 白跑\nif (/暂时没有提醒内容/.test(html)) {\n    return [{ index: 0, from: '', summary: '暂时没有提醒内容', time: '', threadUrl: '' }];\n}\n\n// ✅ 新写法：empty 是合法状态，但不是 row。exit code 66 让 agent 直接 branch\nif (/暂时没有提醒内容/.test(html)) {\n    throw new EmptyResultError('1point3acres notifications', '暂时没有提醒内容');\n}\n```\n\n更多反例和详细 routing 见 [`typed-errors.md` §3](./typed-errors.md)。\n\n---\n\n## 同类型 adapter 对照\n\n| 类型 | 代表 | 参考 |\n|------|------|-----|\n| clist 分页排行 | `convertible.js` / `rank.js` / `etf.js` / `sectors.js` | 都共享 `fs` + `fid` + `po` 结构 |\n| ulist 批量报价 | `quote.js` | `secids` 逗号拼接 |\n| K 线历史 | `kline.js` | `fields1 / fields2` 控列，CSV 解析 |\n| 报表（datacenter-web） | `longhu.js` / `holders.js` | `reportName` 驱动 |\n| 7x24 新闻 | `kuaixun.js` | `np-listapi` 栏目 id |\n| 公司公告 | `announcement.js` | `np-anotice-stock` |\n| 指数/北上 | `index-board.js` / `northbound.js` | push2 专用端点 |\n\n新写一条时，选最像的那类，复制后改 `name` / URL / fields / column 映射三处。\n\n---\n\n## Verify fixture（每个 adapter 配一份 `~/.opencli/sites/<site>/verify/<name>.json`）\n\nverify fixture 是\"adapter 产出长什么样\"的结构锚点。没有它，`opencli adapter test` 只能证\"adapter 能跑完不抛\"，证不出数据没错位。**必写**。\n\n详细 schema 见 `site-memory.md` 的 `verify/<cmd>.json` 节。这里只讲两个容易踩的地方：\n\n### args 形态：object vs array\n\n`args` 字段决定 verify 怎么调你的 adapter：\n\n- **对象形态** `{ \"limit\": 3 }` → 展开成 `--limit 3`，标准 named-flag adapter 用这个\n- **数组形态** `[\"123\", \"--limit\", \"3\"]` → 原样 append 到命令后，**positional 主语型** adapter 必须用这个\n\nrepo 约定\"主语优先 positional\"——thread 详情型、url 解析型、关键词搜索型都用 positional：\n\n```js\n// clis/1point3acres/thread.js — 接收 <tid> 作为主语\ncli({\n  site: '1point3acres',\n  name: 'thread',\n  args: [\n    { name: 'tid',   type: 'string', required: true, positional: true },\n    { name: 'limit', type: 'int',    default: 20 },\n  ],\n  // ...\n});\n```\n\n对应 fixture：\n\n```json\n{\n  \"args\": [\"1234567\", \"--limit\", \"3\"],\n  \"expect\": { \"rowCount\": { \"min\": 1, \"max\": 3 }, \"...\": \"...\" }\n}\n```\n\n**不要写成** `{ \"tid\": \"1234567\", \"limit\": 3 }`——这会被展开成 `--tid 1234567 --limit 3`，commander 把 `--tid` 当未知 flag 报错，或者 adapter 根本不认。\n\n### 种子 → 手改\n\nnamed-flag adapter（`hot` / `latest` 类）可以直接让工具生成种子：\n\n```bash\n# 1. 让 verify 先跑一遍，--write-fixture 生成种子（默认追加 --limit 3）\nopencli adapter test 1point3acres/hot --write-fixture\n\n# 2. 手改 ~/.opencli/sites/1point3acres/verify/hot.json\n#    - patterns: 加 URL / 日期 / ID 正则\n#    - notEmpty: 加核心字段（title / author / url）\n#    - rowCount: 收紧到业务合理区间\n\n# 3. 再跑 verify，fixture 吃得动就 OK\nopencli adapter test 1point3acres/hot\n```\n\npositional adapter 目前 `--write-fixture` 没法表达主语，**首份 fixture 要手写**：\n\n```bash\n# 1. 先直跑 adapter 看输出长啥样\nopencli 1point3acres thread 1173710 --limit 2 --format json | head\n\n# 2. 照着响应手写 ~/.opencli/sites/1point3acres/verify/thread.json\n#    （args 一定用数组: [\"1173710\", \"--limit\", \"2\"]）\n\n# 3. 跑 verify 核对\nopencli adapter test 1point3acres/thread\n```\n\n机器生成的种子只有 rowCount.min=1 / columns / types，挡不住字段值错位。**patterns + notEmpty 无论哪种情形都是肉写的**。\n\n---\n\n## 私人 adapter vs repo 贡献\n\n```\n~/.opencli/clis/<site>/<name>.js    # 私人\nclis/<site>/<name>.js               # repo 贡献\n```\n\n**两者在 `cli({...})` 层面完全一样**。差别只在运行入口：\n\n- 私人：写完立即可跑（`opencli <site> <name>`）\n- repo：要 `npm run build` 才被注册\n\n先在 `~/.opencli/clis/` 调通再拷贝到 `clis/`。\n","skills/opencli-adapter-author/references/api-discovery.md":"# API Discovery\n\n**寻找目标数据 endpoint。** 按当前证据选择需要的调查手段。\n\n下面提供五种调查手段。候选需要结合真实业务结果判断；复杂协议可以参考 [`deep-recon.md`](./deep-recon.md)，无需固定流程。\n\n---\n\n## Evidence from loaded scripts and network requests\n\n```bash\nopencli browser --session research open https://example.com\nopencli browser --session research network --static\nopencli browser --session research network --detail '<returned request key>'\n```\n\n`network --static` combines captured requests with AST candidates from `fetch`, axios, XHR and related calls. It reports method, URL, parameter names, script/line and request key. `network+static` means both sources support the same route; `static` alone is an unexecuted candidate. `EXPR` marks unresolved expressions. Check `scripts[].error`, `omittedScripts`, and cache provenance (`networkSource`, `networkSavedAt`). The bounded scan covers current document scripts, without following imports or proving authentication/pagination behavior.\n\nUse the evidence to choose the next investigation. Write or edit the normal JS adapter in place and run it through the existing CLI to inspect real business results; no draft directory or activation step is required.\n\n## §0 进入 §1 之前：先看两条红线\n\n这两条不看清楚，后面的 endpoint 验证会一直在错的前提下兜圈子。\n\n### 0.1 反爬厂商 → 决定 fetch 能不能从 Node 走\n\n`opencli browser state --diagnostics --session research` 的 `anti_bot` 字段给答案；手查看 cookies 也行：\n\n| cookie / body 信号 | 厂商 | 裸 Node fetch / curl 结果 | 策略 |\n|------------------|------|-----|-----|\n| `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()` |\n| `__cf_bm` / `cf_clearance` / `__cfduid`；body 含 `Cloudflare Ray ID` / `Checking your browser` | **Cloudflare** | TLS 指纹被标记，失败 | 同上：先 browser-context probe，最终 adapter 仍按模板选 fetch 路线 |\n| `_abck` / `bm_sz` / `bm_sv` | **Akamai** | 即使带 cookie 也常被挡 | 同上 |\n| body 含 `geetest` / `gt_captcha` | **Geetest** | 滑块/拼图，程序无解 | 超出 skill 范围，放弃或 UI 策略 |\n\n**规则**：看到上面四种任一个，先不要拿**裸** Node fetch 做 endpoint 验证。先用 browser-context probe 或目标 origin 页面确认接口能通；最终 adapter 的 fetch 路线仍按 `adapter-template.md` 选，HTML 型 COOKIE adapter 继续走 Node-side fetch + `page.getCookies()`。\n\n### 0.2 跨 subdomain = CORS 默认关\n\n`jobs.51job.com` 页面 fetch `cupid.51job.com` 的 API，默认会被浏览器 CORS 预检挡住——除非目标接口回了 `Access-Control-Allow-Origin`。\n\n判断：\n\n```bash\nopencli browser --session research eval \"fetch('https://<target-subdomain>/api/...', {credentials:'include'}).then(r=>r.status).catch(e=>'cors:'+e.message)\"\n```\n\n- 返回 status 数字 → CORS 通，继续\n- 返回 `cors:...` 或 `TypeError: Failed to fetch` → 挡住了\n\n**挡住时**：不要把 `credentials:'include'` 当万能药——这只解决\"带 cookie\"，不解决\"跨 origin\"。降级路径：\n\n1. 换同 origin 的 endpoint（同一个 subdomain 下的 API 往往更宽松）\n2. 用 `opencli browser --session research open https://<target-subdomain>/`，让页面在目标 subdomain 本身打开，再 fetch 相对路径\n3. 真跨域且无替代 → 走 `§5 intercept`，从页面自身发的请求里抓响应\n\n---\n\n## §1 network 精读（首选，Pattern A / D 命中率最高）\n\n### 拿候选\n\n```bash\nopencli browser --session research network\n```\n\n默认输出是 JSON，每个候选都带：\n- `key` — 稳定引用（GraphQL 的 `operationName` 或 `METHOD host+pathname`）\n- `shape` — response body 的路径→类型映射（不含原 body，省 token）\n- `status / url / method / ct / size`\n\n静态资源 / 埋点 / 追踪默认已过滤。默认会保留 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，而不是永久丢掉被隐藏的条目。\n\n如果是冷启动，先看 `opencli browser state --diagnostics --session research` 里的 `api_candidates`：\n\n- `verdict: \"likely_data\"`：优先 replay 这条，拿 status / content-type / sample shape 填 strategy note\n- `verdict: \"maybe_data\"`：可以试，但必须人工核对字段是否是目标业务数据\n- `verdict: \"noise\"`：多半是 analytics / beacon / personalization，不要因为 XHR 数量多就判 Pattern A\n- `verdict: \"blocked\"`：401/403；先排 cookie / token / CSRF，别直接退到 selector\n\n`real_data_score` 是证据，不是自动 strategy。最终仍要在 strategy note 里写 replay 结果和降级理由。\n\n### 按 shape 初筛\n\n挑 `key` 里含业务词（`list / detail / Timeline / User / Tweets / Quote`）的优先看 `shape`：\n\n- `$.data` 是 `object` 且下面出现 `array(N)` / `total` / `page` → 基本是它\n- 路径里出现 `nickname / avatar / title / price / tweets / items` → 就是它\n- shape 只有 `$: string` 或全是 HTML 噪音 → 下一条\n\n### 按期望字段反查（`--filter`）\n\n已经知道目标 body 该含哪些字段就直接让 CLI 把列表筛到只剩候选，不用自己 scroll 翻 shape：\n\n```bash\nopencli browser --session research network --filter author,text,likes\n```\n\n- 字段以英文逗号分隔；AND 语义，必须每个字段都作为 shape 路径的**任意一段**出现才保留（`$.data.items[0].author` 命中 `author`、`items`、`data` 都算）\n- 区分大小写（JSON key 本来就 case-sensitive）\n- 输出 envelope 新增 `filter` / `filter_dropped`，`count` 是过滤后数量\n- 0 命中不是 error，返回 `entries: []`；说明字段组合不对，换一组或去掉约束再试\n- 不要跟 `--detail` 一起用——`--detail` 按 key 取单条、`--filter` 是列表缩窄，组合会报 `invalid_args`\n- 空值 / `,,,` → `invalid_filter` 结构化错误\n- capture 依然按全量持久化，后续 `--detail <key>` 能找到被过滤掉的条目\n\n### 拉完整 body\n\n候选定了再拉完整 body（by key，不是 index — 数组顺序会随每次 capture 变）：\n\n```bash\nopencli browser --session research network --detail <key>\n```\n\ncapture 会持久化到 `~/.opencli/cache/browser-network/<session>.json`（默认 TTL 24h），所以 `--detail` 即使跨多条其他命令也还在。\n\n`--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——这说明请求合同仍不完整。\n\n这也意味着私有页面的 response 可能落在本地 cache。侦察结束要删除相关 session capture 并释放 browser session；不要依赖 24h TTL 代替清理。\n\n### 关键 request headers\n\n先用 `browser network --detail <key>` 看脱敏后的 request headers / body shape；不要打印或复制 credential 原值。旧 capture provider 若没有返回 `request`，再去 DevTools Network 面板核字段名，或用页面自然动作重新 capture，不能用 `browser eval` 猜造一份缺 header/body 的 URL-only 请求：\n\n| 看到 | 含义 | 对应策略 |\n|------|------|---------|\n| 只有 `Cookie` | 登录态靠 cookie | `Strategy.COOKIE` |\n| `Authorization: Bearer xxx` | token 鉴权 | 先找 token 来源（localStorage / cookie / bundle 硬编码） |\n| `X-Csrf-Token: xxx` 同时存在 cookie 里 | CSRF 防护 | `Strategy.COOKIE`，从 cookie 读 ct0 类字段拼头 |\n| `X-Workspace-Id / X-Tenant-Id` | 多租户业务头 | 先调 `/workspaces` 拿 ID，缓存下来 |\n| 啥自定义头都没有 | 匿名接口 | `Strategy.PUBLIC` |\n\n### 触发懒加载接口\n\n默认页加载完后滚动 / 点击才会出的接口不在首屏 network 里。需要：\n\n```bash\n# 滚到底（虚拟列表）\nopencli browser --session research eval \"window.scrollTo(0, document.body.scrollHeight)\"\nopencli browser --session research wait time 2\nopencli browser --session research network\n\n# 点某个按钮\nopencli browser --session research click <N>\nopencli browser --session research wait time 2\nopencli browser --session research network\n```\n\n---\n\n## §2 `__INITIAL_STATE__` / inline HTML（Pattern B）\n\n首屏数据常挂在这几个全局变量上：\n\n```bash\nopencli browser --session research eval \"Object.keys(window).filter(k=>k.startsWith('__'))\"\n```\n\n命中的常见名：\n\n| 全局 | 框架 |\n|------|-----|\n| `__NEXT_DATA__` | Next.js |\n| `__NUXT__` | Nuxt.js |\n| `__INITIAL_STATE__` | 自定义 Vue / React SSR |\n| `__PRELOADED_STATE__` | Redux SSR |\n| `__REMIX_CONTEXT__` | Remix |\n\n取数据：\n\n```bash\nopencli browser --session research eval \"JSON.stringify(window.__NEXT_DATA__).slice(0, 3000)\"\n```\n\n**关键**：inline state 只覆盖首屏的一部分（通常是 SEO 相关字段）。分页 / 评论 / 懒加载还是得回 §1 抓 API。\n\n把首屏 state 当作 adapter 的兜底数据源：公开访问时 state 里有 → 直接 parse；数据更新快 / 分页 → 回到 API。\n\n---\n\n## §3 JS bundle / script src 搜索（Pattern C，也是 A/D 的降级）\n\n### 扫 script src\n\n```bash\nopencli browser --session research 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))\"\n```\n\n看结果里的 hostname：\n\n- 明显像 API 的域名（`api.xxx / push.xxx / data.xxx / gateway.xxx`）→ 直接去试\n- 主 bundle（`main.js / app.js / index.xxx.js`）→ 继续下一步下载 bundle 搜 baseURL\n\n### 搜 bundle 里的 baseURL\n\n```bash\nopencli browser --session research 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})()\"\n```\n\n命中 `baseURL:\"https://api.foo.com\"` 直接拿 host 拼 endpoint。\n\n### 用 jsluice 扩大候选面（可选）\n\n手工搜 `baseURL` 只适合小 bundle。站点脚本多、压缩重或 endpoint 通过 `fetch` / XHR / 字符串拼接生成时，可以把**已经加载的脚本文本**通过 stdin 交给本机可选的 [jsluice](https://github.com/BishopFox/jsluice) 做语法感知扫描。\n\n```bash\n# bundle 只短暂落 /tmp；扫描后删除\njsluice urls < /tmp/example-bundle.js\n```\n\n边界：jsluice 输出是 candidate，不是 contract。`EXPR` 表示动态值未知；扫描无法证明 token、签名、CORS、权限、分页、字段语义或副作用。不要把命中 URL 直接写进 adapter，更不要把扫描到的疑似 secret 原值保存到 trace/site memory。\n\n每个候选至少记录：来源 bundle + 代码位置、method/path、触发它的可见动作、动态 network 是否发生、replay status/content-type/shape、选择或拒绝原因。复杂站直接转 [`deep-recon.md`](./deep-recon.md) 的 evidence ledger。\n\n### 直接试候选 endpoint\n\n像 eastmoney 这种经验 endpoint 可以直接喂：\n\n```bash\nopencli browser --session research 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())\"\n```\n\n200 只是 transport 成功。至少换一个输入再试，并核 content-type、目标 identity、非空 shape、分页和可见页面值；写入或复杂私有协议转 `deep-recon.md`，不能“数据看起来像”就认。\n\n### URL 后缀探测\n\n有些站直接在 URL 加 `.json` 就是 REST：\n\n- `https://www.reddit.com/r/rust.json` — Reddit 全覆盖\n- `https://xueqiu.com/S/SH600000.json` — 雪球部分页\n\n```bash\n# 当前页加 .json 试\nopencli browser --session research eval \"fetch(location.pathname.replace(/\\\\/$/,'')+'.json').then(r=>r.ok?r.json():'no')\"\n```\n\n---\n\n## §4 Token / CSRF 来源排查（Pattern D）\n\n已经在 network 里看到请求带自定义头，怎么拿到那个值：\n\n### Cookie 里\n\n```bash\nopencli browser --session research eval \"document.cookie.split('; ').map(x=>x.slice(0,x.indexOf('='))).filter(Boolean)\"\n```\n\n常见 token cookie 名：`ct0`（Twitter CSRF）、`xq_a_token`（雪球）、`SESSDATA`（B 站）、`_csrf / csrfToken`（通用）。\n\n**`document.cookie` 只能看到 non-HttpOnly 的 cookie。** 上面那条命令侦察阶段够用，真写 adapter 时 auth 经常是 HttpOnly，一定要用 `page.getCookies(...)` 从 CDP cookie jar 拿——见 `adapter-template.md` 的 \"COOKIE adapter 骨架\"。\n\n论坛 / BBS 引擎（Discuz!X / phpBB / vBulletin）还多一坑：auth cookie 设在**根域** `.example.com`（不是 `www.example.com`），且 HttpOnly。要查 `{ domain: '.<root>' }` **和** `{ domain: 'www.<root>' }` 两次，否则 adapter 在有 cookie 的前提下仍然 401。\n\n### localStorage / sessionStorage 里\n\n```bash\nopencli browser --session research eval \"Object.keys(localStorage).filter(k=>/token|auth|jwt|bearer|csrf/i.test(k))\"\n```\n\n先只列 key 名，找 `token / auth / jwt / bearer / csrf`。只有选定 production auth source 后才在页面内使用对应值；不要把值打印进聊天、trace、shell history 或 site memory。\n\n### Bundle 硬编码\n\n有些站的 Bearer 是全站一个常量（Twitter 的匿名 Bearer）。在 bundle 里搜：\n\n```bash\nopencli browser --session research 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)}})()\"\n```\n\n只返回数量/位置，不返回 token 原值。即使 bundle 中是公共匿名 Bearer，也先确认它是否是预期公开合同；不要复制未知 credential-shaped string。\n\n### 调用页面 runtime 让站点自己生成请求（只读、最后手段）\n\nVue + Pinia / Redux / React Context 有时能调用页面自己的只读 store method，让站点 runtime 自己生成签名和请求：\n\n```bash\n# Pinia\nopencli browser --session research eval \"typeof __pinia !== 'undefined' ? Object.keys(__pinia.state.value) : 'no pinia'\"\n\n# 只调用已证明是 read-only 的 store action（每个站点具体 action 名要查）\nopencli browser --session research eval \"window.__pinia.state.value.someStore.someMethod({...})\"\n```\n\n这不是“绕签名”，也不是 direct API contract：它仍依赖页面 controller/runtime，production strategy 通常是 `INTERCEPT`。只有动作语义被可见 UI 和动态请求证明为 read-only 才能在侦察中调用。未知 effect 或 write action 禁止自动调用；写入只观察用户明确授权的一次自然操作，按 `deep-recon.md` 处理。\n\n---\n\n## §5 让页面自然发请求并截获 response（最后降级）\n\n所有手段都试过还拿不到请求签名时，让页面自己自然发请求，adapter 用现有 Browser Bridge capture/interceptor 读取响应。优先 CDP network capture；只有已存在站点实现依赖 XHR interceptor 时才复用它，不要再写页面内 fetch/XHR monkey patch。\n\n```javascript\n// func 里：capture 必须先于触发动作安装，并先 drain stale entries\nawait page.startNetworkCapture('/api/foo');\nawait page.readNetworkCapture();\nawait page.goto('https://xxx.com/trigger-page');\n// 等页面自己发请求，再读取所有相关完整 response\nconst entries = await page.readNetworkCapture();\n```\n\ncapture queue 可能是破坏性 drain：过滤 relevant URL 后，只要看到 bodyless/truncated relevant entry 就拒绝 partial；多 response 要按业务 identity 合并，不能只取最后一个。分页、缓存和 no-partial 规则见 `deep-recon.md`。\n\n代价是要等页面真的触发请求，慢且依赖内部合同。只在 §1-4 都不行时用。\n\n---\n\n## 诊断不出来怎么办\n\n按这个顺序试到命中：\n\n```\n§1 network ──→ 命中？yes → 走\n      │        no\n      ↓\n§2 state  ──→ 命中？yes → 走\n      │        no\n      ↓\n§3 bundle ──→ 命中？yes → 走\n      │        no\n      ↓\n§4 token  ──→ 401 解除？yes → 走\n      │        no\n      ↓\n§5 intercept → 让页面自己发\n```\n\n**四条都命不中的站（罕见）**：多半是视觉化渲染（canvas / webgl），数据不以 HTTP/JSON 形式存在。这种放弃或换源。\n","skills/opencli-adapter-author/references/coverage-matrix.md":"# Coverage Matrix\n\nskill 明确承诺能搞定什么、搞不定什么。动手前先看一眼这张表，判断目标站落在哪一格。\n\n**状态标记**：\n- ✅ **已验证**：有可跑通的真实 adapter / dry-run 证据\n- 🟡 **已列招但未硬跑**：文档把方法写全了，但这一版没拿真实站点跑过；第一次遇到时按文档走，踩坑回来补 `site-memory`\n- ❌ **不支持**：skill 明确不碰，走绕开方案\n\n---\n\n## 支持（skill 里有对应的招）\n\n| 维度 | 支持 | 状态 | 走哪节 |\n|------|------|------|-------|\n| 页面形态 | 列表页 / 排行页 | ✅ | `adapter-template.md`（convertible.js / rank.js 类） |\n| | 详情页（单对象） | ✅ | `adapter-template.md`（stock.js / holders.js 类） |\n| | 时间序列（K 线 / 分钟线） | ✅ | `adapter-template.md`（kline.js） |\n| | 嵌套列表（列表里含列表） | 🟡 | `adapter-template.md` + `output-design.md` 打平规则 |\n| 站点类型 | SPA（React/Vue，JSON XHR） | ✅ | `site-recon.md` Pattern A + `api-discovery.md` §network |\n| | SSR（HTML with inline data） | 🟡 | `site-recon.md` Pattern B + `api-discovery.md` §state |\n| | JSONP / push/script[src] | ✅ | `site-recon.md` Pattern C + `api-discovery.md` §bundle（eastmoney / tonghuashun 已覆盖） |\n| | SPA + 独立 BFF domain | 🟡 | `api-discovery.md` §bundle §suffix |\n| Strategy（详见 `strategy-selection.md`） | 裸 `fetch()` 拿到 | ✅ | `PUBLIC_API`（一方文档化接口，最稳：fixes/adapter-year=1.18） |\n| | cookie 透传 | ✅ | `COOKIE_API`（官方 web 接口 + 用户登录态，fixes/adapter-year=2.01） |\n| | publish / upload / click / 表单 | ✅ | `UI_SELECTOR`（DOM 的 a11y / semantic 也是契约，fixes/adapter-year=1.92） |\n| | hydration state / inline JSON | 🟡 | `DOM_STATE`（fixes/adapter-year=0.91 小样本 N=11，按 UI_SELECTOR 同档） |\n| | page-context fetch（CORS / same-origin runtime） | 🟡 | `PAGE_FETCH`（无契约内部 endpoint，fixes/adapter-year=8.41，必须正向论证） |\n| | 触发 UI 拦截响应 | 🟡 | `INTERCEPT`（无契约，fixes/adapter-year=8.69，必须正向论证） |\n| 字段形态 | 自解释（`title / price / current`） | ✅ | 直接映射 |\n| | 已登记代号 | ✅ | `field-conventions.md` 查表 |\n| | 未登记代号 | 🟡 | `field-decode-playbook.md` 排序键对比法 |\n| | 嵌套路径 `data.diff[].f2` | ✅ | `field-decode-playbook.md` §3 结构差分 |\n| 分页 | `page` / `pn` / `pageNum` | ✅ | `adapter-template.md` 例子 |\n| | `cursor` / `next_cursor` | ✅ | adapter 里 while 循环，收集到 limit |\n| | `offset` / `start` | ✅ | 同上 |\n| 响应格式 | JSON | ✅ | 默认 |\n| | JSONP（`?callback=`） | ✅ | 去掉 callback 参数直接请求，返回仍是 JSON 字符串包裹 |\n| | CSV 字符串（eastmoney kline） | ✅ | `response.split(',')` 按列序解 |\n| | HTML 表格（tonghuashun） | 🟡 | `page.evaluate` 里用 `querySelectorAll` 拿 |\n\n🟡 的维度意思：方法在文档里，但这一版没拿真实站点跑过端到端。第一次遇到时按文档走，遇到和文档不一致的地方记到 `~/.opencli/sites/<site>/notes.md`，下一次再打开就是 ✅。\n\n---\n\n## 不支持（承认搞不定，skill 不教）\n\n| 场景 | 原因 | 绕开方案 |\n|------|------|---------|\n| 首次登录获取 token | 需要用户真实输入账密 | 让用户先在 browser session 里手动登录，adapter 拿 cookie 就行 |\n| 复杂 anti-bot（captcha） | 反爬拒流量 | 放弃，换同数据的其他站点 |\n| 加密字段（客户端 crypto） | 要破解 bundle 逆向 | 换 endpoint；实在不行发请求到 intercept 让页面自己解 |\n| WebSocket 流式数据 | 状态管理复杂 | 退回 HTTP 轮询版本（多数站都有） |\n| 私有 binary 协议 | 非 HTTP/WS | 不在 skill 范围 |\n| 视觉化图表（只有 canvas） | 数据埋在渲染层 | 找对应 API；找不到就放弃 |\n| 签名算法涉及静态密钥 | 需要长期跟踪 bundle 变更 | 走 `Strategy.INTERCEPT`，让页面自己发带签名的请求 |\n| 频控 / rate-limit 严格 | 多发几次就 429 | adapter 层控并发 + 加退避；但 skill 不解决 |\n\n---\n\n## 决定用不用 skill 的快速自测\n\n三个问题：\n\n1. **数据能在浏览器里看到吗？** 看不到（登录墙 / 付费墙）→ 先解决鉴权，再回来\n2. **数据来源是 HTTP/JSON/HTML 之一吗？** 不是（binary / 加密）→ 不在 skill 范围\n3. **需不需要每秒推送？** 需要 → 找同数据 HTTP 接口；没有就放弃\n\n三个都 yes 再往下走。\n\n---\n\n## 本轮硬验证 / 当前证据\n\n| 类型 | 证据 adapter | 覆盖维度 |\n|------|-------------|---------|\n| PUBLIC + 自解释字段 + SPA | `~/.opencli/clis/coingecko/top.js`（本轮 dry run） | `Strategy.PUBLIC` + REST JSON + 自解释字段 + 列表页 |\n| COOKIE + 代号字段 + JSONP | `clis/eastmoney/*.js` × 13（PR #1091 merged） | `Strategy.PUBLIC`（匿名 `ut=`）+ JSONP + f-代号 + 列表/详情/K 线 |\n| COOKIE + SPA | `clis/bilibili/*.js` × 10+（已存在） | `Strategy.COOKIE + browser:true` + wbi 签名 |\n\n**本 PR 新增的 skill 还未硬验证的维度**：🟡 行，尤其 SSR Pattern B + Bearer/CSRF + 未登记代号解码。这些放到第一批真实用户 adapter 写作中打磨，skill 文档先落，踩坑回来补 `site-memory`。合 PR 之前先拿 coingecko 跑第二轮（带着第一轮写出的 `~/.opencli/sites/coingecko/`）验证 memory 命中 → endpoint re-verify → 字段抽查 → 写 adapter 这条回路。\n","skills/opencli-adapter-author/references/deep-recon.md":"# Deep Recon: evidence-first discovery for undocumented sites\n\nUse 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.\n\n## 1. Freeze the command surface and mutation boundary\n\nBefore browsing, make an intent matrix:\n\n| Command | User intent | Read/write | Completeness | Exact target | Allowed live action |\n|---|---|---|---|---|---|\n| `search` | find matching entities | read | exact limit or upstream exhaustion | query | safe replay after proof |\n| `send` | create external side effect | write | one confirmed mutation | recipient + content | only with explicit authorization |\n\nGroup 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.\n\nWrite 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.\n\n## 2. Build an evidence ledger, not a traffic dump\n\nTrack one row per candidate:\n\n| Intent | Visible action | Source | Method/path | Effect | Auth/signing | Response shape | Replay result | Decision |\n|---|---|---|---|---|---|---|---|---|---|\n\n`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.\n\nNever paste credentials or response bodies into the ledger. Store structural facts: status, content type, arity/paths, row counts, cursor behavior, and source location.\n\n## 3. Triangulate three evidence planes\n\n1. **Visible truth**: page rows, counts, URLs, identity, and semantic controls.\n2. **Dynamic truth**: DevTools requests caused by one controlled action.\n3. **Static candidates**: loaded bundles scanned manually or with syntax-aware tools such as jsluice.\n\nDynamic 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.\n\nDo 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.\n\njsluice 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.\n\n## 4. Attribute requests with causal diffs\n\nFor each intent:\n\n1. establish a network/state baseline;\n2. perform exactly one visible action;\n3. inspect only newly added requests/state;\n4. repeat with one changed input;\n5. run a negative control when ambiguity remains.\n\nThe 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.\n\nFor 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.\n\n## 5. Rank candidates before decoding\n\nPrefer a candidate that:\n\n- contains the user-visible target data or mutation identity;\n- works across two inputs without copying ephemeral values;\n- has a safe, explainable auth source;\n- paginates to the requested completeness;\n- returns typed, distinguishable auth/HTTP/upstream failures;\n- is cheaper to maintain than the strongest UI/DOM alternative.\n\nPenalize 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.\n\n## 6. Pass the contract gate\n\nA read contract must prove all of these:\n\n1. **Occurrence**: the page naturally sends it, or it is documented/public.\n2. **Reproducibility**: a safe replay works across two non-empty inputs; if replay is impossible, `INTERCEPT` preserves the page-owned request.\n3. **Identity**: returned rows match visible target identity, not adjacent recommendations/ads/sidebars.\n4. **Completeness**: pagination reaches exact limit or proven upstream exhaustion.\n5. **Auth boundary**: cookies/CSRF/origin/runtime requirements are explicit and do not leak secrets.\n6. **Failure semantics**: auth, HTTP, malformed/truncated body, repeated cursor/page, timeout, and partial data fail typed.\n\nReplay 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.\n\nA direct API-backed write contract additionally must prove:\n\n1. target identity is deterministically bound in the request;\n2. auth/CSRF/signing can be reproduced without fabricating or exfiltrating runtime secrets;\n3. idempotency or duplicate-risk semantics are known;\n4. pre-write failure is distinguishable from post-write uncertainty;\n5. success is verified independently of the edited control/request echo;\n6. retries are disabled after an uncertain write unless the user first checks state.\n\n“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).\n\n## 7. Handle capture, pagination, and cache explicitly\n\nBrowser capture queues may be destructive drains. Before relying on them:\n\n- install capture before the action and drain stale entries;\n- cache the raw selected capture before applying display-only MIME, static-resource, or shape filters;\n- allow in-flight responses to settle;\n- treat bodyless or truncated relevant entries as possible data loss;\n- inspect non-JSON structured streams with request method, safely redacted headers, body shape, and size/truncation metadata;\n- merge all relevant completed responses in the action window;\n- identify pages/cursors by content, not arrival order alone;\n- deduplicate by stable entity ID;\n- reject repeated pages/cursors and page-cap exhaustion rather than return accumulated partial rows.\n\nNever 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.\n\nA 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.\n\n## 8. Choose strategy per command\n\nUse the repository strategy ladder after the contract gate. One site may legitimately mix strategies:\n\n- `PUBLIC_API` / `COOKIE_API` for reproducible contracts;\n- `PAGE_FETCH` for a verified same-origin read contract that must execute in page context;\n- `DOM_STATE` for stable hydration or cached state;\n- `INTERCEPT` when page-owned signing is unavoidable for reads;\n- `UI_SELECTOR` for user-visible writes only when that surface is allowed and safer;\n- no command when the requested contract cannot be proved.\n\nPreserve 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.\n\n## 9. Decode and test against invariants\n\nFor 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.\n\nValidation set:\n\n- two non-empty inputs plus one true empty result;\n- more than one page when pagination exists;\n- exact limit and upstream exhaustion;\n- cache/repeat behavior;\n- auth, HTTP, JSON/shape, truncated/bodyless capture, timeout, repeated cursor/page, and page cap;\n- every public column and target identity.\n\nTests 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.\n\n## 10. Deliver a clean, reviewable artifact set\n\nLeave durable outputs at the right layer:\n\n- **adapter**: executable contract, shared helpers, typed errors;\n- **tests**: production-path behavior and structural invariants;\n- **docs**: command surface, limits, auth, uncertainty, examples;\n- **site memory**: verified routes, triggers, fallbacks, rejected strategies, pitfalls, and verification date;\n- **recon conclusion**: evidence ledger without secrets or private bodies.\n\nBefore PR:\n\n1. regenerate manifest and validate the site;\n2. run focused/site tests, typecheck, build, docs coverage, typed-error and silent-column gates;\n3. scan the diff for live addresses, account IDs, tokens, message IDs, bodies, attachments, raw captures, and temporary dumps;\n4. delete raw capture/cache artifacts and release browser sessions;\n5. obtain independent exact-head review for writes, auth/signing, positional schemas, or no-partial pagination.\n\nRecord 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.\n","skills/opencli-adapter-author/references/field-conventions.md":"# Field Conventions\n\n响应字段代号在主要站点上的解码表。写 adapter 前先查一遍，查不到再用 SKILL.md 里 Step 2 的实测法推出来，推完补到本表。\n\n---\n\n## eastmoney（东方财富）\n\n域名：`push2.eastmoney.com` / `push2his.eastmoney.com` / `datacenter-web.eastmoney.com` / `np-listapi.eastmoney.com` / `np-anotice-stock.eastmoney.com`\n\n### 通用行情字段（`push2` clist/ulist/stock）\n\n| 代号 | 含义 | 备注 |\n|------|------|------|\n| `f1`  | 精度位数 | `fltt=2` 后可忽略 |\n| `f2`  | 最新价 | 要 `fltt=2` 才是格式化浮点 |\n| `f3`  | 涨跌幅 % | 同上 |\n| `f4`  | 涨跌额 | 同上 |\n| `f5`  | 成交量（手） | |\n| `f6`  | 成交额（元） | 接口默认是\"元\"，个别接口是\"万元\"，看 `f152` |\n| `f7`  | 振幅 % | |\n| `f8`  | 换手率 % | |\n| `f9`  | 市盈率（动态） | |\n| `f10` | 量比 | |\n| `f12` | 代码 | 股票/债券/ETF/指数统一 |\n| `f13` | market | 数字市场代号，见下表 |\n| `f14` | 名称 | |\n| `f15` | 最高 | |\n| `f16` | 最低 | |\n| `f17` | 今开 | |\n| `f18` | 昨收 | |\n| `f20` | 总市值 | |\n| `f21` | 流通市值 | |\n| `f23` | 市净率 | |\n| `f62` | 主力净流入 | **单位是\"元\"**，但有的接口返回\"万元\"，要核对 |\n| `f66` | 超大单净流入 | 同上 |\n| `f72` | 大单净流入 | |\n| `f78` | 中单净流入 | |\n| `f84` | 小单净流入 | |\n| `f100` | 所属板块 | |\n| `f152` | 精度（当 `fltt` 不传时要除以 10^f152） | 所以一律 `fltt=2` |\n\n### Convertible bond（可转债）专属\n\n| 代号 | 含义 |\n|------|------|\n| `f229` | 正股价 |\n| `f230` | 正股涨跌幅 |\n| `f232` | 正股代码 |\n| `f234` | 正股名称 |\n| `f235` | 转股价 |\n| `f236` | 转股价值 |\n| `f237` | 转股溢价率 |\n| `f238` | 剩余年限 |\n| `f239` | 到期收益率 (YTM) |\n| `f243` | 上市日期 (YYYYMMDD int) |\n\n### K-line `push2his` stock/kline/get\n\n返回形如 `data.klines: [\"2024-01-02,10.5,10.8,10.9,10.4,12345,...\"]` 的 CSV 字符串数组。用 `fields1 / fields2` 控制列。典型列序：\n\n```\ndate, open, close, high, low, volume, turnover, amplitude, changePct, changeAmt, turnoverRate\n```\n\n### Market 前缀（secid 格式 `<market>.<code>`）\n\n| 前缀 | 市场 |\n|------|------|\n| `1.`   | Shanghai (SSE) |\n| `0.`   | Shenzhen (SZSE) + Beijing (BSE) |\n| `116.` | Hong Kong (HKEX) |\n| `105.` | NASDAQ |\n| `106.` | NYSE |\n| `107.` | AMEX |\n| `100.` | 指数（HSI / SPX / DJIA / 各板块指数） |\n\n判断 symbol 是否已经是 secid 时，只在数字前缀属于上表时才认，否则视作普通 code（防止 `00700.HK` 这种 `<digits>.<alpha>` 误判）。\n\n### fs 市场/板块过滤码\n\n| 代码 | 含义 |\n|------|------|\n| `m:0+t:6,m:0+t:80,m:1+t:2,m:1+t:23,m:0+t:81+s:2048` | 沪深 A 股全集 |\n| `m:1+t:2,m:1+t:23` | 沪 A |\n| `m:0+t:6,m:0+t:80` | 深 A |\n| `m:0+t:81+s:2048` | 北证 A |\n| `m:0+t:80` | 创业板 |\n| `m:1+t:23` | 科创板 |\n| `m:116+t:3,m:116+t:4,m:116+t:1,m:116+t:2` | 港股 |\n| `m:105,m:106,m:107` | 美股 |\n| `b:MK0021` | ETF |\n| `b:MK0354` | 可转债 |\n| `m:90+t:2` | 行业板块 |\n| `m:90+t:3` | 概念板块 |\n| `m:90+t:1` | 地域板块 |\n\n### datacenter-web 报表 `reportName`\n\n| 名称 | 内容 |\n|------|------|\n| `RPT_DAILYBILLBOARD_DETAILS` | 龙虎榜 |\n| `RPT_F10_EH_FREEHOLDERS`     | 十大流通股东 |\n\n---\n\n## xueqiu（雪球）\n\n域名：`stock.xueqiu.com` / `xueqiu.com`\n\n行情 API 返回字段是人类可读，不需要词典：\n\n| 字段 | 含义 |\n|------|------|\n| `symbol` | SH600000 / 00700 / AAPL 样式 |\n| `name` | 名称 |\n| `current` | 最新价 |\n| `chg` | 涨跌额 |\n| `percent` | 涨跌幅 % |\n| `volume` | 成交量 |\n| `amount` | 成交额 |\n| `high52w / low52w` | 52 周高低 |\n| `market_capital` | 总市值 |\n| `pe_ttm` | 市盈率 TTM |\n| `pb` | 市净率 |\n\n鉴权：需要 `xq_a_token` cookie。走 `Strategy.COOKIE + browser: true`。\n\n---\n\n## bilibili\n\n域名：`api.bilibili.com` / `space.bilibili.com` / `passport.bilibili.com`\n\nB 站接口字段也大多人类可读。关键坑是 **wbi 签名**：\n\n- 凡 URL 含 `/wbi/` 的接口都需要 `w_rid + wts` 签名\n- 签名算法依赖每日轮换的 `img_key + sub_key`（从 `nav` 接口拿）\n- 平台 SDK 在 `clis/bilibili/utils.js`：`apiGet(page, path, { signed: true, params })` 自动签\n- 普通 cookie JSON 接口优先用 `page.fetchJson(url)`\n\n| 字段 | 含义 |\n|------|------|\n| `mid` | 用户 UID |\n| `aid / bvid` | 视频 ID（av 号 / bv 号） |\n| `cid` | 视频分 P ID |\n| `uname / upname` | up 主昵称 |\n| `view / danmaku / reply / favorite / coin / share / like` | 各类计数 |\n| `pubdate / ctime` | 发布/创建时间（秒级 unix） |\n\n---\n\n## tonghuashun（同花顺）\n\n域名：`q.10jqka.com.cn` / `d.10jqka.com.cn` / `data.10jqka.com.cn`\n\n同花顺多数接口返回 HTML 表格，需要 DOM 解析。JSONP 接口要设 `Referer: http://q.10jqka.com.cn/`，否则返回空。\n\n| 字段（API） | 含义 |\n|------|------|\n| `openPrice / closePrice` | 开/收盘 |\n| `zdf` | 涨跌幅 % |\n| `hsl` | 换手率 |\n| `zf`  | 振幅 |\n\n---\n\n## 字段不在词典时\n\n看 `field-decode-playbook.md`。里面是标准 SOP（排序键对比、结构差分、精度排查），10 分钟能推一条。\n\n推完一个代号补回本文件，下次直接查。\n","skills/opencli-adapter-author/references/field-decode-playbook.md":"# Field Decode Playbook\n\n响应里出现 `f237 / zdf / oc5 / x4` 这种看不懂的代号时走这套流程。目标：10 分钟内搞定一条未知字段，不靠猜。\n\n适用于 `field-conventions.md` 没收录的站点、没收录的代号、或者收录了但怀疑对不上的情况。\n\n---\n\n## 决策树\n\n```\n拿到一条响应，有字段看不懂？\n├── 字段值是字符串 / 时间 / URL → 走 §1 「肉眼对网页」\n├── 字段值是数字 → 走 §2 「排序键对比法」\n├── 字段值是数组 / 对象 → 走 §3 「结构差分法」\n└── 改参数响应不动 → 走 §4 「常量 / 精度位排查」\n```\n\n---\n\n## §1 肉眼对网页（字符串/时间/URL 类）\n\n最快。打开对应网页，响应的这条记录在哪行，把响应值跟页面上的文字一一对照。\n\n```bash\n# 举例：eastmoney convertible，响应里有 f243=20231215，页面上这支债写着\"上市日期 2023-12-15\"\n# 直接判断 f243 是上市日期（YYYYMMDD int）\n```\n\n**时间字段的判别**：\n\n| 观察到的形态 | 大概率是 |\n|-------------|---------|\n| 10 位整数 `1712345678` | unix 秒 |\n| 13 位整数 `1712345678000` | unix 毫秒 |\n| 8 位整数 `20240101` | YYYYMMDD int |\n| 6 位整数 `240101` | YYMMDD int |\n| 字符串 `2024-01-01` / `2024/01/01` | ISO 日期 |\n| 字符串 `01/01/2024` | 美式日期，小心月日顺序 |\n\n**URL 字段的判别**：\n\n```bash\n# 如果是相对路径，拼 domain 回浏览器验证\nopencli browser --session research eval \"window.location.origin + '/<path>'\"\n```\n\n---\n\n## §2 排序键对比法（数字类核心手段）\n\n数字字段是最容易踩坑的一类——看起来都是小数，但可能是涨跌幅、换手率、振幅、溢价率、市盈率……单位和量级也可能带陷阱。\n\n### 流程\n\n1. **找到一个能改变排序的参数**\n\n   | 站点 | 排序参数 | 值域 |\n   |------|---------|------|\n   | eastmoney (push2 clist) | `fid` + `po` | `fid` 是字段代号，`po` 是 `0=asc / 1=desc` |\n   | xueqiu | `order_by` | `percent / volume / amount / ...` |\n   | bilibili | `order` | `pubdate / click / stow / ...` |\n   | tonghuashun | `sort` | 数字代号 |\n   | 通用 | 页面点击表头切排序，抓包看新参数 | — |\n\n2. **用两个已知含义的值各抓一份响应**\n\n   比如 eastmoney，`fid=f2`（最新价）和 `fid=f3`（涨跌幅）：\n\n   ```bash\n   opencli browser --session research eval \"fetch('<url>&fid=f2&po=1').then(r=>r.json()).then(d=>d.data.diff.slice(0,3))\"\n   opencli browser --session research eval \"fetch('<url>&fid=f3&po=1').then(r=>r.json()).then(d=>d.data.diff.slice(0,3))\"\n   ```\n\n3. **对比两组数据**：\n\n   - 第一条记录的 `symbol/name` 变了吗？没变说明这个参数无效或者白名单窄\n   - 新的第一条里，哪个字段数量级 / 正负号 / 小数位数变化最大？那个字段就是和 `fid` 对应的业务语义\n   - 单调性：按 `desc` 取前 3 条，看新旧两组里目标字段是不是都单调下降\n\n4. **用第三个参数交叉验证**\n\n   ```bash\n   opencli browser --session research eval \"fetch('<url>&fid=f6&po=1').then(r=>r.json()).then(d=>d.data.diff.slice(0,3))\"  # 成交额\n   ```\n\n   对照网页上\"成交额排行\"的前三名。对得上就认。\n\n### 实例：推 f237（可转债溢价率）\n\n```bash\n# 按价格排，拿一条观察\nopencli browser --session research 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])\"\n# 返回 {f12:'123456', f14:'XX转债', f2:180.5, f3:2.1, f236:98.5, f237:83.2, f239:-4.1}\n\n# 按 f237 排\nopencli browser --session research eval \"fetch('...&fid=f237&po=1...').then(...)\"\n# 第一条变了，f237 值变成 400+\n\n# 打开 eastmoney 可转债页，切\"溢价率\"排序，第一条的溢价率确实是 400+ —— f237 就是溢价率 %\n```\n\n---\n\n## §3 结构差分法（数组 / 嵌套对象类）\n\n响应顶层是 `{data: {diff: [...]}}` 还是 `{list: [{...}, {...}]}` 还是 `{rows: [{k:v}, ...]}`，不同接口差别大。\n\n### 流程\n\n1. **先数一次嵌套路径**\n\n   ```bash\n   opencli browser --session research eval \"fetch('<url>').then(r=>r.json()).then(j=>({keys:Object.keys(j), type:Array.isArray(j)?'array':'object'}))\"\n   ```\n\n2. **一层一层剥**\n\n   ```bash\n   opencli browser --session research eval \"fetch('<url>').then(r=>r.json()).then(j=>{const d=j.data; return {keys:Object.keys(d), sample: d[Object.keys(d)[0]]}})\"\n   ```\n\n3. **数数组长度对照 pz / pageSize**\n\n   如果请求 `pz=20` 拿回的数组正好 20，那就是结果数组；如果是 1 那可能是 pagination meta。\n\n4. **同一字段在不同条目间是否变化**\n\n   ```bash\n   # 取前三条的 keys，看哪些 key 的值在变（业务数据），哪些不变（常量/配置）\n   opencli browser --session research 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})))\"\n   ```\n\n---\n\n## §4 常量 / 精度位排查\n\n有的字段每条都一样，是精度指示器或类型标记。\n\n| 典型现象 | 通常含义 | 处理 |\n|---------|---------|------|\n| 每条值都是 `2` 或 `3` | 精度位数（小数几位） | 除以 `10^n` 还原浮点 |\n| 每条值都是 `0` 或 `1` | 涨跌方向 / 停牌标记 | 枚举，查同类 adapter |\n| 每条值都是 `6` / `8` / `80` | 市场代号 / 分类 ID | 查 `field-conventions.md` 市场表 |\n| 每条值都是空字符串 | 接口不返回但字段有占位 | 请求里去掉 / 用别的字段 |\n\n**eastmoney 精度坑**：不传 `fltt=2` 时，价格字段是 `int * 10^f152`。所以一律加 `fltt=2`，避免所有除法问题。\n\n---\n\n## §5 写完要做的事\n\n推出一个新代号后：\n\n1. **补进 `references/field-conventions.md`**：找到对应站点的表格加一行。下次直接查。\n2. **在 adapter 代码里留一条注释**：如果是实测推出来的不常见代号，写一行 `// f237 = convertible premium rate (verified 2026-04-20 against page)` 方便复核。\n3. **通过 `opencli adapter test` 验一次**：字段值能对上网页上眼见的数字。\n\n---\n\n## §6 通用坑\n\n| 坑 | 症状 | 解 |\n|----|------|-----|\n| 单位\"万\"当\"元\"用 | 成交额少 4 个零 | eastmoney `f6` 是元、`f62 / f64` 等净流入是万，核对每条接口 |\n| 百分比没 × 100 | 涨跌幅显示 0.0XX | 响应已经乘过 100 或者站点用小数 —— 看一眼网页 |\n| 股票代码被截 | `600000` 变 `60000` | 某些接口返回 int，前导 0 掉了。永远用 `String(code).padStart(6, '0')` 处理 A 股代码 |\n| 时间区无前导 0 | `2024-1-1` 排序坏 | parse 后用 ISO 回写 |\n| JSON 里数字是字符串 | 无法 `.toFixed()` | `Number(x)` 显式转 |\n\n---\n\n## §7 真推不出来\n\n三条兜底：\n\n1. **找同站点已有的 adapter** — `ls clis/<site>/` 翻文件，别站点邻居的 adapter 通常共享字段命名\n2. **找开源实现** — GitHub 搜 `<域名> fields` 或 `<域名> api`，往往有 Python / Go 库已经解码过\n3. **灰度**：把这条字段先输出成 raw，命令仍可用，下一版再语义化\n\n不要猜。猜错了后面验证不到、线上跑出来用户看到乱码也会误导决策。\n","skills/opencli-adapter-author/references/jsdom-fixture-pattern.md":"# JSDOM-against-frozen-fixture pattern (for in-browser DOM extractors)\n\n## When this pattern applies\n\nYou're writing an adapter where the data extraction happens **inside the\nlive browser** via `page.evaluate(...)` — not in Node-side post-processing.\nTypical signal: the adapter has a function literal stringified into\n`page.evaluate('(' + fn.toString() + ')()')`, walking `document.querySelector`\nand other DOM APIs.\n\nThese extractors are invisible to mocked `page.evaluate` unit tests — those\ntests feed pre-baked results to the func, so the real DOM walk never runs.\nPR #1312 found two such silent in-browser bugs in dianping that only\nsurfaced on live verify:\n\n1. shop title fallback split on ASCII `[]` while the page renders\n   full-width `【】`, so `name` was always empty.\n2. `headText.replace(/\\s+/g, ' ')` collapsed rating \"4.8\" with reviews\n   \"21241条\", and a head-wide `/\\d+条/` regex captured `4.821241` → 5.\n\nIf either category looks plausible for your site, freeze a representative\nHTML snapshot and replay it through JSDOM in a unit test.\n\n## File layout\n\n- Test file: `clis/<site>/<site>.test.js` (alongside the adapter file)\n- Fixture file: `clis/<site>/__fixtures__/<command>.html`\n\nReference implementation: `clis/dianping/__fixtures__/{shop,search}.html`\n(see PR #1313 for the original test + PR #1318 for the whitespace-strip\nfollow-up that this doc grew out of).\n\n## Creating the HTML fixture\n\nThe whole point is to commit a **representative snapshot of the live\npage's DOM** — so the JSDOM unit test exercises the real selector paths\nthe live extractor walks.\n\n### Mandatory steps (in order)\n\n1. **Capture** the page's HTML from a live verify run:\n   ```bash\n   opencli browser --session research open https://www.example.com/<page>\n   # In another shell, dump page.content():\n   opencli browser --session research eval 'document.documentElement.outerHTML' \\\n     > /tmp/raw-<command>.html\n   ```\n\n2. **Strip noise blocks** that JSDOM doesn't need and that change every\n   page load (so committed fixtures wouldn't survive a re-capture diff\n   anyway):\n   - All `<script>...</script>` content\n   - All `<style>...</style>` content\n   - All `<iframe>...</iframe>` content\n   - All `<!-- ... -->` HTML comments\n   - All `<link rel=\"preload\" ...>` / tracking pixels\n\n3. **Replace `<img src=\"...\">`** with a placeholder\n   (`src=\"placeholder.png\"`) — real CDN URLs leak account-scoped tokens\n   and are noise.\n\n4. **Trim to the minimum subtree** that exercises the extractor and\n   triggers the bug you're guarding against. For dianping shop, that\n   was `.shop-head` + `.desc-info` + `.review-title`; for search,\n   3 of 15 result `<li>` cards (rank 1, 2, 3).\n\n5. **MANDATORY whitespace normalization step** — strip all\n   whitespace-only lines:\n   ```bash\n   awk 'NF>0' /tmp/raw-<command>.html > clis/<site>/__fixtures__/<command>.html\n   ```\n   JSDOM's HTML parser is whitespace-tolerant; blank lines have zero\n   semantic effect on the test, but they bloat the committed diff and\n   obscure the meaningful DOM subtree from reviewers.\n   **Skipping this step is the most common silent quality regression\n   in fixture creation.** PR #1318 cleaned 239 leftover blank lines\n   from dianping's two fixtures (84.6% / 54.8% of file content) and\n   the JSDOM tests still passed unchanged.\n\n6. **Commit** at `clis/<site>/__fixtures__/<command>.html`.\n\n### Anti-patterns to avoid\n\n- ❌ \"Strip script/style content\" but leave the surrounding newlines.\n  Removing inline script body without collapsing the now-blank line is\n  the source of the noise — Step 5 exists specifically to clean this up.\n- ❌ Trim to minimum subtree, skip Step 5. The fixture works for the\n  test but reviewers see hundreds of blank lines.\n- ❌ Pretty-print the **mega-line** (e.g. `<div>...</div>` collapsed\n  onto one giant line by the source page). Some bugs depend on\n  text-node adjacency without intervening whitespace (e.g. `4.8` and\n  `21241条` immediately adjacent → `headText` fusion bug). Pretty-print\n  inserts whitespace that masks the very condition you're testing for.\n  Step 5 only deletes empty lines — never re-flow content.\n- ❌ Re-capture from live and overwrite the committed fixture without\n  re-running Steps 2-5. The fixture is a **frozen** snapshot; if the\n  page layout changes, that's a separate decision (update test\n  expectations + re-trim + re-strip).\n\n## Writing the JSDOM unit test\n\n```js\nimport { describe, it, expect, beforeEach, afterEach } from 'vitest';\nimport { JSDOM } from 'jsdom';\nimport { readFileSync } from 'node:fs';\nimport { join, dirname } from 'node:path';\nimport { fileURLToPath } from 'node:url';\nimport { extractShopFields } from './shop.js';\n\nconst __dirname = dirname(fileURLToPath(import.meta.url));\nconst SHOP_FIXTURE = readFileSync(join(__dirname, '__fixtures__/shop.html'), 'utf8');\n\ndescribe('shop adapter — extractor against frozen HTML fixture', () => {\n    let originalDocument;\n    let originalLocation;\n\n    beforeEach(() => {\n        originalDocument = globalThis.document;\n        originalLocation = globalThis.location;\n    });\n\n    afterEach(() => {\n        globalThis.document = originalDocument;\n        globalThis.location = originalLocation;\n    });\n\n    function loadFixture(html, url) {\n        const dom = new JSDOM(html, { url });\n        globalThis.document = dom.window.document;\n        globalThis.location = dom.window.location;\n        return dom;\n    }\n\n    it('extracts the canonical fields and avoids known silent bugs', () => {\n        loadFixture(SHOP_FIXTURE, 'https://www.example.com/shop/123');\n\n        const data = extractShopFields();\n\n        expect(data.ok).toBe(true);\n        expect(data.name).toBe('...');\n        // Add explicit regression guards for each known silent bug.\n        expect(data.reviewsRaw).toBe('...');  // not the fused \"<rating><reviews>\" form\n    });\n});\n```\n\nFor this to work, the adapter's extractor must be a **top-level\nfunction** that uses bare `document` / `location` (not `window.document`),\nso the same code is exercised by:\n\n- live browser: injected via `${extractFn.toString()}` into\n  `page.evaluate`\n- JSDOM unit test: with `globalThis.document` swapped\n\nIf your adapter currently has the extractor as an IIFE inside a\ntemplate literal, refactor to a top-level `export function` first.\nReference: `clis/dianping/{shop,search}.js` extracts `extractShopFields()`\nand `extractSearchRows()` with bare `document`/`location`.\n\n## Reverse-validation (mandatory before claiming the test catches the bug)\n\nA test that \"passes 18/18\" doesn't prove it would have caught the original\nbug — only that it agrees with the current implementation. Before\ntrusting a regression guard:\n\n1. Make a backup of the adapter source.\n2. Reintroduce the buggy variant of the relevant extractor.\n3. Run the test. It MUST fail with an assertion that points at the\n   silent bug.\n4. Restore from backup.\n\nFor dianping bug #2 (rating/reviews fusion):\n```js\n// BUGGY VARIANT — replaces the .reviews selector path\nconst buggyMatch = headText.match(/(\\d+)条/);\nlet reviewsRaw = buggyMatch ? buggyMatch[0] : '';\n```\n\nIf after Step 5 of fixture creation the test still fails on this buggy\nvariant with `expected '21241条' to be '21241条'` actually receiving\n`'821241条'` (the fused digits), the regression guard is intact. If the\ntest still passes with the buggy variant, the fixture is too stripped /\nnormalization went too far / the assertion is too loose — go back and\ntighten.\n\nThis is the same discipline as `--write-fixture` Step 10 in the main\nrunbook (verify fixture catches what it should), applied to the JSDOM\nHTML fixture instead of the response JSON fixture.\n\n## See also\n\n- `references/adapter-template.md` — basic adapter file structure\n- `references/output-design.md` — column naming for the post-extract mapping\n- `references/success-rate-pitfalls.md` — broader \"verify can pass while\n  data is silently wrong\" catalog; the mocked-page.evaluate gap that\n  motivates this whole pattern is one entry there\n","skills/opencli-adapter-author/references/output-design.md":"# Output Design\n\nadapter 的 `columns` 不是随便列。要让下游（用户、其他 adapter 合并、agent 后续分析）都能直接读。\n\n---\n\n## 核心约定\n\n### 1. 命名\n\n**camelCase，全英文**：\n\n| 好 | 差 |\n|----|---|\n| `marketCap` | `market_cap` / `市值` / `MarketCap` |\n| `change24hPct` | `change_percentage_24h` / `涨跌幅24h` / `changePct_24h` |\n| `bondCode` | `bond_code` / `BOND_CODE` |\n| `pubTime` | `publish_time` / `pubdate` |\n\n原则：\n- 缩写：`pct`（百分比）/ `pe`（市盈率）/ `pb`（市净率）/ `ytm`（到期收益率）/ `id`\n- 时间后缀：`Time`（具体时刻）/ `Date`（日期）/ `Ts`（unix 秒）\n- 百分比一律 `Pct` 结尾，数值是\"已乘 100\"形式（`2.5` 表示 2.5%，不是 `0.025`）\n- 数量后缀：`Count`（整数计数）/ `Total`（累计值）\n\n### 2. 类型\n\n| 字段类 | JS 类型 | 格式 |\n|--------|---------|------|\n| 价格 / 金额 | `number` | 原始小数，别除 1000 别取整 |\n| 百分比 | `number` | 已 × 100（`2.5` = 2.5%） |\n| 计数 / rank | `number` | 正整数 |\n| 代码 / id / symbol | `string` | 股票代码 `'600000'` 保留前导 0 |\n| 名称 / 标题 | `string` | 去首尾空白 |\n| 时间 | `string` ISO（`'2024-01-15T10:30:00Z'`）或 `number` unix 秒 | 不要本地字符串 `'2024/1/15'` |\n| 布尔 | `boolean` | 不用 `0/1` |\n| URL | `string` | 绝对路径，相对路径要拼 host |\n\n**特殊**：\n- 缺失用 `null`，不用 `0` / `''`（0 和空字符串有业务含义，别混）\n- 枚举 → string，不要 int 代号（`'listed'` 比 `0` 清楚）\n\n### 3. 顺序\n\n固定三段：\n\n```\n[识别列 ...] [业务数字 ...] [metadata ...]\n```\n\n**识别列**（前 1-3 列）：`rank / symbol / code / bondCode / name / title / id`\n\n**业务数字**（中间）：价格、涨跌幅、成交量、市值等业务语义\n\n**metadata**（最后 1-3 列）：`pubTime / updateTime / source / url`\n\n### 4. 必有列（按类型）\n\n| adapter 类型 | 必须包含 |\n|-------------|---------|\n| 排行 / 列表 | `rank` + 识别列 + 业务数字 |\n| 时序 / K 线 | `date`（或 `ts`）+ 数值 |\n| 详情（单对象） | 识别列 + 业务字段 |\n| 新闻 / 公告 | `title` + `pubTime` + `url` |\n\n### 5. 控量\n\n**单条 ≤ 15 列**。超了就得考虑：\n- 拆成多个 adapter（列表版 + 详情版）\n- 次要字段合进 `extras: {...}` 对象\n- 只有少数用户关心的字段默认隐藏（靠参数开关）\n\n---\n\n## 对齐邻居 adapter\n\n写新 adapter 前先看同站点现有 adapter 怎么命名：\n\n```bash\ngrep -h \"columns:\" clis/<site>/*.js\n```\n\n复用同类列名。比如 `clis/eastmoney/convertible.js` 用 `bondCode / bondName / stockCode / stockName`，新写 eastmoney 某个涉及股票代码的 adapter 就沿用 `stockCode / stockName`，不要发明 `securityId / securityName`。\n\n---\n\n## 常见错误\n\n| 错 | 对 |\n|----|---|\n| `columns: ['id', 'name', 'data.price']`（点路径） | 把 `data.price` 在 func 里打平成 `price` |\n| `{date: '2024-01-15 10:30'}`（空格 + 非 ISO） | `'2024-01-15T10:30:00Z'` 或 `Date.toISOString()` |\n| `{pct: '2.5%'}`（字符串 + 单位） | `{changePct: 2.5}` 纯数字 |\n| `{volume: '1.2万'}` | `{volume: 12000}` |\n| `{code: 600000}`（整数丢前导 0） | `{code: '600000'}` string，或 `String(code).padStart(6, '0')` |\n| columns 和 func 返回的 keys 对不上 | 列出的每个 key 必须在返回对象里，顺序也一致 |\n\n---\n\n## description 字段\n\nadapter `description` 是用户第一眼看到的，写清楚：\n\n1. **数据是什么**：`\"A 股涨幅排行\"` vs `\"大盘指数分时\"`\n2. **默认行为**：`\"默认按涨幅排序，前 20 条\"`\n3. **重要参数**：`\"支持 market 参数切换沪深/北证\"`\n\n不要：\n- `\"get data\"` — 废话\n- `\"查询 xxx 数据\"` — 也是废话\n- 塞完整 URL / 字段代号列表 — 留给 help\n\n一行 30 字左右够了。\n\n---\n\n## args 命名\n\n- `limit` 而不是 `count / num / n / size`\n- `sort` 而不是 `sort_by / order_by / sortKey`\n- `market` 而不是 `exchange / platform / type`\n- `symbol` / `code` / `query` 根据业务选，保持和邻居 adapter 一致\n- 布尔参数用 `enableX / includeX`，默认 false 免得用户改变认知负担\n\n`help` 文案给**所有合法值**，别让用户猜：\n\n```javascript\n{ name: 'sort', type: 'string', default: 'turnover', help: '排序：turnover / change / drop / price / premium' }\n```\n\n---\n\n## 示例对比\n\n**差的**：\n\n```javascript\ncolumns: ['股票代码', 'name', 'PRICE', 'change%', 'vol', 'time']\n```\n\n问题：中英混、大小写乱、百分号字符串、缩写不统一。\n\n**好的**：\n\n```javascript\ncolumns: ['rank', 'stockCode', 'stockName', 'price', 'changePct', 'volume', 'updateTime']\n```\n\n识别列在前，metadata 在后，命名统一 camelCase。\n","skills/opencli-adapter-author/references/site-memory.md":"# Site Memory\n\n站点记忆分两层：**in-repo 种子**（skill 自带的已知站点公共知识）+ **本地工作目录**（每台机器跑过的站点累积产物）。\n\n---\n\n## 两层结构\n\n```\nskills/opencli-adapter-author/references/site-memory/<site>.md\n    — 公共种子。手写 + PR 审核进入。多 agent 共享的第一批起点。\n    — 已铺：eastmoney / xueqiu / bilibili / tonghuashun / gmail\n\n~/.opencli/sites/<site>/\n    — 本地累积。agent 跑 adapter 过程里自动写入，跨 session 复用。\n    — 不进 git，不进 PR。\n```\n\n用法：开头先读本地，命中 **不跳写 adapter**，仍要跑 Step 5 endpoint 验证 + Step 7 字段抽查（memory 可能过期或站点换版）；没命中读 in-repo；都没有走完整 recon。\n\n---\n\n## Layer 1 — In-repo 种子（`references/site-memory/<site>.md`）\n\n每个覆盖站点一个 `.md`，结构固定：\n\n```markdown\n# <site>\n\n## 域名\n主 API / 备 API / 登录 / 静态资源\n\n## 默认鉴权\n`Strategy.XXX` + 必需 cookie/header + 获取方式\n\n## 已知 endpoint（选最常用的 5-10 条）\n- `GET <url>` — 返回 X，分页参数 Y\n- ...\n\n## 字段（指向 `field-conventions.md` 的某一节）\n\n## 坑 / 陷阱\n- fltt=2 必传\n- 单位是\"万\"不是\"元\"\n- ...\n\n## 可参考的 adapter\n`clis/<site>/<name>.js` × N\n```\n\n审核门槛高，里面写的东西必须是\"多数人都会踩到\"的共识。一次性试错、站点局部怪癖放 Layer 2。\n\n---\n\n## Layer 2 — 本地工作目录（`~/.opencli/sites/<site>/`）\n\nagent 每跑一次相关 adapter 就可以自动写/读：\n\n```\n~/.opencli/sites/<site>/\n  notes.md               — 累积笔记（时间戳 + 写入人 + 发现）\n  endpoints.json         — 已验证的 endpoint 目录\n  field-map.json         — 字段代号 → 含义（key 为字段代号，value 为 {meaning, verified_at, source}）\n  verify/                — `opencli adapter test` 期望值（值级校验锚点，每个 adapter 一份）\n    <cmd>.json\n  fixtures/              — 公开/合成/已完成脱敏的响应样本（给字段对比 / 离线 replay；高敏私人响应不落盘）\n    <cmd>-<ts>.json\n  last-probe.log         — 最近一次侦察输出（下次接着用）\n```\n\n`verify/` vs `fixtures/` 别混：\n- `verify/<cmd>.json` 是**结构期望**（rowCount / columns / types / patterns / notEmpty），每 adapter 一份、会被 verify 读。\n- `fixtures/<cmd>-<ts>.json` 是**可安全持久化的响应样本**，给人 / 下一个 agent 做字段比对用，verify 不会读；高敏私人响应只用合成 fixture。\n\n### `endpoints.json` 格式（schema 锁死）\n\nkey = endpoint 的短名（`clist` / `kline` / `search` 等），不要用全 URL 当 key。\n\n```json\n{\n  \"clist\": {\n    \"url\": \"https://push2.eastmoney.com/api/qt/clist/get\",\n    \"method\": \"GET\",\n    \"params\": {\n      \"required\": [\"fs\", \"fields\"],\n      \"optional\": [\"pn\", \"pz\", \"fid\", \"po\", \"fltt\"]\n    },\n    \"response\": \"data.diff[] 数组\",\n    \"verified_at\": \"2026-04-20\",\n    \"notes\": \"fltt=2 必传\"\n  }\n}\n```\n\n字段说明：\n\n- `url` / `method`：原样存，query string 不入 `url`，都归 `params`\n- `params.required` / `params.optional`：参数名列表。**不存具体值**（值会变，记例子放 `notes`）\n- `response`：一句话写清响应形状入口（`data.diff[] 数组` / `result.data.items` / `纯数组`），而不是把整个响应贴进来\n- `verified_at`：`YYYY-MM-DD`。超过 30 天下次读到当作过期重验\n- `notes`：一两句关键坑（`fltt=2 必传` / `ms 单位 begin` 之类），不要写长文\n\n### `field-map.json` 格式（schema 锁死）\n\nkey = 字段代号（`f237` / `f152`），value 三件套：\n\n```json\n{\n  \"f237\": {\n    \"meaning\": \"convertible premium rate (%)\",\n    \"verified_at\": \"2026-04-20\",\n    \"source\": \"field-decode-playbook sort-key comparison vs page\"\n  }\n}\n```\n\n- `meaning`：人话 + 单位/精度（`%` / `元` / `万元` / `× 10^f152` 等）\n- `verified_at`：`YYYY-MM-DD`\n- `source`：怎么推出来的，让下次能复查（`field-decode-playbook sort-key` / `网页标签对照` / `bundle 搜索 var pricePct =`）\n- **已存在的 key 不要默默覆盖**。有冲突时先用 `fixtures/` 里的真实样本 + 网页肉眼值再确认一遍\n\n### `verify/<cmd>.json` 格式（schema 锁死）\n\n每个 adapter 一份，`opencli adapter test <site>/<cmd>` 会自动读。**没有这份 = verify 只能证\"能跑\"，证不出数据对**——可按任务需要维护 fixture。\n\n```json\n{\n  \"args\": { \"limit\": 3 },\n  \"expect\": {\n    \"rowCount\": { \"min\": 1, \"max\": 3 },\n    \"columns\": [\"rank\", \"tid\", \"title\", \"url\"],\n    \"types\": {\n      \"rank\": \"number\",\n      \"tid\": \"string|number\",\n      \"title\": \"string\",\n      \"url\": \"string\"\n    },\n    \"patterns\": {\n      \"url\": \"^https://www\\\\.1point3acres\\\\.com/bbs/thread-\"\n    },\n    \"notEmpty\": [\"title\", \"url\"]\n  }\n}\n```\n\n字段说明：\n\n- `args`：verify 调 adapter 时要带的参数。支持两种形态：\n  - **对象**：`{ \"limit\": 3 }` → 展开成 `--limit 3`，用于标准 named flag 适配器\n  - **数组**：`[\"123\", \"--limit\", \"3\"]` → **原样**追加到命令后，用于 positional 主语型适配器（`<tid>` / `<url>` / `<query>`）。repo 约定\"主语优先 positional\"，所以这类适配器只能用数组形态\n- `expect.rowCount.{min,max}`：包含边界。稳定列表接口收紧到 `[min, max]`，动态接口给一个宽区间\n- `expect.columns`：每行必须都有这些 key（严格要求——漏了就 fail）\n- `expect.types`：支持 `|` union（`string|null`）和 `any` 通配。写多少列类型看列的稳定性；波动大的列直接 `any` 比频繁改 fixture 好\n- `expect.patterns`：正则表达式 **字符串**（注意 `\\\\` 转义）。`null` / `undefined` 会被跳过，不要用正则校验可空字段\n- `expect.notEmpty`：trim 后不能为空的列。这是\"adapter 没吃掉核心业务字段\"的最后一道保险\n- `expect.mustNotContain`：`Record<col, string[]>`。列值里不允许出现这些子串。用来挡\"字段内容污染\"——比如 `description` 里混进了 `address:` / `category:` 的邻居节点文字、`title` 前面粘了面包屑前缀。`notEmpty` 挡不住这种软污染\n- `expect.mustBeTruthy`：列数组。列值必须是 JS truthy。用来挡\"silent `|| 0` / `|| false` 兜底\"——数值列返回 0 / 空字符串 / false 都会被 `notEmpty` 放过，但业务上通常是\"没抓到\"\n\n### 什么时候手写 vs `--write-fixture` 自动生成\n\n- `--write-fixture` 只是种子：生成 `rowCount.min=1` / `columns` / `types`，**没有** `patterns` / `notEmpty` / `mustNotContain` / `mustBeTruthy`——纯类型 fixture 挡不住数值错位 / 字段污染 / silent fallback。\n- 拿到种子后**必须手改**，四件套一起上：\n  - `patterns`：URL / 日期 / ID 等格式列\n  - `notEmpty`：核心业务字段\n  - `mustNotContain`：描述类文本列容易被兄弟节点污染时，把禁词（`address:` / `category:` 等）列出来\n  - `mustBeTruthy`：数值 / 布尔业务列，挡 `|| 0` / `|| false`\n- adapter 是 positional 主语型（`<tid>` / `<url>` / `<query>`）时，`--write-fixture` 的 `args` 要手写成数组形态。工具不会替你决定形态。\n- 站点换版导致 fixture 过时：`--update-fixture` 覆盖。改之前**先用肉眼核对一次网页值**，别闭着眼把错的响应固化下来。\n- **规避反模式：不要为了让 verify 通过去放松 pattern**。失败的 pattern 说明 adapter 输出有问题，要收紧 adapter，不是收紧 fixture。放松 fixture 等于默认把错数据接受下来。\n\n### `notes.md` 格式\n\n```markdown\n## 2026-04-20 by opencli-user\n写 `convertible.js` 时遇到：\n- f237 推断是溢价率（排序对比法，页面对照）\n- `fltt=2` 不加的话价格是整数 × 10^f152\n- `fs=b:MK0354` 过滤可转债\n```\n\n顶部追加新段落，老的不删。每段有日期 + 写入人。\n\n### `fixtures/<cmd>-<YYYYMMDDHHMM>.json` 格式\n\n一份该 endpoint 的**可安全持久化**响应样本。优先级：合成 fixture > 公开响应 > 可证明完成脱敏的真实响应。用途：\n\n- 未来字段代号再变时，拿样本和 `field-map.json` 做 regression 对比\n- 站点换版时，新响应和旧 fixture 做 diff 看哪个字段结构变了\n\n**存之前做数据分级**：\n\n- 公开列表/行情等无账户私有内容：可保存，仍需移除 cookie/token/header。\n- 低敏账号数据：只有完成字段级脱敏且不会从正文、snippet、URL、附件名反推个人时才保存。\n- 邮箱、私信、通讯录、支付、健康、草稿、上传文件等高敏内容：不要保存真实 response，即使“删了邮箱地址”也不够；用保持 shape/arity 的合成 fixture。\n\n原始 capture 只在 `/tmp/` 或受控本地 cache 中短暂存在，任务结束删除；不要把 raw private response 当成“完整 fixture”长期积累。\n\n---\n\n## runbook 里的读/写时机\n\n```\nStep 2 开始前 → 读  ~/.opencli/sites/<site>/\n                → 读  references/site-memory/<site>.md\n                命中后 → 不跳写 adapter，仍要跑 Step 5 (endpoint 验证) + Step 7 (字段抽查)\n                        verified_at 超 30 天 → 当作过期，按冷启动走 Step 3 → 4\n\nStep 10 verify 首轮通过后 → 写 ~/.opencli/sites/<site>/verify/<cmd>.json\n                            - 先 `--write-fixture` 拿种子，再手改 patterns / notEmpty / rowCount\n                            - 没这份后续 verify 挡不住数据错位，**必填**\n\nStep 11 肉眼对比通过后 → 写 ~/.opencli/sites/<site>/\n                        - endpoints.json：按 schema 追加或更新 verified_at\n                        - field-map.json：只追加新 key，已有的不默默覆盖\n                        - notes.md：顶部追加一段\n                        - fixtures/：按数据分级保存公开/合成/已脱敏样本（区别于 verify/）\n```\n\n**回写是 commit，不是 stash**：不过 Step 10 verify + Step 11 肉眼对比不写，防止把错的映射喂给下一轮。\n\n---\n\n## 不要写进 `~/.opencli/sites/` 的东西\n\n- 真实账户 cookie / token — 不要保存任何鉴权凭据\n- 用户私有数据：高敏内容一律不存；低敏内容只有完成字段级脱敏才可存\n- 过期超过 30 天的 last-probe.log（自动清）\n\n## 不要写进 **repo / adapter 目录** 的东西\n\n调试过程里的临时 dump（`.dbg-*.html` / `raw-*.json` / `sample-*` / `trace-*.txt`）**只能**落在系统 `/tmp/`；只有通过上面数据分级、准备长期保留的安全样本才进入 `~/.opencli/sites/<site>/fixtures/`。PR diff 会把 repo 根目录和 `clis/<site>/` 下的文件一起带走；任务结束还要删除原始 capture/cache。\n\n---\n\n## 没有 site-memory 时\n\n新站点没对应 `.md`，也没本地目录 → 完整走 recon + discovery，跑完直接写 `~/.opencli/sites/<site>/`，后面就有了。\n","skills/opencli-adapter-author/references/site-memory/bilibili.md":"# bilibili（B 站）\n\n## 域名\n\n| 用途 | 域名 |\n|------|------|\n| 主 API | `api.bilibili.com` |\n| 个人主页 / 空间 | `space.bilibili.com` |\n| 登录 / 鉴权 | `passport.bilibili.com` |\n| 动态 | `t.bilibili.com` / `api.vc.bilibili.com` |\n| 直播 | `api.live.bilibili.com` |\n\n## 默认鉴权\n\n- `Strategy.COOKIE + browser: true`\n- 核心 cookie：`SESSDATA`（登录态）、`bili_jct`（CSRF token，部分写接口要带到 header `Referer + csrf`）\n- **未登录也能调多数读接口**，但有 wbi 签名要求\n\n## 关键：wbi 签名\n\n- 凡 URL 含 `/wbi/` 的接口都要 `w_rid + wts` 签名\n- 签名算法依赖每日轮换的 `img_key / sub_key`，从 `api.bilibili.com/x/web-interface/nav` 的 `wbi_img` 字段拿\n- **不要自己重新实现**：`clis/bilibili/utils.js` 里的 `apiGet(page, path, { signed: true, params })` 已经封装好\n- 普通 cookie JSON 接口优先用 `page.fetchJson(url)`；站点级签名逻辑仍复用 `utils.js`\n\n## 已知 endpoint\n\n- `GET api.bilibili.com/x/web-interface/nav` — 登录态 + 拿 wbi key\n- `GET api.bilibili.com/x/space/wbi/arc/search?mid=<uid>` — 用户视频（需 wbi 签）\n- `GET api.bilibili.com/x/space/acc/info?mid=<uid>` — 用户资料\n- `GET api.bilibili.com/x/web-interface/view?bvid=BV...` — 视频详情\n- `GET api.bilibili.com/x/web-interface/popular?ps=20&pn=1` — 热门\n- `GET api.bilibili.com/x/web-interface/ranking/v2?rid=0` — 排行\n- `GET api.bilibili.com/x/v2/reply/wbi/main?type=1&oid=<aid>&mode=3` — 评论（需 wbi）\n- `GET api.bilibili.com/x/web-interface/search/all/v2?keyword=<q>` — 综合搜索\n- `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`）\n- `GET api.bilibili.com/x/v2/history/cursor` — 观看历史（需登录）\n- `GET api.bilibili.com/x/v3/fav/folder/created/list-all` — 收藏夹列表（需登录）\n\n## 字段\n\n字段基本人类可读，见 `../field-conventions.md` 的 bilibili 节（`mid / aid / bvid / cid / view / danmaku / reply / favorite / coin / share / like / pubdate / ctime`）。\n\n## 坑 / 陷阱\n\n1. **wbi 签名缓存 `img_key / sub_key`**：24 小时内有效，每次请求都重新 fetch `nav` 会被限频。utils.js 内部做了缓存\n2. **`mid` 和 `uid` 一回事**：接口不统一，`mid=` 居多，个别接口要 `host_mid=`\n3. **视频 ID 两种**：`aid`（老数字）和 `bvid`（BV1xxx），视频详情要用 `bvid`\n4. **`ps=` 最大 50**（popular/ranking），超过了多发几页拼\n5. **动态接口换版**：老的 `dynamic_svr` 已经废，新的走 `x/polymer/web-dynamic/v1/feed/space`，写新 adapter 直接用新接口\n6. **评论分页靠 `next` 游标**，不是页号\n7. **B 站限频 `-352 风控`**：短时间高频调会拦截，adapter 层加 500ms 间隔\n8. **搜索要先 \"cold start\"**：空 cookie 的 session 第一次搜会 412，先访问首页拿 `buvid3` cookie\n9. **直播接口在 `api.live.bilibili.com`**，不是主域名\n\n## 可参考的 adapter\n\n| 模板类型 | 参考文件 |\n|---------|---------|\n| 用户资料 / 视频列表 | `clis/bilibili/user-videos.js` / `me.js` |\n| 视频详情 / 字幕 | `clis/bilibili/download.js` / `subtitle.js` |\n| 评论 | `clis/bilibili/comments.js` |\n| 搜索 | `clis/bilibili/search.js` |\n| 热门 / 排行 | `clis/bilibili/hot.js` / `ranking.js` |\n| 动态 | `clis/bilibili/dynamic.js` |\n| 关注 | `clis/bilibili/following.js` |\n| 收藏 | `clis/bilibili/favorite.js` |\n| 观看历史 | `clis/bilibili/history.js` |\n\n通用工具：`clis/bilibili/utils.js`。新 adapter 先 `import { apiGet, fetchJson } from './utils.js'`，不要重写。\n","skills/opencli-adapter-author/references/site-memory/eastmoney.md":"# eastmoney（东方财富）\n\n## 域名\n\n| 用途 | 域名 |\n|------|------|\n| 行情列表 / 批量报价 | `push2.eastmoney.com` |\n| K 线历史 | `push2his.eastmoney.com` |\n| 报表类（龙虎榜 / 十大股东） | `datacenter-web.eastmoney.com` |\n| 7x24 快讯 | `np-listapi.eastmoney.com` |\n| 公司公告 | `np-anotice-stock.eastmoney.com` |\n| 静态页（网页端入口） | `quote.eastmoney.com` / `data.eastmoney.com` |\n\n## 默认鉴权\n\n- `Strategy.PUBLIC + browser: false`\n- 统一带 `ut=bd1d9ddb04089700cf9c27f6f7426281`（push2 系列的公共 token）\n- User-Agent 随意给个 `Mozilla/5.0` 就行，不加会偶发被拦\n\n## 已知 endpoint\n\n- `GET push2.eastmoney.com/api/qt/clist/get` — 列表 / 排行\n  - 必需：`fs`（市场/板块过滤码）、`fields`（字段清单）\n  - 可选：`pn`（页码，1-based）、`pz`（每页数量）、`fid`（排序字段）、`po`（0=asc / 1=desc）、`fltt`（2=浮点格式化）、`invt`（2=固定）、`np`（1=固定）\n  - 返回：`data.diff[]` 数组\n- `GET push2.eastmoney.com/api/qt/ulist.np/get` — 批量报价（给定 secids）\n  - 必需：`secids`（逗号拼接的 `<market>.<code>`）、`fields`\n  - 返回：`data.diff[]` 数组\n- `GET push2.eastmoney.com/api/qt/stock/get` — 单只详情\n  - 必需：`secid`、`fields`\n- `GET push2his.eastmoney.com/api/qt/stock/kline/get` — K 线历史\n  - 必需：`secid`、`klt`（周期：1 分 / 5 / 15 / 30 / 60 / 101 日 / 102 周 / 103 月）、`fqt`（0=不复权 / 1=前复权 / 2=后复权）、`fields1`、`fields2`\n  - 返回：`data.klines[]` — CSV 字符串数组\n- `GET datacenter-web.eastmoney.com/api/data/v1/get` — 报表类\n  - 必需：`reportName`（如 `RPT_DAILYBILLBOARD_DETAILS`）、`columns`、`pageSize`、`pageNumber`、`sortColumns`、`sortTypes`\n  - 返回：`result.data[]`\n- `GET np-listapi.eastmoney.com/nlist/api/list/get` — 7x24 快讯\n  - 必需：`client=web`、`column_id`、`limit`、`last_time`\n- `GET np-anotice-stock.eastmoney.com/api/security/ann` — 公司公告\n  - 必需：`sr=-1`、`page_size`、`page_index`、`ann_type`、`stock_list`\n\n## 字段\n\n字段代号词典见 `../field-conventions.md` 的 eastmoney 节（`f2 / f3 / f12 / f14 / f62 / f229-f243` 等全集）。\n\n市场前缀、fs 过滤码、secid 格式也都在那里。\n\n## 坑 / 陷阱\n\n1. **`fltt=2` 必传**：否则价格字段是 `int × 10^f152` 的整数表示，要自己除\n2. **资金流单位混乱**：`f6` 成交额是元，但 `f62 / f66 / f72 / f78 / f84` 等净流入大部分接口返回\"万元\"，核对单条接口后再乘\n3. **secid vs code**：`0.000001 / 1.600000` 是 secid（market + code），只有纯 code 时要先判断所属市场前缀再拼。用 `clis/eastmoney/_secid.js` 导出的 `resolveSecid(input)`（单股）或 `splitSymbols(s)`（批量参数拆分），不要自己硬拼前缀\n4. **港股代码 `00700.HK` 不是 secid**：只有前缀属于 `{0, 1, 105, 106, 107, 116, 100, 90}` 才当 secid\n5. **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`\n6. **datacenter-web 字段名大写**：`SECURITY_CODE` 不是 `security_code`\n\n## 可参考的 adapter\n\n| 模板类型 | 参考文件 |\n|---------|---------|\n| clist 分页排行 | `clis/eastmoney/convertible.js` / `rank.js` / `etf.js` / `sectors.js` |\n| ulist 批量报价 | `clis/eastmoney/quote.js` |\n| K 线历史 | `clis/eastmoney/kline.js` |\n| 报表类 | `clis/eastmoney/longhu.js` / `holders.js` |\n| 7x24 快讯 | `clis/eastmoney/kuaixun.js` |\n| 公司公告 | `clis/eastmoney/announcement.js` |\n| 指数 / 北上 | `clis/eastmoney/index-board.js` / `northbound.js` |\n| 资金流 | `clis/eastmoney/money-flow.js` |\n\n新写 eastmoney adapter 时，照最像的那条 copy + 改 `name` / URL 参数 / 字段映射三处。\n","skills/opencli-adapter-author/references/site-memory/gmail.md":"# gmail\n\n## Domain and auth\n\n- Desktop UI: `mail.google.com/mail/u/<account>/`.\n- Requires Google session cookies and a signed-in Gmail surface.\n- The official Gmail API requires separate OAuth; browser cookies are not an official API credential.\n\n## Verified read paths\n\n- `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]`.\n- `POST /sync/u/<account>/i/fd`: natural thread/message detail response. Cached threads may render without a fresh request.\n- `POST /sync/u/<account>/i/s`: incremental synchronization; not needed by the initial adapter.\n- Legacy `POST /mail/u/<account>/?ui=2` still appears during bootstrap. Do not assume a single transport variant.\n\n## Positional fields verified in 2026-08\n\n- Thread: subject `[0]`, snippet `[1]`, timestamp `[2]`, sync id `[3]`, summaries `[4]`.\n- Summary: sender card `[1]`, label ids `[10]`.\n- FD thread: id `[0]`, message wrappers `[2]`; wrapper is `[messageId, record]`.\n- Message: to `[0]`, cc `[1]`, subject `[4]`, body `[5]`, snippet `[6]`, sender `[10]`, attachments `[13]`, date `[16]`, legacy id `[34]`.\n- Sender: display name `[14]`, address `[16]`.\n- Attachment node: id `[1]`, data `[3]`; data contains name `[2]`, MIME `[3]`, size `[4]`.\n\n## Strategy\n\n- Reads: `INTERCEPT`. Let Gmail submit the visible search/open action, then parse the natural `bv/fd` response. Never reconstruct private XSRF/BTAI request bodies.\n- Cached thread fallback: rendered message containers (`data-message-id`, `.a3s`, scoped attachment cards).\n- 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.\n\n## Durable pitfalls\n\n1. 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.\n2. `thread-f:<decimal>` converts to legacy hex with `BigInt(decimal).toString(16)`; never use Number.\n3. `bv` search responses may omit label definitions. The complete visible fallback is `#settings/labels`.\n4. A new `/fd` response is not guaranteed for cached messages. Treat rendered content as a legitimate visible-ui fallback.\n5. Responses are anti-XSSI-prefixed positional arrays. Enforce arity/required-field guards and fail typed on drift.\n6. Network capture is a destructive drain; a relevant bodyless/truncated entry means possible data loss, not an empty/short page.\n7. Never store live subjects, addresses, message bodies, attachment names, cookies, or account-specific ids in fixtures/site memory.\n8. 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.\n","skills/opencli-adapter-author/references/site-memory/tonghuashun.md":"# tonghuashun（同花顺 / ths）\n\n## 域名\n\n| 用途 | 域名 |\n|------|------|\n| 行情页 / 热榜 | `q.10jqka.com.cn` |\n| 数据中心（多维筛选） | `data.10jqka.com.cn` |\n| 行情推送 | `d.10jqka.com.cn` / `dq.10jqka.com.cn` |\n| 基本面 / F10 | `basic.10jqka.com.cn` |\n| 资讯 / 新闻 | `news.10jqka.com.cn` |\n\n## 默认鉴权\n\n- `Strategy.COOKIE + browser: true`（多数接口有 cookie 风控）\n- `Referer: http://q.10jqka.com.cn/` 是关键 header，很多接口缺它直接返回空\n- 有些 JSONP 接口没 cookie 要求，但 Referer 还是要带\n- 不支持登录态扩展（加密/签名客户端逻辑复杂）\n\n## 已知 endpoint\n\n- `GET d.10jqka.com.cn/v6/line/hs_<code>/01/last1200.js` — K 线（JSONP，返回 `quotebridge_v6_line_...({...})`）\n- `GET q.10jqka.com.cn/thsq/quote/v6/<code>` — 实时行情快照（页面内 XHR）\n- `GET q.10jqka.com.cn/stock/attention/` — 热度榜（HTML，抽表）\n- `GET data.10jqka.com.cn/funds/ggzjl/field/zdf/order/desc/page/1/ajax/1/` — 资金流排行（HTML 表格 + JSONP 混合）\n- `GET news.10jqka.com.cn/tapp/news/push/stock/?tag=&page=1&limit=20` — 快讯\n- `GET data.10jqka.com.cn/ifinance/hotNews/` — 热点新闻\n\n## 字段\n\n见 `../field-conventions.md` 的 tonghuashun 节（`openPrice / closePrice / zdf / hsl / zf` 等）。\n\n数值字段多数是字符串（带百分号或\"万\"单位），解析时 `parseFloat` 并处理单位。\n\n## 坑 / 陷阱\n\n1. **`Referer` 必须带**，否则 302 到错误页\n2. **多数 JSONP 接口回调名固定**：`quotebridge_v6_...`，adapter 里直接 `.replace(/^[\\w_]+\\((.*)\\)$/, '$1')` 剥壳再 JSON.parse\n3. **HTML 表格接口**（如资金流）：`page.evaluate` 里 `document.querySelectorAll('table tr')` 抽，每列顺序在同类 adapter 之间复用\n4. **`<code>` 格式 `hs_600000`**：hs 是上海，sz 是深圳；港美股前缀不同\n5. **数据中心接口有日期快照切换**：`date=YYYYMMDD` 参数默认当天，历史日期要显式传\n6. **限频特别严**：连续 10 次会跳风控页，adapter 层 1s 间隔起\n7. **响应 gzip 强制**：某些接口不带 Accept-Encoding 会 406，`fetch` 默认 OK，node 原生 http 要显式加\n\n## 可参考的 adapter\n\n| 模板类型 | 参考文件 |\n|---------|---------|\n| 热度榜 | `clis/ths/hot-rank.js`（目前唯一一个，其他待补） |\n\nths 覆盖度低，新写 adapter 参考 eastmoney 同类型 adapter 的结构，把 URL / 解析逻辑换成 ths 版本。\n","skills/opencli-adapter-author/references/site-memory/xueqiu.md":"# xueqiu（雪球）\n\n## 域名\n\n| 用途 | 域名 |\n|------|------|\n| 行情 / 搜索 / 关注列表 | `stock.xueqiu.com` / `xueqiu.com` |\n| 动态 / 评论 / 热帖 | `xueqiu.com` |\n| 基金（蛋卷） | `danjuanfunds.com` |\n\n## 默认鉴权\n\n- `Strategy.COOKIE + browser: true`\n- 核心 cookie：`xq_a_token`（匿名也有，登录后含用户身份）\n- `page.evaluate` 里 `fetch(url, { credentials: 'include' })` 带 cookie\n- 浏览器先访问一次 `xueqiu.com` 触发 cookie 下发，再去调接口（否则 `xq_a_token` 不存在 → 400）\n\n## 已知 endpoint\n\n- `GET stock.xueqiu.com/v5/stock/quote.json?symbol=SH600000` — 单股详情\n- `GET stock.xueqiu.com/v5/stock/batch/quote.json?symbol=SH600000,SZ000001` — 批量报价\n- `GET stock.xueqiu.com/v5/stock/chart/kline.json?symbol=SH600000&begin=<ts>&period=day&type=before&count=-100` — K 线\n- `GET stock.xueqiu.com/v5/stock/screener/quote/list.json?market=CN&type=sh_sz` — 排行\n  - 可选：`order_by=percent`（涨幅）/ `volume`（成交量）/ `amount`（成交额）/ `market_capital`\n- `GET stock.xueqiu.com/v5/stock/search.json?keyword=<q>` — 搜索\n- `GET xueqiu.com/statuses/hot/list.json?since_id=-1&max_id=-1&size=20&type=stock` — 热门帖\n- `GET xueqiu.com/statuses/search.json?source=all&q=<q>` — 动态搜索\n- `GET xueqiu.com/service/v5/stock/portfolio/list.json` — 关注组合（需登录）\n- `GET xueqiu.com/v4/statuses/user_timeline.json?user_id=<uid>` — 用户动态\n\n## 字段\n\n字段基本人类可读（`symbol / name / current / chg / percent / volume / amount / market_capital / pe_ttm / pb`），详见 `../field-conventions.md` 的 xueqiu 节。\n\n## 坑 / 陷阱\n\n1. **必须先访问首页**：冷启动直接调 API 会 400 `cookie is invalid`，先 `page.goto('https://xueqiu.com/')` 再 fetch\n2. **`symbol` 前缀硬编码**：SH/SZ/HK/US —— 港股 `00700` 是 `'HK00700'`，美股 `AAPL` 是 `'AAPL'`（无前缀）\n3. **kline 的 `begin` 是毫秒 unix**，不是秒\n4. **screener 的 `type`**：`sh_sz / hk / us` 必传，漏了拿空数组\n5. **评论接口 `comments.json` 要传 `statusId`**，不是 `status_id`\n6. **限频明显**：同 symbol 短时间反复调会 403，adapter 层建议加 300ms 间隔\n7. **蛋卷基金（`danjuanfunds.com`）是独立子品牌**：cookie 域不同，要单独获取\n\n## 可参考的 adapter\n\n| 模板类型 | 参考文件 |\n|---------|---------|\n| 单股 / 批量报价 | `clis/xueqiu/stock.js` |\n| K 线 | `clis/xueqiu/kline.js` |\n| 排行 | `clis/xueqiu/hot-stock.js` |\n| 热帖 / feed | `clis/xueqiu/hot.js` / `feed.js` |\n| 关注 | `clis/xueqiu/watchlist.js` |\n| 搜索 | `clis/xueqiu/search.js` |\n| 评论 | `clis/xueqiu/comments.js` |\n| 基金持仓（蛋卷） | `clis/xueqiu/fund-holdings.js` |\n\n工具：`clis/xueqiu/utils.js` 有 `fetchWithRetry` + symbol normalize，直接用别重写。\n","skills/opencli-adapter-author/references/site-recon.md":"# Site Recon\n\n**Layer 1：这是哪种站？** 分类完直接进 `api-discovery.md` 找 endpoint。\n\n本文件**只做分类**，不讲 endpoint 怎么找。\n\n---\n\n## 页面与请求事实\n\n```bash\nopencli browser open <url> --session research\nopencli browser state --diagnostics --session research\nopencli browser network --static --session research\n```\n\n读取 success envelope 的 `data`。`state --diagnostics` 返回页面与可用 state globals；\n`network --static` 返回捕获请求和脚本候选。由 agent 判断最适合的数据源，CLI 不再给出\nPattern 分类或 `recommended_next_step`。下面的分类仅作为分析参考。\n\n看 `network` 输出判：\n\n| `network` 看到什么 | 站点类型 | 特征 |\n|------------------|---------|------|\n| 大量 `/api/...` JSON 请求，包含目标数据 | **A. SPA / JSON XHR** | React/Vue，数据走 fetch |\n| 有请求但都是广告 / 埋点，无目标数据 | **B. SSR / inline data** | 首屏在 HTML 里，深层再走 API |\n| 完全空 / 只有静态资源 | **C. JSONP / `<script src>` 驱动** | 老金融行情站常见 |\n| 有 API 但 401/403/签名错 | **D. Token / CSRF 鉴权型** | 在 A 基础上加鉴权 |\n| `Content-Type: text/event-stream` / WebSocket 握手 | **E. 流式** | 行情 tick / chat |\n\n分不清时参考下面五节的其他信号。\n\n**数据是 SPA / 异步加载时，`wait time 2` 可能不够**。改用 `opencli browser --session research wait xhr '/api/path-fragment'` 直接等具体接口到场，比盲 `wait time 5` 更稳。\n\n---\n\n## Pattern A — SPA / JSON XHR\n\n**代表**：xueqiu、linear、notion、大多数现代 SaaS\n\n**信号**：\n- URL 一访问就是 `/`，后续数据都在 network tab\n- `document.querySelector('main').childElementCount` 一开始为 0，后被 JS 填充\n- `window.React / window.Vue / window.__REACT_DEVTOOLS_GLOBAL_HOOK__` 存在\n\n**下一步**：`api-discovery.md` §1（network 精读）\n\n**注意 — Pattern A 命中不等于 strategy 选 `PAGE_FETCH`**：\n- 检查 network response shape 与业务目标是否一致；analytics / beacon / personalization 不能单独证明目标 API 可用。\n- booking #1680 反例：17 个 JSON XHR 看起来像 Pattern A，但全是 analytics side-channel，最终走 `DOM_STATE` / `UI_SELECTOR`\n- replay 候选 endpoint 后，按 `strategy-selection.md` 的契约模型选 strategy；`PUBLIC_API` / `COOKIE_API` 都不通才考虑 `PAGE_FETCH`\n\n---\n\n## Pattern B — SSR / inline data\n\n**代表**：bilibili 个人主页、小红书、微博、部分 Next.js / Nuxt 页\n\n**信号**：\n- 第一个请求（`document`）返回的 HTML 里已经含目标数据（`curl <url> | grep <某数字>`）\n- `window.__INITIAL_STATE__` / `window.__NEXT_DATA__` / `window.__NUXT__` 存在\n- 关 JS 仍能看到首屏数据\n\n**下一步**：`api-discovery.md` §2（state 抽取） + §1（深层数据回到 network）\n\n---\n\n## Pattern C — JSONP / `<script src>` 驱动\n\n**代表**：eastmoney、tonghuashun、老一代金融站\n\n**信号**：\n- `network` 空或只有 css/font\n- 页面上肯定有数据（价格、成交量等）\n- `document.querySelectorAll('script[src]')` 里有指向 `push / api / data` 域名的 src\n- 响应是 `jQuery123({...})` 这种回调包裹（JSONP）\n\n**下一步**：`api-discovery.md` §3（bundle / script src 搜索）\n\n---\n\n## Pattern D — Token / CSRF / Bearer\n\n**代表**：Twitter/X、部分企业 SaaS\n\n**信号**：\n- 已经是 Pattern A，但 `fetch(url, {credentials:'include'})` 返回 401/403\n- network 里请求头有 `X-Csrf-Token / Authorization: Bearer / X-Client-Id / X-Workspace-Id` 等自定义字段\n- 401 响应体带 `{\"code\":\"AUTH_REQUIRED\",\"csrf\":\"...\"}` 类提示\n\n**下一步**：`api-discovery.md` §4（token 来源排查） + §5（store action / intercept 降级）\n\n---\n\n## Pattern E — 流式\n\n**代表**：行情 tick、LLM chat\n\n**信号**：\n- `network` 里有 `101 Switching Protocols`（WebSocket 握手）\n- Response headers 含 `Content-Type: text/event-stream`\n- 请求一直 pending 不结束\n\n**下一步**：先找**同数据的 HTTP 轮询接口**（90% 概率有）。真没有再走 intercept 收 N 条。\n\n---\n\n## 识别失败怎么办\n\n诊断信号互相矛盾（比如 network 非空但目标数据不在里面），按优先级硬走：\n\n1. 先当 A，试 `api-discovery.md` §1\n2. 不行当 B，试 §2\n3. 还不行当 C，试 §3\n4. 401 出现了切 D，试 §4\n5. 所有手段都试过，启动 intercept（§5）\n\n不要纠结分类。分类是帮忙定第一步，没命中就按顺序降级。\n","skills/opencli-adapter-author/references/strategy-selection.md":"# Choosing an adapter strategy\n\nUse the site's available contract and the task's needs to choose an implementation.\nThis reference is optional; no strategy note or classification artifact is required.\n\n| Source | Useful when | Check |\n| --- | --- | --- |\n| Public API | A documented endpoint covers the task | Inputs, authentication, pagination and error semantics |\n| Cookie-authenticated API | The endpoint works with the current account | Cookie/CSRF scope and real returned identity |\n| Page state / SSR | The page already contains the required data | Field meaning, completeness and hydration timing |\n| Semantic UI | The visible controls express the operation reliably | Stable selectors, target identity and observable result |\n| Browser fetch | The request needs page origin or runtime context | Actual request parameters and response meaning |\n| Network interception | The page produces a request that is difficult to reproduce | The captured response belongs to the intended action and input |\n\nThere is no universal API-over-DOM rule. A stable visible control can be easier to\nmaintain than an undocumented internal endpoint. Reuse an existing adapter or\nshared helper when it already models the same operation.\n\n## Gather facts as needed\n\n```sh\nopencli browser state --diagnostics --session research\nopencli browser network --static --session research\nopencli browser network --detail RequestKey --session research\n```\n\nRead `data` from the success envelope. Page diagnostics expose title, URL and\ninitial-state globals. Network evidence provides captured request keys, response\nshapes and static source locations. `static` is a candidate; `network+static`\nmeans both sources support the route. Neither proves business correctness.\nThe CLI does not assign a strategy score or prescribe the next action.\n\nCheck representative values against the visible page. For lists, distinguish a\ntrue end of pagination from a failed or partial response. For writes, bind the\noperation to the requested target and inspect its result; do not replay an action\nwhose previous outcome is unknown.\n\nIf an endpoint needs a runtime-generated signature, consider observing the page's\nown request or using semantic UI. Use `references/api-discovery.md` for techniques\nand `references/deep-recon.md` when the protocol requires further investigation.\n","skills/opencli-adapter-author/references/success-rate-pitfalls.md":"# Success-Rate Pitfalls\n\n11 个**静默失败**（adapter 看起来能跑、verify 能过，但数据是错的）的坑。每条给：现象 → 根因 → 防御手段。\n\n不是风格建议。每条都对应过一次真实翻车。\n\n---\n\n## 1. fixture pattern 被放松以过 verify\n\n**现象**：`verify` 报 `pattern \"url\" does not match /^https?:\\/\\/.*\\.com\\/bbs\\/thread-/`。agent 的\"修法\"是把 fixture 里的 pattern 改宽（`^https?://`），verify 一下就通过了。\n\n**根因**：adapter 丢了 URL 前缀 / 拼错了路径 / 吃到了相对路径。pattern 失败不是 fixture 太严，是 adapter 输出真的破了。\n\n**防御**：\n- `autofix` skill 现有纪律：**verify pattern 失败 = 收紧 adapter，不是收紧 fixture**（`opencli-autofix` SKILL.md §Rules for Patching 第 6 条）\n- 要改 fixture 的唯一合法理由：**站点本身换了格式**（例如 URL 规范迁移）。这种情况下在 `~/.opencli/sites/<site>/notes.md` 顶部写一段说明\n\n---\n\n## 2. 字段内容污染但 `notEmpty` / `columns` 都过\n\n**现象**：`description` 字段不为空，`verify` 通过。肉眼看输出发现描述里混了 `\"address: 上海 category: IT\"` 之类明显不属于描述的片段——兄弟 DOM 节点或父节点文字被一起 `textContent` 了。\n\n**根因**：`.container` 的 `textContent` 包括所有后代文字。调 `innerText` 仅好一点；用 `querySelector('.desc').textContent` 时，如果 `.desc` 里嵌套了 `.tag.address`，一样吃进去。\n\n**防御**：\n- fixture 用 `mustNotContain`：`{ \"description\": [\"address:\", \"category:\", \"工作年限:\"] }` 把已踩过的污染词列出来\n- adapter 侧：定位更精确的 selector，或抓到后 `.replace(/address:[^\\n]*/g, '').trim()`\n- 别信 `textContent.trim()` 就完事\n\n---\n\n## 3. 字段语义分歧（两个字段看起来都对）\n\n**现象**：51job 列表有 `updatedate` 和 `publishDate`，eastmoney 债券有 `f10`（发行日）和 `f26`（上市日）。adapter 随便选一个，verify 通过，用户一对照发现时间错位 1 个月。\n\n**根因**：两个字段都是合法日期，format 也对，只是含义不同。`notEmpty` / `types` 都挡不住。\n\n**防御**：\n- Step 7 字段解码**必须和网页肉眼对至少一条已知记录**（\"这条债券首页写的上市日是 2025-02-14\"，看 adapter 输出对不对得上）\n- 字段写进 `field-map.json` 时 `meaning` 要精确到\"上市日\"而不是\"日期\"\n\n---\n\n## 4. 字段单位混淆（数值量级错）\n\n**现象**：eastmoney 返回 `totalMarketCap: 128`（单位：亿元），adapter 直接写进 `marketCap`，用户对照 K 线页看是 128 元。常见错位：\n\n| 接口返回 | 网页显示 | 实际单位 | 错误写法 |\n|---------|---------|----------|---------|\n| `0.025` | `2.5%` | 小数 | adapter 再 × 100 → 显示 250% |\n| `12800` | `1.28 万` | 元 | adapter 不除 10000 → 显示 12800 万 |\n| `f152 = 2` | `9.27` | 价格 = 原值 ÷ 10^f152 | adapter 忽略 f152 → 显示 927 |\n\n**防御**：\n- fixture 加 `mustBeTruthy` 挡 `|| 0` / `|| false` silent fallback（数值列应该有值，不是 0）\n- `field-map.json` 的 `meaning` 写单位：`\"premium pct (0-1 fraction, NOT already × 100)\"`\n- Step 11 肉眼比对不能只比\"有没有数字\"，要比数量级\n\n---\n\n## 5. JSON-in-attribute vs 渲染后 innerText\n\n**现象**：51job 把完整 JSON 塞在 `<div data-sensorsdata='{\"job_title\":\"...\"}'>` 里，用 innerText 取的是渲染后的截断显示文字（\"Lead... ↩ 上海...\"），字段边界丢了。\n\n**根因**：现代站点常把结构化数据放在 `data-*` 属性里，渲染层只挑部分显示。取 innerText 相当于丢掉了结构。\n\n**防御**：\n- 看到 `data-sensorsdata` / `data-ng-state` / `data-page-props` / `data-track` 类属性先读属性，不读 innerText\n- 先在 `browser eval` 里检查：`document.querySelector('.item').dataset` 看有没有 JSON 串\n- 搜 bundle 时也搜 `JSON.parse(el.dataset.*)` 模式看 vendor 把数据塞哪了\n\n---\n\n## 6. cookie 域 / origin 不一致的隐式假设\n\n**现象**：在 `jobs.51job.com` 页面调 `cupid.51job.com` 的接口，headless 里带了 cookie 也跨不过去——`credentials:'include'` 只管带 cookie，不管 CORS。\n\n**根因**：浏览器 CORS 预检默认关闭跨 subdomain 请求。`credentials:'include'` 不是万能药。\n\n**防御**：参见 `api-discovery.md §0.2`。判断：`fetch(target).catch(e=>'cors:'+e.message)` 看是不是 TypeError。降级路径：改用 same-origin endpoint / 改在目标 subdomain 上打开页面 / 走 `§5 intercept`。\n\n---\n\n## 7. 等不够就抓导致空 DOM / 空 network\n\n**现象**：`open url && wait time 2 && network` 看到 0 条业务 API。agent 以为是 Pattern C（静态），去 bundle 里找 baseURL，找不到就卡住。真相：SPA 3.5 秒才发出第一个 API。\n\n**根因**：`wait time N` 是盲等。不同站点 JS 执行速度差很多。\n\n**防御**：\n- 数据是异步加载时**不用 `wait time`**，用 `opencli browser --session research wait xhr '/api/path-fragment'`，等具体 XHR 到场再 `network`\n- 不确定 endpoint 路径时：先 `wait time 2 && network`，看到候选路径再转 `wait xhr` 确认\n\n---\n\n## 8. adapter 里的 falsy `|| 0` 兜底静默\n\n**现象**：`likes: data.likes || 0`。接口偶尔返回 `likes: null`（可能因字段名改了、权限问题等），adapter 写成 0，verify `types: {likes: 'number'}` 通过，用户看到的是\"所有帖子 0 赞\"。\n\n**根因**：`||` 兜底把\"没抓到\"变成\"是 0\"。`notEmpty` 挡不住 0，`types` 也不挡。\n\n**防御**：\n- fixture 用 `mustBeTruthy: [\"likes\", \"count\", ...]`——业务数值列必须 truthy\n- adapter 侧 prefer `?.` 而不是 `||`；真的想兜底就兜 `undefined`，让 verify 能看见\n- 全部 `|| 0` 要过一遍眼：这个 0 是合法值还是漏抓 fallback\n\n---\n\n## 9. 跨 session cookie 污染 / 登录态漂移\n\n**现象**：本地开发时用自己的登录态验 endpoint 能通，PR 一合 verify fixture 跑在 CI 环境里立刻 401——顺手把样本数据也固化进了 fixture，看起来\"一切正常\"。\n\n**根因**：fixture 样本是带登录态跑出来的。存 `~/.opencli/sites/<site>/fixtures/*.json` 没脱敏，把 cookie / token / 自己的 uid / 昵称存了进去。\n\n**防御**：\n- `site-memory.md` 的脱敏规则：存 fixtures 前去掉 cookie / token / 用户私有字段（手机号 / 邮箱 / 昵称 / uid）\n- 需要登录态的接口：adapter 用 `Strategy.COOKIE`，adapter 代码里**不写任何具体 cookie 值**，只声明\"我需要 domain X 的 cookie\"\n- verify 样本里看到 `Bearer ...` 或 32 位 hex token → 先删再存\n\n---\n\n## 10. adapter 默认 timeout 不统一\n\n**现象**：同一个站两个 adapter，一个默认 15s timeout，另一个默认 60s。慢接口在一个命令里 ok，在另一个一样的慢接口却 timeout 了。\n\n**根因**：模板没统一 timeout；agent 依赖\"最像的邻居\"复制，复制到的邻居选了短 timeout。\n\n**防御**：\n- 邻居 adapter 的 `requestTimeoutMs` / `browser.wait` 配置**不能盲抄**。每个 adapter 应该结合自己的接口特性设一个\n- 真实接口延迟：Step 5 endpoint 验证时用 `time curl`（或 `performance.now()` 包 fetch）量一下 p50 / p95，timeout 设 p95 × 2 比较安全\n- 出现偶发 timeout 别 retry 掩盖；记到 `notes.md`，下次就知道这接口 p95 偏高\n\n---\n\n## 11. `aria-label` / `placeholder` / `title` 是 locale-dependent 文本\n\n**现象**：你本地英文 Chrome 测 `button[aria-label=\"Submit\"]` 一切正常，verify fixture 也是英文环境抓的。用户把 `chrome://settings/languages` 切中文，同一个按钮变 `aria-label=\"提交\"`，adapter silent 0 匹配——退化成 `notEmpty` / `types` 都没法 fire 的\"adapter 跑完返 0 行\"。\n\n**根因**：`aria-label` / `title` / `placeholder` / `alt` / `textContent` 都是页面的**用户可见文本**，被站点 i18n 框架翻译。用它们当 selector anchor 等于 \"select by visible text\"，locale 一动整个选择器就废。\n\n**防御**：\n- 优先用 locale-stable 标识：`data-testid` / `data-*` / 稳定 `id` / `class`。先确认不是 hash / A-B test 产物\n- `role` 不按 locale 翻译，但通常不唯一；只能当 semantic / scope filter，不能用裸 `[role=\"button\"]` 当 primary\n- 站点只暴露 `aria-label`（典型如 ChatGPT web 某些 control）时，写 fallback list，至少 en + zh-CN：`'[aria-label=\"Send\"], [aria-label=\"发送\"]'`\n- commit 前 grep `aria-label=` / `placeholder=` / `title=` 的硬编码字符串，确认每条都有兜底 locale\n- 找不到 control 要 typed fail-fast（例如 `CommandExecutionError` / send-failed），不要把 selector miss 变成空 rows 或假成功\n- 详细 framework + 活例见 `adapter-template.md §Selector 稳定性`\n\n不要去给 framework 加 `--i18n` flag 自动展开——多一层 indirection 还要维护翻译字典，纯 over-engineering。\n\n---\n\n## 总结：静默失败的共同特征\n\n1. **verify 绿 ≠ 数据对**。verify 只能证\"结构没坏\"，证不出\"值对不对\"。Step 11 肉眼比对是必须的。\n2. **\"字段有值\"是个比\"字段为空\"更危险的失败态**。空你会去查，有值你会 fallthrough。\n3. **fixture 四件套一起上**：`patterns` + `notEmpty` + `mustNotContain` + `mustBeTruthy`——每件挡一类问题，缺一个就漏。\n\n回写 `notes.md` 时把你踩的新坑写进去。下次就有第 12 条了。\n","skills/opencli-adapter-author/references/typed-errors.md":"# Typed Error Conventions\n\nOpenCLI 用 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 只能减不能加，新违例必须立刻收掉）。\n\n每条 rule 都挂了真实 anti-pattern 反例（PR #1329 三轮迭代是主要素材库）。\n\n---\n\n## 1. 5-classification 落点表\n\n| 场景 | 抛 | code | exit |\n|------|-----|------|------|\n| 参数不合法（包括越界、格式错、缺主语 positional） | `ArgumentError(message)` | `ARGUMENT` | 2 |\n| 业务无数据 / 资源不存在 | `EmptyResultError(command, hint?)` | `EMPTY_RESULT` | 66 |\n| HTTP 非 2xx / fetch throw / JSON parse 错 / 接口业务报错 | `CommandExecutionError(message)` | `COMMAND_EXEC` | 1 |\n| 需要登录（cookie 缺 / 401 / 302 → /login / \"请登录\" 页） | `AuthRequiredError(domain, message?)` | `AUTH_REQUIRED` | 77 |\n| 等响应超时（CDP / page.evaluate / 流式回复） | `TimeoutError(label, seconds)` | `TIMEOUT` | 75 |\n\n类签名定义见 [`src/errors.ts`](../../../src/errors.ts)。\n\n**禁止泛用 `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。\n\n---\n\n## 2. Anti-pattern A：silent-clamp 不止 limit\n\n**反例**（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)）：\n\n```js\n// ❌ silent clamp — 200 当 100，0/-1 当 1，timeout=5 当 15\nconst limit = Math.max(1, Math.min(Number(args.limit) || 20, 100));\nconst timeout = Math.max(15, parseInt(kwargs.timeout, 10) || 120);\nconst contentLimit = Math.max(50, Number(args.contentLimit) || 400);\nconst page = Math.max(1, Number(args.page) || 1);\n```\n\n**修法**（[`clis/1point3acres/utils.js`](../../../clis/1point3acres/utils.js) 的 `normalizePositiveInteger` / `normalizeLimit`）：\n\n```js\nimport { ArgumentError } from '@jackwener/opencli/errors';\n\n/** Validate a positive integer arg without silently flooring/clamping. */\nexport function normalizePositiveInteger(value, defaultValue, label = 'value', { min = 1 } = {}) {\n    const raw = value ?? defaultValue;\n    const n = Number(raw);\n    if (!Number.isInteger(n) || n <= 0) throw new ArgumentError(`${label} must be a positive integer`);\n    if (n < min) throw new ArgumentError(`${label} must be >= ${min}`);\n    return n;\n}\n\n/** With both lower bound and an explicit ceiling. */\nexport function normalizeLimit(value, defaultValue, maxValue, label = 'limit') {\n    const n = normalizePositiveInteger(value, defaultValue, label);\n    if (n > maxValue) throw new ArgumentError(`${label} must be <= ${maxValue}`);\n    return n;\n}\n```\n\n**用法**（[`clis/1point3acres/thread.js#L37-L39`](../../../clis/1point3acres/thread.js)）：\n\n```js\nconst page = normalizePositiveInteger(args.page, 1, 'page');\nconst limit = normalizePositiveInteger(args.limit, 10, 'limit');\nconst contentLimit = normalizePositiveInteger(args.contentLimit, 400, 'contentLimit', { min: 50 });\n```\n\n**注意**：当前 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**。\n\n### 何时抽 site-level helper\n\n≥ 2 个同站 adapter 都要做同样的 limit / page 校验时，把 `normalizeLimit` / `normalizePositiveInteger` 抽到 `clis/<site>/utils.js`。1 个 adapter 用就直接 inline，别为 1 处单点抽 helper。\n\n---\n\n## 3. Anti-pattern B：sentinel row / scalar sentinel 吞 empty / unknown / failure\n\n**反例 1**（PR #1329 R3 修掉的，[`clis/1point3acres/notifications.js` before](https://github.com/jackwener/OpenCLI/blob/2b8609b82fccaf98505c5b1b1859e3ffdaa1a55c/clis/1point3acres/notifications.js#L35-L37)）：\n\n```js\n// ❌ \"暂时没有提醒内容\" 是 empty result，不是一行业务数据\nif (/暂时没有提醒内容/.test(html)) {\n    return [{ index: 0, from: '', summary: '暂时没有提醒内容', time: '', threadUrl: '' }];\n}\n```\n\n**反例 2**（[`clis/1point3acres/search.js` before](https://github.com/jackwener/OpenCLI/blob/2b8609b82fccaf98505c5b1b1859e3ffdaa1a55c/clis/1point3acres/search.js#L52-L60)）：\n\n```js\n// ❌ \"抱歉\" hint 是搜索无结果，不是 rank=0 的伪结果行\nif (items.length === 0) {\n    const hint = html.match(/<p>([^<]*?抱歉[^<]*?)<\\/p>/);\n    if (hint) {\n        return [{ rank: 0, tid: '', title: hint[1].trim(), forum: '', author: '', replies: 0, views: 0, postTime: '', url: '' }];\n    }\n    return [];\n}\n```\n\n**反例 3**（[`clis/qwen/history.js` before](https://github.com/jackwener/OpenCLI/blob/2b8609b82fccaf98505c5b1b1859e3ffdaa1a55c/clis/qwen/history.js#L44-L51)）：\n\n```js\n// ❌ API failure 不是 conversation 的一行\nif (!result.ok && !result.sessions.length) {\n    return [{ Index: 0, Title: `API failed (status=${result.status})`, Updated: '', Url: '' }];\n}\n```\n\n**反例 4**（[`clis/qwen/image.js` before](https://github.com/jackwener/OpenCLI/blob/2b8609b82fccaf98505c5b1b1859e3ffdaa1a55c/clis/qwen/image.js#L160-L164)）：\n\n```js\n// ❌ 单张图片 fetch 失败，伪装成 \"⚠️ fetch-failed\" 状态行继续\nif (!asset?.ok) {\n    results.push({ Status: `⚠️ fetch-failed(${asset?.status || '?'})`, File: '-', Link: link });\n    continue;\n}\n```\n\n**修法**：\n\n```js\nimport { EmptyResultError, CommandExecutionError } from '@jackwener/opencli/errors';\n\nif (/暂时没有提醒内容/.test(html)) {\n    throw new EmptyResultError('1point3acres notifications', '暂时没有提醒内容');\n}\nif (items.length === 0) {\n    throw new EmptyResultError('1point3acres search', `No results for \"${query}\"`);\n}\nif (!result.ok && !result.sessions.length) {\n    throw new CommandExecutionError(`Qianwen history API failed (status=${result.status})`);\n}\nif (!asset?.ok) {\n    throw new CommandExecutionError(`Failed to fetch image: status=${asset?.status || '?'}`);\n}\n```\n\n**反例 5：scalar sentinel**（PR #1329 R2 修掉的，[`clis/qwen/status.js` before](https://github.com/jackwener/OpenCLI/blob/42e5303c792d9f71d9a30dde2e391405e03661e7/clis/qwen/status.js#L24-L29)）：\n\n```js\n// ❌ '-' 会被下游当成真实 model/session id 字符串\nreturn [{\n    Status: 'Connected',\n    Login: loggedIn ? 'Yes' : 'No (guest mode)',\n    Model: model || '-',\n    SessionId: sessionId || '-',\n}];\n\n// ✅ unknown 用 null，agent 可以 if (row.Model === null) 干净分支\nreturn [{\n    Status: 'Connected',\n    Login: loggedIn ? 'Yes' : 'No (guest mode)',\n    Model: model ? model : null,\n    SessionId: sessionId ? sessionId : null,\n}];\n```\n\n**根因**：success row 是\"业务数据\"的合同。把 empty / failure 塞进 row 会破坏：\n\n1. **round-trip pipeline**：listing → detail 类下游 adapter 拿到 `tid: ''` 会去查 `thread `（\"\"），白跑一轮\n2. **exit code semantics**：empty 应该 exit 66、API fail 应该 exit 1，混进 row 后两者都 exit 0，agent 没法 branch\n3. **fixture rowCount.min=1**：sentinel row 让 verify 永远过 → 真的接口挂了也看不见\n\n**对比 #1290 twitter trending**：当时也踩过 silent N/A row 的坑，drop 掉那行比留个 silent-wrong 字段诚实——这是 2026-05-04 的早期教训。typed-errors 把\"drop\"升级成\"throw EmptyResultError\"，下游 agent 能直接从 exit code 66 分辨 empty vs 真实数据错位。\n\n---\n\n## 4. Anti-pattern C：`return []` 当 silent fallback\n\n`adapter` 跑完什么都没拿到不能 `return []`：\n\n```js\n// ❌\nif (items.length === 0) return [];\n\n// ✅\nif (items.length === 0) throw new EmptyResultError('site command', `optional context`);\n```\n\n`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> 在该论坛没有发过帖子\"）。\n\n---\n\n## 5. Anti-pattern D：generic `CliError('CODE')` 隐藏分类\n\n**反例**（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)）：\n\n```js\n// ❌ 都是 exit 1，agent 无法区分服务失败 vs 空结果 vs bad args\nif (!resp.ok) throw new CliError('HTTP_ERROR', `HTTP ${resp.status}`);\nif (!Array.isArray(data) || data.length === 0) throw new CliError('NO_DATA', 'no data');\nif (!/id=\"postlist\"/.test(html)) throw new CliError('THREAD_NOT_FOUND', `帖子 ${tid} 不存在`);\n```\n\n**修法**：\n\n```js\n// HTTP / fetch / JSON / in-band API error → runtime failure\nif (!resp.ok) throw new CommandExecutionError(`request failed: HTTP ${resp.status}`);\nif (!Array.isArray(data)) throw new CommandExecutionError('unexpected response shape');\n\n// valid empty / resource missing → empty result\nif (data.length === 0) throw new EmptyResultError('coingecko top', 'no market data');\nif (!/id=\"postlist\"/.test(html)) throw new EmptyResultError('1point3acres thread', `帖子 ${tid} 不存在`);\n```\n\n`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.\n\n---\n\n## 6. Verify fixture 怎么挡这三类\n\n新写 fixture 时（`~/.opencli/sites/<site>/verify/<cmd>.json`）：\n\n- **rowCount.min ≥ 1**：保证 sentinel-row 不能伪装通过\n- **patterns**：核心 id 列加 `^\\d+$` 类正则，挡 `tid: ''` / `pid: ''` 之类空字符串污染\n- **mustBeTruthy**：业务数值列（`replies` / `views` / `count`）必须 truthy，挡 `|| 0` silent fallback\n- **预期 EmptyResultError 是合法返回时**（例如某条 fixture 故意搜不出来）用 `expect.exitCode: 66` 让\"空态合法\"和\"adapter 崩了\"在 fixture 层就分开（参见 [`site-memory.md`](./site-memory.md) verify schema）\n\n---\n\n## 7. 已经 grandfathered 的旧 adapter\n\nrepo 里仍有相当数量的 `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 不会卡。\n\n---\n\n## 8. 自查清单（PR push 前）\n\n- [ ] adapter 没有 `Math.max(1, Math.min(...))` / `Math.max(N, ...)` clamp 在外部参数上\n- [ ] adapter 没有 `return []` / `return [{...sentinel...}]` 当 empty / failure 兜底\n- [ ] adapter 没有 `'-'` / `'N/A'` 这类 scalar sentinel 冒充 real value；语义可空就返回 `null`\n- [ ] adapter 抛的不是 `CliError('XXX')`，而是 5 类 typed error 之一\n- [ ] verify fixture 的 `rowCount.min ≥ 1`，sentinel row 过不了\n- [ ] `npm run check:typed-error-lint` 跑过 → baseline 没增加\n","skills/opencli-autofix/SKILL.md":"---\nname: opencli-autofix\ndescription: Automatically fix broken OpenCLI adapters when commands fail. Load this skill when an opencli command fails — it guides you through collecting a trace artifact, patching the adapter, retrying, and filing an upstream GitHub issue after a verified fix. Works with any AI agent.\nallowed-tools: Bash(opencli:*), Bash(gh:*), Read, Edit, Write\n---\n\n# OpenCLI AutoFix — Automatic Adapter Self-Repair\n\nWhen an `opencli` command fails because a website changed its DOM, API, or response schema, **automatically diagnose, fix the adapter, and retry** — don't just report the error.\n\n## Safety Boundaries\n\n**Before starting any repair, check these hard stops:**\n\n- **`AUTH_REQUIRED`** (exit code 77) — **STOP.** Do not modify code. Tell the user to log into the site in Chrome.\n- **`BROWSER_CONNECT`** (exit code 69) — **STOP.** Do not modify code. Tell the user to run `opencli doctor`.\n- **CAPTCHA / rate limiting** — **STOP.** Not an adapter issue.\n\n**Scope constraint:**\n- **Only modify the file at `adapterSourcePath` in the trace `summary.md` front matter** — this is the authoritative adapter location (may be `clis/<site>/` in repo or `~/.opencli/clis/<site>/` for npm installs)\n- **Never modify** `src/`, `extension/`, `tests/`, `package.json`, or `tsconfig.json`\n\n**Retry budget:** Max **3 repair rounds** per failure. If 3 rounds of diagnose → fix → retry don't resolve it, stop and report what was tried.\n\n## Prerequisites\n\n```bash\nopencli doctor    # Verify extension + daemon connectivity\n```\n\n## When to Use This Skill\n\nUse when `opencli <site> <command>` fails with repairable errors:\n- **SELECTOR** — element not found (DOM changed)\n- **EMPTY_RESULT** — no data returned (API response changed)\n- **API_ERROR** / **NETWORK** — endpoint moved or broke\n- **PAGE_CHANGED** — page structure no longer matches\n- **COMMAND_EXEC** — runtime error in adapter logic\n- **TIMEOUT** — page loads differently, adapter waits for wrong thing\n\n## Before Entering Repair: \"Empty\" ≠ \"Broken\"\n\n`EMPTY_RESULT` — and sometimes a structurally-valid `SELECTOR` that returns nothing — is often **not an adapter bug**. Platforms actively degrade results under anti-scrape heuristics, and a \"not found\" response from the site doesn't mean the content is actually missing. Rule this out **before** committing to a repair round:\n\n- **Retry with an alternative query or entry point.** If `opencli xiaohongshu search \"X\"` returns 0 but `opencli xiaohongshu search \"X 攻略\"` returns 20, the adapter is fine — the platform was shaping results for the first query.\n- **Spot-check in a normal Chrome tab.** If the data is visible in the user's own browser but the adapter comes back empty, the issue is usually authentication state, rate limiting, or a soft block — not a code bug. The fix is `opencli doctor` / re-login, not editing source.\n- **Look for soft 404s.** Sites like xiaohongshu / weibo / douyin return HTTP 200 with an empty payload instead of a real 404 when an item is hidden or deleted. The snapshot will look structurally correct. A retry 2-3 seconds later often distinguishes \"temporarily hidden\" from \"actually gone\".\n- **\"0 results\" from a search is an answer.** If the adapter successfully reached the search endpoint, got an HTTP 200, and the platform returned `results: []`, that is a valid answer — report it to the user as \"no matches for this query\" rather than patching the adapter.\n\nOnly proceed to Step 1 if the empty/selector-missing result is **reproducible across retries and alternative entry points**. Otherwise you're patching a working adapter to chase noise, and the patched version will break the next working path.\n\n## Step 1: Collect Trace Context\n\nRun the failing command with failure-retained trace enabled:\n\n```bash\nopencli <site> <command> [args...] --trace retain-on-failure 2>trace-error.yaml\n```\n\nOn failure, stderr contains the normal error envelope plus a small `trace` block:\n\n```yaml\nok: false\nerror:\n  code: SELECTOR\n  message: \"Could not find element: .old-selector\"\ntrace:\n  schemaVersion: 1\n  opencliVersion: \"...\"\n  traceId: \"...\"\n  dir: \"/path/to/.opencli/profiles/default/traces/...\"\n  summaryPath: \"/path/to/.opencli/profiles/default/traces/.../summary.md\"\n  receiptPath: \"/path/to/.opencli/profiles/default/traces/.../receipt.json\"\n```\n\nRead `summaryPath` first. It is the LLM-oriented entry point and includes front matter:\n\n```yaml\n---\nschemaVersion: 1\nopencliVersion: \"...\"\ntraceId: \"...\"\nstatus: failure\nsite: \"example\"\ncommand: \"example/search\"\nadapterSourcePath: \"/path/to/clis/example/search.js\"\nerrorCode: \"SELECTOR\"\nerrorMessage: \"Could not find element: .old-selector\"\n---\n```\n\nThe artifact directory contains:\n\n```text\nsummary.md      # start here\nreceipt.json    # machine-readable trace receipt\ntrace.jsonl     # full redacted timeline\nnetwork.jsonl   # redacted network events\nconsole.jsonl   # redacted console events\nstate/          # final snapshots when available\nscreenshots/    # final screenshots when available\n```\n\nIf you redirected stderr to a file, read that file and copy `trace.summaryPath`.\n\nDo not ask the user to rerun with legacy diagnostic env vars. Trace is the repair evidence path.\n\n## Step 2: Analyze the Failure\n\nRead the trace summary and the adapter source. Classify the root cause:\n\n| Error Code | Likely Cause | Repair Strategy |\n|-----------|-------------|-----------------|\n| SELECTOR | DOM restructured, class/id renamed | Explore current DOM → find new selector |\n| EMPTY_RESULT | API response schema changed, or data moved | Check network → find new response path |\n| API_ERROR | Endpoint URL changed, new params required | Discover new API via network intercept |\n| AUTH_REQUIRED | Login flow changed, cookies expired | **STOP** — tell user to log in, do not modify code |\n| TIMEOUT | Page loads differently, spinner/lazy-load | Add/update wait conditions |\n| PAGE_CHANGED | Major redesign | May need full adapter rewrite |\n\n**Key questions to answer:**\n1. What is the adapter trying to do? (Read the file at `adapterSourcePath`)\n2. What did the page look like when it failed? (Read `summary.md`, then `state/` if needed)\n3. What network requests happened? (Read `Failed Network` in `summary.md`, then `network.jsonl` if needed)\n4. What's the gap between what the adapter expects and what the page provides?\n\n## Step 3: Explore the Current Website\n\nUse `opencli browser` to inspect the live website. **Never use the broken adapter** — it will just fail again.\n\n### DOM changed (SELECTOR errors)\n\n```bash\n# Open the page and inspect current DOM\nopencli browser open https://example.com/target-page && opencli browser --session research state\n\n# Look for elements that match the adapter's intent\n# Compare the snapshot with what the adapter expects\n```\n\n### API changed (API_ERROR, EMPTY_RESULT)\n\n```bash\n# Open page with network interceptor, then trigger the action manually\nopencli browser open https://example.com/target-page && opencli browser --session research state\n\n# Interact to trigger API calls\nopencli browser click <N> && opencli browser --session research network\n\n# Narrow to the request you care about by the fields its body should have\nopencli browser --session research network --filter author,text,likes\n\n# Inspect specific API response (key is the `key` field from the default JSON output)\nopencli browser --session research network --detail <key>\n```\n\n## Step 4: Patch the Adapter\n\nRead the adapter source file at `adapterSourcePath` from the trace summary front matter and make targeted fixes. This path is authoritative — it may be in the repo (`clis/`) or user-local (`~/.opencli/clis/`).\n\nUse the `Read` tool on the exact path from summary.md front matter.\n\n### Common Fixes\n\n**Selector update:**\n```typescript\n// Before: page.evaluate('document.querySelector(\".old-class\")...')\n// After:  page.evaluate('document.querySelector(\".new-class\")...')\n```\n\n**API endpoint change:**\n```typescript\n// Before: const resp = await page.evaluate(`fetch('/api/v1/old-endpoint')...`)\n// After:  const resp = await page.evaluate(`fetch('/api/v2/new-endpoint')...`)\n```\n\n**Response schema change:**\n```typescript\n// Before: const items = data.results\n// After:  const items = data.data.items  // API now nests under \"data\"\n```\n\n**Wait condition update:**\n```typescript\n// Before: await page.wait({ selector: '.loading-spinner', hidden: true })\n// After:  await page.wait({ selector: '[data-loaded=\"true\"]' })\n```\n\n### Rules for Patching\n\n1. **Make minimal changes** — fix only what's broken, don't refactor\n2. **Keep the same output structure** — `columns` and return format must stay compatible\n3. **Prefer API over DOM scraping** — if you discover a JSON API during exploration, switch to it\n4. **Use `@jackwener/opencli/*` imports only** — never add third-party package imports\n5. **Test after patching** — run the command again to verify\n6. **Never relax `verify/<cmd>.json` fixtures to silence a failure.** A failing `patterns` / `notEmpty` / `mustNotContain` / `mustBeTruthy` rule means the adapter's output is broken. Tighten the adapter so it produces correct values; do not loosen the fixture to accept the broken values. The one legitimate reason to edit a fixture during repair is when the **site itself** changed shape (e.g. URL format migration) — in that case update the fixture and note the change in `~/.opencli/sites/<site>/notes.md`. Otherwise editing the fixture is covering up a silent correctness regression.\n\n## Step 5: Verify the Fix\n\n```bash\n# Run the command normally\nopencli <site> <command> [args...]\n```\n\nIf it still fails, go back to Step 1 and collect a fresh trace. You have a budget of **3 repair rounds** (trace → fix → retry). If the same error persists after a fix, try a different approach. After 3 rounds, stop and report what was tried.\n\n## Step 6: File an Upstream Issue\n\nIf the retry **passes**, the local adapter has drifted from upstream. File a GitHub issue so the fix flows back to `jackwener/OpenCLI`.\n\n**Do NOT file for:**\n- `AUTH_REQUIRED`, `BROWSER_CONNECT`, `ARGUMENT`, `CONFIG` — environment/usage issues, not adapter bugs\n- CAPTCHA or rate limiting — not fixable upstream\n- Failures you couldn't actually fix (3 rounds exhausted)\n\n**Only file after a verified local fix** — the retry must pass first.\n\n**Procedure:**\n\n1. Prepare the issue content from the trace summary you already have:\n   - **Title:** `[autofix] <site>/<command>: <error_code>` (e.g. `[autofix] zhihu/hot: SELECTOR`)\n   - **Body** (use this template):\n\n```markdown\n## Summary\nOpenCLI autofix repaired this adapter locally, and the retry passed.\n\n## Adapter\n- Site: `<site>`\n- Command: `<command>`\n- OpenCLI version: `<version from opencli --version>`\n\n## Original failure\n- Error code: `<error_code>`\n\n~~~\n<error_message>\n~~~\n\n## Local fix summary\n\n~~~\n<1-2 sentence description of what you changed and why>\n~~~\n\n_Issue filed by OpenCLI autofix after a verified local repair._\n```\n\n2. **Ask the user before filing.** Show them the draft title and body. Only proceed if they confirm.\n\n3. If the user approves and `gh auth status` succeeds:\n\n```bash\ngh issue create --repo jackwener/OpenCLI \\\n  --title \"[autofix] <site>/<command>: <error_code>\" \\\n  --body \"<the body above>\"\n```\n\nIf `gh` is not installed or not authenticated, tell the user and skip — do not error out.\n\n## When to Stop\n\n**Hard stops (do not modify code):**\n- **AUTH_REQUIRED / BROWSER_CONNECT** — environment issue, not adapter bug\n- **Site requires CAPTCHA** — can't automate this\n- **Rate limited / IP blocked** — not an adapter issue\n\n**Soft stops (report after attempting):**\n- **3 repair rounds exhausted** — stop, report what was tried and what failed\n- **Feature completely removed** — the data no longer exists\n- **Major redesign** — needs full adapter rewrite via `opencli-adapter-author` skill\n\nIn all stop cases, clearly communicate the situation to the user rather than making futile patches.\n\n## Example Repair Session\n\n```\n1. User runs: opencli zhihu hot\n   → Fails: SELECTOR \"Could not find element: .HotList-item\"\n\n2. AI runs: opencli zhihu hot --trace retain-on-failure 2>trace-error.yaml\n   → Gets trace summary with final state and failed action evidence\n\n3. AI reads summary/state: page loaded but uses \".HotItem\" instead of \".HotList-item\"\n\n4. AI explores: opencli browser open https://www.zhihu.com/hot && opencli browser --session research state\n   → Confirms new class name \".HotItem\" with child \".HotItem-content\"\n\n5. AI patches: Edit adapter at `adapterSourcePath` — replace \".HotList-item\" with \".HotItem\"\n\n6. AI verifies: opencli zhihu hot\n   → Success: returns hot topics\n\n7. AI prepares upstream issue draft, shows it to the user\n\n8. User approves → AI runs: gh issue create --repo jackwener/OpenCLI --title \"[autofix] zhihu/hot: SELECTOR\" --body \"...\"\n```\n","skills/opencli-browser/SKILL.md":"---\nname: opencli-browser\ndescription: Drive Chrome with OpenCLI to inspect pages, interact with logged-in sites, capture API evidence, and manage native tabs, groups and windows. For reusable adapters, see opencli-adapter-author.\nallowed-tools: Bash(opencli:*), Read, Edit, Write\n---\n\n# Browser tools for agents\n\nUse the capability that fits the task. Discover commands with `opencli list <query>`;\nread exact arguments with `opencli browser <command> --help -f json`.\nRun `opencli doctor` when diagnosing a connection problem.\n\n## Scope and results\n\nPage commands require `--session <name>`. Reuse that name across a task; different\nsessions isolate page work. `--profile <name>` selects a Chrome profile when needed.\nOptions can follow command arguments:\n\n```sh\nopencli browser open https://example.com --session research\nopencli browser state --session research\nopencli browser click --role button --name Search --session research\nopencli browser close --session research\n```\n\nDefault output is one JSON value: `{ \"ok\": true, \"data\": ... }`.\nFailures return `{ \"ok\": false, \"error\": { \"code\": \"...\", \"message\": \"...\", \"hint\": \"...\" } }`\nand a nonzero exit code. Diagnostic logs go to stderr. `network --follow` and\n`console --follow` stream one envelope per line. Help, version and shell completion\nare inspection/export surfaces; structured help uses `--help -f json`.\n\nRead the returned data and error fields. When `error.outcome` is `unknown`, inspect\nthe affected state before retrying a write.\n\n## Page targets and interaction\n\n- `state` returns URL and interactive element refs. `--diagnostics` also reports\n  page title and available initial-state globals; `--source ax` selects the AX tree.\n- `find` locates elements. Interaction targets accept a ref, CSS selector, or\n  semantic flags such as `--role`, `--name`, `--label`, `--text`, `--testid`.\n- Use `--nth` to resolve deliberate multiple matches. On stale refs, read fresh\n  state or locate the element again; refs describe the observed page.\n- `fill` replaces a field and verifies its value; `type` types text. Other actions\n  include `click`, `select`, `check`, `upload`, `drag`, `keys` and `dialog`.\n- `get text|value|attributes|html`, `extract`, `screenshot` and `eval` expose page data.\n  `eval` executes JavaScript in the page and can mutate state.\n- `wait xhr|selector|text|download` observes an expected condition. Consult help for\n  timeouts and matching rules.\n- `tab list|new|select|close` manages session tabs. `--tab` accepts the page identity\n  returned by these commands; these are different from numeric native Chrome IDs.\n- `frames` lists frame targets; `eval --frame` evaluates in a selected frame.\n\n`bind --session research` attaches the current user tab. Bound sessions permit\nnavigation but block tab creation, selection and closure. `unbind` releases the\nbinding without closing the user tab. Owned sessions keep leased tabs until\n`close` or idle expiry. `--window foreground|background` controls owned windows.\n\n## Network and scripts\n\n```sh\nopencli browser network --session research\nopencli browser network --detail RequestKey --session research\nopencli browser network --static --session research\n```\n\n`network` returns captured request keys and response shapes. `--detail` reads a\ncached request; `--filter` selects shapes; `--raw` exposes response bodies.\n`--static` adds Acorn AST candidates from loaded scripts, with source locations,\nmethods, query/body parameter names and observed request evidence. `EXPR` means\nan unresolved expression. Static candidates are not executed or proven APIs.\nCheck `analysisComplete`, script errors, omitted scripts and `networkSource`.\nAn empty live capture can reuse a recent cache; reported provenance distinguishes it.\n\n## Native Chrome management\n\nNative calls need no session and do not attach a page:\n\n```sh\nopencli browser chrome describe\nopencli browser chrome describe tabs.group\nopencli browser chrome call tabs.query '[{}]'\nopencli browser chrome call tabs.group '[{\"tabIds\":[123,456],\"createProperties\":{\"windowId\":10}}]'\nopencli browser chrome call tabGroups.update '[789,{\"title\":\"Research\",\"color\":\"blue\"}]'\n```\n\nUse numeric IDs returned by Chrome, replacing the example IDs. `describe` lists\nsupported methods, signatures, live permissions and official documentation.\nNative-created tabs/windows persist. Mutations touching active OpenCLI session\ntabs or managed groups return `managed_resource_conflict`; ordinary groups can be\ncreated, renamed, recolored, moved and dissolved with `tabs.ungroup`.\n","skills/opencli-usage/SKILL.md":"---\nname: opencli-usage\ndescription: Discover and invoke OpenCLI capabilities for websites and Chrome. Find commands, inspect schemas, interpret results and choose browser or adapter tools.\nallowed-tools: Bash(opencli:*), Read\n---\n\n# OpenCLI\n\nOpenCLI exposes reusable website adapters and direct Chrome tools to agents.\n\n```sh\nopencli list search\nopencli list --site github --limit 20\nopencli github search --help -f json\n```\n\n`list [query]` searches command names and descriptions across built-ins and adapters.\nResults are compact and paginated: `{ok:true,data:{commands,total,nextOffset?}}`.\nPass `--offset <nextOffset>` to continue. Inspect exact inputs using command help.\nDo not hard-code the adapter catalog or dump all commands into context.\n\n## Results\n\nCommands default to JSON: `{ok:true,data:...}`. Failures produce a nonzero exit\ncode and `{ok:false,error:{code,message,hint?,...}}` on stdout. Logs use stderr.\nExplicit `-f table|yaml|csv|md|plain` exports are available where command help lists\nthem. Help, version, completion and explicit plain exports retain their own formats.\nBrowser follow modes stream one JSON envelope per line.\n\nUse error codes and supplied candidates/keys to recover. Unknown write outcomes\nrequire inspecting state before repeating an action. An empty successful result\nis distinct from an execution failure.\n\n## Browser\n\n```sh\nopencli browser open https://example.com --session research\nopencli browser state --session research\nopencli browser close --session research\nopencli browser chrome describe\n```\n\nPage commands use `--session`; native Chrome methods need no session. `--profile`\nselects a connected Chrome profile. `profile list` discovers profiles and\n`profile use <name>` selects the default. Use `doctor` for connection diagnosis.\nSee `opencli-browser` for interaction, API evidence and tab/group management.\n\n## Adapters and installation\n\n```sh\nopencli adapter init example/search\nopencli adapter check example/search\nopencli adapter test example/search --args '{\"query\":\"hello\"}'\nopencli adapter status\n```\n\nSee `opencli-adapter-author` for implementation. `adapter eject` makes an official\nsite editable locally; `adapter reset` removes that local override. Adapter tests\nexecute real commands and can perform writes.\n\n`plugin list|install|update|uninstall|create` manages plugin packages. `auth status`\nreports website login facts; `auth refresh` touches sessions. `daemon status|restart|stop`\nmanages the bridge process. Read help for the selected capability.\n\nBundled skill content is available through `skills list` and\n`skills read opencli-browser -f plain` (omit `-f plain` for JSON).\n","skills/smart-search/SKILL.md":"---\nname: smart-search\ndescription: 基于 opencli 命令的智能搜索路由器。当用户想要使用 OpenCLI、CLI 或 API 搜索、查询、查找或研究信息时，尤其是涉及指定网站、社交媒体、技术资料、新闻、购物、旅游、求职、金融或中文内容时，务必使用此 skill\n---\n\n# 智能搜索路由器\n\n根据话题和场景，将查询路由到最佳的 opencli 搜索源。此 skill 的核心目标不是记忆命令，而是先定位数据源，再让 Agent 通过 `opencli` 自己读取实时帮助，避免文档漂移。\n\n## 强制预检\n\n每次使用前，必须先做下面两步：\n\n- 运行 `opencli list -f yaml`\n- 用 live registry 确认候选站点是否存在，并检查 `strategy`、`browser`、`domain`\n\n选定站点后，必须再做下面两步：\n\n- 运行 `opencli <site> -h` 查看该站点有哪些子命令\n- 若已锁定某个子命令，再运行 `opencli <site> <command> -h` 查看参数、输出列、策略\n\n不要在 skill 文档里硬编码参数或假设命令签名；以 `opencli ... -h` 的实时输出为准。\n\n## 主路由规则\n\n只使用这一条规则，不再维护多套优先级：\n\n1. 当用户明确指定网站、平台或数据源时，直接使用对应网站。\n2. 当用户没有指定网站时，优先只选择一个 AI 源：`grok`、`doubao`、`gemini` 三选一。\n3. 当 AI 返回内容不足、缺少原始数据、需要权威佐证或需要垂直结果时，再补充 1-2 个专用源。\n\n## 单题预算与频率限制\n\n把“单个用户问题”理解为同一意图链路下的一次问题求解；同一轮追问、澄清、补充条件，若核心问题未变，仍算同一题。\n\n先建立一份站点调用台账。每次真正执行搜索命令后，立刻更新：\n\n- `site`\n- `query`\n- `count`\n- `status`\n\n计数规则：\n\n- `opencli list -f yaml`、`opencli <site> -h`、`opencli <site> <command> -h` 属于预检与帮助，不计入搜索次数\n- 一次真正的 `opencli <site> ...` 搜索/查询执行，计为该站点 1 次调用\n- 同站点因为报错、超时、验证码、反爬、登录态异常而失败，也算 1 次调用，不要无限重试\n\n频率上限：\n\n- AI 站点硬限制：同一题内，每个 AI 站点最多调用 1 次\n- 默认策略仍然是只选 1 个 AI 站点，不要把多个 AI 站点串成常规流程\n- 只有当用户明确要求比较多个 AI 站点时，才可以额外调用其他 AI 站点；但每个被点名的 AI 站点仍然最多 1 次\n- 非 AI 站点默认最多调用 2 次\n- 非 AI 站点第 2 次调用必须有明确理由，例如第一次结果过宽，需要加时间、地区、类别、排序或关键词限定\n- 非 AI 站点不要进行第 3 次调用；若信息仍不足，停止扩搜并明确说明缺口\n\n触发限频后的处理：\n\n- 记录：「已跳过：<site> 达到频率上限」\n- 优先改用其他同类站点\n- 若没有合适替代源，则直接基于已收集信息回答，并说明覆盖范围与缺口\n\n## 查询结束汇报\n\n每次查询结束后，回答末尾必须追加一段简短的“搜索摘要”，至少包含下面三项：\n\n- 使用了什么网站搜索\n- 每个网站搜了什么词\n- 每个网站搜了几次\n\n如果有被限频跳过的站点，也要明确写出。\n\n建议使用下面的固定格式：\n\n```md\n搜索摘要\n- 网站：<site1> | 查询词：<term1> | 次数：<n>\n- 网站：<site2> | 查询词：<term2>；<term3> | 次数：<n>\n- 已跳过：<site3>，原因：达到频率上限\n```\n\n## AI 源选择\n\n- `grok`\n  适合实时讨论、英文互联网舆论、Twitter/X 语境、热点追踪。\n- `doubao`\n  适合中文语境、字节抖音生态、生活方式内容、中文热点与泛中文问答。\n- `gemini`\n  适合全球网页、英文资料、通用信息检索、背景综述。\n\n如果用户没有指定网站，默认先判断语言和语境，再从这三个里只选一个。\n\n一旦某个 AI 站点已经执行过一次真实查询，就不要在同一题里改写关键词后再次调用该 AI 站点。若答案不足，优先补专用源，不要反复追打同一个 AI 站点。\n\n## AI 查询词建议\n\n当使用 AI 源时，不要只丢一个过短关键词。优先构造成“主题 + 目标 + 限定条件”的查询。\n\n- 主题\n  用户真正要查的对象、事件、产品、人物、公司、技术名词。\n- 目标\n  想要什么结果，例如总结、对比、原因、趋势、推荐、原始线索。\n- 限定条件\n  语言、地区、时间范围、平台范围、受众、价格带、岗位地点、是否要引用原始来源。\n\n优先使用下面这种表达方式：\n\n- `<主题> + <你要回答的问题>`\n- `<主题> + <时间范围/地区/语言>`\n- `<主题> + <平台或来源范围>`\n- `<主题> + <输出要求>`\n\n避免只输入：\n\n- 单个名词\n- 没有时间范围的热点问题\n- 没有地区限制的购物、求职、旅游问题\n- 没有平台限制的社交媒体问题\n\n## 专用源补充时机\n\n当出现以下任一情况时，再补充专用源：\n\n- AI 给出的是摘要，但你需要原始帖子、原始视频、原始商品或原始职位结果\n- AI 覆盖面不足，漏掉垂直站点信息\n- 需要更高权威性或更强领域相关性\n- 用户明确要求“从某个平台找”\n\n单次查询通常控制在 1 个 AI 源 + 1 到 2 个专用源，避免结果过载。\n\n## 处理不可用的源\n\n当站点不可用时：\n\n- 不要因为单个源失败而中止整个搜索\n- 记录：「已跳过：<site> 不可用」\n- 回退到同类其他站点，或回退到一个 AI 源\n- 始终以 `opencli list -f yaml` 与 `opencli <site> -h` 的实际结果为准\n\n不要假设任何站点“绝对可用”。即使是公开站点，也以当前环境中的 live help 和执行结果为准。\n\n## 参考文件\n\n根据需要读取对应文件：\n\n- **`references/sources-ai.md`** — AI 默认源\n- **`references/sources-tech.md`** — 技术 / 学术\n- **`references/sources-social.md`** — 社交媒体\n- **`references/sources-media.md`** — 媒体 / 娱乐\n- **`references/sources-info.md`** — 资讯 / 知识\n- **`references/sources-shopping.md`** — 购物\n- **`references/sources-travel.md`** — 旅游\n- **`references/sources-other.md`** — 其他垂直源\n\n只读与当前查询相关的文件，无需全部加载。\n","skills/smart-search/references/sources-ai.md":"# AI 默认源\n\n当用户没有明确指定网站时，先在 `grok`、`doubao`、`gemini` 中选择一个，不要一开始并行跑多个 AI 源。\n\n## 使用规则\n\n1. 先运行 `opencli list -f yaml`\n2. 确认 `grok`、`doubao`、`gemini` 哪些在当前 registry 中可用\n3. 运行 `opencli <site> -h`\n4. 锁定具体子命令后，再运行 `opencli <site> <command> -h`\n\n## 路由建议\n\n### grok\n\n- 适用：实时热点、Twitter/X 语境、英文互联网讨论、舆论与趋势\n- 补充源常见去向：`twitter`、`reddit`、`reuters`、`google`\n- 搜索词建议：\n  - 加上时间范围，例如“today / this week”\n  - 加上平台范围，例如“on X”, “from social posts”\n  - 加上目标，例如“latest reactions”, “main viewpoints”, “key claims”\n  - 示例：\n    - `OpenAI latest reactions on X this week`\n    - `TSLA earnings main viewpoints on social media April 2026`\n    - `Nintendo Switch 2 rumors latest discussion on X`\n\n### doubao\n\n- 适用：中文语境、国内热点、生活方式、字节生态、中文泛问答\n- 补充源常见去向：`xiaohongshu`、`weibo`、`zhihu`、`bilibili`、`36kr`\n- 搜索词建议：\n  - 加上中文场景限定，例如“中文讨论”“国内用户”“小红书/微博语境”\n  - 加上需求目标，例如“帮我总结”“给我对比”“提取推荐理由”\n  - 加上人群或用途，例如“适合新手”“预算 500 元内”“上海求职”\n  - 示例：\n    - `2026年MacBook Air值得买吗 中文讨论里主要观点是什么`\n    - `上海 AI 产品经理招聘趋势 近一个月中文信息总结`\n    - `敏感肌防晒推荐 中文用户常提到的优缺点`\n\n### gemini\n\n- 适用：全球网页、英文资料、背景综述、通用检索\n- 补充源常见去向：`google`、`wikipedia`、`arxiv`、`stackoverflow`\n- 搜索词建议：\n  - 加上主题类型，例如“overview”, “comparison”, “background”, “best sources”\n  - 加上范围限定，例如地区、时间、语言、行业\n  - 加上结果形式，例如“with sources”, “compare pros and cons”, “official guidance”\n  - 示例：\n    - `MCP overview and official guidance with sources`\n    - `best budget travel destinations in Japan April 2026 compare pros and cons`\n    - `TypeScript decorators current status official sources`\n\n## 补充原则\n\n- 先用一个 AI 源拿到初步答案\n- 若答案缺少原始数据、垂直结果或权威来源，再补 1-2 个专用源\n- 不要把 AI 默认源当成命令签名的事实来源；命令细节始终以 `opencli ... -h` 为准\n\n## 通用写法模板\n\n可直接按下面模板构造 AI 查询：\n\n- 热点/新闻：\n  `<事件> + 最新进展 + <时间范围> + <地区/平台>`\n- 对比/推荐：\n  `<对象A> vs <对象B> + <关注维度> + <人群/预算/用途>`\n- 中文社区：\n  `<主题> + 中文讨论里主要观点 + <时间范围>`\n- 全球资料：\n  `<主题> + overview/background + with sources`\n- 求职：\n  `<岗位> + <城市/国家> + market trends/hiring + <时间范围>`\n- 购物：\n  `<商品> + reviews/price/value + <地区/预算>`\n","skills/smart-search/references/sources-info.md":"# 资讯 / 知识\n\n用于新闻、百科、通用知识、中文科技资讯。\n\n## 站点\n\n### google\n\n- 适用：通用网页搜索、跨站点兜底\n- 使用前先运行：`opencli google -h`\n\n### wikipedia\n\n- 适用：名词解释、背景知识、历史事实\n- 使用前先运行：`opencli wikipedia -h`\n\n### reuters\n\n- 适用：国际新闻、事实性报道\n- 使用前先运行：`opencli reuters -h`\n\n### weibo\n\n- 适用：微博热点、话题、中文舆论\n- 使用前先运行：`opencli weibo -h`\n\n### 36kr\n\n- 适用：中文科技、创业、融资资讯\n- 使用前先运行：`opencli 36kr -h`\n\n### substack\n\n- 适用：newsletter、作者订阅内容、长文\n- 使用前先运行：`opencli substack -h`\n\n## 路由提示\n\n- 背景知识与定义，优先 `wikipedia`\n- 通用网页与跨站信息，优先 `google`\n- 国际新闻优先 `reuters`\n- 中文科技资讯优先 `36kr`\n- 当 AI 回答过于泛化时，用这些源补充事实和链接\n","skills/smart-search/references/sources-media.md":"# 媒体 / 娱乐\n\n用于视频、电影、剧集、音乐、播客、内容平台搜索。\n\n## 站点\n\n### bilibili\n\n- 适用：中文视频、教程、评测、UP 主内容\n- 使用前先运行：`opencli bilibili -h`\n\n### youtube\n\n- 适用：英文视频、教程、评测、访谈\n- 使用前先运行：`opencli youtube -h`\n\n### tiktok\n\n- 适用：短视频、热点、娱乐内容\n- 使用前先运行：`opencli tiktok -h`\n\n### imdb\n\n- 适用：电影、剧集、演员、评分\n- 使用前先运行：`opencli imdb -h`\n\n### douban\n\n- 适用：中文电影、书籍、音乐口碑与评分\n- 使用前先运行：`opencli douban -h`\n\n### spotify\n\n- 适用：歌曲、歌手、专辑\n- 使用前先运行：`opencli spotify -h`\n\n### apple-podcasts\n\n- 适用：播客节目、播客搜索\n- 使用前先运行：`opencli apple-podcasts -h`\n\n### medium\n\n- 适用：长文、技术博客、设计与产品文章\n- 使用前先运行：`opencli medium -h`\n\n## 路由提示\n\n- 用户只说“找视频/影评/播客/文章”，先按内容形态选站点\n- 用户没有指定站点时，可先用 `doubao`、`grok` 或 `gemini` 做一轮粗检索，再补媒体站点拿原始结果\n","skills/smart-search/references/sources-other.md":"# 其他垂直源\n\n用于求职、金融、书籍、词典等不适合归入前述分类的场景。\n\n## 站点\n\n### linkedin\n\n- 适用：全球职位、英文岗位、跨国公司招聘\n- 使用前先运行：`opencli linkedin -h`\n\n### boss\n\n- 适用：国内职位搜索、招聘与岗位信息\n- 使用前先运行：`opencli boss -h`\n\n### xueqiu\n\n- 适用：股票、金融讨论、行情相关线索\n- 使用前先运行：`opencli xueqiu -h`\n\n### weread\n\n- 适用：中文书籍搜索与书单线索\n- 使用前先运行：`opencli weread -h`\n\n### dictionary\n\n- 适用：英文词义、基础词典查询\n- 使用前先运行：`opencli dictionary -h`\n\n### sinablog\n\n- 适用：较旧的中文博客内容\n- 使用前先运行：`opencli sinablog -h`\n\n## 路由提示\n\n- 求职：全球优先 `linkedin`，国内优先 `boss`\n- 金融：优先 `xueqiu`\n- 书籍：中文优先 `weread`\n- 英文单词定义：优先 `dictionary`\n","skills/smart-search/references/sources-shopping.md":"# 购物\n\n用于商品搜索、价格、好价、口碑与区域电商结果。\n\n## 站点\n\n### amazon\n\n- 适用：全球商品搜索、价格参考、英文电商\n- 使用前先运行：`opencli amazon -h`\n\n### smzdm\n\n- 适用：国内好价、优惠、导购、商品讨论\n- 使用前先运行：`opencli smzdm -h`\n\n### coupang\n\n- 适用：韩国电商商品搜索\n- 使用前先运行：`opencli coupang -h`\n\n### douban\n\n- 适用：图书、影视、音乐类口碑补充，不是标准电商，但可用于消费决策补充\n- 使用前先运行：`opencli douban -h`\n\n## 路由提示\n\n- 用户指定平台时直接使用该平台\n- 未指定平台时，全球商品优先 `amazon`，国内好价优先 `smzdm`\n- 若先用 AI 做产品调研，后续可补电商站点拿实际商品结果\n","skills/smart-search/references/sources-social.md":"# 社交媒体\n\n用于需要原始帖子、原始用户结果、中文或英文社区讨论时。\n\n## 站点\n\n### twitter\n\n- 适用：Twitter/X 原始帖子、作者、实时讨论\n- 使用前先运行：`opencli twitter -h`\n\n### weibo\n\n- 适用：微博热点、话题、中文舆论\n- 使用前先运行：`opencli weibo -h`\n\n### xiaohongshu\n\n- 适用：生活方式、穿搭、美妆、旅行、真实体验\n- 使用前先运行：`opencli xiaohongshu -h`\n\n### zhihu\n\n- 适用：中文深度问答、专业解释、行业经验\n- 使用前先运行：`opencli zhihu -h`\n\n### tieba\n\n- 适用：兴趣圈子、历史帖子、粉丝社区\n- 使用前先运行：`opencli tieba -h`\n\n### instagram\n\n- 适用：账号搜索、图片社交线索\n- 使用前先运行：`opencli instagram -h`\n\n### facebook\n\n- 适用：主页、人物、帖子线索\n- 使用前先运行：`opencli facebook -h`\n\n### bluesky\n\n- 适用：Bluesky 用户与账号搜索\n- 使用前先运行：`opencli bluesky -h`\n\n## 路由提示\n\n- 用户明确指定某个平台时，直接用该平台\n- 用户只说“社交媒体上怎么看”时，可先选 `grok` 或 `doubao`\n- AI 只给摘要而没有原始帖子时，再切到对应社交站点\n","skills/smart-search/references/sources-tech.md":"# 技术 / 学术\n\n用于技术问题、研究论文、开发者讨论、开源社区信息。\n\n## 站点\n\n### arxiv\n\n- 适用：论文、研究、模型、算法、学术背景\n- 使用前先运行：`opencli arxiv -h`\n\n### stackoverflow\n\n- 适用：具体报错、API 用法、代码模式、实现细节\n- 使用前先运行：`opencli stackoverflow -h`\n\n### hackernews\n\n- 适用：技术社区讨论、开发者观点、创业和产品话题\n- 使用前先运行：`opencli hackernews -h`\n\n### reddit\n\n- 适用：英文社区问答、经验贴、推荐、对比讨论\n- 使用前先运行：`opencli reddit -h`\n\n### linux-do\n\n- 适用：人工智能、开源工具、中文技术社区\n- 使用前先运行：`opencli linux-do -h`\n\n### v2ex\n\n- 适用：中文技术社区\n- 使用前先运行：`opencli linux-do \n\n## 路由提示\n\n- 用户明确说“论文”“研究”时，优先 `arxiv`\n- 用户明确说“报错”“API 怎么用”时，优先 `stackoverflow`\n- 用户要“社区讨论”“开发者观点”时，优先 `hackernews` 或 `reddit`\n- 若用户没有明确指定站点，可先选 `gemini` 或 `grok`，内容不足时再补这些专用源\n","skills/smart-search/references/sources-travel.md":"# 旅游\n\n用于目的地、景区、酒店联想和旅行线索。\n\n## 站点\n\n### ctrip\n\n- 适用：目的地、景区、酒店、旅行联想搜索\n- 使用前先运行：`opencli ctrip -h`\n\n### xiaohongshu\n\n- 适用：生活方式、穿搭、美妆、旅行、真实体验\n- 使用前先运行：`opencli xiaohongshu -h`\n\n## 路由提示\n\n- 用户明确说“携程”时直接使用 `ctrip`\n- 用户只说“旅行/目的地/景区”时，可先选 `doubao` 做中文语境粗检索，再补 `ctrip`\n"},"items":[{"name":"llms.txt","path":"llms.txt","rawUrl":"https://raw.githubusercontent.com/jackwener/OpenCLI/HEAD/llms.txt","title":"LLM Index & Context Digest","category":"root-instruction","format":"text","content":"# OpenCLI\n\n> General-purpose browser automation for AI agents, with reusable site commands.\n> An agent-browser alternative: navigate, inspect, click, fill, extract, and screenshot without a site adapter.\n\n## What OpenCLI does\n\nOpenCLI gives you one surface for three kinds of automation:\n\n1. **Built-in adapters** — 100+ sites pre-built with deterministic commands (Twitter/X, Xiaohongshu, Bilibili, Zhihu, LinkedIn, YouTube, Reddit, HackerNews, and more)\n2. **Browser automation for AI agents** — AI agents (Claude Code, Cursor, etc.) can navigate, click, type, fill, extract, and screenshot any page via `opencli browser` primitives and your logged-in Chrome session\n3. **Adapter authoring** — Write new site adapters end-to-end using `opencli-adapter-author` skill + `opencli browser recon/init/verify` workflow\n\n\n## OpenCLI as an agent-browser alternative\n\nOpenCLI supports general-purpose browser workflows without pre-built site adapters. The browser loop is: `opencli browser work open https://example.com`, `opencli browser work state`, then choose actions such as `click`, `fill`, or `extract`. Inspect state again after page changes; release the session with `opencli browser work close`.\n\nUse the same named session across calls. Element references, CLI arguments, outputs, and session lifecycle differ from agent-browser, so migrate and verify existing scripts rather than treating the CLIs as syntax-compatible. Adapters are optional shortcuts for recurring site tasks, not a prerequisite for browsing.\n\n- [OpenCLI vs agent-browser and migration](https://opencli.info/docs/guide/agent-browser-alternative.html)\n- [中文迁移指南](https://opencli.info/docs/zh/guide/agent-browser-alternative.html)\n- [Browser Bridge setup](https://opencli.info/docs/guide/browser-bridge.html)\n\n## Install\n\n```bash\nnpm install -g @jackwener/opencli\n# Install Browser Bridge extension from Chrome Web Store\n# Run: opencli doctor\n```\n\nRequires Node.js >= 20.\n\n## Key capabilities\n\n- **No built-in LLM dependency** — commands do not invoke an LLM; an AI agent orchestrating actions has its own model costs\n- **Account safe** — reuses Chrome's logged-in state, credentials never leave the browser\n- **Deterministic adapters** — site commands provide structured output schemas. Pipeable, CI-friendly\n- **Plugin system** — `opencli plugin install github:user/repo`\n- **Multiple output formats** — JSON, CSV, Markdown, YAML, table\n\n## Skills for AI agents\n\nInstall with: `npx skills add jackwener/opencli`\n\n- `opencli-browser` — give AI agents direct browser control (click, type, extract, screenshot)\n- `opencli-adapter-author` — write new adapters from recon to verification\n- `opencli-autofix` — repair broken adapters\n- `opencli-usage` — command and site reference\n\n## Built-in sites (selected)\n\nTwitter/X, Xiaohongshu, Bilibili, Zhihu, LinkedIn, YouTube, Reddit, HackerNews, Weibo, Amazon, Bloomberg, V2EX, Boss直聘, Douyin, Spotify, WeChat, Xiaoyuzhou, NotebookLM, Claude, Gemini, GeoGebra, xueqiu, zlibrary, upwork, 1688, pixiv, and 80+ more.\n\nFull list: https://github.com/jackwener/opencli/blob/main/docs/adapters/index.md\n\n## Configuration\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `OPENCLI_BROWSER_COMMAND_TIMEOUT` | `60` | Seconds to wait for a browser command |\n| `OPENCLI_PROFILE` | — | Browser Bridge profile alias for multi-profile setups |\n| `OPENCLI_VERBOSE` | `false` | Enable verbose logging |\n\n## Exit codes\n\n`0` success · `66` empty result · `69` Browser Bridge down · `75` timeout · `77` auth required · `78` config error · `130` Ctrl-C\n\n## Key links\n\n- Website: https://opencli.info\n- GitHub: https://github.com/jackwener/opencli\n- npm: https://www.npmjs.com/package/@jackwener/opencli\n- Chrome Extension: https://chromewebstore.google.com/detail/opencli/ildkmabpimmkaediidaifkhjpohdnifk\n- Docs: https://opencli.info\n- Plugins: https://opencli.info/plugins\n\n## Author\n\njackwener — https://github.com/jackwener\n\n## Related resources\n\n- Website llms.txt: https://opencli.info/llms.txt\n- Full adapter docs: https://github.com/jackwener/opencli/blob/main/docs/adapters/index.md\n- Extending guide: https://github.com/jackwener/opencli/blob/main/docs/guide/extending-opencli.md\n- Plugin guide: https://github.com/jackwener/opencli/blob/main/docs/guide/plugins.md\n","isInternal":false,"tokens":1073,"sizeBytes":4343},{"name":"adapter-template.md","path":"skills/opencli-adapter-author/references/adapter-template.md","rawUrl":"https://raw.githubusercontent.com/jackwener/OpenCLI/HEAD/skills/opencli-adapter-author/references/adapter-template.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# Adapter Template\n\n一份 adapter 就是一次 `cli({...})` 调用。文件结构固定，三段：declaration、args、func。\n\n拿 `clis/eastmoney/convertible.js` 当活例子，对照拆解。\n\n---\n\n## 活例子：convertible.js\n\n> **注意（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(...)` 一起抄过去。\n\n```javascript\n// eastmoney convertible — on-market convertible bond listing.\nimport { cli, Strategy } from '@jackwener/opencli/registry';\nimport { CliError } from '@jackwener/opencli/errors';\n\nconst SORTS = {\n  change:   { fid: 'f3',   order: 'desc' },\n  drop:     { fid: 'f3',   order: 'asc' },\n  turnover: { fid: 'f6',   order: 'desc' },\n  price:    { fid: 'f2',   order: 'desc' },\n  premium:  { fid: 'f237', order: 'desc' },\n  value:    { fid: 'f236', order: 'desc' },\n  ytm:      { fid: 'f239', order: 'desc' },\n};\n\ncli({\n  site: 'eastmoney',\n  name: 'convertible',\n  description: '可转债行情列表（默认按成交额排序）',\n  domain: 'push2.eastmoney.com',\n  strategy: Strategy.PUBLIC,\n  browser: false,\n  args: [\n    { name: 'sort',  type: 'string', default: 'turnover', help: '排序：turnover / change / drop / price / premium' },\n    { name: 'limit', type: 'int',    default: 20,         help: '返回数量 (max 100)' },\n  ],\n  columns: ['rank', 'bondCode', 'bondName', 'bondPrice', 'bondChangePct',\n            'stockCode', 'stockName', 'stockPrice', 'stockChangePct',\n            'convPrice', 'convValue', 'convPremiumPct', 'remainingYears', 'ytm', 'listDate'],\n  func: async (args) => {\n    const sortKey = String(args.sort ?? 'turnover').toLowerCase();\n    const sort = SORTS[sortKey];\n    if (!sort) throw new CliError('INVALID_ARGUMENT', `Unknown sort \"${sortKey}\". Valid: ${Object.keys(SORTS).join(', ')}`);\n    const limit = Math.max(1, Math.min(Number(args.limit) || 20, 100));\n\n    const url = new URL('https://push2.eastmoney.com/api/qt/clist/get');\n    url.searchParams.set('pn', '1');\n    url.searchParams.set('pz', String(limit));\n    url.searchParams.set('po', sort.order === 'desc' ? '1' : '0');\n    url.searchParams.set('np', '1');\n    url.searchParams.set('fltt', '2');\n    url.searchParams.set('invt', '2');\n    url.searchParams.set('fid', sort.fid);\n    url.searchParams.set('fs', 'b:MK0354');\n    url.searchParams.set('fields', 'f12,f14,f2,f3,f6,f229,f230,f232,f234,f235,f236,f237,f238,f239,f243');\n    url.searchParams.set('ut', 'bd1d9ddb04089700cf9c27f6f7426281');\n\n    const resp = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0' } });\n    if (!resp.ok) throw new CliError('HTTP_ERROR', `convertible failed: HTTP ${resp.status}`);\n    const data = await resp.json();\n    const diff = Array.isArray(data?.data?.diff) ? data.data.diff : [];\n    if (diff.length === 0) throw new CliError('NO_DATA', 'eastmoney returned no convertible data');\n\n    return diff.slice(0, limit).map((it, i) => ({\n      rank: i + 1,\n      bondCode: it.f12,\n      bondName: it.f14,\n      bondPrice: it.f2,\n      bondChangePct: it.f3,\n      stockCode: it.f232,\n      stockName: it.f234,\n      stockPrice: it.f229,\n      stockChangePct: it.f230,\n      convPrice: it.f235,\n      convValue: it.f236,\n      convPremiumPct: it.f237,\n      remainingYears: it.f238,\n      ytm: it.f239,\n      listDate: String(it.f243 ?? ''),\n    }));\n  },\n});\n```\n\n---\n\n## 三段解剖\n\n### 1. Declaration — 标头\n\n```javascript\ncli({\n  site: 'eastmoney',          // 第一级命名空间，目录名一致\n  name: 'convertible',        // 第二级，CLI 上的子命令\n  description: '...',         // 一句话，出现在 `opencli list` 和 `opencli <site> -h`\n  domain: 'push2.eastmoney.com',  // 主要请求域名（诊断面板用）\n  strategy: Strategy.PUBLIC,  // PUBLIC / COOKIE / INTERCEPT / UI\n  browser: false,             // PUBLIC 几乎总是 false；COOKIE/INTERCEPT/UI 一律 true\n  ...\n});\n```\n\n### 2. Args & Columns\n\n```javascript\nargs: [\n  { name: 'sort',  type: 'string', default: 'turnover', help: '...' },\n  { name: 'limit', type: 'int',    default: 20,         help: '...' },\n],\ncolumns: ['rank', 'bondCode', 'bondName', /* ... */ ],\n```\n\n**规则**：\n\n- `type`: `string` / `int` / `float` / `bool`\n- `default` 必填（缺失的命令会拒绝启动）\n- `columns` 数组必须跟 `func` 返回的 object keys 完全对上，顺序也一致（决定表格列顺序）\n- 列名 camelCase，跟 `cli({...})` 其他 adapter 保持统一\n- **中间解析对象 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)）\n\n### 3. func — 主体\n\n```javascript\nimport { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';\n\nfunc: async (args) => {\n  // 1. 解析参数 — 越界一律抛，不要 silent clamp\n  const n = Number(args.limit ?? 20);\n  if (!Number.isInteger(n) || n <= 0) throw new ArgumentError('limit must be a positive integer');\n  if (n > 100) throw new ArgumentError('limit must be <= 100');\n  const limit = n;\n\n  // 2. 构造 URL / 请求\n  const url = new URL(...);\n  url.searchParams.set(...);\n\n  // 3. 发请求 — fetch 抛 / HTTP 非 2xx 都归 CommandExecutionError\n  let resp;\n  try {\n    resp = await fetch(url, { headers: { /* ... */ } });\n  } catch (error) {\n    throw new CommandExecutionError(`request failed: ${error?.message || error}`);\n  }\n  if (!resp.ok) throw new CommandExecutionError(`request failed: HTTP ${resp.status}`);\n\n  // 4. 解析 + 业务校验 — 业务空 → EmptyResultError，不要 sentinel row 也不要 return []\n  const data = await resp.json();\n  const diff = Array.isArray(data?.data?.diff) ? data.data.diff : [];\n  if (diff.length === 0) throw new EmptyResultError('site command', 'API returned no rows');\n\n  // 5. map 到 columns 同名 keys\n  return diff.slice(0, limit).map((it, i) => ({\n    rank: i + 1,\n    bondCode: it.f12,\n    // ...\n  }));\n},\n```\n\n**站点级 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。\n\n**参数形态**（**踩过最多次的坑**：搞反签名后 `args` 实际是 `debug` flag，所有 `args.foo` 静默 undefined → fallback 到 default。#1329 upstream 之前 8 个 non-browser adapter 写错过签名，全部 silently fallback 到默认参数）：\n\n- `browser: false`：`func: async (args, debug?) => { ... }` —— **单参 args**，不会收到 `page`\n- `browser: true`：`func: async (page, args, debug?) => { ... }` —— **双参 (page, args)**，第一参是浏览器上下文\n- `args`：所有 `args[]` 声明的参数解析后的 object\n\n**错误处理**：用 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）决定要不要重试。\n\n---\n\n## COOKIE adapter 骨架（需要登录态）\n\nPUBLIC 模式不够（接口 401 / 302 到 login / 响应是\"请登录\"页）就走这里。要点三条：\n\n1. 读 cookie 走 `page.getCookies(...)`，**不要读 `document.cookie`**。\n2. 拿 HTML 走 Node 端 `fetch` + 手动解码，**不要塞进 `page.evaluate` 里**。\n3. Declaration 加 `browser: true`；不需要真的打开目标页时 `navigateBefore: false`。\n\n```javascript\nimport { cli, Strategy } from '@jackwener/opencli/registry';\nimport { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';\n\nconst BASE = 'https://www.example.com';\nconst HOST = 'www.example.com';\nconst ROOT = '.example.com';  // 根域（auth 常在这里）\n\nasync function readCookie(page) {\n    const seen = new Map();\n    for (const opts of [{ domain: HOST }, { domain: ROOT }]) {\n        try {\n            const cookies = await page.getCookies(opts);\n            for (const c of cookies || []) {\n                if (!seen.has(c.name)) seen.set(c.name, c.value);\n            }\n        } catch { /* try next domain */ }\n    }\n    return [...seen].map(([k, v]) => `${k}=${v}`).join('; ');\n}\n\nasync function fetchHtml(url, { cookie, encoding = 'utf-8', headers = {} } = {}) {\n    let resp;\n    try {\n        resp = await fetch(url, {\n            headers: {\n                'User-Agent': 'Mozilla/5.0',\n                'Accept-Language': 'zh-CN,zh;q=0.9',\n                Referer: `${BASE}/`,\n                ...(cookie ? { Cookie: cookie } : {}),\n                ...headers,\n            },\n            redirect: 'follow',\n        });\n    } catch (error) {\n        throw new CommandExecutionError(`example request failed: ${error?.message || error}`);\n    }\n    if (!resp.ok) throw new CommandExecutionError(`example request failed: HTTP ${resp.status}`);\n    const buf = await resp.arrayBuffer();\n    return new TextDecoder(encoding).decode(buf);\n}\n\ncli({\n    site: 'example',\n    name: 'me',\n    access: 'read',\n    description: '示例：需要登录的私有页面',\n    domain: HOST,\n    strategy: Strategy.COOKIE,\n    browser: true,\n    navigateBefore: false,    // 本命令不需要先开目标页\n    args: [{ name: 'limit', type: 'int', default: 20, help: '返回条数' }],\n    columns: ['index', 'title', 'time'],\n    func: async (page, args) => {\n        const limit = Number(args.limit ?? 20);\n        if (!Number.isInteger(limit) || limit <= 0) throw new ArgumentError('limit must be a positive integer');\n\n        const cookie = await readCookie(page);\n        const html = await fetchHtml(`${BASE}/inbox`, { cookie, encoding: 'gbk' });\n\n        if (/请登录|需要登录|<title>Login/i.test(html)) {\n            throw new AuthRequiredError(HOST);\n        }\n\n        // parse html → rows\n        if (!rows.length) throw new EmptyResultError('example me', 'inbox is empty');\n        return rows.slice(0, limit);\n    },\n});\n```\n\n### JSON API 用 `page.fetchJson()`，不要手写 `page.evaluate(fetch(...))`\n\n如果接口必须在浏览器上下文里请求（依赖当前页面 cookie / CORS / origin），用内置 primitive：\n\n```javascript\nconst data = await page.fetchJson(`${BASE}/api/list`, {\n  method: 'POST',\n  headers: { 'X-Requested-With': 'XMLHttpRequest' },\n  body: { page: 1, size: limit },\n});\n```\n\n它固定 `credentials: 'include'`，带 timeout，HTTP 非 2xx / 非 JSON 会抛统一 runtime error。adapter 里不用再手写 `page.evaluate(fetch(...))`；如果你需要额外包一层业务语义，按 [`typed-errors.md`](./typed-errors.md) 映射到 `CommandExecutionError` / `AuthRequiredError` / `EmptyResultError`。\n\n### 页面内 DOM 逻辑用 `page.evaluate(fn, ...args)`\n\n新 adapter 优先写函数形式，外部变量通过参数传入：\n\n```javascript\nconst href = await page.evaluate((selector) => {\n  const link = document.querySelector(selector);\n  return link ? link.getAttribute('href') : null;\n}, 'a[data-testid=\"profile\"]');\n```\n\n`fn` 在浏览器页面上下文执行，不能读取 Node 侧闭包变量；参数必须能被 `JSON.stringify` 序列化。字符串形式 `page.evaluate('document.title')` 仍可用于简单表达式和既有代码，但不要再写依赖隐式 auto-IIFE 的模板字符串函数。\n\n### HTML 不走 browser fetch\n\n三个坑，踩一个就重写：\n\n- **HttpOnly cookie 看不见**：绝大多数登录站点把 auth cookie 标 `HttpOnly`，`document.cookie` 永远读不到它，只能通过 CDP 的 cookie jar 拿（`page.getCookies`）。塞到 `page.evaluate` 里就等于回到 `document.cookie` 那条路，必挂。\n- **`navigateBefore: false` 时当前 tab 不在目标站**：页面 origin 可能是 `about:blank` 或上一条命令留下的别处，从那儿发 fetch 到目标域就是 cross-origin，浏览器 CORS 一挡就是 \"Failed to fetch\"。\n- **非 UTF-8 编码解码麻烦**：GBK / Big5 / Shift-JIS 的站（Discuz / phpBB 老版 / 日站）在 `page.evaluate` 里用 `response.text()` 拿到的是乱码，`TextDecoder('gbk').decode(buf)` 的写法只在 Node 侧干净。\n\n**规则**：JSON 型浏览器接口用 `page.fetchJson()`；HTML 型 COOKIE adapter 一律 Node 侧 `fetch`，浏览器只当 cookie jar 用。\n\n### Selector 稳定性 — 不要 select 用户可见文本\n\nissue #1474 触发：同一个发送按钮在英文 Chrome 是 `aria-label=\"Submit\"`，在中文 Chrome（`chrome://settings/languages` 设中文）变 `aria-label=\"提交\"`，CSS 选择器 `button[aria-label=\"Submit\"]` 在中文环境下直接 0 匹配，silent empty result。\n\n根因不是 i18n bug，是**选择器的 anchor 选错了**。把页面 DOM 属性按 \"locale-stable vs locale-dependent\" 分两类：\n\n| 类 | 例子 | locale 切换会变吗 | 用作 primary selector？ |\n|----|------|----------------|---------------------|\n| **locale-stable 标识** | `data-testid`、`data-*`、稳定 `id` / `class` | 通常不变（开发者内部 ID） | ✅ 首选，但要先确认不是 hash / A-B test |\n| **semantic / scope anchor** | `role`、结构关系、邻近稳定容器 | 不按 locale 翻译，但常常不唯一 | ⚠️ 只作 scope/filter；不要单独用 `button[role=\"button\"]` |\n| **locale-dependent 文本** | `aria-label`、`title`、`placeholder`、`alt`、`textContent` | 变（被 i18n 框架翻译） | ❌ 仅当 stable 选择器全都不存在时的兜底 |\n\nChatGPT 的 web 端就是反例驱动的：有些 controls 暴露稳定 `data-testid`，有些 surfaces 只暴露 `aria-label` / `placeholder`。这种站必须先用 stable selector，再用多语言 fallback list：\n\n```javascript\n// clis/chatgpt/utils.js（简化活例）\nconst COMPOSER_SELECTORS = [\n  '#prompt-textarea',\n  '[data-testid=\"composer\"] [contenteditable=\"true\"]',\n  '[aria-label=\"Chat with ChatGPT\"]',   // en\n  '[aria-label=\"与 ChatGPT 聊天\"]',      // zh-CN\n  '[placeholder=\"Ask anything\"]',\n  '[placeholder=\"有问题，尽管问\"]',       // zh-CN\n];\nconst SEND_BUTTON_SELECTORS = [\n  'button[data-testid=\"send-button\"]:not([disabled])',\n  'button[aria-label=\"Send prompt\"]:not([disabled])',\n  'button[aria-label=\"发送提示\"]:not([disabled])',\n];\n```\n\n写 fallback list 的纪律：\n\n1. **stable selector 放最前**（`#prompt-textarea` / `[data-testid=\"send-button\"]`），locale-dependent 的放后面当兜底\n2. **`role` 只能当 semantic / scope filter**：`dialog [role=\"textbox\"]` 可以；裸 `button` / `[role=\"button\"]` 不够，因为同页可能有多个按钮\n3. **每种 locale 至少列一条**（en + zh-CN 是底线；扩到 ja / ko / ar 看站点用户分布）\n4. **commit 前 grep `aria-label=` / `placeholder=` / `title=` 看是不是漏了 fallback locale**——见 success-rate-pitfalls.md §11\n5. **失败要 typed fail-fast**：找不到 control 应该 `CommandExecutionError` / send-failed，不要返回空 rows 或假成功\n6. **不要给 framework 加 `--i18n \"zh:提交,ja:送信\"` 这种 flag** —— 等于把 fallback list 从 adapter 挪到 CLI，多一层 indirection 还要维护翻译字典。这是 over-engineering，已经在评审时被否\n\n为什么不在 daemon 端固定 Chrome locale？因为 opencli **不启动 Chrome**——daemon 是连用户已经在跑的 Chrome（CDP via extension），用户可能就是中文 UI / 中文资料检索需求。强制 en-US 会破坏用户的正当工作流。\n\n### Cookie 域的双查\n\n```javascript\nfor (const opts of [{ domain: HOST }, { domain: ROOT }]) { ... }\n```\n\n不是所有站都这么玄学，但下面这几类踩坑最多：\n\n| 站点类型 | 坑 |\n|---------|----|\n| Discuz!X / phpBB / vBulletin 论坛 | Auth cookie 设在 `.<root>.com`，HttpOnly；业务页在 `www.<root>.com`。只查 `www.` 会漏 |\n| 多子域账户体系（`account.x.com` vs `api.x.com`） | 登录时写在 account 域，API 域读取时拿不到 |\n| 新版 Chrome SameSite=Lax 默认 | 某些 cookie 查 `url:` 才给返，查 `domain:` 不给 |\n\n双查成本很低，不确定就两个都查，用 Map 去重第一次出现的 name。\n\n### 空态抛 `EmptyResultError`，**不**塞 sentinel 行\n\n历史上这里写的是\"返回一行说明 row 比 `return []` 安全\"。**这条已经反过来了**——见 PR #1329 R3 的 four anti-pattern fixes。现在的契约：\n\n```javascript\nimport { EmptyResultError } from '@jackwener/opencli/errors';\n\n// ❌ 老写法：sentinel 行污染 row 合同，让 listing→detail round-trip 拿到 tid='' 白跑\nif (/暂时没有提醒内容/.test(html)) {\n    return [{ index: 0, from: '', summary: '暂时没有提醒内容', time: '', threadUrl: '' }];\n}\n\n// ✅ 新写法：empty 是合法状态，但不是 row。exit code 66 让 agent 直接 branch\nif (/暂时没有提醒内容/.test(html)) {\n    throw new EmptyResultError('1point3acres notifications', '暂时没有提醒内容');\n}\n```\n\n更多反例和详细 routing 见 [`typed-errors.md` §3](./typed-errors.md)。\n\n---\n\n## 同类型 adapter 对照\n\n| 类型 | 代表 | 参考 |\n|------|------|-----|\n| clist 分页排行 | `convertible.js` / `rank.js` / `etf.js` / `sectors.js` | 都共享 `fs` + `fid` + `po` 结构 |\n| ulist 批量报价 | `quote.js` | `secids` 逗号拼接 |\n| K 线历史 | `kline.js` | `fields1 / fields2` 控列，CSV 解析 |\n| 报表（datacenter-web） | `longhu.js` / `holders.js` | `reportName` 驱动 |\n| 7x24 新闻 | `kuaixun.js` | `np-listapi` 栏目 id |\n| 公司公告 | `announcement.js` | `np-anotice-stock` |\n| 指数/北上 | `index-board.js` / `northbound.js` | push2 专用端点 |\n\n新写一条时，选最像的那类，复制后改 `name` / URL / fields / column 映射三处。\n\n---\n\n## Verify fixture（每个 adapter 配一份 `~/.opencli/sites/<site>/verify/<name>.json`）\n\nverify fixture 是\"adapter 产出长什么样\"的结构锚点。没有它，`opencli adapter test` 只能证\"adapter 能跑完不抛\"，证不出数据没错位。**必写**。\n\n详细 schema 见 `site-memory.md` 的 `verify/<cmd>.json` 节。这里只讲两个容易踩的地方：\n\n### args 形态：object vs array\n\n`args` 字段决定 verify 怎么调你的 adapter：\n\n- **对象形态** `{ \"limit\": 3 }` → 展开成 `--limit 3`，标准 named-flag adapter 用这个\n- **数组形态** `[\"123\", \"--limit\", \"3\"]` → 原样 append 到命令后，**positional 主语型** adapter 必须用这个\n\nrepo 约定\"主语优先 positional\"——thread 详情型、url 解析型、关键词搜索型都用 positional：\n\n```js\n// clis/1point3acres/thread.js — 接收 <tid> 作为主语\ncli({\n  site: '1point3acres',\n  name: 'thread',\n  args: [\n    { name: 'tid',   type: 'string', required: true, positional: true },\n    { name: 'limit', type: 'int',    default: 20 },\n  ],\n  // ...\n});\n```\n\n对应 fixture：\n\n```json\n{\n  \"args\": [\"1234567\", \"--limit\", \"3\"],\n  \"expect\": { \"rowCount\": { \"min\": 1, \"max\": 3 }, \"...\": \"...\" }\n}\n```\n\n**不要写成** `{ \"tid\": \"1234567\", \"limit\": 3 }`——这会被展开成 `--tid 1234567 --limit 3`，commander 把 `--tid` 当未知 flag 报错，或者 adapter 根本不认。\n\n### 种子 → 手改\n\nnamed-flag adapter（`hot` / `latest` 类）可以直接让工具生成种子：\n\n```bash\n# 1. 让 verify 先跑一遍，--write-fixture 生成种子（默认追加 --limit 3）\nopencli adapter test 1point3acres/hot --write-fixture\n\n# 2. 手改 ~/.opencli/sites/1point3acres/verify/hot.json\n#    - patterns: 加 URL / 日期 / ID 正则\n#    - notEmpty: 加核心字段（title / author / url）\n#    - rowCount: 收紧到业务合理区间\n\n# 3. 再跑 verify，fixture 吃得动就 OK\nopencli adapter test 1point3acres/hot\n```\n\npositional adapter 目前 `--write-fixture` 没法表达主语，**首份 fixture 要手写**：\n\n```bash\n# 1. 先直跑 adapter 看输出长啥样\nopencli 1point3acres thread 1173710 --limit 2 --format json | head\n\n# 2. 照着响应手写 ~/.opencli/sites/1point3acres/verify/thread.json\n#    （args 一定用数组: [\"1173710\", \"--limit\", \"2\"]）\n\n# 3. 跑 verify 核对\nopencli adapter test 1point3acres/thread\n```\n\n机器生成的种子只有 rowCount.min=1 / columns / types，挡不住字段值错位。**patterns + notEmpty 无论哪种情形都是肉写的**。\n\n---\n\n## 私人 adapter vs repo 贡献\n\n```\n~/.opencli/clis/<site>/<name>.js    # 私人\nclis/<site>/<name>.js               # repo 贡献\n```\n\n**两者在 `cli({...})` 层面完全一样**。差别只在运行入口：\n\n- 私人：写完立即可跑（`opencli <site> <name>`）\n- repo：要 `npm run build` 才被注册\n\n先在 `~/.opencli/clis/` 调通再拷贝到 `clis/`。\n","isInternal":false,"tokens":6871,"sizeBytes":21660},{"name":"api-discovery.md","path":"skills/opencli-adapter-author/references/api-discovery.md","rawUrl":"https://raw.githubusercontent.com/jackwener/OpenCLI/HEAD/skills/opencli-adapter-author/references/api-discovery.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# API Discovery\n\n**寻找目标数据 endpoint。** 按当前证据选择需要的调查手段。\n\n下面提供五种调查手段。候选需要结合真实业务结果判断；复杂协议可以参考 [`deep-recon.md`](./deep-recon.md)，无需固定流程。\n\n---\n\n## Evidence from loaded scripts and network requests\n\n```bash\nopencli browser --session research open https://example.com\nopencli browser --session research network --static\nopencli browser --session research network --detail '<returned request key>'\n```\n\n`network --static` combines captured requests with AST candidates from `fetch`, axios, XHR and related calls. It reports method, URL, parameter names, script/line and request key. `network+static` means both sources support the same route; `static` alone is an unexecuted candidate. `EXPR` marks unresolved expressions. Check `scripts[].error`, `omittedScripts`, and cache provenance (`networkSource`, `networkSavedAt`). The bounded scan covers current document scripts, without following imports or proving authentication/pagination behavior.\n\nUse the evidence to choose the next investigation. Write or edit the normal JS adapter in place and run it through the existing CLI to inspect real business results; no draft directory or activation step is required.\n\n## §0 进入 §1 之前：先看两条红线\n\n这两条不看清楚，后面的 endpoint 验证会一直在错的前提下兜圈子。\n\n### 0.1 反爬厂商 → 决定 fetch 能不能从 Node 走\n\n`opencli browser state --diagnostics --session research` 的 `anti_bot` 字段给答案；手查看 cookies 也行：\n\n| cookie / body 信号 | 厂商 | 裸 Node fetch / curl 结果 | 策略 |\n|------------------|------|-----|-----|\n| `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()` |\n| `__cf_bm` / `cf_clearance` / `__cfduid`；body 含 `Cloudflare Ray ID` / `Checking your browser` | **Cloudflare** | TLS 指纹被标记，失败 | 同上：先 browser-context probe，最终 adapter 仍按模板选 fetch 路线 |\n| `_abck` / `bm_sz` / `bm_sv` | **Akamai** | 即使带 cookie 也常被挡 | 同上 |\n| body 含 `geetest` / `gt_captcha` | **Geetest** | 滑块/拼图，程序无解 | 超出 skill 范围，放弃或 UI 策略 |\n\n**规则**：看到上面四种任一个，先不要拿**裸** Node fetch 做 endpoint 验证。先用 browser-context probe 或目标 origin 页面确认接口能通；最终 adapter 的 fetch 路线仍按 `adapter-template.md` 选，HTML 型 COOKIE adapter 继续走 Node-side fetch + `page.getCookies()`。\n\n### 0.2 跨 subdomain = CORS 默认关\n\n`jobs.51job.com` 页面 fetch `cupid.51job.com` 的 API，默认会被浏览器 CORS 预检挡住——除非目标接口回了 `Access-Control-Allow-Origin`。\n\n判断：\n\n```bash\nopencli browser --session research eval \"fetch('https://<target-subdomain>/api/...', {credentials:'include'}).then(r=>r.status).catch(e=>'cors:'+e.message)\"\n```\n\n- 返回 status 数字 → CORS 通，继续\n- 返回 `cors:...` 或 `TypeError: Failed to fetch` → 挡住了\n\n**挡住时**：不要把 `credentials:'include'` 当万能药——这只解决\"带 cookie\"，不解决\"跨 origin\"。降级路径：\n\n1. 换同 origin 的 endpoint（同一个 subdomain 下的 API 往往更宽松）\n2. 用 `opencli browser --session research open https://<target-subdomain>/`，让页面在目标 subdomain 本身打开，再 fetch 相对路径\n3. 真跨域且无替代 → 走 `§5 intercept`，从页面自身发的请求里抓响应\n\n---\n\n## §1 network 精读（首选，Pattern A / D 命中率最高）\n\n### 拿候选\n\n```bash\nopencli browser --session research network\n```\n\n默认输出是 JSON，每个候选都带：\n- `key` — 稳定引用（GraphQL 的 `operationName` 或 `METHOD host+pathname`）\n- `shape` — response body 的路径→类型映射（不含原 body，省 token）\n- `status / url / method / ct / size`\n\n静态资源 / 埋点 / 追踪默认已过滤。默认会保留 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，而不是永久丢掉被隐藏的条目。\n\n如果是冷启动，先看 `opencli browser state --diagnostics --session research` 里的 `api_candidates`：\n\n- `verdict: \"likely_data\"`：优先 replay 这条，拿 status / content-type / sample shape 填 strategy note\n- `verdict: \"maybe_data\"`：可以试，但必须人工核对字段是否是目标业务数据\n- `verdict: \"noise\"`：多半是 analytics / beacon / personalization，不要因为 XHR 数量多就判 Pattern A\n- `verdict: \"blocked\"`：401/403；先排 cookie / token / CSRF，别直接退到 selector\n\n`real_data_score` 是证据，不是自动 strategy。最终仍要在 strategy note 里写 replay 结果和降级理由。\n\n### 按 shape 初筛\n\n挑 `key` 里含业务词（`list / detail / Timeline / User / Tweets / Quote`）的优先看 `shape`：\n\n- `$.data` 是 `object` 且下面出现 `array(N)` / `total` / `page` → 基本是它\n- 路径里出现 `nickname / avatar / title / price / tweets / items` → 就是它\n- shape 只有 `$: string` 或全是 HTML 噪音 → 下一条\n\n### 按期望字段反查（`--filter`）\n\n已经知道目标 body 该含哪些字段就直接让 CLI 把列表筛到只剩候选，不用自己 scroll 翻 shape：\n\n```bash\nopencli browser --session research network --filter author,text,likes\n```\n\n- 字段以英文逗号分隔；AND 语义，必须每个字段都作为 shape 路径的**任意一段**出现才保留（`$.data.items[0].author` 命中 `author`、`items`、`data` 都算）\n- 区分大小写（JSON key 本来就 case-sensitive）\n- 输出 envelope 新增 `filter` / `filter_dropped`，`count` 是过滤后数量\n- 0 命中不是 error，返回 `entries: []`；说明字段组合不对，换一组或去掉约束再试\n- 不要跟 `--detail` 一起用——`--detail` 按 key 取单条、`--filter` 是列表缩窄，组合会报 `invalid_args`\n- 空值 / `,,,` → `invalid_filter` 结构化错误\n- capture 依然按全量持久化，后续 `--detail <key>` 能找到被过滤掉的条目\n\n### 拉完整 body\n\n候选定了再拉完整 body（by key，不是 index — 数组顺序会随每次 capture 变）：\n\n```bash\nopencli browser --session research network --detail <key>\n```\n\ncapture 会持久化到 `~/.opencli/cache/browser-network/<session>.json`（默认 TTL 24h），所以 `--detail` 即使跨多条其他命令也还在。\n\n`--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——这说明请求合同仍不完整。\n\n这也意味着私有页面的 response 可能落在本地 cache。侦察结束要删除相关 session capture 并释放 browser session；不要依赖 24h TTL 代替清理。\n\n### 关键 request headers\n\n先用 `browser network --detail <key>` 看脱敏后的 request headers / body shape；不要打印或复制 credential 原值。旧 capture provider 若没有返回 `request`，再去 DevTools Network 面板核字段名，或用页面自然动作重新 capture，不能用 `browser eval` 猜造一份缺 header/body 的 URL-only 请求：\n\n| 看到 | 含义 | 对应策略 |\n|------|------|---------|\n| 只有 `Cookie` | 登录态靠 cookie | `Strategy.COOKIE` |\n| `Authorization: Bearer xxx` | token 鉴权 | 先找 token 来源（localStorage / cookie / bundle 硬编码） |\n| `X-Csrf-Token: xxx` 同时存在 cookie 里 | CSRF 防护 | `Strategy.COOKIE`，从 cookie 读 ct0 类字段拼头 |\n| `X-Workspace-Id / X-Tenant-Id` | 多租户业务头 | 先调 `/workspaces` 拿 ID，缓存下来 |\n| 啥自定义头都没有 | 匿名接口 | `Strategy.PUBLIC` |\n\n### 触发懒加载接口\n\n默认页加载完后滚动 / 点击才会出的接口不在首屏 network 里。需要：\n\n```bash\n# 滚到底（虚拟列表）\nopencli browser --session research eval \"window.scrollTo(0, document.body.scrollHeight)\"\nopencli browser --session research wait time 2\nopencli browser --session research network\n\n# 点某个按钮\nopencli browser --session research click <N>\nopencli browser --session research wait time 2\nopencli browser --session research network\n```\n\n---\n\n## §2 `__INITIAL_STATE__` / inline HTML（Pattern B）\n\n首屏数据常挂在这几个全局变量上：\n\n```bash\nopencli browser --session research eval \"Object.keys(window).filter(k=>k.startsWith('__'))\"\n```\n\n命中的常见名：\n\n| 全局 | 框架 |\n|------|-----|\n| `__NEXT_DATA__` | Next.js |\n| `__NUXT__` | Nuxt.js |\n| `__INITIAL_STATE__` | 自定义 Vue / React SSR |\n| `__PRELOADED_STATE__` | Redux SSR |\n| `__REMIX_CONTEXT__` | Remix |\n\n取数据：\n\n```bash\nopencli browser --session research eval \"JSON.stringify(window.__NEXT_DATA__).slice(0, 3000)\"\n```\n\n**关键**：inline state 只覆盖首屏的一部分（通常是 SEO 相关字段）。分页 / 评论 / 懒加载还是得回 §1 抓 API。\n\n把首屏 state 当作 adapter 的兜底数据源：公开访问时 state 里有 → 直接 parse；数据更新快 / 分页 → 回到 API。\n\n---\n\n## §3 JS bundle / script src 搜索（Pattern C，也是 A/D 的降级）\n\n### 扫 script src\n\n```bash\nopencli browser --session research 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))\"\n```\n\n看结果里的 hostname：\n\n- 明显像 API 的域名（`api.xxx / push.xxx / data.xxx / gateway.xxx`）→ 直接去试\n- 主 bundle（`main.js / app.js / index.xxx.js`）→ 继续下一步下载 bundle 搜 baseURL\n\n### 搜 bundle 里的 baseURL\n\n```bash\nopencli browser --session research 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})()\"\n```\n\n命中 `baseURL:\"https://api.foo.com\"` 直接拿 host 拼 endpoint。\n\n### 用 jsluice 扩大候选面（可选）\n\n手工搜 `baseURL` 只适合小 bundle。站点脚本多、压缩重或 endpoint 通过 `fetch` / XHR / 字符串拼接生成时，可以把**已经加载的脚本文本**通过 stdin 交给本机可选的 [jsluice](https://github.com/BishopFox/jsluice) 做语法感知扫描。\n\n```bash\n# bundle 只短暂落 /tmp；扫描后删除\njsluice urls < /tmp/example-bundle.js\n```\n\n边界：jsluice 输出是 candidate，不是 contract。`EXPR` 表示动态值未知；扫描无法证明 token、签名、CORS、权限、分页、字段语义或副作用。不要把命中 URL 直接写进 adapter，更不要把扫描到的疑似 secret 原值保存到 trace/site memory。\n\n每个候选至少记录：来源 bundle + 代码位置、method/path、触发它的可见动作、动态 network 是否发生、replay status/content-type/shape、选择或拒绝原因。复杂站直接转 [`deep-recon.md`](./deep-recon.md) 的 evidence ledger。\n\n### 直接试候选 endpoint\n\n像 eastmoney 这种经验 endpoint 可以直接喂：\n\n```bash\nopencli browser --session research 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())\"\n```\n\n200 只是 transport 成功。至少换一个输入再试，并核 content-type、目标 identity、非空 shape、分页和可见页面值；写入或复杂私有协议转 `deep-recon.md`，不能“数据看起来像”就认。\n\n### URL 后缀探测\n\n有些站直接在 URL 加 `.json` 就是 REST：\n\n- `https://www.reddit.com/r/rust.json` — Reddit 全覆盖\n- `https://xueqiu.com/S/SH600000.json` — 雪球部分页\n\n```bash\n# 当前页加 .json 试\nopencli browser --session research eval \"fetch(location.pathname.replace(/\\\\/$/,'')+'.json').then(r=>r.ok?r.json():'no')\"\n```\n\n---\n\n## §4 Token / CSRF 来源排查（Pattern D）\n\n已经在 network 里看到请求带自定义头，怎么拿到那个值：\n\n### Cookie 里\n\n```bash\nopencli browser --session research eval \"document.cookie.split('; ').map(x=>x.slice(0,x.indexOf('='))).filter(Boolean)\"\n```\n\n常见 token cookie 名：`ct0`（Twitter CSRF）、`xq_a_token`（雪球）、`SESSDATA`（B 站）、`_csrf / csrfToken`（通用）。\n\n**`document.cookie` 只能看到 non-HttpOnly 的 cookie。** 上面那条命令侦察阶段够用，真写 adapter 时 auth 经常是 HttpOnly，一定要用 `page.getCookies(...)` 从 CDP cookie jar 拿——见 `adapter-template.md` 的 \"COOKIE adapter 骨架\"。\n\n论坛 / BBS 引擎（Discuz!X / phpBB / vBulletin）还多一坑：auth cookie 设在**根域** `.example.com`（不是 `www.example.com`），且 HttpOnly。要查 `{ domain: '.<root>' }` **和** `{ domain: 'www.<root>' }` 两次，否则 adapter 在有 cookie 的前提下仍然 401。\n\n### localStorage / sessionStorage 里\n\n```bash\nopencli browser --session research eval \"Object.keys(localStorage).filter(k=>/token|auth|jwt|bearer|csrf/i.test(k))\"\n```\n\n先只列 key 名，找 `token / auth / jwt / bearer / csrf`。只有选定 production auth source 后才在页面内使用对应值；不要把值打印进聊天、trace、shell history 或 site memory。\n\n### Bundle 硬编码\n\n有些站的 Bearer 是全站一个常量（Twitter 的匿名 Bearer）。在 bundle 里搜：\n\n```bash\nopencli browser --session research 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)}})()\"\n```\n\n只返回数量/位置，不返回 token 原值。即使 bundle 中是公共匿名 Bearer，也先确认它是否是预期公开合同；不要复制未知 credential-shaped string。\n\n### 调用页面 runtime 让站点自己生成请求（只读、最后手段）\n\nVue + Pinia / Redux / React Context 有时能调用页面自己的只读 store method，让站点 runtime 自己生成签名和请求：\n\n```bash\n# Pinia\nopencli browser --session research eval \"typeof __pinia !== 'undefined' ? Object.keys(__pinia.state.value) : 'no pinia'\"\n\n# 只调用已证明是 read-only 的 store action（每个站点具体 action 名要查）\nopencli browser --session research eval \"window.__pinia.state.value.someStore.someMethod({...})\"\n```\n\n这不是“绕签名”，也不是 direct API contract：它仍依赖页面 controller/runtime，production strategy 通常是 `INTERCEPT`。只有动作语义被可见 UI 和动态请求证明为 read-only 才能在侦察中调用。未知 effect 或 write action 禁止自动调用；写入只观察用户明确授权的一次自然操作，按 `deep-recon.md` 处理。\n\n---\n\n## §5 让页面自然发请求并截获 response（最后降级）\n\n所有手段都试过还拿不到请求签名时，让页面自己自然发请求，adapter 用现有 Browser Bridge capture/interceptor 读取响应。优先 CDP network capture；只有已存在站点实现依赖 XHR interceptor 时才复用它，不要再写页面内 fetch/XHR monkey patch。\n\n```javascript\n// func 里：capture 必须先于触发动作安装，并先 drain stale entries\nawait page.startNetworkCapture('/api/foo');\nawait page.readNetworkCapture();\nawait page.goto('https://xxx.com/trigger-page');\n// 等页面自己发请求，再读取所有相关完整 response\nconst entries = await page.readNetworkCapture();\n```\n\ncapture queue 可能是破坏性 drain：过滤 relevant URL 后，只要看到 bodyless/truncated relevant entry 就拒绝 partial；多 response 要按业务 identity 合并，不能只取最后一个。分页、缓存和 no-partial 规则见 `deep-recon.md`。\n\n代价是要等页面真的触发请求，慢且依赖内部合同。只在 §1-4 都不行时用。\n\n---\n\n## 诊断不出来怎么办\n\n按这个顺序试到命中：\n\n```\n§1 network ──→ 命中？yes → 走\n      │        no\n      ↓\n§2 state  ──→ 命中？yes → 走\n      │        no\n      ↓\n§3 bundle ──→ 命中？yes → 走\n      │        no\n      ↓\n§4 token  ──→ 401 解除？yes → 走\n      │        no\n      ↓\n§5 intercept → 让页面自己发\n```\n\n**四条都命不中的站（罕见）**：多半是视觉化渲染（canvas / webgl），数据不以 HTTP/JSON 形式存在。这种放弃或换源。\n","isInternal":false,"tokens":5459,"sizeBytes":17413},{"name":"coverage-matrix.md","path":"skills/opencli-adapter-author/references/coverage-matrix.md","rawUrl":"https://raw.githubusercontent.com/jackwener/OpenCLI/HEAD/skills/opencli-adapter-author/references/coverage-matrix.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# Coverage Matrix\n\nskill 明确承诺能搞定什么、搞不定什么。动手前先看一眼这张表，判断目标站落在哪一格。\n\n**状态标记**：\n- ✅ **已验证**：有可跑通的真实 adapter / dry-run 证据\n- 🟡 **已列招但未硬跑**：文档把方法写全了，但这一版没拿真实站点跑过；第一次遇到时按文档走，踩坑回来补 `site-memory`\n- ❌ **不支持**：skill 明确不碰，走绕开方案\n\n---\n\n## 支持（skill 里有对应的招）\n\n| 维度 | 支持 | 状态 | 走哪节 |\n|------|------|------|-------|\n| 页面形态 | 列表页 / 排行页 | ✅ | `adapter-template.md`（convertible.js / rank.js 类） |\n| | 详情页（单对象） | ✅ | `adapter-template.md`（stock.js / holders.js 类） |\n| | 时间序列（K 线 / 分钟线） | ✅ | `adapter-template.md`（kline.js） |\n| | 嵌套列表（列表里含列表） | 🟡 | `adapter-template.md` + `output-design.md` 打平规则 |\n| 站点类型 | SPA（React/Vue，JSON XHR） | ✅ | `site-recon.md` Pattern A + `api-discovery.md` §network |\n| | SSR（HTML with inline data） | 🟡 | `site-recon.md` Pattern B + `api-discovery.md` §state |\n| | JSONP / push/script[src] | ✅ | `site-recon.md` Pattern C + `api-discovery.md` §bundle（eastmoney / tonghuashun 已覆盖） |\n| | SPA + 独立 BFF domain | 🟡 | `api-discovery.md` §bundle §suffix |\n| Strategy（详见 `strategy-selection.md`） | 裸 `fetch()` 拿到 | ✅ | `PUBLIC_API`（一方文档化接口，最稳：fixes/adapter-year=1.18） |\n| | cookie 透传 | ✅ | `COOKIE_API`（官方 web 接口 + 用户登录态，fixes/adapter-year=2.01） |\n| | publish / upload / click / 表单 | ✅ | `UI_SELECTOR`（DOM 的 a11y / semantic 也是契约，fixes/adapter-year=1.92） |\n| | hydration state / inline JSON | 🟡 | `DOM_STATE`（fixes/adapter-year=0.91 小样本 N=11，按 UI_SELECTOR 同档） |\n| | page-context fetch（CORS / same-origin runtime） | 🟡 | `PAGE_FETCH`（无契约内部 endpoint，fixes/adapter-year=8.41，必须正向论证） |\n| | 触发 UI 拦截响应 | 🟡 | `INTERCEPT`（无契约，fixes/adapter-year=8.69，必须正向论证） |\n| 字段形态 | 自解释（`title / price / current`） | ✅ | 直接映射 |\n| | 已登记代号 | ✅ | `field-conventions.md` 查表 |\n| | 未登记代号 | 🟡 | `field-decode-playbook.md` 排序键对比法 |\n| | 嵌套路径 `data.diff[].f2` | ✅ | `field-decode-playbook.md` §3 结构差分 |\n| 分页 | `page` / `pn` / `pageNum` | ✅ | `adapter-template.md` 例子 |\n| | `cursor` / `next_cursor` | ✅ | adapter 里 while 循环，收集到 limit |\n| | `offset` / `start` | ✅ | 同上 |\n| 响应格式 | JSON | ✅ | 默认 |\n| | JSONP（`?callback=`） | ✅ | 去掉 callback 参数直接请求，返回仍是 JSON 字符串包裹 |\n| | CSV 字符串（eastmoney kline） | ✅ | `response.split(',')` 按列序解 |\n| | HTML 表格（tonghuashun） | 🟡 | `page.evaluate` 里用 `querySelectorAll` 拿 |\n\n🟡 的维度意思：方法在文档里，但这一版没拿真实站点跑过端到端。第一次遇到时按文档走，遇到和文档不一致的地方记到 `~/.opencli/sites/<site>/notes.md`，下一次再打开就是 ✅。\n\n---\n\n## 不支持（承认搞不定，skill 不教）\n\n| 场景 | 原因 | 绕开方案 |\n|------|------|---------|\n| 首次登录获取 token | 需要用户真实输入账密 | 让用户先在 browser session 里手动登录，adapter 拿 cookie 就行 |\n| 复杂 anti-bot（captcha） | 反爬拒流量 | 放弃，换同数据的其他站点 |\n| 加密字段（客户端 crypto） | 要破解 bundle 逆向 | 换 endpoint；实在不行发请求到 intercept 让页面自己解 |\n| WebSocket 流式数据 | 状态管理复杂 | 退回 HTTP 轮询版本（多数站都有） |\n| 私有 binary 协议 | 非 HTTP/WS | 不在 skill 范围 |\n| 视觉化图表（只有 canvas） | 数据埋在渲染层 | 找对应 API；找不到就放弃 |\n| 签名算法涉及静态密钥 | 需要长期跟踪 bundle 变更 | 走 `Strategy.INTERCEPT`，让页面自己发带签名的请求 |\n| 频控 / rate-limit 严格 | 多发几次就 429 | adapter 层控并发 + 加退避；但 skill 不解决 |\n\n---\n\n## 决定用不用 skill 的快速自测\n\n三个问题：\n\n1. **数据能在浏览器里看到吗？** 看不到（登录墙 / 付费墙）→ 先解决鉴权，再回来\n2. **数据来源是 HTTP/JSON/HTML 之一吗？** 不是（binary / 加密）→ 不在 skill 范围\n3. **需不需要每秒推送？** 需要 → 找同数据 HTTP 接口；没有就放弃\n\n三个都 yes 再往下走。\n\n---\n\n## 本轮硬验证 / 当前证据\n\n| 类型 | 证据 adapter | 覆盖维度 |\n|------|-------------|---------|\n| PUBLIC + 自解释字段 + SPA | `~/.opencli/clis/coingecko/top.js`（本轮 dry run） | `Strategy.PUBLIC` + REST JSON + 自解释字段 + 列表页 |\n| COOKIE + 代号字段 + JSONP | `clis/eastmoney/*.js` × 13（PR #1091 merged） | `Strategy.PUBLIC`（匿名 `ut=`）+ JSONP + f-代号 + 列表/详情/K 线 |\n| COOKIE + SPA | `clis/bilibili/*.js` × 10+（已存在） | `Strategy.COOKIE + browser:true` + wbi 签名 |\n\n**本 PR 新增的 skill 还未硬验证的维度**：🟡 行，尤其 SSR Pattern B + Bearer/CSRF + 未登记代号解码。这些放到第一批真实用户 adapter 写作中打磨，skill 文档先落，踩坑回来补 `site-memory`。合 PR 之前先拿 coingecko 跑第二轮（带着第一轮写出的 `~/.opencli/sites/coingecko/`）验证 memory 命中 → endpoint re-verify → 字段抽查 → 写 adapter 这条回路。\n","isInternal":false,"tokens":2019,"sizeBytes":5600},{"name":"deep-recon.md","path":"skills/opencli-adapter-author/references/deep-recon.md","rawUrl":"https://raw.githubusercontent.com/jackwener/OpenCLI/HEAD/skills/opencli-adapter-author/references/deep-recon.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# Deep Recon: evidence-first discovery for undocumented sites\n\nUse 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.\n\n## 1. Freeze the command surface and mutation boundary\n\nBefore browsing, make an intent matrix:\n\n| Command | User intent | Read/write | Completeness | Exact target | Allowed live action |\n|---|---|---|---|---|---|\n| `search` | find matching entities | read | exact limit or upstream exhaustion | query | safe replay after proof |\n| `send` | create external side effect | write | one confirmed mutation | recipient + content | only with explicit authorization |\n\nGroup 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.\n\nWrite 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.\n\n## 2. Build an evidence ledger, not a traffic dump\n\nTrack one row per candidate:\n\n| Intent | Visible action | Source | Method/path | Effect | Auth/signing | Response shape | Replay result | Decision |\n|---|---|---|---|---|---|---|---|---|---|\n\n`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.\n\nNever paste credentials or response bodies into the ledger. Store structural facts: status, content type, arity/paths, row counts, cursor behavior, and source location.\n\n## 3. Triangulate three evidence planes\n\n1. **Visible truth**: page rows, counts, URLs, identity, and semantic controls.\n2. **Dynamic truth**: DevTools requests caused by one controlled action.\n3. **Static candidates**: loaded bundles scanned manually or with syntax-aware tools such as jsluice.\n\nDynamic 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.\n\nDo 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.\n\njsluice 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.\n\n## 4. Attribute requests with causal diffs\n\nFor each intent:\n\n1. establish a network/state baseline;\n2. perform exactly one visible action;\n3. inspect only newly added requests/state;\n4. repeat with one changed input;\n5. run a negative control when ambiguity remains.\n\nThe 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.\n\nFor 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.\n\n## 5. Rank candidates before decoding\n\nPrefer a candidate that:\n\n- contains the user-visible target data or mutation identity;\n- works across two inputs without copying ephemeral values;\n- has a safe, explainable auth source;\n- paginates to the requested completeness;\n- returns typed, distinguishable auth/HTTP/upstream failures;\n- is cheaper to maintain than the strongest UI/DOM alternative.\n\nPenalize 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.\n\n## 6. Pass the contract gate\n\nA read contract must prove all of these:\n\n1. **Occurrence**: the page naturally sends it, or it is documented/public.\n2. **Reproducibility**: a safe replay works across two non-empty inputs; if replay is impossible, `INTERCEPT` preserves the page-owned request.\n3. **Identity**: returned rows match visible target identity, not adjacent recommendations/ads/sidebars.\n4. **Completeness**: pagination reaches exact limit or proven upstream exhaustion.\n5. **Auth boundary**: cookies/CSRF/origin/runtime requirements are explicit and do not leak secrets.\n6. **Failure semantics**: auth, HTTP, malformed/truncated body, repeated cursor/page, timeout, and partial data fail typed.\n\nReplay 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.\n\nA direct API-backed write contract additionally must prove:\n\n1. target identity is deterministically bound in the request;\n2. auth/CSRF/signing can be reproduced without fabricating or exfiltrating runtime secrets;\n3. idempotency or duplicate-risk semantics are known;\n4. pre-write failure is distinguishable from post-write uncertainty;\n5. success is verified independently of the edited control/request echo;\n6. retries are disabled after an uncertain write unless the user first checks state.\n\n“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).\n\n## 7. Handle capture, pagination, and cache explicitly\n\nBrowser capture queues may be destructive drains. Before relying on them:\n\n- install capture before the action and drain stale entries;\n- cache the raw selected capture before applying display-only MIME, static-resource, or shape filters;\n- allow in-flight responses to settle;\n- treat bodyless or truncated relevant entries as possible data loss;\n- inspect non-JSON structured streams with request method, safely redacted headers, body shape, and size/truncation metadata;\n- merge all relevant completed responses in the action window;\n- identify pages/cursors by content, not arrival order alone;\n- deduplicate by stable entity ID;\n- reject repeated pages/cursors and page-cap exhaustion rather than return accumulated partial rows.\n\nNever 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.\n\nA 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.\n\n## 8. Choose strategy per command\n\nUse the repository strategy ladder after the contract gate. One site may legitimately mix strategies:\n\n- `PUBLIC_API` / `COOKIE_API` for reproducible contracts;\n- `PAGE_FETCH` for a verified same-origin read contract that must execute in page context;\n- `DOM_STATE` for stable hydration or cached state;\n- `INTERCEPT` when page-owned signing is unavoidable for reads;\n- `UI_SELECTOR` for user-visible writes only when that surface is allowed and safer;\n- no command when the requested contract cannot be proved.\n\nPreserve 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.\n\n## 9. Decode and test against invariants\n\nFor 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.\n\nValidation set:\n\n- two non-empty inputs plus one true empty result;\n- more than one page when pagination exists;\n- exact limit and upstream exhaustion;\n- cache/repeat behavior;\n- auth, HTTP, JSON/shape, truncated/bodyless capture, timeout, repeated cursor/page, and page cap;\n- every public column and target identity.\n\nTests 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.\n\n## 10. Deliver a clean, reviewable artifact set\n\nLeave durable outputs at the right layer:\n\n- **adapter**: executable contract, shared helpers, typed errors;\n- **tests**: production-path behavior and structural invariants;\n- **docs**: command surface, limits, auth, uncertainty, examples;\n- **site memory**: verified routes, triggers, fallbacks, rejected strategies, pitfalls, and verification date;\n- **recon conclusion**: evidence ledger without secrets or private bodies.\n\nBefore PR:\n\n1. regenerate manifest and validate the site;\n2. run focused/site tests, typecheck, build, docs coverage, typed-error and silent-column gates;\n3. scan the diff for live addresses, account IDs, tokens, message IDs, bodies, attachments, raw captures, and temporary dumps;\n4. delete raw capture/cache artifacts and release browser sessions;\n5. obtain independent exact-head review for writes, auth/signing, positional schemas, or no-partial pagination.\n\nRecord 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.\n","isInternal":false,"tokens":2139,"sizeBytes":10585},{"name":"field-conventions.md","path":"skills/opencli-adapter-author/references/field-conventions.md","rawUrl":"https://raw.githubusercontent.com/jackwener/OpenCLI/HEAD/skills/opencli-adapter-author/references/field-conventions.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# Field Conventions\n\n响应字段代号在主要站点上的解码表。写 adapter 前先查一遍，查不到再用 SKILL.md 里 Step 2 的实测法推出来，推完补到本表。\n\n---\n\n## eastmoney（东方财富）\n\n域名：`push2.eastmoney.com` / `push2his.eastmoney.com` / `datacenter-web.eastmoney.com` / `np-listapi.eastmoney.com` / `np-anotice-stock.eastmoney.com`\n\n### 通用行情字段（`push2` clist/ulist/stock）\n\n| 代号 | 含义 | 备注 |\n|------|------|------|\n| `f1`  | 精度位数 | `fltt=2` 后可忽略 |\n| `f2`  | 最新价 | 要 `fltt=2` 才是格式化浮点 |\n| `f3`  | 涨跌幅 % | 同上 |\n| `f4`  | 涨跌额 | 同上 |\n| `f5`  | 成交量（手） | |\n| `f6`  | 成交额（元） | 接口默认是\"元\"，个别接口是\"万元\"，看 `f152` |\n| `f7`  | 振幅 % | |\n| `f8`  | 换手率 % | |\n| `f9`  | 市盈率（动态） | |\n| `f10` | 量比 | |\n| `f12` | 代码 | 股票/债券/ETF/指数统一 |\n| `f13` | market | 数字市场代号，见下表 |\n| `f14` | 名称 | |\n| `f15` | 最高 | |\n| `f16` | 最低 | |\n| `f17` | 今开 | |\n| `f18` | 昨收 | |\n| `f20` | 总市值 | |\n| `f21` | 流通市值 | |\n| `f23` | 市净率 | |\n| `f62` | 主力净流入 | **单位是\"元\"**，但有的接口返回\"万元\"，要核对 |\n| `f66` | 超大单净流入 | 同上 |\n| `f72` | 大单净流入 | |\n| `f78` | 中单净流入 | |\n| `f84` | 小单净流入 | |\n| `f100` | 所属板块 | |\n| `f152` | 精度（当 `fltt` 不传时要除以 10^f152） | 所以一律 `fltt=2` |\n\n### Convertible bond（可转债）专属\n\n| 代号 | 含义 |\n|------|------|\n| `f229` | 正股价 |\n| `f230` | 正股涨跌幅 |\n| `f232` | 正股代码 |\n| `f234` | 正股名称 |\n| `f235` | 转股价 |\n| `f236` | 转股价值 |\n| `f237` | 转股溢价率 |\n| `f238` | 剩余年限 |\n| `f239` | 到期收益率 (YTM) |\n| `f243` | 上市日期 (YYYYMMDD int) |\n\n### K-line `push2his` stock/kline/get\n\n返回形如 `data.klines: [\"2024-01-02,10.5,10.8,10.9,10.4,12345,...\"]` 的 CSV 字符串数组。用 `fields1 / fields2` 控制列。典型列序：\n\n```\ndate, open, close, high, low, volume, turnover, amplitude, changePct, changeAmt, turnoverRate\n```\n\n### Market 前缀（secid 格式 `<market>.<code>`）\n\n| 前缀 | 市场 |\n|------|------|\n| `1.`   | Shanghai (SSE) |\n| `0.`   | Shenzhen (SZSE) + Beijing (BSE) |\n| `116.` | Hong Kong (HKEX) |\n| `105.` | NASDAQ |\n| `106.` | NYSE |\n| `107.` | AMEX |\n| `100.` | 指数（HSI / SPX / DJIA / 各板块指数） |\n\n判断 symbol 是否已经是 secid 时，只在数字前缀属于上表时才认，否则视作普通 code（防止 `00700.HK` 这种 `<digits>.<alpha>` 误判）。\n\n### fs 市场/板块过滤码\n\n| 代码 | 含义 |\n|------|------|\n| `m:0+t:6,m:0+t:80,m:1+t:2,m:1+t:23,m:0+t:81+s:2048` | 沪深 A 股全集 |\n| `m:1+t:2,m:1+t:23` | 沪 A |\n| `m:0+t:6,m:0+t:80` | 深 A |\n| `m:0+t:81+s:2048` | 北证 A |\n| `m:0+t:80` | 创业板 |\n| `m:1+t:23` | 科创板 |\n| `m:116+t:3,m:116+t:4,m:116+t:1,m:116+t:2` | 港股 |\n| `m:105,m:106,m:107` | 美股 |\n| `b:MK0021` | ETF |\n| `b:MK0354` | 可转债 |\n| `m:90+t:2` | 行业板块 |\n| `m:90+t:3` | 概念板块 |\n| `m:90+t:1` | 地域板块 |\n\n### datacenter-web 报表 `reportName`\n\n| 名称 | 内容 |\n|------|------|\n| `RPT_DAILYBILLBOARD_DETAILS` | 龙虎榜 |\n| `RPT_F10_EH_FREEHOLDERS`     | 十大流通股东 |\n\n---\n\n## xueqiu（雪球）\n\n域名：`stock.xueqiu.com` / `xueqiu.com`\n\n行情 API 返回字段是人类可读，不需要词典：\n\n| 字段 | 含义 |\n|------|------|\n| `symbol` | SH600000 / 00700 / AAPL 样式 |\n| `name` | 名称 |\n| `current` | 最新价 |\n| `chg` | 涨跌额 |\n| `percent` | 涨跌幅 % |\n| `volume` | 成交量 |\n| `amount` | 成交额 |\n| `high52w / low52w` | 52 周高低 |\n| `market_capital` | 总市值 |\n| `pe_ttm` | 市盈率 TTM |\n| `pb` | 市净率 |\n\n鉴权：需要 `xq_a_token` cookie。走 `Strategy.COOKIE + browser: true`。\n\n---\n\n## bilibili\n\n域名：`api.bilibili.com` / `space.bilibili.com` / `passport.bilibili.com`\n\nB 站接口字段也大多人类可读。关键坑是 **wbi 签名**：\n\n- 凡 URL 含 `/wbi/` 的接口都需要 `w_rid + wts` 签名\n- 签名算法依赖每日轮换的 `img_key + sub_key`（从 `nav` 接口拿）\n- 平台 SDK 在 `clis/bilibili/utils.js`：`apiGet(page, path, { signed: true, params })` 自动签\n- 普通 cookie JSON 接口优先用 `page.fetchJson(url)`\n\n| 字段 | 含义 |\n|------|------|\n| `mid` | 用户 UID |\n| `aid / bvid` | 视频 ID（av 号 / bv 号） |\n| `cid` | 视频分 P ID |\n| `uname / upname` | up 主昵称 |\n| `view / danmaku / reply / favorite / coin / share / like` | 各类计数 |\n| `pubdate / ctime` | 发布/创建时间（秒级 unix） |\n\n---\n\n## tonghuashun（同花顺）\n\n域名：`q.10jqka.com.cn` / `d.10jqka.com.cn` / `data.10jqka.com.cn`\n\n同花顺多数接口返回 HTML 表格，需要 DOM 解析。JSONP 接口要设 `Referer: http://q.10jqka.com.cn/`，否则返回空。\n\n| 字段（API） | 含义 |\n|------|------|\n| `openPrice / closePrice` | 开/收盘 |\n| `zdf` | 涨跌幅 % |\n| `hsl` | 换手率 |\n| `zf`  | 振幅 |\n\n---\n\n## 字段不在词典时\n\n看 `field-decode-playbook.md`。里面是标准 SOP（排序键对比、结构差分、精度排查），10 分钟能推一条。\n\n推完一个代号补回本文件，下次直接查。\n","isInternal":false,"tokens":2265,"sizeBytes":5308},{"name":"field-decode-playbook.md","path":"skills/opencli-adapter-author/references/field-decode-playbook.md","rawUrl":"https://raw.githubusercontent.com/jackwener/OpenCLI/HEAD/skills/opencli-adapter-author/references/field-decode-playbook.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# Field Decode Playbook\n\n响应里出现 `f237 / zdf / oc5 / x4` 这种看不懂的代号时走这套流程。目标：10 分钟内搞定一条未知字段，不靠猜。\n\n适用于 `field-conventions.md` 没收录的站点、没收录的代号、或者收录了但怀疑对不上的情况。\n\n---\n\n## 决策树\n\n```\n拿到一条响应，有字段看不懂？\n├── 字段值是字符串 / 时间 / URL → 走 §1 「肉眼对网页」\n├── 字段值是数字 → 走 §2 「排序键对比法」\n├── 字段值是数组 / 对象 → 走 §3 「结构差分法」\n└── 改参数响应不动 → 走 §4 「常量 / 精度位排查」\n```\n\n---\n\n## §1 肉眼对网页（字符串/时间/URL 类）\n\n最快。打开对应网页，响应的这条记录在哪行，把响应值跟页面上的文字一一对照。\n\n```bash\n# 举例：eastmoney convertible，响应里有 f243=20231215，页面上这支债写着\"上市日期 2023-12-15\"\n# 直接判断 f243 是上市日期（YYYYMMDD int）\n```\n\n**时间字段的判别**：\n\n| 观察到的形态 | 大概率是 |\n|-------------|---------|\n| 10 位整数 `1712345678` | unix 秒 |\n| 13 位整数 `1712345678000` | unix 毫秒 |\n| 8 位整数 `20240101` | YYYYMMDD int |\n| 6 位整数 `240101` | YYMMDD int |\n| 字符串 `2024-01-01` / `2024/01/01` | ISO 日期 |\n| 字符串 `01/01/2024` | 美式日期，小心月日顺序 |\n\n**URL 字段的判别**：\n\n```bash\n# 如果是相对路径，拼 domain 回浏览器验证\nopencli browser --session research eval \"window.location.origin + '/<path>'\"\n```\n\n---\n\n## §2 排序键对比法（数字类核心手段）\n\n数字字段是最容易踩坑的一类——看起来都是小数，但可能是涨跌幅、换手率、振幅、溢价率、市盈率……单位和量级也可能带陷阱。\n\n### 流程\n\n1. **找到一个能改变排序的参数**\n\n   | 站点 | 排序参数 | 值域 |\n   |------|---------|------|\n   | eastmoney (push2 clist) | `fid` + `po` | `fid` 是字段代号，`po` 是 `0=asc / 1=desc` |\n   | xueqiu | `order_by` | `percent / volume / amount / ...` |\n   | bilibili | `order` | `pubdate / click / stow / ...` |\n   | tonghuashun | `sort` | 数字代号 |\n   | 通用 | 页面点击表头切排序，抓包看新参数 | — |\n\n2. **用两个已知含义的值各抓一份响应**\n\n   比如 eastmoney，`fid=f2`（最新价）和 `fid=f3`（涨跌幅）：\n\n   ```bash\n   opencli browser --session research eval \"fetch('<url>&fid=f2&po=1').then(r=>r.json()).then(d=>d.data.diff.slice(0,3))\"\n   opencli browser --session research eval \"fetch('<url>&fid=f3&po=1').then(r=>r.json()).then(d=>d.data.diff.slice(0,3))\"\n   ```\n\n3. **对比两组数据**：\n\n   - 第一条记录的 `symbol/name` 变了吗？没变说明这个参数无效或者白名单窄\n   - 新的第一条里，哪个字段数量级 / 正负号 / 小数位数变化最大？那个字段就是和 `fid` 对应的业务语义\n   - 单调性：按 `desc` 取前 3 条，看新旧两组里目标字段是不是都单调下降\n\n4. **用第三个参数交叉验证**\n\n   ```bash\n   opencli browser --session research eval \"fetch('<url>&fid=f6&po=1').then(r=>r.json()).then(d=>d.data.diff.slice(0,3))\"  # 成交额\n   ```\n\n   对照网页上\"成交额排行\"的前三名。对得上就认。\n\n### 实例：推 f237（可转债溢价率）\n\n```bash\n# 按价格排，拿一条观察\nopencli browser --session research 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])\"\n# 返回 {f12:'123456', f14:'XX转债', f2:180.5, f3:2.1, f236:98.5, f237:83.2, f239:-4.1}\n\n# 按 f237 排\nopencli browser --session research eval \"fetch('...&fid=f237&po=1...').then(...)\"\n# 第一条变了，f237 值变成 400+\n\n# 打开 eastmoney 可转债页，切\"溢价率\"排序，第一条的溢价率确实是 400+ —— f237 就是溢价率 %\n```\n\n---\n\n## §3 结构差分法（数组 / 嵌套对象类）\n\n响应顶层是 `{data: {diff: [...]}}` 还是 `{list: [{...}, {...}]}` 还是 `{rows: [{k:v}, ...]}`，不同接口差别大。\n\n### 流程\n\n1. **先数一次嵌套路径**\n\n   ```bash\n   opencli browser --session research eval \"fetch('<url>').then(r=>r.json()).then(j=>({keys:Object.keys(j), type:Array.isArray(j)?'array':'object'}))\"\n   ```\n\n2. **一层一层剥**\n\n   ```bash\n   opencli browser --session research eval \"fetch('<url>').then(r=>r.json()).then(j=>{const d=j.data; return {keys:Object.keys(d), sample: d[Object.keys(d)[0]]}})\"\n   ```\n\n3. **数数组长度对照 pz / pageSize**\n\n   如果请求 `pz=20` 拿回的数组正好 20，那就是结果数组；如果是 1 那可能是 pagination meta。\n\n4. **同一字段在不同条目间是否变化**\n\n   ```bash\n   # 取前三条的 keys，看哪些 key 的值在变（业务数据），哪些不变（常量/配置）\n   opencli browser --session research 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})))\"\n   ```\n\n---\n\n## §4 常量 / 精度位排查\n\n有的字段每条都一样，是精度指示器或类型标记。\n\n| 典型现象 | 通常含义 | 处理 |\n|---------|---------|------|\n| 每条值都是 `2` 或 `3` | 精度位数（小数几位） | 除以 `10^n` 还原浮点 |\n| 每条值都是 `0` 或 `1` | 涨跌方向 / 停牌标记 | 枚举，查同类 adapter |\n| 每条值都是 `6` / `8` / `80` | 市场代号 / 分类 ID | 查 `field-conventions.md` 市场表 |\n| 每条值都是空字符串 | 接口不返回但字段有占位 | 请求里去掉 / 用别的字段 |\n\n**eastmoney 精度坑**：不传 `fltt=2` 时，价格字段是 `int * 10^f152`。所以一律加 `fltt=2`，避免所有除法问题。\n\n---\n\n## §5 写完要做的事\n\n推出一个新代号后：\n\n1. **补进 `references/field-conventions.md`**：找到对应站点的表格加一行。下次直接查。\n2. **在 adapter 代码里留一条注释**：如果是实测推出来的不常见代号，写一行 `// f237 = convertible premium rate (verified 2026-04-20 against page)` 方便复核。\n3. **通过 `opencli adapter test` 验一次**：字段值能对上网页上眼见的数字。\n\n---\n\n## §6 通用坑\n\n| 坑 | 症状 | 解 |\n|----|------|-----|\n| 单位\"万\"当\"元\"用 | 成交额少 4 个零 | eastmoney `f6` 是元、`f62 / f64` 等净流入是万，核对每条接口 |\n| 百分比没 × 100 | 涨跌幅显示 0.0XX | 响应已经乘过 100 或者站点用小数 —— 看一眼网页 |\n| 股票代码被截 | `600000` 变 `60000` | 某些接口返回 int，前导 0 掉了。永远用 `String(code).padStart(6, '0')` 处理 A 股代码 |\n| 时间区无前导 0 | `2024-1-1` 排序坏 | parse 后用 ISO 回写 |\n| JSON 里数字是字符串 | 无法 `.toFixed()` | `Number(x)` 显式转 |\n\n---\n\n## §7 真推不出来\n\n三条兜底：\n\n1. **找同站点已有的 adapter** — `ls clis/<site>/` 翻文件，别站点邻居的 adapter 通常共享字段命名\n2. **找开源实现** — GitHub 搜 `<域名> fields` 或 `<域名> api`，往往有 Python / Go 库已经解码过\n3. **灰度**：把这条字段先输出成 raw，命令仍可用，下一版再语义化\n\n不要猜。猜错了后面验证不到、线上跑出来用户看到乱码也会误导决策。\n","isInternal":false,"tokens":2712,"sizeBytes":7304},{"name":"jsdom-fixture-pattern.md","path":"skills/opencli-adapter-author/references/jsdom-fixture-pattern.md","rawUrl":"https://raw.githubusercontent.com/jackwener/OpenCLI/HEAD/skills/opencli-adapter-author/references/jsdom-fixture-pattern.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# JSDOM-against-frozen-fixture pattern (for in-browser DOM extractors)\n\n## When this pattern applies\n\nYou're writing an adapter where the data extraction happens **inside the\nlive browser** via `page.evaluate(...)` — not in Node-side post-processing.\nTypical signal: the adapter has a function literal stringified into\n`page.evaluate('(' + fn.toString() + ')()')`, walking `document.querySelector`\nand other DOM APIs.\n\nThese extractors are invisible to mocked `page.evaluate` unit tests — those\ntests feed pre-baked results to the func, so the real DOM walk never runs.\nPR #1312 found two such silent in-browser bugs in dianping that only\nsurfaced on live verify:\n\n1. shop title fallback split on ASCII `[]` while the page renders\n   full-width `【】`, so `name` was always empty.\n2. `headText.replace(/\\s+/g, ' ')` collapsed rating \"4.8\" with reviews\n   \"21241条\", and a head-wide `/\\d+条/` regex captured `4.821241` → 5.\n\nIf either category looks plausible for your site, freeze a representative\nHTML snapshot and replay it through JSDOM in a unit test.\n\n## File layout\n\n- Test file: `clis/<site>/<site>.test.js` (alongside the adapter file)\n- Fixture file: `clis/<site>/__fixtures__/<command>.html`\n\nReference implementation: `clis/dianping/__fixtures__/{shop,search}.html`\n(see PR #1313 for the original test + PR #1318 for the whitespace-strip\nfollow-up that this doc grew out of).\n\n## Creating the HTML fixture\n\nThe whole point is to commit a **representative snapshot of the live\npage's DOM** — so the JSDOM unit test exercises the real selector paths\nthe live extractor walks.\n\n### Mandatory steps (in order)\n\n1. **Capture** the page's HTML from a live verify run:\n   ```bash\n   opencli browser --session research open https://www.example.com/<page>\n   # In another shell, dump page.content():\n   opencli browser --session research eval 'document.documentElement.outerHTML' \\\n     > /tmp/raw-<command>.html\n   ```\n\n2. **Strip noise blocks** that JSDOM doesn't need and that change every\n   page load (so committed fixtures wouldn't survive a re-capture diff\n   anyway):\n   - All `<script>...</script>` content\n   - All `<style>...</style>` content\n   - All `<iframe>...</iframe>` content\n   - All `<!-- ... -->` HTML comments\n   - All `<link rel=\"preload\" ...>` / tracking pixels\n\n3. **Replace `<img src=\"...\">`** with a placeholder\n   (`src=\"placeholder.png\"`) — real CDN URLs leak account-scoped tokens\n   and are noise.\n\n4. **Trim to the minimum subtree** that exercises the extractor and\n   triggers the bug you're guarding against. For dianping shop, that\n   was `.shop-head` + `.desc-info` + `.review-title`; for search,\n   3 of 15 result `<li>` cards (rank 1, 2, 3).\n\n5. **MANDATORY whitespace normalization step** — strip all\n   whitespace-only lines:\n   ```bash\n   awk 'NF>0' /tmp/raw-<command>.html > clis/<site>/__fixtures__/<command>.html\n   ```\n   JSDOM's HTML parser is whitespace-tolerant; blank lines have zero\n   semantic effect on the test, but they bloat the committed diff and\n   obscure the meaningful DOM subtree from reviewers.\n   **Skipping this step is the most common silent quality regression\n   in fixture creation.** PR #1318 cleaned 239 leftover blank lines\n   from dianping's two fixtures (84.6% / 54.8% of file content) and\n   the JSDOM tests still passed unchanged.\n\n6. **Commit** at `clis/<site>/__fixtures__/<command>.html`.\n\n### Anti-patterns to avoid\n\n- ❌ \"Strip script/style content\" but leave the surrounding newlines.\n  Removing inline script body without collapsing the now-blank line is\n  the source of the noise — Step 5 exists specifically to clean this up.\n- ❌ Trim to minimum subtree, skip Step 5. The fixture works for the\n  test but reviewers see hundreds of blank lines.\n- ❌ Pretty-print the **mega-line** (e.g. `<div>...</div>` collapsed\n  onto one giant line by the source page). Some bugs depend on\n  text-node adjacency without intervening whitespace (e.g. `4.8` and\n  `21241条` immediately adjacent → `headText` fusion bug). Pretty-print\n  inserts whitespace that masks the very condition you're testing for.\n  Step 5 only deletes empty lines — never re-flow content.\n- ❌ Re-capture from live and overwrite the committed fixture without\n  re-running Steps 2-5. The fixture is a **frozen** snapshot; if the\n  page layout changes, that's a separate decision (update test\n  expectations + re-trim + re-strip).\n\n## Writing the JSDOM unit test\n\n```js\nimport { describe, it, expect, beforeEach, afterEach } from 'vitest';\nimport { JSDOM } from 'jsdom';\nimport { readFileSync } from 'node:fs';\nimport { join, dirname } from 'node:path';\nimport { fileURLToPath } from 'node:url';\nimport { extractShopFields } from './shop.js';\n\nconst __dirname = dirname(fileURLToPath(import.meta.url));\nconst SHOP_FIXTURE = readFileSync(join(__dirname, '__fixtures__/shop.html'), 'utf8');\n\ndescribe('shop adapter — extractor against frozen HTML fixture', () => {\n    let originalDocument;\n    let originalLocation;\n\n    beforeEach(() => {\n        originalDocument = globalThis.document;\n        originalLocation = globalThis.location;\n    });\n\n    afterEach(() => {\n        globalThis.document = originalDocument;\n        globalThis.location = originalLocation;\n    });\n\n    function loadFixture(html, url) {\n        const dom = new JSDOM(html, { url });\n        globalThis.document = dom.window.document;\n        globalThis.location = dom.window.location;\n        return dom;\n    }\n\n    it('extracts the canonical fields and avoids known silent bugs', () => {\n        loadFixture(SHOP_FIXTURE, 'https://www.example.com/shop/123');\n\n        const data = extractShopFields();\n\n        expect(data.ok).toBe(true);\n        expect(data.name).toBe('...');\n        // Add explicit regression guards for each known silent bug.\n        expect(data.reviewsRaw).toBe('...');  // not the fused \"<rating><reviews>\" form\n    });\n});\n```\n\nFor this to work, the adapter's extractor must be a **top-level\nfunction** that uses bare `document` / `location` (not `window.document`),\nso the same code is exercised by:\n\n- live browser: injected via `${extractFn.toString()}` into\n  `page.evaluate`\n- JSDOM unit test: with `globalThis.document` swapped\n\nIf your adapter currently has the extractor as an IIFE inside a\ntemplate literal, refactor to a top-level `export function` first.\nReference: `clis/dianping/{shop,search}.js` extracts `extractShopFields()`\nand `extractSearchRows()` with bare `document`/`location`.\n\n## Reverse-validation (mandatory before claiming the test catches the bug)\n\nA test that \"passes 18/18\" doesn't prove it would have caught the original\nbug — only that it agrees with the current implementation. Before\ntrusting a regression guard:\n\n1. Make a backup of the adapter source.\n2. Reintroduce the buggy variant of the relevant extractor.\n3. Run the test. It MUST fail with an assertion that points at the\n   silent bug.\n4. Restore from backup.\n\nFor dianping bug #2 (rating/reviews fusion):\n```js\n// BUGGY VARIANT — replaces the .reviews selector path\nconst buggyMatch = headText.match(/(\\d+)条/);\nlet reviewsRaw = buggyMatch ? buggyMatch[0] : '';\n```\n\nIf after Step 5 of fixture creation the test still fails on this buggy\nvariant with `expected '21241条' to be '21241条'` actually receiving\n`'821241条'` (the fused digits), the regression guard is intact. If the\ntest still passes with the buggy variant, the fixture is too stripped /\nnormalization went too far / the assertion is too loose — go back and\ntighten.\n\nThis is the same discipline as `--write-fixture` Step 10 in the main\nrunbook (verify fixture catches what it should), applied to the JSDOM\nHTML fixture instead of the response JSON fixture.\n\n## See also\n\n- `references/adapter-template.md` — basic adapter file structure\n- `references/output-design.md` — column naming for the post-extract mapping\n- `references/success-rate-pitfalls.md` — broader \"verify can pass while\n  data is silently wrong\" catalog; the mocked-page.evaluate gap that\n  motivates this whole pattern is one entry there\n","isInternal":false,"tokens":1965,"sizeBytes":8049},{"name":"output-design.md","path":"skills/opencli-adapter-author/references/output-design.md","rawUrl":"https://raw.githubusercontent.com/jackwener/OpenCLI/HEAD/skills/opencli-adapter-author/references/output-design.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# Output Design\n\nadapter 的 `columns` 不是随便列。要让下游（用户、其他 adapter 合并、agent 后续分析）都能直接读。\n\n---\n\n## 核心约定\n\n### 1. 命名\n\n**camelCase，全英文**：\n\n| 好 | 差 |\n|----|---|\n| `marketCap` | `market_cap` / `市值` / `MarketCap` |\n| `change24hPct` | `change_percentage_24h` / `涨跌幅24h` / `changePct_24h` |\n| `bondCode` | `bond_code` / `BOND_CODE` |\n| `pubTime` | `publish_time` / `pubdate` |\n\n原则：\n- 缩写：`pct`（百分比）/ `pe`（市盈率）/ `pb`（市净率）/ `ytm`（到期收益率）/ `id`\n- 时间后缀：`Time`（具体时刻）/ `Date`（日期）/ `Ts`（unix 秒）\n- 百分比一律 `Pct` 结尾，数值是\"已乘 100\"形式（`2.5` 表示 2.5%，不是 `0.025`）\n- 数量后缀：`Count`（整数计数）/ `Total`（累计值）\n\n### 2. 类型\n\n| 字段类 | JS 类型 | 格式 |\n|--------|---------|------|\n| 价格 / 金额 | `number` | 原始小数，别除 1000 别取整 |\n| 百分比 | `number` | 已 × 100（`2.5` = 2.5%） |\n| 计数 / rank | `number` | 正整数 |\n| 代码 / id / symbol | `string` | 股票代码 `'600000'` 保留前导 0 |\n| 名称 / 标题 | `string` | 去首尾空白 |\n| 时间 | `string` ISO（`'2024-01-15T10:30:00Z'`）或 `number` unix 秒 | 不要本地字符串 `'2024/1/15'` |\n| 布尔 | `boolean` | 不用 `0/1` |\n| URL | `string` | 绝对路径，相对路径要拼 host |\n\n**特殊**：\n- 缺失用 `null`，不用 `0` / `''`（0 和空字符串有业务含义，别混）\n- 枚举 → string，不要 int 代号（`'listed'` 比 `0` 清楚）\n\n### 3. 顺序\n\n固定三段：\n\n```\n[识别列 ...] [业务数字 ...] [metadata ...]\n```\n\n**识别列**（前 1-3 列）：`rank / symbol / code / bondCode / name / title / id`\n\n**业务数字**（中间）：价格、涨跌幅、成交量、市值等业务语义\n\n**metadata**（最后 1-3 列）：`pubTime / updateTime / source / url`\n\n### 4. 必有列（按类型）\n\n| adapter 类型 | 必须包含 |\n|-------------|---------|\n| 排行 / 列表 | `rank` + 识别列 + 业务数字 |\n| 时序 / K 线 | `date`（或 `ts`）+ 数值 |\n| 详情（单对象） | 识别列 + 业务字段 |\n| 新闻 / 公告 | `title` + `pubTime` + `url` |\n\n### 5. 控量\n\n**单条 ≤ 15 列**。超了就得考虑：\n- 拆成多个 adapter（列表版 + 详情版）\n- 次要字段合进 `extras: {...}` 对象\n- 只有少数用户关心的字段默认隐藏（靠参数开关）\n\n---\n\n## 对齐邻居 adapter\n\n写新 adapter 前先看同站点现有 adapter 怎么命名：\n\n```bash\ngrep -h \"columns:\" clis/<site>/*.js\n```\n\n复用同类列名。比如 `clis/eastmoney/convertible.js` 用 `bondCode / bondName / stockCode / stockName`，新写 eastmoney 某个涉及股票代码的 adapter 就沿用 `stockCode / stockName`，不要发明 `securityId / securityName`。\n\n---\n\n## 常见错误\n\n| 错 | 对 |\n|----|---|\n| `columns: ['id', 'name', 'data.price']`（点路径） | 把 `data.price` 在 func 里打平成 `price` |\n| `{date: '2024-01-15 10:30'}`（空格 + 非 ISO） | `'2024-01-15T10:30:00Z'` 或 `Date.toISOString()` |\n| `{pct: '2.5%'}`（字符串 + 单位） | `{changePct: 2.5}` 纯数字 |\n| `{volume: '1.2万'}` | `{volume: 12000}` |\n| `{code: 600000}`（整数丢前导 0） | `{code: '600000'}` string，或 `String(code).padStart(6, '0')` |\n| columns 和 func 返回的 keys 对不上 | 列出的每个 key 必须在返回对象里，顺序也一致 |\n\n---\n\n## description 字段\n\nadapter `description` 是用户第一眼看到的，写清楚：\n\n1. **数据是什么**：`\"A 股涨幅排行\"` vs `\"大盘指数分时\"`\n2. **默认行为**：`\"默认按涨幅排序，前 20 条\"`\n3. **重要参数**：`\"支持 market 参数切换沪深/北证\"`\n\n不要：\n- `\"get data\"` — 废话\n- `\"查询 xxx 数据\"` — 也是废话\n- 塞完整 URL / 字段代号列表 — 留给 help\n\n一行 30 字左右够了。\n\n---\n\n## args 命名\n\n- `limit` 而不是 `count / num / n / size`\n- `sort` 而不是 `sort_by / order_by / sortKey`\n- `market` 而不是 `exchange / platform / type`\n- `symbol` / `code` / `query` 根据业务选，保持和邻居 adapter 一致\n- 布尔参数用 `enableX / includeX`，默认 false 免得用户改变认知负担\n\n`help` 文案给**所有合法值**，别让用户猜：\n\n```javascript\n{ name: 'sort', type: 'string', default: 'turnover', help: '排序：turnover / change / drop / price / premium' }\n```\n\n---\n\n## 示例对比\n\n**差的**：\n\n```javascript\ncolumns: ['股票代码', 'name', 'PRICE', 'change%', 'vol', 'time']\n```\n\n问题：中英混、大小写乱、百分号字符串、缩写不统一。\n\n**好的**：\n\n```javascript\ncolumns: ['rank', 'stockCode', 'stockName', 'price', 'changePct', 'volume', 'updateTime']\n```\n\n识别列在前，metadata 在后，命名统一 camelCase。\n","isInternal":false,"tokens":1802,"sizeBytes":4816},{"name":"site-memory.md","path":"skills/opencli-adapter-author/references/site-memory.md","rawUrl":"https://raw.githubusercontent.com/jackwener/OpenCLI/HEAD/skills/opencli-adapter-author/references/site-memory.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# Site Memory\n\n站点记忆分两层：**in-repo 种子**（skill 自带的已知站点公共知识）+ **本地工作目录**（每台机器跑过的站点累积产物）。\n\n---\n\n## 两层结构\n\n```\nskills/opencli-adapter-author/references/site-memory/<site>.md\n    — 公共种子。手写 + PR 审核进入。多 agent 共享的第一批起点。\n    — 已铺：eastmoney / xueqiu / bilibili / tonghuashun / gmail\n\n~/.opencli/sites/<site>/\n    — 本地累积。agent 跑 adapter 过程里自动写入，跨 session 复用。\n    — 不进 git，不进 PR。\n```\n\n用法：开头先读本地，命中 **不跳写 adapter**，仍要跑 Step 5 endpoint 验证 + Step 7 字段抽查（memory 可能过期或站点换版）；没命中读 in-repo；都没有走完整 recon。\n\n---\n\n## Layer 1 — In-repo 种子（`references/site-memory/<site>.md`）\n\n每个覆盖站点一个 `.md`，结构固定：\n\n```markdown\n# <site>\n\n## 域名\n主 API / 备 API / 登录 / 静态资源\n\n## 默认鉴权\n`Strategy.XXX` + 必需 cookie/header + 获取方式\n\n## 已知 endpoint（选最常用的 5-10 条）\n- `GET <url>` — 返回 X，分页参数 Y\n- ...\n\n## 字段（指向 `field-conventions.md` 的某一节）\n\n## 坑 / 陷阱\n- fltt=2 必传\n- 单位是\"万\"不是\"元\"\n- ...\n\n## 可参考的 adapter\n`clis/<site>/<name>.js` × N\n```\n\n审核门槛高，里面写的东西必须是\"多数人都会踩到\"的共识。一次性试错、站点局部怪癖放 Layer 2。\n\n---\n\n## Layer 2 — 本地工作目录（`~/.opencli/sites/<site>/`）\n\nagent 每跑一次相关 adapter 就可以自动写/读：\n\n```\n~/.opencli/sites/<site>/\n  notes.md               — 累积笔记（时间戳 + 写入人 + 发现）\n  endpoints.json         — 已验证的 endpoint 目录\n  field-map.json         — 字段代号 → 含义（key 为字段代号，value 为 {meaning, verified_at, source}）\n  verify/                — `opencli adapter test` 期望值（值级校验锚点，每个 adapter 一份）\n    <cmd>.json\n  fixtures/              — 公开/合成/已完成脱敏的响应样本（给字段对比 / 离线 replay；高敏私人响应不落盘）\n    <cmd>-<ts>.json\n  last-probe.log         — 最近一次侦察输出（下次接着用）\n```\n\n`verify/` vs `fixtures/` 别混：\n- `verify/<cmd>.json` 是**结构期望**（rowCount / columns / types / patterns / notEmpty），每 adapter 一份、会被 verify 读。\n- `fixtures/<cmd>-<ts>.json` 是**可安全持久化的响应样本**，给人 / 下一个 agent 做字段比对用，verify 不会读；高敏私人响应只用合成 fixture。\n\n### `endpoints.json` 格式（schema 锁死）\n\nkey = endpoint 的短名（`clist` / `kline` / `search` 等），不要用全 URL 当 key。\n\n```json\n{\n  \"clist\": {\n    \"url\": \"https://push2.eastmoney.com/api/qt/clist/get\",\n    \"method\": \"GET\",\n    \"params\": {\n      \"required\": [\"fs\", \"fields\"],\n      \"optional\": [\"pn\", \"pz\", \"fid\", \"po\", \"fltt\"]\n    },\n    \"response\": \"data.diff[] 数组\",\n    \"verified_at\": \"2026-04-20\",\n    \"notes\": \"fltt=2 必传\"\n  }\n}\n```\n\n字段说明：\n\n- `url` / `method`：原样存，query string 不入 `url`，都归 `params`\n- `params.required` / `params.optional`：参数名列表。**不存具体值**（值会变，记例子放 `notes`）\n- `response`：一句话写清响应形状入口（`data.diff[] 数组` / `result.data.items` / `纯数组`），而不是把整个响应贴进来\n- `verified_at`：`YYYY-MM-DD`。超过 30 天下次读到当作过期重验\n- `notes`：一两句关键坑（`fltt=2 必传` / `ms 单位 begin` 之类），不要写长文\n\n### `field-map.json` 格式（schema 锁死）\n\nkey = 字段代号（`f237` / `f152`），value 三件套：\n\n```json\n{\n  \"f237\": {\n    \"meaning\": \"convertible premium rate (%)\",\n    \"verified_at\": \"2026-04-20\",\n    \"source\": \"field-decode-playbook sort-key comparison vs page\"\n  }\n}\n```\n\n- `meaning`：人话 + 单位/精度（`%` / `元` / `万元` / `× 10^f152` 等）\n- `verified_at`：`YYYY-MM-DD`\n- `source`：怎么推出来的，让下次能复查（`field-decode-playbook sort-key` / `网页标签对照` / `bundle 搜索 var pricePct =`）\n- **已存在的 key 不要默默覆盖**。有冲突时先用 `fixtures/` 里的真实样本 + 网页肉眼值再确认一遍\n\n### `verify/<cmd>.json` 格式（schema 锁死）\n\n每个 adapter 一份，`opencli adapter test <site>/<cmd>` 会自动读。**没有这份 = verify 只能证\"能跑\"，证不出数据对**——可按任务需要维护 fixture。\n\n```json\n{\n  \"args\": { \"limit\": 3 },\n  \"expect\": {\n    \"rowCount\": { \"min\": 1, \"max\": 3 },\n    \"columns\": [\"rank\", \"tid\", \"title\", \"url\"],\n    \"types\": {\n      \"rank\": \"number\",\n      \"tid\": \"string|number\",\n      \"title\": \"string\",\n      \"url\": \"string\"\n    },\n    \"patterns\": {\n      \"url\": \"^https://www\\\\.1point3acres\\\\.com/bbs/thread-\"\n    },\n    \"notEmpty\": [\"title\", \"url\"]\n  }\n}\n```\n\n字段说明：\n\n- `args`：verify 调 adapter 时要带的参数。支持两种形态：\n  - **对象**：`{ \"limit\": 3 }` → 展开成 `--limit 3`，用于标准 named flag 适配器\n  - **数组**：`[\"123\", \"--limit\", \"3\"]` → **原样**追加到命令后，用于 positional 主语型适配器（`<tid>` / `<url>` / `<query>`）。repo 约定\"主语优先 positional\"，所以这类适配器只能用数组形态\n- `expect.rowCount.{min,max}`：包含边界。稳定列表接口收紧到 `[min, max]`，动态接口给一个宽区间\n- `expect.columns`：每行必须都有这些 key（严格要求——漏了就 fail）\n- `expect.types`：支持 `|` union（`string|null`）和 `any` 通配。写多少列类型看列的稳定性；波动大的列直接 `any` 比频繁改 fixture 好\n- `expect.patterns`：正则表达式 **字符串**（注意 `\\\\` 转义）。`null` / `undefined` 会被跳过，不要用正则校验可空字段\n- `expect.notEmpty`：trim 后不能为空的列。这是\"adapter 没吃掉核心业务字段\"的最后一道保险\n- `expect.mustNotContain`：`Record<col, string[]>`。列值里不允许出现这些子串。用来挡\"字段内容污染\"——比如 `description` 里混进了 `address:` / `category:` 的邻居节点文字、`title` 前面粘了面包屑前缀。`notEmpty` 挡不住这种软污染\n- `expect.mustBeTruthy`：列数组。列值必须是 JS truthy。用来挡\"silent `|| 0` / `|| false` 兜底\"——数值列返回 0 / 空字符串 / false 都会被 `notEmpty` 放过，但业务上通常是\"没抓到\"\n\n### 什么时候手写 vs `--write-fixture` 自动生成\n\n- `--write-fixture` 只是种子：生成 `rowCount.min=1` / `columns` / `types`，**没有** `patterns` / `notEmpty` / `mustNotContain` / `mustBeTruthy`——纯类型 fixture 挡不住数值错位 / 字段污染 / silent fallback。\n- 拿到种子后**必须手改**，四件套一起上：\n  - `patterns`：URL / 日期 / ID 等格式列\n  - `notEmpty`：核心业务字段\n  - `mustNotContain`：描述类文本列容易被兄弟节点污染时，把禁词（`address:` / `category:` 等）列出来\n  - `mustBeTruthy`：数值 / 布尔业务列，挡 `|| 0` / `|| false`\n- adapter 是 positional 主语型（`<tid>` / `<url>` / `<query>`）时，`--write-fixture` 的 `args` 要手写成数组形态。工具不会替你决定形态。\n- 站点换版导致 fixture 过时：`--update-fixture` 覆盖。改之前**先用肉眼核对一次网页值**，别闭着眼把错的响应固化下来。\n- **规避反模式：不要为了让 verify 通过去放松 pattern**。失败的 pattern 说明 adapter 输出有问题，要收紧 adapter，不是收紧 fixture。放松 fixture 等于默认把错数据接受下来。\n\n### `notes.md` 格式\n\n```markdown\n## 2026-04-20 by opencli-user\n写 `convertible.js` 时遇到：\n- f237 推断是溢价率（排序对比法，页面对照）\n- `fltt=2` 不加的话价格是整数 × 10^f152\n- `fs=b:MK0354` 过滤可转债\n```\n\n顶部追加新段落，老的不删。每段有日期 + 写入人。\n\n### `fixtures/<cmd>-<YYYYMMDDHHMM>.json` 格式\n\n一份该 endpoint 的**可安全持久化**响应样本。优先级：合成 fixture > 公开响应 > 可证明完成脱敏的真实响应。用途：\n\n- 未来字段代号再变时，拿样本和 `field-map.json` 做 regression 对比\n- 站点换版时，新响应和旧 fixture 做 diff 看哪个字段结构变了\n\n**存之前做数据分级**：\n\n- 公开列表/行情等无账户私有内容：可保存，仍需移除 cookie/token/header。\n- 低敏账号数据：只有完成字段级脱敏且不会从正文、snippet、URL、附件名反推个人时才保存。\n- 邮箱、私信、通讯录、支付、健康、草稿、上传文件等高敏内容：不要保存真实 response，即使“删了邮箱地址”也不够；用保持 shape/arity 的合成 fixture。\n\n原始 capture 只在 `/tmp/` 或受控本地 cache 中短暂存在，任务结束删除；不要把 raw private response 当成“完整 fixture”长期积累。\n\n---\n\n## runbook 里的读/写时机\n\n```\nStep 2 开始前 → 读  ~/.opencli/sites/<site>/\n                → 读  references/site-memory/<site>.md\n                命中后 → 不跳写 adapter，仍要跑 Step 5 (endpoint 验证) + Step 7 (字段抽查)\n                        verified_at 超 30 天 → 当作过期，按冷启动走 Step 3 → 4\n\nStep 10 verify 首轮通过后 → 写 ~/.opencli/sites/<site>/verify/<cmd>.json\n                            - 先 `--write-fixture` 拿种子，再手改 patterns / notEmpty / rowCount\n                            - 没这份后续 verify 挡不住数据错位，**必填**\n\nStep 11 肉眼对比通过后 → 写 ~/.opencli/sites/<site>/\n                        - endpoints.json：按 schema 追加或更新 verified_at\n                        - field-map.json：只追加新 key，已有的不默默覆盖\n                        - notes.md：顶部追加一段\n                        - fixtures/：按数据分级保存公开/合成/已脱敏样本（区别于 verify/）\n```\n\n**回写是 commit，不是 stash**：不过 Step 10 verify + Step 11 肉眼对比不写，防止把错的映射喂给下一轮。\n\n---\n\n## 不要写进 `~/.opencli/sites/` 的东西\n\n- 真实账户 cookie / token — 不要保存任何鉴权凭据\n- 用户私有数据：高敏内容一律不存；低敏内容只有完成字段级脱敏才可存\n- 过期超过 30 天的 last-probe.log（自动清）\n\n## 不要写进 **repo / adapter 目录** 的东西\n\n调试过程里的临时 dump（`.dbg-*.html` / `raw-*.json` / `sample-*` / `trace-*.txt`）**只能**落在系统 `/tmp/`；只有通过上面数据分级、准备长期保留的安全样本才进入 `~/.opencli/sites/<site>/fixtures/`。PR diff 会把 repo 根目录和 `clis/<site>/` 下的文件一起带走；任务结束还要删除原始 capture/cache。\n\n---\n\n## 没有 site-memory 时\n\n新站点没对应 `.md`，也没本地目录 → 完整走 recon + discovery，跑完直接写 `~/.opencli/sites/<site>/`，后面就有了。\n","isInternal":false,"tokens":3811,"sizeBytes":10999},{"name":"bilibili.md","path":"skills/opencli-adapter-author/references/site-memory/bilibili.md","rawUrl":"https://raw.githubusercontent.com/jackwener/OpenCLI/HEAD/skills/opencli-adapter-author/references/site-memory/bilibili.md","title":"Site-memory Skill","category":"anthropic-skill","format":"markdown","content":"# bilibili（B 站）\n\n## 域名\n\n| 用途 | 域名 |\n|------|------|\n| 主 API | `api.bilibili.com` |\n| 个人主页 / 空间 | `space.bilibili.com` |\n| 登录 / 鉴权 | `passport.bilibili.com` |\n| 动态 | `t.bilibili.com` / `api.vc.bilibili.com` |\n| 直播 | `api.live.bilibili.com` |\n\n## 默认鉴权\n\n- `Strategy.COOKIE + browser: true`\n- 核心 cookie：`SESSDATA`（登录态）、`bili_jct`（CSRF token，部分写接口要带到 header `Referer + csrf`）\n- **未登录也能调多数读接口**，但有 wbi 签名要求\n\n## 关键：wbi 签名\n\n- 凡 URL 含 `/wbi/` 的接口都要 `w_rid + wts` 签名\n- 签名算法依赖每日轮换的 `img_key / sub_key`，从 `api.bilibili.com/x/web-interface/nav` 的 `wbi_img` 字段拿\n- **不要自己重新实现**：`clis/bilibili/utils.js` 里的 `apiGet(page, path, { signed: true, params })` 已经封装好\n- 普通 cookie JSON 接口优先用 `page.fetchJson(url)`；站点级签名逻辑仍复用 `utils.js`\n\n## 已知 endpoint\n\n- `GET api.bilibili.com/x/web-interface/nav` — 登录态 + 拿 wbi key\n- `GET api.bilibili.com/x/space/wbi/arc/search?mid=<uid>` — 用户视频（需 wbi 签）\n- `GET api.bilibili.com/x/space/acc/info?mid=<uid>` — 用户资料\n- `GET api.bilibili.com/x/web-interface/view?bvid=BV...` — 视频详情\n- `GET api.bilibili.com/x/web-interface/popular?ps=20&pn=1` — 热门\n- `GET api.bilibili.com/x/web-interface/ranking/v2?rid=0` — 排行\n- `GET api.bilibili.com/x/v2/reply/wbi/main?type=1&oid=<aid>&mode=3` — 评论（需 wbi）\n- `GET api.bilibili.com/x/web-interface/search/all/v2?keyword=<q>` — 综合搜索\n- `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`）\n- `GET api.bilibili.com/x/v2/history/cursor` — 观看历史（需登录）\n- `GET api.bilibili.com/x/v3/fav/folder/created/list-all` — 收藏夹列表（需登录）\n\n## 字段\n\n字段基本人类可读，见 `../field-conventions.md` 的 bilibili 节（`mid / aid / bvid / cid / view / danmaku / reply / favorite / coin / share / like / pubdate / ctime`）。\n\n## 坑 / 陷阱\n\n1. **wbi 签名缓存 `img_key / sub_key`**：24 小时内有效，每次请求都重新 fetch `nav` 会被限频。utils.js 内部做了缓存\n2. **`mid` 和 `uid` 一回事**：接口不统一，`mid=` 居多，个别接口要 `host_mid=`\n3. **视频 ID 两种**：`aid`（老数字）和 `bvid`（BV1xxx），视频详情要用 `bvid`\n4. **`ps=` 最大 50**（popular/ranking），超过了多发几页拼\n5. **动态接口换版**：老的 `dynamic_svr` 已经废，新的走 `x/polymer/web-dynamic/v1/feed/space`，写新 adapter 直接用新接口\n6. **评论分页靠 `next` 游标**，不是页号\n7. **B 站限频 `-352 风控`**：短时间高频调会拦截，adapter 层加 500ms 间隔\n8. **搜索要先 \"cold start\"**：空 cookie 的 session 第一次搜会 412，先访问首页拿 `buvid3` cookie\n9. **直播接口在 `api.live.bilibili.com`**，不是主域名\n\n## 可参考的 adapter\n\n| 模板类型 | 参考文件 |\n|---------|---------|\n| 用户资料 / 视频列表 | `clis/bilibili/user-videos.js` / `me.js` |\n| 视频详情 / 字幕 | `clis/bilibili/download.js` / `subtitle.js` |\n| 评论 | `clis/bilibili/comments.js` |\n| 搜索 | `clis/bilibili/search.js` |\n| 热门 / 排行 | `clis/bilibili/hot.js` / `ranking.js` |\n| 动态 | `clis/bilibili/dynamic.js` |\n| 关注 | `clis/bilibili/following.js` |\n| 收藏 | `clis/bilibili/favorite.js` |\n| 观看历史 | `clis/bilibili/history.js` |\n\n通用工具：`clis/bilibili/utils.js`。新 adapter 先 `import { apiGet, fetchJson } from './utils.js'`，不要重写。\n","isInternal":false,"tokens":1363,"sizeBytes":3714},{"name":"eastmoney.md","path":"skills/opencli-adapter-author/references/site-memory/eastmoney.md","rawUrl":"https://raw.githubusercontent.com/jackwener/OpenCLI/HEAD/skills/opencli-adapter-author/references/site-memory/eastmoney.md","title":"Site-memory Skill","category":"anthropic-skill","format":"markdown","content":"# eastmoney（东方财富）\n\n## 域名\n\n| 用途 | 域名 |\n|------|------|\n| 行情列表 / 批量报价 | `push2.eastmoney.com` |\n| K 线历史 | `push2his.eastmoney.com` |\n| 报表类（龙虎榜 / 十大股东） | `datacenter-web.eastmoney.com` |\n| 7x24 快讯 | `np-listapi.eastmoney.com` |\n| 公司公告 | `np-anotice-stock.eastmoney.com` |\n| 静态页（网页端入口） | `quote.eastmoney.com` / `data.eastmoney.com` |\n\n## 默认鉴权\n\n- `Strategy.PUBLIC + browser: false`\n- 统一带 `ut=bd1d9ddb04089700cf9c27f6f7426281`（push2 系列的公共 token）\n- User-Agent 随意给个 `Mozilla/5.0` 就行，不加会偶发被拦\n\n## 已知 endpoint\n\n- `GET push2.eastmoney.com/api/qt/clist/get` — 列表 / 排行\n  - 必需：`fs`（市场/板块过滤码）、`fields`（字段清单）\n  - 可选：`pn`（页码，1-based）、`pz`（每页数量）、`fid`（排序字段）、`po`（0=asc / 1=desc）、`fltt`（2=浮点格式化）、`invt`（2=固定）、`np`（1=固定）\n  - 返回：`data.diff[]` 数组\n- `GET push2.eastmoney.com/api/qt/ulist.np/get` — 批量报价（给定 secids）\n  - 必需：`secids`（逗号拼接的 `<market>.<code>`）、`fields`\n  - 返回：`data.diff[]` 数组\n- `GET push2.eastmoney.com/api/qt/stock/get` — 单只详情\n  - 必需：`secid`、`fields`\n- `GET push2his.eastmoney.com/api/qt/stock/kline/get` — K 线历史\n  - 必需：`secid`、`klt`（周期：1 分 / 5 / 15 / 30 / 60 / 101 日 / 102 周 / 103 月）、`fqt`（0=不复权 / 1=前复权 / 2=后复权）、`fields1`、`fields2`\n  - 返回：`data.klines[]` — CSV 字符串数组\n- `GET datacenter-web.eastmoney.com/api/data/v1/get` — 报表类\n  - 必需：`reportName`（如 `RPT_DAILYBILLBOARD_DETAILS`）、`columns`、`pageSize`、`pageNumber`、`sortColumns`、`sortTypes`\n  - 返回：`result.data[]`\n- `GET np-listapi.eastmoney.com/nlist/api/list/get` — 7x24 快讯\n  - 必需：`client=web`、`column_id`、`limit`、`last_time`\n- `GET np-anotice-stock.eastmoney.com/api/security/ann` — 公司公告\n  - 必需：`sr=-1`、`page_size`、`page_index`、`ann_type`、`stock_list`\n\n## 字段\n\n字段代号词典见 `../field-conventions.md` 的 eastmoney 节（`f2 / f3 / f12 / f14 / f62 / f229-f243` 等全集）。\n\n市场前缀、fs 过滤码、secid 格式也都在那里。\n\n## 坑 / 陷阱\n\n1. **`fltt=2` 必传**：否则价格字段是 `int × 10^f152` 的整数表示，要自己除\n2. **资金流单位混乱**：`f6` 成交额是元，但 `f62 / f66 / f72 / f78 / f84` 等净流入大部分接口返回\"万元\"，核对单条接口后再乘\n3. **secid vs code**：`0.000001 / 1.600000` 是 secid（market + code），只有纯 code 时要先判断所属市场前缀再拼。用 `clis/eastmoney/_secid.js` 导出的 `resolveSecid(input)`（单股）或 `splitSymbols(s)`（批量参数拆分），不要自己硬拼前缀\n4. **港股代码 `00700.HK` 不是 secid**：只有前缀属于 `{0, 1, 105, 106, 107, 116, 100, 90}` 才当 secid\n5. **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`\n6. **datacenter-web 字段名大写**：`SECURITY_CODE` 不是 `security_code`\n\n## 可参考的 adapter\n\n| 模板类型 | 参考文件 |\n|---------|---------|\n| clist 分页排行 | `clis/eastmoney/convertible.js` / `rank.js` / `etf.js` / `sectors.js` |\n| ulist 批量报价 | `clis/eastmoney/quote.js` |\n| K 线历史 | `clis/eastmoney/kline.js` |\n| 报表类 | `clis/eastmoney/longhu.js` / `holders.js` |\n| 7x24 快讯 | `clis/eastmoney/kuaixun.js` |\n| 公司公告 | `clis/eastmoney/announcement.js` |\n| 指数 / 北上 | `clis/eastmoney/index-board.js` / `northbound.js` |\n| 资金流 | `clis/eastmoney/money-flow.js` |\n\n新写 eastmoney adapter 时，照最像的那条 copy + 改 `name` / URL 参数 / 字段映射三处。\n","isInternal":false,"tokens":1522,"sizeBytes":3896},{"name":"gmail.md","path":"skills/opencli-adapter-author/references/site-memory/gmail.md","rawUrl":"https://raw.githubusercontent.com/jackwener/OpenCLI/HEAD/skills/opencli-adapter-author/references/site-memory/gmail.md","title":"Site-memory Skill","category":"anthropic-skill","format":"markdown","content":"# gmail\n\n## Domain and auth\n\n- Desktop UI: `mail.google.com/mail/u/<account>/`.\n- Requires Google session cookies and a signed-in Gmail surface.\n- The official Gmail API requires separate OAuth; browser cookies are not an official API credential.\n\n## Verified read paths\n\n- `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]`.\n- `POST /sync/u/<account>/i/fd`: natural thread/message detail response. Cached threads may render without a fresh request.\n- `POST /sync/u/<account>/i/s`: incremental synchronization; not needed by the initial adapter.\n- Legacy `POST /mail/u/<account>/?ui=2` still appears during bootstrap. Do not assume a single transport variant.\n\n## Positional fields verified in 2026-08\n\n- Thread: subject `[0]`, snippet `[1]`, timestamp `[2]`, sync id `[3]`, summaries `[4]`.\n- Summary: sender card `[1]`, label ids `[10]`.\n- FD thread: id `[0]`, message wrappers `[2]`; wrapper is `[messageId, record]`.\n- Message: to `[0]`, cc `[1]`, subject `[4]`, body `[5]`, snippet `[6]`, sender `[10]`, attachments `[13]`, date `[16]`, legacy id `[34]`.\n- Sender: display name `[14]`, address `[16]`.\n- Attachment node: id `[1]`, data `[3]`; data contains name `[2]`, MIME `[3]`, size `[4]`.\n\n## Strategy\n\n- Reads: `INTERCEPT`. Let Gmail submit the visible search/open action, then parse the natural `bv/fd` response. Never reconstruct private XSRF/BTAI request bodies.\n- Cached thread fallback: rendered message containers (`data-message-id`, `.a3s`, scoped attachment cards).\n- 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.\n\n## Durable pitfalls\n\n1. 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.\n2. `thread-f:<decimal>` converts to legacy hex with `BigInt(decimal).toString(16)`; never use Number.\n3. `bv` search responses may omit label definitions. The complete visible fallback is `#settings/labels`.\n4. A new `/fd` response is not guaranteed for cached messages. Treat rendered content as a legitimate visible-ui fallback.\n5. Responses are anti-XSSI-prefixed positional arrays. Enforce arity/required-field guards and fail typed on drift.\n6. Network capture is a destructive drain; a relevant bodyless/truncated entry means possible data loss, not an empty/short page.\n7. Never store live subjects, addresses, message bodies, attachment names, cookies, or account-specific ids in fixtures/site memory.\n8. 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.\n","isInternal":false,"tokens":694,"sizeBytes":2901},{"name":"tonghuashun.md","path":"skills/opencli-adapter-author/references/site-memory/tonghuashun.md","rawUrl":"https://raw.githubusercontent.com/jackwener/OpenCLI/HEAD/skills/opencli-adapter-author/references/site-memory/tonghuashun.md","title":"Site-memory Skill","category":"anthropic-skill","format":"markdown","content":"# tonghuashun（同花顺 / ths）\n\n## 域名\n\n| 用途 | 域名 |\n|------|------|\n| 行情页 / 热榜 | `q.10jqka.com.cn` |\n| 数据中心（多维筛选） | `data.10jqka.com.cn` |\n| 行情推送 | `d.10jqka.com.cn` / `dq.10jqka.com.cn` |\n| 基本面 / F10 | `basic.10jqka.com.cn` |\n| 资讯 / 新闻 | `news.10jqka.com.cn` |\n\n## 默认鉴权\n\n- `Strategy.COOKIE + browser: true`（多数接口有 cookie 风控）\n- `Referer: http://q.10jqka.com.cn/` 是关键 header，很多接口缺它直接返回空\n- 有些 JSONP 接口没 cookie 要求，但 Referer 还是要带\n- 不支持登录态扩展（加密/签名客户端逻辑复杂）\n\n## 已知 endpoint\n\n- `GET d.10jqka.com.cn/v6/line/hs_<code>/01/last1200.js` — K 线（JSONP，返回 `quotebridge_v6_line_...({...})`）\n- `GET q.10jqka.com.cn/thsq/quote/v6/<code>` — 实时行情快照（页面内 XHR）\n- `GET q.10jqka.com.cn/stock/attention/` — 热度榜（HTML，抽表）\n- `GET data.10jqka.com.cn/funds/ggzjl/field/zdf/order/desc/page/1/ajax/1/` — 资金流排行（HTML 表格 + JSONP 混合）\n- `GET news.10jqka.com.cn/tapp/news/push/stock/?tag=&page=1&limit=20` — 快讯\n- `GET data.10jqka.com.cn/ifinance/hotNews/` — 热点新闻\n\n## 字段\n\n见 `../field-conventions.md` 的 tonghuashun 节（`openPrice / closePrice / zdf / hsl / zf` 等）。\n\n数值字段多数是字符串（带百分号或\"万\"单位），解析时 `parseFloat` 并处理单位。\n\n## 坑 / 陷阱\n\n1. **`Referer` 必须带**，否则 302 到错误页\n2. **多数 JSONP 接口回调名固定**：`quotebridge_v6_...`，adapter 里直接 `.replace(/^[\\w_]+\\((.*)\\)$/, '$1')` 剥壳再 JSON.parse\n3. **HTML 表格接口**（如资金流）：`page.evaluate` 里 `document.querySelectorAll('table tr')` 抽，每列顺序在同类 adapter 之间复用\n4. **`<code>` 格式 `hs_600000`**：hs 是上海，sz 是深圳；港美股前缀不同\n5. **数据中心接口有日期快照切换**：`date=YYYYMMDD` 参数默认当天，历史日期要显式传\n6. **限频特别严**：连续 10 次会跳风控页，adapter 层 1s 间隔起\n7. **响应 gzip 强制**：某些接口不带 Accept-Encoding 会 406，`fetch` 默认 OK，node 原生 http 要显式加\n\n## 可参考的 adapter\n\n| 模板类型 | 参考文件 |\n|---------|---------|\n| 热度榜 | `clis/ths/hot-rank.js`（目前唯一一个，其他待补） |\n\nths 覆盖度低，新写 adapter 参考 eastmoney 同类型 adapter 的结构，把 URL / 解析逻辑换成 ths 版本。\n","isInternal":false,"tokens":981,"sizeBytes":2492},{"name":"xueqiu.md","path":"skills/opencli-adapter-author/references/site-memory/xueqiu.md","rawUrl":"https://raw.githubusercontent.com/jackwener/OpenCLI/HEAD/skills/opencli-adapter-author/references/site-memory/xueqiu.md","title":"Site-memory Skill","category":"anthropic-skill","format":"markdown","content":"# xueqiu（雪球）\n\n## 域名\n\n| 用途 | 域名 |\n|------|------|\n| 行情 / 搜索 / 关注列表 | `stock.xueqiu.com` / `xueqiu.com` |\n| 动态 / 评论 / 热帖 | `xueqiu.com` |\n| 基金（蛋卷） | `danjuanfunds.com` |\n\n## 默认鉴权\n\n- `Strategy.COOKIE + browser: true`\n- 核心 cookie：`xq_a_token`（匿名也有，登录后含用户身份）\n- `page.evaluate` 里 `fetch(url, { credentials: 'include' })` 带 cookie\n- 浏览器先访问一次 `xueqiu.com` 触发 cookie 下发，再去调接口（否则 `xq_a_token` 不存在 → 400）\n\n## 已知 endpoint\n\n- `GET stock.xueqiu.com/v5/stock/quote.json?symbol=SH600000` — 单股详情\n- `GET stock.xueqiu.com/v5/stock/batch/quote.json?symbol=SH600000,SZ000001` — 批量报价\n- `GET stock.xueqiu.com/v5/stock/chart/kline.json?symbol=SH600000&begin=<ts>&period=day&type=before&count=-100` — K 线\n- `GET stock.xueqiu.com/v5/stock/screener/quote/list.json?market=CN&type=sh_sz` — 排行\n  - 可选：`order_by=percent`（涨幅）/ `volume`（成交量）/ `amount`（成交额）/ `market_capital`\n- `GET stock.xueqiu.com/v5/stock/search.json?keyword=<q>` — 搜索\n- `GET xueqiu.com/statuses/hot/list.json?since_id=-1&max_id=-1&size=20&type=stock` — 热门帖\n- `GET xueqiu.com/statuses/search.json?source=all&q=<q>` — 动态搜索\n- `GET xueqiu.com/service/v5/stock/portfolio/list.json` — 关注组合（需登录）\n- `GET xueqiu.com/v4/statuses/user_timeline.json?user_id=<uid>` — 用户动态\n\n## 字段\n\n字段基本人类可读（`symbol / name / current / chg / percent / volume / amount / market_capital / pe_ttm / pb`），详见 `../field-conventions.md` 的 xueqiu 节。\n\n## 坑 / 陷阱\n\n1. **必须先访问首页**：冷启动直接调 API 会 400 `cookie is invalid`，先 `page.goto('https://xueqiu.com/')` 再 fetch\n2. **`symbol` 前缀硬编码**：SH/SZ/HK/US —— 港股 `00700` 是 `'HK00700'`，美股 `AAPL` 是 `'AAPL'`（无前缀）\n3. **kline 的 `begin` 是毫秒 unix**，不是秒\n4. **screener 的 `type`**：`sh_sz / hk / us` 必传，漏了拿空数组\n5. **评论接口 `comments.json` 要传 `statusId`**，不是 `status_id`\n6. **限频明显**：同 symbol 短时间反复调会 403，adapter 层建议加 300ms 间隔\n7. **蛋卷基金（`danjuanfunds.com`）是独立子品牌**：cookie 域不同，要单独获取\n\n## 可参考的 adapter\n\n| 模板类型 | 参考文件 |\n|---------|---------|\n| 单股 / 批量报价 | `clis/xueqiu/stock.js` |\n| K 线 | `clis/xueqiu/kline.js` |\n| 排行 | `clis/xueqiu/hot-stock.js` |\n| 热帖 / feed | `clis/xueqiu/hot.js` / `feed.js` |\n| 关注 | `clis/xueqiu/watchlist.js` |\n| 搜索 | `clis/xueqiu/search.js` |\n| 评论 | `clis/xueqiu/comments.js` |\n| 基金持仓（蛋卷） | `clis/xueqiu/fund-holdings.js` |\n\n工具：`clis/xueqiu/utils.js` 有 `fetchWithRetry` + symbol normalize，直接用别重写。\n","isInternal":false,"tokens":1106,"sizeBytes":2875},{"name":"site-recon.md","path":"skills/opencli-adapter-author/references/site-recon.md","rawUrl":"https://raw.githubusercontent.com/jackwener/OpenCLI/HEAD/skills/opencli-adapter-author/references/site-recon.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# Site Recon\n\n**Layer 1：这是哪种站？** 分类完直接进 `api-discovery.md` 找 endpoint。\n\n本文件**只做分类**，不讲 endpoint 怎么找。\n\n---\n\n## 页面与请求事实\n\n```bash\nopencli browser open <url> --session research\nopencli browser state --diagnostics --session research\nopencli browser network --static --session research\n```\n\n读取 success envelope 的 `data`。`state --diagnostics` 返回页面与可用 state globals；\n`network --static` 返回捕获请求和脚本候选。由 agent 判断最适合的数据源，CLI 不再给出\nPattern 分类或 `recommended_next_step`。下面的分类仅作为分析参考。\n\n看 `network` 输出判：\n\n| `network` 看到什么 | 站点类型 | 特征 |\n|------------------|---------|------|\n| 大量 `/api/...` JSON 请求，包含目标数据 | **A. SPA / JSON XHR** | React/Vue，数据走 fetch |\n| 有请求但都是广告 / 埋点，无目标数据 | **B. SSR / inline data** | 首屏在 HTML 里，深层再走 API |\n| 完全空 / 只有静态资源 | **C. JSONP / `<script src>` 驱动** | 老金融行情站常见 |\n| 有 API 但 401/403/签名错 | **D. Token / CSRF 鉴权型** | 在 A 基础上加鉴权 |\n| `Content-Type: text/event-stream` / WebSocket 握手 | **E. 流式** | 行情 tick / chat |\n\n分不清时参考下面五节的其他信号。\n\n**数据是 SPA / 异步加载时，`wait time 2` 可能不够**。改用 `opencli browser --session research wait xhr '/api/path-fragment'` 直接等具体接口到场，比盲 `wait time 5` 更稳。\n\n---\n\n## Pattern A — SPA / JSON XHR\n\n**代表**：xueqiu、linear、notion、大多数现代 SaaS\n\n**信号**：\n- URL 一访问就是 `/`，后续数据都在 network tab\n- `document.querySelector('main').childElementCount` 一开始为 0，后被 JS 填充\n- `window.React / window.Vue / window.__REACT_DEVTOOLS_GLOBAL_HOOK__` 存在\n\n**下一步**：`api-discovery.md` §1（network 精读）\n\n**注意 — Pattern A 命中不等于 strategy 选 `PAGE_FETCH`**：\n- 检查 network response shape 与业务目标是否一致；analytics / beacon / personalization 不能单独证明目标 API 可用。\n- booking #1680 反例：17 个 JSON XHR 看起来像 Pattern A，但全是 analytics side-channel，最终走 `DOM_STATE` / `UI_SELECTOR`\n- replay 候选 endpoint 后，按 `strategy-selection.md` 的契约模型选 strategy；`PUBLIC_API` / `COOKIE_API` 都不通才考虑 `PAGE_FETCH`\n\n---\n\n## Pattern B — SSR / inline data\n\n**代表**：bilibili 个人主页、小红书、微博、部分 Next.js / Nuxt 页\n\n**信号**：\n- 第一个请求（`document`）返回的 HTML 里已经含目标数据（`curl <url> | grep <某数字>`）\n- `window.__INITIAL_STATE__` / `window.__NEXT_DATA__` / `window.__NUXT__` 存在\n- 关 JS 仍能看到首屏数据\n\n**下一步**：`api-discovery.md` §2（state 抽取） + §1（深层数据回到 network）\n\n---\n\n## Pattern C — JSONP / `<script src>` 驱动\n\n**代表**：eastmoney、tonghuashun、老一代金融站\n\n**信号**：\n- `network` 空或只有 css/font\n- 页面上肯定有数据（价格、成交量等）\n- `document.querySelectorAll('script[src]')` 里有指向 `push / api / data` 域名的 src\n- 响应是 `jQuery123({...})` 这种回调包裹（JSONP）\n\n**下一步**：`api-discovery.md` §3（bundle / script src 搜索）\n\n---\n\n## Pattern D — Token / CSRF / Bearer\n\n**代表**：Twitter/X、部分企业 SaaS\n\n**信号**：\n- 已经是 Pattern A，但 `fetch(url, {credentials:'include'})` 返回 401/403\n- network 里请求头有 `X-Csrf-Token / Authorization: Bearer / X-Client-Id / X-Workspace-Id` 等自定义字段\n- 401 响应体带 `{\"code\":\"AUTH_REQUIRED\",\"csrf\":\"...\"}` 类提示\n\n**下一步**：`api-discovery.md` §4（token 来源排查） + §5（store action / intercept 降级）\n\n---\n\n## Pattern E — 流式\n\n**代表**：行情 tick、LLM chat\n\n**信号**：\n- `network` 里有 `101 Switching Protocols`（WebSocket 握手）\n- Response headers 含 `Content-Type: text/event-stream`\n- 请求一直 pending 不结束\n\n**下一步**：先找**同数据的 HTTP 轮询接口**（90% 概率有）。真没有再走 intercept 收 N 条。\n\n---\n\n## 识别失败怎么办\n\n诊断信号互相矛盾（比如 network 非空但目标数据不在里面），按优先级硬走：\n\n1. 先当 A，试 `api-discovery.md` §1\n2. 不行当 B，试 §2\n3. 还不行当 C，试 §3\n4. 401 出现了切 D，试 §4\n5. 所有手段都试过，启动 intercept（§5）\n\n不要纠结分类。分类是帮忙定第一步，没命中就按顺序降级。\n","isInternal":false,"tokens":1509,"sizeBytes":4570},{"name":"strategy-selection.md","path":"skills/opencli-adapter-author/references/strategy-selection.md","rawUrl":"https://raw.githubusercontent.com/jackwener/OpenCLI/HEAD/skills/opencli-adapter-author/references/strategy-selection.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# Choosing an adapter strategy\n\nUse the site's available contract and the task's needs to choose an implementation.\nThis reference is optional; no strategy note or classification artifact is required.\n\n| Source | Useful when | Check |\n| --- | --- | --- |\n| Public API | A documented endpoint covers the task | Inputs, authentication, pagination and error semantics |\n| Cookie-authenticated API | The endpoint works with the current account | Cookie/CSRF scope and real returned identity |\n| Page state / SSR | The page already contains the required data | Field meaning, completeness and hydration timing |\n| Semantic UI | The visible controls express the operation reliably | Stable selectors, target identity and observable result |\n| Browser fetch | The request needs page origin or runtime context | Actual request parameters and response meaning |\n| Network interception | The page produces a request that is difficult to reproduce | The captured response belongs to the intended action and input |\n\nThere is no universal API-over-DOM rule. A stable visible control can be easier to\nmaintain than an undocumented internal endpoint. Reuse an existing adapter or\nshared helper when it already models the same operation.\n\n## Gather facts as needed\n\n```sh\nopencli browser state --diagnostics --session research\nopencli browser network --static --session research\nopencli browser network --detail RequestKey --session research\n```\n\nRead `data` from the success envelope. Page diagnostics expose title, URL and\ninitial-state globals. Network evidence provides captured request keys, response\nshapes and static source locations. `static` is a candidate; `network+static`\nmeans both sources support the route. Neither proves business correctness.\nThe CLI does not assign a strategy score or prescribe the next action.\n\nCheck representative values against the visible page. For lists, distinguish a\ntrue end of pagination from a failed or partial response. For writes, bind the\noperation to the requested target and inspect its result; do not replay an action\nwhose previous outcome is unknown.\n\nIf an endpoint needs a runtime-generated signature, consider observing the page's\nown request or using semantic UI. Use `references/api-discovery.md` for techniques\nand `references/deep-recon.md` when the protocol requires further investigation.\n","isInternal":false,"tokens":448,"sizeBytes":2338},{"name":"success-rate-pitfalls.md","path":"skills/opencli-adapter-author/references/success-rate-pitfalls.md","rawUrl":"https://raw.githubusercontent.com/jackwener/OpenCLI/HEAD/skills/opencli-adapter-author/references/success-rate-pitfalls.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# Success-Rate Pitfalls\n\n11 个**静默失败**（adapter 看起来能跑、verify 能过，但数据是错的）的坑。每条给：现象 → 根因 → 防御手段。\n\n不是风格建议。每条都对应过一次真实翻车。\n\n---\n\n## 1. fixture pattern 被放松以过 verify\n\n**现象**：`verify` 报 `pattern \"url\" does not match /^https?:\\/\\/.*\\.com\\/bbs\\/thread-/`。agent 的\"修法\"是把 fixture 里的 pattern 改宽（`^https?://`），verify 一下就通过了。\n\n**根因**：adapter 丢了 URL 前缀 / 拼错了路径 / 吃到了相对路径。pattern 失败不是 fixture 太严，是 adapter 输出真的破了。\n\n**防御**：\n- `autofix` skill 现有纪律：**verify pattern 失败 = 收紧 adapter，不是收紧 fixture**（`opencli-autofix` SKILL.md §Rules for Patching 第 6 条）\n- 要改 fixture 的唯一合法理由：**站点本身换了格式**（例如 URL 规范迁移）。这种情况下在 `~/.opencli/sites/<site>/notes.md` 顶部写一段说明\n\n---\n\n## 2. 字段内容污染但 `notEmpty` / `columns` 都过\n\n**现象**：`description` 字段不为空，`verify` 通过。肉眼看输出发现描述里混了 `\"address: 上海 category: IT\"` 之类明显不属于描述的片段——兄弟 DOM 节点或父节点文字被一起 `textContent` 了。\n\n**根因**：`.container` 的 `textContent` 包括所有后代文字。调 `innerText` 仅好一点；用 `querySelector('.desc').textContent` 时，如果 `.desc` 里嵌套了 `.tag.address`，一样吃进去。\n\n**防御**：\n- fixture 用 `mustNotContain`：`{ \"description\": [\"address:\", \"category:\", \"工作年限:\"] }` 把已踩过的污染词列出来\n- adapter 侧：定位更精确的 selector，或抓到后 `.replace(/address:[^\\n]*/g, '').trim()`\n- 别信 `textContent.trim()` 就完事\n\n---\n\n## 3. 字段语义分歧（两个字段看起来都对）\n\n**现象**：51job 列表有 `updatedate` 和 `publishDate`，eastmoney 债券有 `f10`（发行日）和 `f26`（上市日）。adapter 随便选一个，verify 通过，用户一对照发现时间错位 1 个月。\n\n**根因**：两个字段都是合法日期，format 也对，只是含义不同。`notEmpty` / `types` 都挡不住。\n\n**防御**：\n- Step 7 字段解码**必须和网页肉眼对至少一条已知记录**（\"这条债券首页写的上市日是 2025-02-14\"，看 adapter 输出对不对得上）\n- 字段写进 `field-map.json` 时 `meaning` 要精确到\"上市日\"而不是\"日期\"\n\n---\n\n## 4. 字段单位混淆（数值量级错）\n\n**现象**：eastmoney 返回 `totalMarketCap: 128`（单位：亿元），adapter 直接写进 `marketCap`，用户对照 K 线页看是 128 元。常见错位：\n\n| 接口返回 | 网页显示 | 实际单位 | 错误写法 |\n|---------|---------|----------|---------|\n| `0.025` | `2.5%` | 小数 | adapter 再 × 100 → 显示 250% |\n| `12800` | `1.28 万` | 元 | adapter 不除 10000 → 显示 12800 万 |\n| `f152 = 2` | `9.27` | 价格 = 原值 ÷ 10^f152 | adapter 忽略 f152 → 显示 927 |\n\n**防御**：\n- fixture 加 `mustBeTruthy` 挡 `|| 0` / `|| false` silent fallback（数值列应该有值，不是 0）\n- `field-map.json` 的 `meaning` 写单位：`\"premium pct (0-1 fraction, NOT already × 100)\"`\n- Step 11 肉眼比对不能只比\"有没有数字\"，要比数量级\n\n---\n\n## 5. JSON-in-attribute vs 渲染后 innerText\n\n**现象**：51job 把完整 JSON 塞在 `<div data-sensorsdata='{\"job_title\":\"...\"}'>` 里，用 innerText 取的是渲染后的截断显示文字（\"Lead... ↩ 上海...\"），字段边界丢了。\n\n**根因**：现代站点常把结构化数据放在 `data-*` 属性里，渲染层只挑部分显示。取 innerText 相当于丢掉了结构。\n\n**防御**：\n- 看到 `data-sensorsdata` / `data-ng-state` / `data-page-props` / `data-track` 类属性先读属性，不读 innerText\n- 先在 `browser eval` 里检查：`document.querySelector('.item').dataset` 看有没有 JSON 串\n- 搜 bundle 时也搜 `JSON.parse(el.dataset.*)` 模式看 vendor 把数据塞哪了\n\n---\n\n## 6. cookie 域 / origin 不一致的隐式假设\n\n**现象**：在 `jobs.51job.com` 页面调 `cupid.51job.com` 的接口，headless 里带了 cookie 也跨不过去——`credentials:'include'` 只管带 cookie，不管 CORS。\n\n**根因**：浏览器 CORS 预检默认关闭跨 subdomain 请求。`credentials:'include'` 不是万能药。\n\n**防御**：参见 `api-discovery.md §0.2`。判断：`fetch(target).catch(e=>'cors:'+e.message)` 看是不是 TypeError。降级路径：改用 same-origin endpoint / 改在目标 subdomain 上打开页面 / 走 `§5 intercept`。\n\n---\n\n## 7. 等不够就抓导致空 DOM / 空 network\n\n**现象**：`open url && wait time 2 && network` 看到 0 条业务 API。agent 以为是 Pattern C（静态），去 bundle 里找 baseURL，找不到就卡住。真相：SPA 3.5 秒才发出第一个 API。\n\n**根因**：`wait time N` 是盲等。不同站点 JS 执行速度差很多。\n\n**防御**：\n- 数据是异步加载时**不用 `wait time`**，用 `opencli browser --session research wait xhr '/api/path-fragment'`，等具体 XHR 到场再 `network`\n- 不确定 endpoint 路径时：先 `wait time 2 && network`，看到候选路径再转 `wait xhr` 确认\n\n---\n\n## 8. adapter 里的 falsy `|| 0` 兜底静默\n\n**现象**：`likes: data.likes || 0`。接口偶尔返回 `likes: null`（可能因字段名改了、权限问题等），adapter 写成 0，verify `types: {likes: 'number'}` 通过，用户看到的是\"所有帖子 0 赞\"。\n\n**根因**：`||` 兜底把\"没抓到\"变成\"是 0\"。`notEmpty` 挡不住 0，`types` 也不挡。\n\n**防御**：\n- fixture 用 `mustBeTruthy: [\"likes\", \"count\", ...]`——业务数值列必须 truthy\n- adapter 侧 prefer `?.` 而不是 `||`；真的想兜底就兜 `undefined`，让 verify 能看见\n- 全部 `|| 0` 要过一遍眼：这个 0 是合法值还是漏抓 fallback\n\n---\n\n## 9. 跨 session cookie 污染 / 登录态漂移\n\n**现象**：本地开发时用自己的登录态验 endpoint 能通，PR 一合 verify fixture 跑在 CI 环境里立刻 401——顺手把样本数据也固化进了 fixture，看起来\"一切正常\"。\n\n**根因**：fixture 样本是带登录态跑出来的。存 `~/.opencli/sites/<site>/fixtures/*.json` 没脱敏，把 cookie / token / 自己的 uid / 昵称存了进去。\n\n**防御**：\n- `site-memory.md` 的脱敏规则：存 fixtures 前去掉 cookie / token / 用户私有字段（手机号 / 邮箱 / 昵称 / uid）\n- 需要登录态的接口：adapter 用 `Strategy.COOKIE`，adapter 代码里**不写任何具体 cookie 值**，只声明\"我需要 domain X 的 cookie\"\n- verify 样本里看到 `Bearer ...` 或 32 位 hex token → 先删再存\n\n---\n\n## 10. adapter 默认 timeout 不统一\n\n**现象**：同一个站两个 adapter，一个默认 15s timeout，另一个默认 60s。慢接口在一个命令里 ok，在另一个一样的慢接口却 timeout 了。\n\n**根因**：模板没统一 timeout；agent 依赖\"最像的邻居\"复制，复制到的邻居选了短 timeout。\n\n**防御**：\n- 邻居 adapter 的 `requestTimeoutMs` / `browser.wait` 配置**不能盲抄**。每个 adapter 应该结合自己的接口特性设一个\n- 真实接口延迟：Step 5 endpoint 验证时用 `time curl`（或 `performance.now()` 包 fetch）量一下 p50 / p95，timeout 设 p95 × 2 比较安全\n- 出现偶发 timeout 别 retry 掩盖；记到 `notes.md`，下次就知道这接口 p95 偏高\n\n---\n\n## 11. `aria-label` / `placeholder` / `title` 是 locale-dependent 文本\n\n**现象**：你本地英文 Chrome 测 `button[aria-label=\"Submit\"]` 一切正常，verify fixture 也是英文环境抓的。用户把 `chrome://settings/languages` 切中文，同一个按钮变 `aria-label=\"提交\"`，adapter silent 0 匹配——退化成 `notEmpty` / `types` 都没法 fire 的\"adapter 跑完返 0 行\"。\n\n**根因**：`aria-label` / `title` / `placeholder` / `alt` / `textContent` 都是页面的**用户可见文本**，被站点 i18n 框架翻译。用它们当 selector anchor 等于 \"select by visible text\"，locale 一动整个选择器就废。\n\n**防御**：\n- 优先用 locale-stable 标识：`data-testid` / `data-*` / 稳定 `id` / `class`。先确认不是 hash / A-B test 产物\n- `role` 不按 locale 翻译，但通常不唯一；只能当 semantic / scope filter，不能用裸 `[role=\"button\"]` 当 primary\n- 站点只暴露 `aria-label`（典型如 ChatGPT web 某些 control）时，写 fallback list，至少 en + zh-CN：`'[aria-label=\"Send\"], [aria-label=\"发送\"]'`\n- commit 前 grep `aria-label=` / `placeholder=` / `title=` 的硬编码字符串，确认每条都有兜底 locale\n- 找不到 control 要 typed fail-fast（例如 `CommandExecutionError` / send-failed），不要把 selector miss 变成空 rows 或假成功\n- 详细 framework + 活例见 `adapter-template.md §Selector 稳定性`\n\n不要去给 framework 加 `--i18n` flag 自动展开——多一层 indirection 还要维护翻译字典，纯 over-engineering。\n\n---\n\n## 总结：静默失败的共同特征\n\n1. **verify 绿 ≠ 数据对**。verify 只能证\"结构没坏\"，证不出\"值对不对\"。Step 11 肉眼比对是必须的。\n2. **\"字段有值\"是个比\"字段为空\"更危险的失败态**。空你会去查，有值你会 fallthrough。\n3. **fixture 四件套一起上**：`patterns` + `notEmpty` + `mustNotContain` + `mustBeTruthy`——每件挡一类问题，缺一个就漏。\n\n回写 `notes.md` 时把你踩的新坑写进去。下次就有第 12 条了。\n","isInternal":false,"tokens":3365,"sizeBytes":9555},{"name":"typed-errors.md","path":"skills/opencli-adapter-author/references/typed-errors.md","rawUrl":"https://raw.githubusercontent.com/jackwener/OpenCLI/HEAD/skills/opencli-adapter-author/references/typed-errors.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# Typed Error Conventions\n\nOpenCLI 用 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 只能减不能加，新违例必须立刻收掉）。\n\n每条 rule 都挂了真实 anti-pattern 反例（PR #1329 三轮迭代是主要素材库）。\n\n---\n\n## 1. 5-classification 落点表\n\n| 场景 | 抛 | code | exit |\n|------|-----|------|------|\n| 参数不合法（包括越界、格式错、缺主语 positional） | `ArgumentError(message)` | `ARGUMENT` | 2 |\n| 业务无数据 / 资源不存在 | `EmptyResultError(command, hint?)` | `EMPTY_RESULT` | 66 |\n| HTTP 非 2xx / fetch throw / JSON parse 错 / 接口业务报错 | `CommandExecutionError(message)` | `COMMAND_EXEC` | 1 |\n| 需要登录（cookie 缺 / 401 / 302 → /login / \"请登录\" 页） | `AuthRequiredError(domain, message?)` | `AUTH_REQUIRED` | 77 |\n| 等响应超时（CDP / page.evaluate / 流式回复） | `TimeoutError(label, seconds)` | `TIMEOUT` | 75 |\n\n类签名定义见 [`src/errors.ts`](../../../src/errors.ts)。\n\n**禁止泛用 `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。\n\n---\n\n## 2. Anti-pattern A：silent-clamp 不止 limit\n\n**反例**（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)）：\n\n```js\n// ❌ silent clamp — 200 当 100，0/-1 当 1，timeout=5 当 15\nconst limit = Math.max(1, Math.min(Number(args.limit) || 20, 100));\nconst timeout = Math.max(15, parseInt(kwargs.timeout, 10) || 120);\nconst contentLimit = Math.max(50, Number(args.contentLimit) || 400);\nconst page = Math.max(1, Number(args.page) || 1);\n```\n\n**修法**（[`clis/1point3acres/utils.js`](../../../clis/1point3acres/utils.js) 的 `normalizePositiveInteger` / `normalizeLimit`）：\n\n```js\nimport { ArgumentError } from '@jackwener/opencli/errors';\n\n/** Validate a positive integer arg without silently flooring/clamping. */\nexport function normalizePositiveInteger(value, defaultValue, label = 'value', { min = 1 } = {}) {\n    const raw = value ?? defaultValue;\n    const n = Number(raw);\n    if (!Number.isInteger(n) || n <= 0) throw new ArgumentError(`${label} must be a positive integer`);\n    if (n < min) throw new ArgumentError(`${label} must be >= ${min}`);\n    return n;\n}\n\n/** With both lower bound and an explicit ceiling. */\nexport function normalizeLimit(value, defaultValue, maxValue, label = 'limit') {\n    const n = normalizePositiveInteger(value, defaultValue, label);\n    if (n > maxValue) throw new ArgumentError(`${label} must be <= ${maxValue}`);\n    return n;\n}\n```\n\n**用法**（[`clis/1point3acres/thread.js#L37-L39`](../../../clis/1point3acres/thread.js)）：\n\n```js\nconst page = normalizePositiveInteger(args.page, 1, 'page');\nconst limit = normalizePositiveInteger(args.limit, 10, 'limit');\nconst contentLimit = normalizePositiveInteger(args.contentLimit, 400, 'contentLimit', { min: 50 });\n```\n\n**注意**：当前 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**。\n\n### 何时抽 site-level helper\n\n≥ 2 个同站 adapter 都要做同样的 limit / page 校验时，把 `normalizeLimit` / `normalizePositiveInteger` 抽到 `clis/<site>/utils.js`。1 个 adapter 用就直接 inline，别为 1 处单点抽 helper。\n\n---\n\n## 3. Anti-pattern B：sentinel row / scalar sentinel 吞 empty / unknown / failure\n\n**反例 1**（PR #1329 R3 修掉的，[`clis/1point3acres/notifications.js` before](https://github.com/jackwener/OpenCLI/blob/2b8609b82fccaf98505c5b1b1859e3ffdaa1a55c/clis/1point3acres/notifications.js#L35-L37)）：\n\n```js\n// ❌ \"暂时没有提醒内容\" 是 empty result，不是一行业务数据\nif (/暂时没有提醒内容/.test(html)) {\n    return [{ index: 0, from: '', summary: '暂时没有提醒内容', time: '', threadUrl: '' }];\n}\n```\n\n**反例 2**（[`clis/1point3acres/search.js` before](https://github.com/jackwener/OpenCLI/blob/2b8609b82fccaf98505c5b1b1859e3ffdaa1a55c/clis/1point3acres/search.js#L52-L60)）：\n\n```js\n// ❌ \"抱歉\" hint 是搜索无结果，不是 rank=0 的伪结果行\nif (items.length === 0) {\n    const hint = html.match(/<p>([^<]*?抱歉[^<]*?)<\\/p>/);\n    if (hint) {\n        return [{ rank: 0, tid: '', title: hint[1].trim(), forum: '', author: '', replies: 0, views: 0, postTime: '', url: '' }];\n    }\n    return [];\n}\n```\n\n**反例 3**（[`clis/qwen/history.js` before](https://github.com/jackwener/OpenCLI/blob/2b8609b82fccaf98505c5b1b1859e3ffdaa1a55c/clis/qwen/history.js#L44-L51)）：\n\n```js\n// ❌ API failure 不是 conversation 的一行\nif (!result.ok && !result.sessions.length) {\n    return [{ Index: 0, Title: `API failed (status=${result.status})`, Updated: '', Url: '' }];\n}\n```\n\n**反例 4**（[`clis/qwen/image.js` before](https://github.com/jackwener/OpenCLI/blob/2b8609b82fccaf98505c5b1b1859e3ffdaa1a55c/clis/qwen/image.js#L160-L164)）：\n\n```js\n// ❌ 单张图片 fetch 失败，伪装成 \"⚠️ fetch-failed\" 状态行继续\nif (!asset?.ok) {\n    results.push({ Status: `⚠️ fetch-failed(${asset?.status || '?'})`, File: '-', Link: link });\n    continue;\n}\n```\n\n**修法**：\n\n```js\nimport { EmptyResultError, CommandExecutionError } from '@jackwener/opencli/errors';\n\nif (/暂时没有提醒内容/.test(html)) {\n    throw new EmptyResultError('1point3acres notifications', '暂时没有提醒内容');\n}\nif (items.length === 0) {\n    throw new EmptyResultError('1point3acres search', `No results for \"${query}\"`);\n}\nif (!result.ok && !result.sessions.length) {\n    throw new CommandExecutionError(`Qianwen history API failed (status=${result.status})`);\n}\nif (!asset?.ok) {\n    throw new CommandExecutionError(`Failed to fetch image: status=${asset?.status || '?'}`);\n}\n```\n\n**反例 5：scalar sentinel**（PR #1329 R2 修掉的，[`clis/qwen/status.js` before](https://github.com/jackwener/OpenCLI/blob/42e5303c792d9f71d9a30dde2e391405e03661e7/clis/qwen/status.js#L24-L29)）：\n\n```js\n// ❌ '-' 会被下游当成真实 model/session id 字符串\nreturn [{\n    Status: 'Connected',\n    Login: loggedIn ? 'Yes' : 'No (guest mode)',\n    Model: model || '-',\n    SessionId: sessionId || '-',\n}];\n\n// ✅ unknown 用 null，agent 可以 if (row.Model === null) 干净分支\nreturn [{\n    Status: 'Connected',\n    Login: loggedIn ? 'Yes' : 'No (guest mode)',\n    Model: model ? model : null,\n    SessionId: sessionId ? sessionId : null,\n}];\n```\n\n**根因**：success row 是\"业务数据\"的合同。把 empty / failure 塞进 row 会破坏：\n\n1. **round-trip pipeline**：listing → detail 类下游 adapter 拿到 `tid: ''` 会去查 `thread `（\"\"），白跑一轮\n2. **exit code semantics**：empty 应该 exit 66、API fail 应该 exit 1，混进 row 后两者都 exit 0，agent 没法 branch\n3. **fixture rowCount.min=1**：sentinel row 让 verify 永远过 → 真的接口挂了也看不见\n\n**对比 #1290 twitter trending**：当时也踩过 silent N/A row 的坑，drop 掉那行比留个 silent-wrong 字段诚实——这是 2026-05-04 的早期教训。typed-errors 把\"drop\"升级成\"throw EmptyResultError\"，下游 agent 能直接从 exit code 66 分辨 empty vs 真实数据错位。\n\n---\n\n## 4. Anti-pattern C：`return []` 当 silent fallback\n\n`adapter` 跑完什么都没拿到不能 `return []`：\n\n```js\n// ❌\nif (items.length === 0) return [];\n\n// ✅\nif (items.length === 0) throw new EmptyResultError('site command', `optional context`);\n```\n\n`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> 在该论坛没有发过帖子\"）。\n\n---\n\n## 5. Anti-pattern D：generic `CliError('CODE')` 隐藏分类\n\n**反例**（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)）：\n\n```js\n// ❌ 都是 exit 1，agent 无法区分服务失败 vs 空结果 vs bad args\nif (!resp.ok) throw new CliError('HTTP_ERROR', `HTTP ${resp.status}`);\nif (!Array.isArray(data) || data.length === 0) throw new CliError('NO_DATA', 'no data');\nif (!/id=\"postlist\"/.test(html)) throw new CliError('THREAD_NOT_FOUND', `帖子 ${tid} 不存在`);\n```\n\n**修法**：\n\n```js\n// HTTP / fetch / JSON / in-band API error → runtime failure\nif (!resp.ok) throw new CommandExecutionError(`request failed: HTTP ${resp.status}`);\nif (!Array.isArray(data)) throw new CommandExecutionError('unexpected response shape');\n\n// valid empty / resource missing → empty result\nif (data.length === 0) throw new EmptyResultError('coingecko top', 'no market data');\nif (!/id=\"postlist\"/.test(html)) throw new EmptyResultError('1point3acres thread', `帖子 ${tid} 不存在`);\n```\n\n`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.\n\n---\n\n## 6. Verify fixture 怎么挡这三类\n\n新写 fixture 时（`~/.opencli/sites/<site>/verify/<cmd>.json`）：\n\n- **rowCount.min ≥ 1**：保证 sentinel-row 不能伪装通过\n- **patterns**：核心 id 列加 `^\\d+$` 类正则，挡 `tid: ''` / `pid: ''` 之类空字符串污染\n- **mustBeTruthy**：业务数值列（`replies` / `views` / `count`）必须 truthy，挡 `|| 0` silent fallback\n- **预期 EmptyResultError 是合法返回时**（例如某条 fixture 故意搜不出来）用 `expect.exitCode: 66` 让\"空态合法\"和\"adapter 崩了\"在 fixture 层就分开（参见 [`site-memory.md`](./site-memory.md) verify schema）\n\n---\n\n## 7. 已经 grandfathered 的旧 adapter\n\nrepo 里仍有相当数量的 `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 不会卡。\n\n---\n\n## 8. 自查清单（PR push 前）\n\n- [ ] adapter 没有 `Math.max(1, Math.min(...))` / `Math.max(N, ...)` clamp 在外部参数上\n- [ ] adapter 没有 `return []` / `return [{...sentinel...}]` 当 empty / failure 兜底\n- [ ] adapter 没有 `'-'` / `'N/A'` 这类 scalar sentinel 冒充 real value；语义可空就返回 `null`\n- [ ] adapter 抛的不是 `CliError('XXX')`，而是 5 类 typed error 之一\n- [ ] verify fixture 的 `rowCount.min ≥ 1`，sentinel row 过不了\n- [ ] `npm run check:typed-error-lint` 跑过 → baseline 没增加\n","isInternal":false,"tokens":4236,"sizeBytes":12910},{"name":"SKILL.md","path":"skills/opencli-adapter-author/SKILL.md","rawUrl":"https://raw.githubusercontent.com/jackwener/OpenCLI/HEAD/skills/opencli-adapter-author/SKILL.md","title":"opencli-adapter-author","category":"anthropic-skill","format":"markdown","content":"---\nname: opencli-adapter-author\ndescription: Build or improve an OpenCLI JS adapter using observed site behavior, existing adapters and browser API evidence. For ad-hoc browser interaction, see opencli-browser.\nallowed-tools: Bash(opencli:*), Bash(jsluice:*), Read, Edit, Write, Grep\n---\n\n# Adapter authoring\n\nChoose the simplest implementation that reliably performs the requested task.\nUse an existing site adapter when it already covers the capability:\n\n```sh\nopencli list search --site example\nopencli example search --help -f json\n```\n\n`list` returns `{ok:true,data:{commands,total,nextOffset?}}`; use `--offset` to\ncontinue, `--limit` to adjust page size. Detailed arguments belong in command help.\n\n## Evidence available to you\n\n```sh\nopencli browser open https://example.com --session research\nopencli browser state --diagnostics --session research\nopencli browser network --session research\nopencli browser network --static --session research\nopencli browser network --detail RequestKey --session research\n```\n\nNetwork captures describe observed requests. Static analysis adds candidates\nfrom loaded scripts without executing them. Inspect source locations, unresolved\nexpressions and capture provenance. Replay read requests when useful and compare\nbusiness data with the page. Write commands have real effects, including when run\nthrough `adapter test`; use a suitable test account or a supported dry-run.\n\nPrefer public APIs with a stable contract when available. Browser fetch, captured\nresponses, page state and semantic UI interactions are also valid choices. Select\nbased on the task and observed reliability. Do not guess endpoint parameters or\nhide failed operations behind empty success results.\n\n## JS interface\n\nAdapters are ordinary files under `~/.opencli/clis/<site>/<command>.js` or repository\n`clis/<site>/`. There is no draft directory, promotion step or required memory file.\n\n```sh\nopencli adapter init example/search\n```\n\nThe existing `cli({...})` interface is unchanged: `func(args)` for Node execution,\n`func(page, args)` with `browser: true`. Reuse neighboring adapters and shared helpers.\nReturn business data; the CLI adds the JSON success envelope. Throw a typed error\nwith an actionable hint for failures. Use `access: 'read'|'write'` accurately.\n`columns` controls human table exports; JSON preserves nested results.\n\n```js\nimport { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n  site: 'example', name: 'search', description: 'Search example items',\n  access: 'read', strategy: Strategy.PUBLIC, browser: false,\n  args: [{ name: 'query', positional: true, required: true }],\n  func: async (args) => {\n    const response = await fetch(`https://example.com/api/search?q=${encodeURIComponent(args.query)}`);\n    if (!response.ok) throw new Error(`Search failed: HTTP ${response.status}`);\n    return response.json();\n  },\n});\n```\n\n## Check and exercise\n\n```sh\nopencli adapter check example/search\nopencli example search 'query'\nopencli adapter test example/search --args '{\"query\":\"query\"}'\n```\n\n`check` validates definitions without executing adapters. `test` executes the normal\nCLI and optionally compares an existing fixture. `--write-fixture`, `--update-fixture`\nand `--no-fixture` control optional expectations; no row-shape or site-memory gate\nis imposed. `--trace retain-on-failure` helps diagnose browser execution.\n\nFor repository changes, cover the core behavior and failure paths with focused\nproduction-path tests, typecheck and build. Verify IDs, fields, units, pagination\nand empty/error semantics relevant to the task. Do not automatically replay a\nwrite with an unknown outcome; inspect state first.\n\n## Optional references\n\nRead only what is useful for the current task: `references/adapter-template.md`\nfor adapter patterns, `api-discovery.md` for request investigation,\n`output-design.md` for result fields, `field-decode-playbook.md` for opaque fields,\nand `deep-recon.md` for difficult private protocols. Site notes and fixtures are\noptional knowledge aids; older reference checklists are not mandatory workflows.\n","frontmatter":{"name":"opencli-adapter-author","description":"Build or improve an OpenCLI JS adapter using observed site behavior, existing adapters and browser API evidence. For ad-hoc browser interaction, see opencli-browser.","allowed-tools":"Bash(opencli:*), Bash(jsluice:*), Read, Edit, Write, Grep","allowedTools":["Bash(opencli:*),","Bash(jsluice:*),","Read,","Edit,","Write,","Grep"]},"allowedTools":["Bash(opencli:*),","Bash(jsluice:*),","Read,","Edit,","Write,","Grep"],"isInternal":false,"tokens":880,"sizeBytes":4085},{"name":"SKILL.md","path":"skills/opencli-autofix/SKILL.md","rawUrl":"https://raw.githubusercontent.com/jackwener/OpenCLI/HEAD/skills/opencli-autofix/SKILL.md","title":"opencli-autofix","category":"anthropic-skill","format":"markdown","content":"---\nname: opencli-autofix\ndescription: Automatically fix broken OpenCLI adapters when commands fail. Load this skill when an opencli command fails — it guides you through collecting a trace artifact, patching the adapter, retrying, and filing an upstream GitHub issue after a verified fix. Works with any AI agent.\nallowed-tools: Bash(opencli:*), Bash(gh:*), Read, Edit, Write\n---\n\n# OpenCLI AutoFix — Automatic Adapter Self-Repair\n\nWhen an `opencli` command fails because a website changed its DOM, API, or response schema, **automatically diagnose, fix the adapter, and retry** — don't just report the error.\n\n## Safety Boundaries\n\n**Before starting any repair, check these hard stops:**\n\n- **`AUTH_REQUIRED`** (exit code 77) — **STOP.** Do not modify code. Tell the user to log into the site in Chrome.\n- **`BROWSER_CONNECT`** (exit code 69) — **STOP.** Do not modify code. Tell the user to run `opencli doctor`.\n- **CAPTCHA / rate limiting** — **STOP.** Not an adapter issue.\n\n**Scope constraint:**\n- **Only modify the file at `adapterSourcePath` in the trace `summary.md` front matter** — this is the authoritative adapter location (may be `clis/<site>/` in repo or `~/.opencli/clis/<site>/` for npm installs)\n- **Never modify** `src/`, `extension/`, `tests/`, `package.json`, or `tsconfig.json`\n\n**Retry budget:** Max **3 repair rounds** per failure. If 3 rounds of diagnose → fix → retry don't resolve it, stop and report what was tried.\n\n## Prerequisites\n\n```bash\nopencli doctor    # Verify extension + daemon connectivity\n```\n\n## When to Use This Skill\n\nUse when `opencli <site> <command>` fails with repairable errors:\n- **SELECTOR** — element not found (DOM changed)\n- **EMPTY_RESULT** — no data returned (API response changed)\n- **API_ERROR** / **NETWORK** — endpoint moved or broke\n- **PAGE_CHANGED** — page structure no longer matches\n- **COMMAND_EXEC** — runtime error in adapter logic\n- **TIMEOUT** — page loads differently, adapter waits for wrong thing\n\n## Before Entering Repair: \"Empty\" ≠ \"Broken\"\n\n`EMPTY_RESULT` — and sometimes a structurally-valid `SELECTOR` that returns nothing — is often **not an adapter bug**. Platforms actively degrade results under anti-scrape heuristics, and a \"not found\" response from the site doesn't mean the content is actually missing. Rule this out **before** committing to a repair round:\n\n- **Retry with an alternative query or entry point.** If `opencli xiaohongshu search \"X\"` returns 0 but `opencli xiaohongshu search \"X 攻略\"` returns 20, the adapter is fine — the platform was shaping results for the first query.\n- **Spot-check in a normal Chrome tab.** If the data is visible in the user's own browser but the adapter comes back empty, the issue is usually authentication state, rate limiting, or a soft block — not a code bug. The fix is `opencli doctor` / re-login, not editing source.\n- **Look for soft 404s.** Sites like xiaohongshu / weibo / douyin return HTTP 200 with an empty payload instead of a real 404 when an item is hidden or deleted. The snapshot will look structurally correct. A retry 2-3 seconds later often distinguishes \"temporarily hidden\" from \"actually gone\".\n- **\"0 results\" from a search is an answer.** If the adapter successfully reached the search endpoint, got an HTTP 200, and the platform returned `results: []`, that is a valid answer — report it to the user as \"no matches for this query\" rather than patching the adapter.\n\nOnly proceed to Step 1 if the empty/selector-missing result is **reproducible across retries and alternative entry points**. Otherwise you're patching a working adapter to chase noise, and the patched version will break the next working path.\n\n## Step 1: Collect Trace Context\n\nRun the failing command with failure-retained trace enabled:\n\n```bash\nopencli <site> <command> [args...] --trace retain-on-failure 2>trace-error.yaml\n```\n\nOn failure, stderr contains the normal error envelope plus a small `trace` block:\n\n```yaml\nok: false\nerror:\n  code: SELECTOR\n  message: \"Could not find element: .old-selector\"\ntrace:\n  schemaVersion: 1\n  opencliVersion: \"...\"\n  traceId: \"...\"\n  dir: \"/path/to/.opencli/profiles/default/traces/...\"\n  summaryPath: \"/path/to/.opencli/profiles/default/traces/.../summary.md\"\n  receiptPath: \"/path/to/.opencli/profiles/default/traces/.../receipt.json\"\n```\n\nRead `summaryPath` first. It is the LLM-oriented entry point and includes front matter:\n\n```yaml\n---\nschemaVersion: 1\nopencliVersion: \"...\"\ntraceId: \"...\"\nstatus: failure\nsite: \"example\"\ncommand: \"example/search\"\nadapterSourcePath: \"/path/to/clis/example/search.js\"\nerrorCode: \"SELECTOR\"\nerrorMessage: \"Could not find element: .old-selector\"\n---\n```\n\nThe artifact directory contains:\n\n```text\nsummary.md      # start here\nreceipt.json    # machine-readable trace receipt\ntrace.jsonl     # full redacted timeline\nnetwork.jsonl   # redacted network events\nconsole.jsonl   # redacted console events\nstate/          # final snapshots when available\nscreenshots/    # final screenshots when available\n```\n\nIf you redirected stderr to a file, read that file and copy `trace.summaryPath`.\n\nDo not ask the user to rerun with legacy diagnostic env vars. Trace is the repair evidence path.\n\n## Step 2: Analyze the Failure\n\nRead the trace summary and the adapter source. Classify the root cause:\n\n| Error Code | Likely Cause | Repair Strategy |\n|-----------|-------------|-----------------|\n| SELECTOR | DOM restructured, class/id renamed | Explore current DOM → find new selector |\n| EMPTY_RESULT | API response schema changed, or data moved | Check network → find new response path |\n| API_ERROR | Endpoint URL changed, new params required | Discover new API via network intercept |\n| AUTH_REQUIRED | Login flow changed, cookies expired | **STOP** — tell user to log in, do not modify code |\n| TIMEOUT | Page loads differently, spinner/lazy-load | Add/update wait conditions |\n| PAGE_CHANGED | Major redesign | May need full adapter rewrite |\n\n**Key questions to answer:**\n1. What is the adapter trying to do? (Read the file at `adapterSourcePath`)\n2. What did the page look like when it failed? (Read `summary.md`, then `state/` if needed)\n3. What network requests happened? (Read `Failed Network` in `summary.md`, then `network.jsonl` if needed)\n4. What's the gap between what the adapter expects and what the page provides?\n\n## Step 3: Explore the Current Website\n\nUse `opencli browser` to inspect the live website. **Never use the broken adapter** — it will just fail again.\n\n### DOM changed (SELECTOR errors)\n\n```bash\n# Open the page and inspect current DOM\nopencli browser open https://example.com/target-page && opencli browser --session research state\n\n# Look for elements that match the adapter's intent\n# Compare the snapshot with what the adapter expects\n```\n\n### API changed (API_ERROR, EMPTY_RESULT)\n\n```bash\n# Open page with network interceptor, then trigger the action manually\nopencli browser open https://example.com/target-page && opencli browser --session research state\n\n# Interact to trigger API calls\nopencli browser click <N> && opencli browser --session research network\n\n# Narrow to the request you care about by the fields its body should have\nopencli browser --session research network --filter author,text,likes\n\n# Inspect specific API response (key is the `key` field from the default JSON output)\nopencli browser --session research network --detail <key>\n```\n\n## Step 4: Patch the Adapter\n\nRead the adapter source file at `adapterSourcePath` from the trace summary front matter and make targeted fixes. This path is authoritative — it may be in the repo (`clis/`) or user-local (`~/.opencli/clis/`).\n\nUse the `Read` tool on the exact path from summary.md front matter.\n\n### Common Fixes\n\n**Selector update:**\n```typescript\n// Before: page.evaluate('document.querySelector(\".old-class\")...')\n// After:  page.evaluate('document.querySelector(\".new-class\")...')\n```\n\n**API endpoint change:**\n```typescript\n// Before: const resp = await page.evaluate(`fetch('/api/v1/old-endpoint')...`)\n// After:  const resp = await page.evaluate(`fetch('/api/v2/new-endpoint')...`)\n```\n\n**Response schema change:**\n```typescript\n// Before: const items = data.results\n// After:  const items = data.data.items  // API now nests under \"data\"\n```\n\n**Wait condition update:**\n```typescript\n// Before: await page.wait({ selector: '.loading-spinner', hidden: true })\n// After:  await page.wait({ selector: '[data-loaded=\"true\"]' })\n```\n\n### Rules for Patching\n\n1. **Make minimal changes** — fix only what's broken, don't refactor\n2. **Keep the same output structure** — `columns` and return format must stay compatible\n3. **Prefer API over DOM scraping** — if you discover a JSON API during exploration, switch to it\n4. **Use `@jackwener/opencli/*` imports only** — never add third-party package imports\n5. **Test after patching** — run the command again to verify\n6. **Never relax `verify/<cmd>.json` fixtures to silence a failure.** A failing `patterns` / `notEmpty` / `mustNotContain` / `mustBeTruthy` rule means the adapter's output is broken. Tighten the adapter so it produces correct values; do not loosen the fixture to accept the broken values. The one legitimate reason to edit a fixture during repair is when the **site itself** changed shape (e.g. URL format migration) — in that case update the fixture and note the change in `~/.opencli/sites/<site>/notes.md`. Otherwise editing the fixture is covering up a silent correctness regression.\n\n## Step 5: Verify the Fix\n\n```bash\n# Run the command normally\nopencli <site> <command> [args...]\n```\n\nIf it still fails, go back to Step 1 and collect a fresh trace. You have a budget of **3 repair rounds** (trace → fix → retry). If the same error persists after a fix, try a different approach. After 3 rounds, stop and report what was tried.\n\n## Step 6: File an Upstream Issue\n\nIf the retry **passes**, the local adapter has drifted from upstream. File a GitHub issue so the fix flows back to `jackwener/OpenCLI`.\n\n**Do NOT file for:**\n- `AUTH_REQUIRED`, `BROWSER_CONNECT`, `ARGUMENT`, `CONFIG` — environment/usage issues, not adapter bugs\n- CAPTCHA or rate limiting — not fixable upstream\n- Failures you couldn't actually fix (3 rounds exhausted)\n\n**Only file after a verified local fix** — the retry must pass first.\n\n**Procedure:**\n\n1. Prepare the issue content from the trace summary you already have:\n   - **Title:** `[autofix] <site>/<command>: <error_code>` (e.g. `[autofix] zhihu/hot: SELECTOR`)\n   - **Body** (use this template):\n\n```markdown\n## Summary\nOpenCLI autofix repaired this adapter locally, and the retry passed.\n\n## Adapter\n- Site: `<site>`\n- Command: `<command>`\n- OpenCLI version: `<version from opencli --version>`\n\n## Original failure\n- Error code: `<error_code>`\n\n~~~\n<error_message>\n~~~\n\n## Local fix summary\n\n~~~\n<1-2 sentence description of what you changed and why>\n~~~\n\n_Issue filed by OpenCLI autofix after a verified local repair._\n```\n\n2. **Ask the user before filing.** Show them the draft title and body. Only proceed if they confirm.\n\n3. If the user approves and `gh auth status` succeeds:\n\n```bash\ngh issue create --repo jackwener/OpenCLI \\\n  --title \"[autofix] <site>/<command>: <error_code>\" \\\n  --body \"<the body above>\"\n```\n\nIf `gh` is not installed or not authenticated, tell the user and skip — do not error out.\n\n## When to Stop\n\n**Hard stops (do not modify code):**\n- **AUTH_REQUIRED / BROWSER_CONNECT** — environment issue, not adapter bug\n- **Site requires CAPTCHA** — can't automate this\n- **Rate limited / IP blocked** — not an adapter issue\n\n**Soft stops (report after attempting):**\n- **3 repair rounds exhausted** — stop, report what was tried and what failed\n- **Feature completely removed** — the data no longer exists\n- **Major redesign** — needs full adapter rewrite via `opencli-adapter-author` skill\n\nIn all stop cases, clearly communicate the situation to the user rather than making futile patches.\n\n## Example Repair Session\n\n```\n1. User runs: opencli zhihu hot\n   → Fails: SELECTOR \"Could not find element: .HotList-item\"\n\n2. AI runs: opencli zhihu hot --trace retain-on-failure 2>trace-error.yaml\n   → Gets trace summary with final state and failed action evidence\n\n3. AI reads summary/state: page loaded but uses \".HotItem\" instead of \".HotList-item\"\n\n4. AI explores: opencli browser open https://www.zhihu.com/hot && opencli browser --session research state\n   → Confirms new class name \".HotItem\" with child \".HotItem-content\"\n\n5. AI patches: Edit adapter at `adapterSourcePath` — replace \".HotList-item\" with \".HotItem\"\n\n6. AI verifies: opencli zhihu hot\n   → Success: returns hot topics\n\n7. AI prepares upstream issue draft, shows it to the user\n\n8. User approves → AI runs: gh issue create --repo jackwener/OpenCLI --title \"[autofix] zhihu/hot: SELECTOR\" --body \"...\"\n```\n","frontmatter":{"name":"opencli-autofix","description":"Automatically fix broken OpenCLI adapters when commands fail. Load this skill when an opencli command fails — it guides you through collecting a trace artifact, patching the adapter, retrying, and filing an upstream GitHub issue after a verified fix. Works with any AI agent.","allowed-tools":"Bash(opencli:*), Bash(gh:*), Read, Edit, Write","allowedTools":["Bash(opencli:*),","Bash(gh:*),","Read,","Edit,","Write"]},"allowedTools":["Bash(opencli:*),","Bash(gh:*),","Read,","Edit,","Write"],"isInternal":false,"tokens":3122,"sizeBytes":12901},{"name":"SKILL.md","path":"skills/opencli-browser/SKILL.md","rawUrl":"https://raw.githubusercontent.com/jackwener/OpenCLI/HEAD/skills/opencli-browser/SKILL.md","title":"opencli-browser","category":"anthropic-skill","format":"markdown","content":"---\nname: opencli-browser\ndescription: Drive Chrome with OpenCLI to inspect pages, interact with logged-in sites, capture API evidence, and manage native tabs, groups and windows. For reusable adapters, see opencli-adapter-author.\nallowed-tools: Bash(opencli:*), Read, Edit, Write\n---\n\n# Browser tools for agents\n\nUse the capability that fits the task. Discover commands with `opencli list <query>`;\nread exact arguments with `opencli browser <command> --help -f json`.\nRun `opencli doctor` when diagnosing a connection problem.\n\n## Scope and results\n\nPage commands require `--session <name>`. Reuse that name across a task; different\nsessions isolate page work. `--profile <name>` selects a Chrome profile when needed.\nOptions can follow command arguments:\n\n```sh\nopencli browser open https://example.com --session research\nopencli browser state --session research\nopencli browser click --role button --name Search --session research\nopencli browser close --session research\n```\n\nDefault output is one JSON value: `{ \"ok\": true, \"data\": ... }`.\nFailures return `{ \"ok\": false, \"error\": { \"code\": \"...\", \"message\": \"...\", \"hint\": \"...\" } }`\nand a nonzero exit code. Diagnostic logs go to stderr. `network --follow` and\n`console --follow` stream one envelope per line. Help, version and shell completion\nare inspection/export surfaces; structured help uses `--help -f json`.\n\nRead the returned data and error fields. When `error.outcome` is `unknown`, inspect\nthe affected state before retrying a write.\n\n## Page targets and interaction\n\n- `state` returns URL and interactive element refs. `--diagnostics` also reports\n  page title and available initial-state globals; `--source ax` selects the AX tree.\n- `find` locates elements. Interaction targets accept a ref, CSS selector, or\n  semantic flags such as `--role`, `--name`, `--label`, `--text`, `--testid`.\n- Use `--nth` to resolve deliberate multiple matches. On stale refs, read fresh\n  state or locate the element again; refs describe the observed page.\n- `fill` replaces a field and verifies its value; `type` types text. Other actions\n  include `click`, `select`, `check`, `upload`, `drag`, `keys` and `dialog`.\n- `get text|value|attributes|html`, `extract`, `screenshot` and `eval` expose page data.\n  `eval` executes JavaScript in the page and can mutate state.\n- `wait xhr|selector|text|download` observes an expected condition. Consult help for\n  timeouts and matching rules.\n- `tab list|new|select|close` manages session tabs. `--tab` accepts the page identity\n  returned by these commands; these are different from numeric native Chrome IDs.\n- `frames` lists frame targets; `eval --frame` evaluates in a selected frame.\n\n`bind --session research` attaches the current user tab. Bound sessions permit\nnavigation but block tab creation, selection and closure. `unbind` releases the\nbinding without closing the user tab. Owned sessions keep leased tabs until\n`close` or idle expiry. `--window foreground|background` controls owned windows.\n\n## Network and scripts\n\n```sh\nopencli browser network --session research\nopencli browser network --detail RequestKey --session research\nopencli browser network --static --session research\n```\n\n`network` returns captured request keys and response shapes. `--detail` reads a\ncached request; `--filter` selects shapes; `--raw` exposes response bodies.\n`--static` adds Acorn AST candidates from loaded scripts, with source locations,\nmethods, query/body parameter names and observed request evidence. `EXPR` means\nan unresolved expression. Static candidates are not executed or proven APIs.\nCheck `analysisComplete`, script errors, omitted scripts and `networkSource`.\nAn empty live capture can reuse a recent cache; reported provenance distinguishes it.\n\n## Native Chrome management\n\nNative calls need no session and do not attach a page:\n\n```sh\nopencli browser chrome describe\nopencli browser chrome describe tabs.group\nopencli browser chrome call tabs.query '[{}]'\nopencli browser chrome call tabs.group '[{\"tabIds\":[123,456],\"createProperties\":{\"windowId\":10}}]'\nopencli browser chrome call tabGroups.update '[789,{\"title\":\"Research\",\"color\":\"blue\"}]'\n```\n\nUse numeric IDs returned by Chrome, replacing the example IDs. `describe` lists\nsupported methods, signatures, live permissions and official documentation.\nNative-created tabs/windows persist. Mutations touching active OpenCLI session\ntabs or managed groups return `managed_resource_conflict`; ordinary groups can be\ncreated, renamed, recolored, moved and dissolved with `tabs.ungroup`.\n","frontmatter":{"name":"opencli-browser","description":"Drive Chrome with OpenCLI to inspect pages, interact with logged-in sites, capture API evidence, and manage native tabs, groups and windows. For reusable adapters, see opencli-adapter-author.","allowed-tools":"Bash(opencli:*), Read, Edit, Write","allowedTools":["Bash(opencli:*),","Read,","Edit,","Write"]},"allowedTools":["Bash(opencli:*),","Read,","Edit,","Write"],"isInternal":false,"tokens":1020,"sizeBytes":4545},{"name":"SKILL.md","path":"skills/opencli-usage/SKILL.md","rawUrl":"https://raw.githubusercontent.com/jackwener/OpenCLI/HEAD/skills/opencli-usage/SKILL.md","title":"opencli-usage","category":"anthropic-skill","format":"markdown","content":"---\nname: opencli-usage\ndescription: Discover and invoke OpenCLI capabilities for websites and Chrome. Find commands, inspect schemas, interpret results and choose browser or adapter tools.\nallowed-tools: Bash(opencli:*), Read\n---\n\n# OpenCLI\n\nOpenCLI exposes reusable website adapters and direct Chrome tools to agents.\n\n```sh\nopencli list search\nopencli list --site github --limit 20\nopencli github search --help -f json\n```\n\n`list [query]` searches command names and descriptions across built-ins and adapters.\nResults are compact and paginated: `{ok:true,data:{commands,total,nextOffset?}}`.\nPass `--offset <nextOffset>` to continue. Inspect exact inputs using command help.\nDo not hard-code the adapter catalog or dump all commands into context.\n\n## Results\n\nCommands default to JSON: `{ok:true,data:...}`. Failures produce a nonzero exit\ncode and `{ok:false,error:{code,message,hint?,...}}` on stdout. Logs use stderr.\nExplicit `-f table|yaml|csv|md|plain` exports are available where command help lists\nthem. Help, version, completion and explicit plain exports retain their own formats.\nBrowser follow modes stream one JSON envelope per line.\n\nUse error codes and supplied candidates/keys to recover. Unknown write outcomes\nrequire inspecting state before repeating an action. An empty successful result\nis distinct from an execution failure.\n\n## Browser\n\n```sh\nopencli browser open https://example.com --session research\nopencli browser state --session research\nopencli browser close --session research\nopencli browser chrome describe\n```\n\nPage commands use `--session`; native Chrome methods need no session. `--profile`\nselects a connected Chrome profile. `profile list` discovers profiles and\n`profile use <name>` selects the default. Use `doctor` for connection diagnosis.\nSee `opencli-browser` for interaction, API evidence and tab/group management.\n\n## Adapters and installation\n\n```sh\nopencli adapter init example/search\nopencli adapter check example/search\nopencli adapter test example/search --args '{\"query\":\"hello\"}'\nopencli adapter status\n```\n\nSee `opencli-adapter-author` for implementation. `adapter eject` makes an official\nsite editable locally; `adapter reset` removes that local override. Adapter tests\nexecute real commands and can perform writes.\n\n`plugin list|install|update|uninstall|create` manages plugin packages. `auth status`\nreports website login facts; `auth refresh` touches sessions. `daemon status|restart|stop`\nmanages the bridge process. Read help for the selected capability.\n\nBundled skill content is available through `skills list` and\n`skills read opencli-browser -f plain` (omit `-f plain` for JSON).\n","frontmatter":{"name":"opencli-usage","description":"Discover and invoke OpenCLI capabilities for websites and Chrome. Find commands, inspect schemas, interpret results and choose browser or adapter tools.","allowed-tools":"Bash(opencli:*), Read","allowedTools":["Bash(opencli:*),","Read"]},"allowedTools":["Bash(opencli:*),","Read"],"isInternal":false,"tokens":575,"sizeBytes":2648},{"name":"sources-ai.md","path":"skills/smart-search/references/sources-ai.md","rawUrl":"https://raw.githubusercontent.com/jackwener/OpenCLI/HEAD/skills/smart-search/references/sources-ai.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# AI 默认源\n\n当用户没有明确指定网站时，先在 `grok`、`doubao`、`gemini` 中选择一个，不要一开始并行跑多个 AI 源。\n\n## 使用规则\n\n1. 先运行 `opencli list -f yaml`\n2. 确认 `grok`、`doubao`、`gemini` 哪些在当前 registry 中可用\n3. 运行 `opencli <site> -h`\n4. 锁定具体子命令后，再运行 `opencli <site> <command> -h`\n\n## 路由建议\n\n### grok\n\n- 适用：实时热点、Twitter/X 语境、英文互联网讨论、舆论与趋势\n- 补充源常见去向：`twitter`、`reddit`、`reuters`、`google`\n- 搜索词建议：\n  - 加上时间范围，例如“today / this week”\n  - 加上平台范围，例如“on X”, “from social posts”\n  - 加上目标，例如“latest reactions”, “main viewpoints”, “key claims”\n  - 示例：\n    - `OpenAI latest reactions on X this week`\n    - `TSLA earnings main viewpoints on social media April 2026`\n    - `Nintendo Switch 2 rumors latest discussion on X`\n\n### doubao\n\n- 适用：中文语境、国内热点、生活方式、字节生态、中文泛问答\n- 补充源常见去向：`xiaohongshu`、`weibo`、`zhihu`、`bilibili`、`36kr`\n- 搜索词建议：\n  - 加上中文场景限定，例如“中文讨论”“国内用户”“小红书/微博语境”\n  - 加上需求目标，例如“帮我总结”“给我对比”“提取推荐理由”\n  - 加上人群或用途，例如“适合新手”“预算 500 元内”“上海求职”\n  - 示例：\n    - `2026年MacBook Air值得买吗 中文讨论里主要观点是什么`\n    - `上海 AI 产品经理招聘趋势 近一个月中文信息总结`\n    - `敏感肌防晒推荐 中文用户常提到的优缺点`\n\n### gemini\n\n- 适用：全球网页、英文资料、背景综述、通用检索\n- 补充源常见去向：`google`、`wikipedia`、`arxiv`、`stackoverflow`\n- 搜索词建议：\n  - 加上主题类型，例如“overview”, “comparison”, “background”, “best sources”\n  - 加上范围限定，例如地区、时间、语言、行业\n  - 加上结果形式，例如“with sources”, “compare pros and cons”, “official guidance”\n  - 示例：\n    - `MCP overview and official guidance with sources`\n    - `best budget travel destinations in Japan April 2026 compare pros and cons`\n    - `TypeScript decorators current status official sources`\n\n## 补充原则\n\n- 先用一个 AI 源拿到初步答案\n- 若答案缺少原始数据、垂直结果或权威来源，再补 1-2 个专用源\n- 不要把 AI 默认源当成命令签名的事实来源；命令细节始终以 `opencli ... -h` 为准\n\n## 通用写法模板\n\n可直接按下面模板构造 AI 查询：\n\n- 热点/新闻：\n  `<事件> + 最新进展 + <时间范围> + <地区/平台>`\n- 对比/推荐：\n  `<对象A> vs <对象B> + <关注维度> + <人群/预算/用途>`\n- 中文社区：\n  `<主题> + 中文讨论里主要观点 + <时间范围>`\n- 全球资料：\n  `<主题> + overview/background + with sources`\n- 求职：\n  `<岗位> + <城市/国家> + market trends/hiring + <时间范围>`\n- 购物：\n  `<商品> + reviews/price/value + <地区/预算>`\n","isInternal":false,"tokens":1107,"sizeBytes":3142},{"name":"sources-info.md","path":"skills/smart-search/references/sources-info.md","rawUrl":"https://raw.githubusercontent.com/jackwener/OpenCLI/HEAD/skills/smart-search/references/sources-info.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# 资讯 / 知识\n\n用于新闻、百科、通用知识、中文科技资讯。\n\n## 站点\n\n### google\n\n- 适用：通用网页搜索、跨站点兜底\n- 使用前先运行：`opencli google -h`\n\n### wikipedia\n\n- 适用：名词解释、背景知识、历史事实\n- 使用前先运行：`opencli wikipedia -h`\n\n### reuters\n\n- 适用：国际新闻、事实性报道\n- 使用前先运行：`opencli reuters -h`\n\n### weibo\n\n- 适用：微博热点、话题、中文舆论\n- 使用前先运行：`opencli weibo -h`\n\n### 36kr\n\n- 适用：中文科技、创业、融资资讯\n- 使用前先运行：`opencli 36kr -h`\n\n### substack\n\n- 适用：newsletter、作者订阅内容、长文\n- 使用前先运行：`opencli substack -h`\n\n## 路由提示\n\n- 背景知识与定义，优先 `wikipedia`\n- 通用网页与跨站信息，优先 `google`\n- 国际新闻优先 `reuters`\n- 中文科技资讯优先 `36kr`\n- 当 AI 回答过于泛化时，用这些源补充事实和链接\n","isInternal":false,"tokens":378,"sizeBytes":968},{"name":"sources-media.md","path":"skills/smart-search/references/sources-media.md","rawUrl":"https://raw.githubusercontent.com/jackwener/OpenCLI/HEAD/skills/smart-search/references/sources-media.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# 媒体 / 娱乐\n\n用于视频、电影、剧集、音乐、播客、内容平台搜索。\n\n## 站点\n\n### bilibili\n\n- 适用：中文视频、教程、评测、UP 主内容\n- 使用前先运行：`opencli bilibili -h`\n\n### youtube\n\n- 适用：英文视频、教程、评测、访谈\n- 使用前先运行：`opencli youtube -h`\n\n### tiktok\n\n- 适用：短视频、热点、娱乐内容\n- 使用前先运行：`opencli tiktok -h`\n\n### imdb\n\n- 适用：电影、剧集、演员、评分\n- 使用前先运行：`opencli imdb -h`\n\n### douban\n\n- 适用：中文电影、书籍、音乐口碑与评分\n- 使用前先运行：`opencli douban -h`\n\n### spotify\n\n- 适用：歌曲、歌手、专辑\n- 使用前先运行：`opencli spotify -h`\n\n### apple-podcasts\n\n- 适用：播客节目、播客搜索\n- 使用前先运行：`opencli apple-podcasts -h`\n\n### medium\n\n- 适用：长文、技术博客、设计与产品文章\n- 使用前先运行：`opencli medium -h`\n\n## 路由提示\n\n- 用户只说“找视频/影评/播客/文章”，先按内容形态选站点\n- 用户没有指定站点时，可先用 `doubao`、`grok` 或 `gemini` 做一轮粗检索，再补媒体站点拿原始结果\n","isInternal":false,"tokens":441,"sizeBytes":1185},{"name":"sources-other.md","path":"skills/smart-search/references/sources-other.md","rawUrl":"https://raw.githubusercontent.com/jackwener/OpenCLI/HEAD/skills/smart-search/references/sources-other.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# 其他垂直源\n\n用于求职、金融、书籍、词典等不适合归入前述分类的场景。\n\n## 站点\n\n### linkedin\n\n- 适用：全球职位、英文岗位、跨国公司招聘\n- 使用前先运行：`opencli linkedin -h`\n\n### boss\n\n- 适用：国内职位搜索、招聘与岗位信息\n- 使用前先运行：`opencli boss -h`\n\n### xueqiu\n\n- 适用：股票、金融讨论、行情相关线索\n- 使用前先运行：`opencli xueqiu -h`\n\n### weread\n\n- 适用：中文书籍搜索与书单线索\n- 使用前先运行：`opencli weread -h`\n\n### dictionary\n\n- 适用：英文词义、基础词典查询\n- 使用前先运行：`opencli dictionary -h`\n\n### sinablog\n\n- 适用：较旧的中文博客内容\n- 使用前先运行：`opencli sinablog -h`\n\n## 路由提示\n\n- 求职：全球优先 `linkedin`，国内优先 `boss`\n- 金融：优先 `xueqiu`\n- 书籍：中文优先 `weread`\n- 英文单词定义：优先 `dictionary`\n","isInternal":false,"tokens":369,"sizeBytes":935},{"name":"sources-shopping.md","path":"skills/smart-search/references/sources-shopping.md","rawUrl":"https://raw.githubusercontent.com/jackwener/OpenCLI/HEAD/skills/smart-search/references/sources-shopping.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# 购物\n\n用于商品搜索、价格、好价、口碑与区域电商结果。\n\n## 站点\n\n### amazon\n\n- 适用：全球商品搜索、价格参考、英文电商\n- 使用前先运行：`opencli amazon -h`\n\n### smzdm\n\n- 适用：国内好价、优惠、导购、商品讨论\n- 使用前先运行：`opencli smzdm -h`\n\n### coupang\n\n- 适用：韩国电商商品搜索\n- 使用前先运行：`opencli coupang -h`\n\n### douban\n\n- 适用：图书、影视、音乐类口碑补充，不是标准电商，但可用于消费决策补充\n- 使用前先运行：`opencli douban -h`\n\n## 路由提示\n\n- 用户指定平台时直接使用该平台\n- 未指定平台时，全球商品优先 `amazon`，国内好价优先 `smzdm`\n- 若先用 AI 做产品调研，后续可补电商站点拿实际商品结果\n","isInternal":false,"tokens":296,"sizeBytes":795},{"name":"sources-social.md","path":"skills/smart-search/references/sources-social.md","rawUrl":"https://raw.githubusercontent.com/jackwener/OpenCLI/HEAD/skills/smart-search/references/sources-social.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# 社交媒体\n\n用于需要原始帖子、原始用户结果、中文或英文社区讨论时。\n\n## 站点\n\n### twitter\n\n- 适用：Twitter/X 原始帖子、作者、实时讨论\n- 使用前先运行：`opencli twitter -h`\n\n### weibo\n\n- 适用：微博热点、话题、中文舆论\n- 使用前先运行：`opencli weibo -h`\n\n### xiaohongshu\n\n- 适用：生活方式、穿搭、美妆、旅行、真实体验\n- 使用前先运行：`opencli xiaohongshu -h`\n\n### zhihu\n\n- 适用：中文深度问答、专业解释、行业经验\n- 使用前先运行：`opencli zhihu -h`\n\n### tieba\n\n- 适用：兴趣圈子、历史帖子、粉丝社区\n- 使用前先运行：`opencli tieba -h`\n\n### instagram\n\n- 适用：账号搜索、图片社交线索\n- 使用前先运行：`opencli instagram -h`\n\n### facebook\n\n- 适用：主页、人物、帖子线索\n- 使用前先运行：`opencli facebook -h`\n\n### bluesky\n\n- 适用：Bluesky 用户与账号搜索\n- 使用前先运行：`opencli bluesky -h`\n\n## 路由提示\n\n- 用户明确指定某个平台时，直接用该平台\n- 用户只说“社交媒体上怎么看”时，可先选 `grok` 或 `doubao`\n- AI 只给摘要而没有原始帖子时，再切到对应社交站点\n","isInternal":false,"tokens":463,"sizeBytes":1216},{"name":"sources-tech.md","path":"skills/smart-search/references/sources-tech.md","rawUrl":"https://raw.githubusercontent.com/jackwener/OpenCLI/HEAD/skills/smart-search/references/sources-tech.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# 技术 / 学术\n\n用于技术问题、研究论文、开发者讨论、开源社区信息。\n\n## 站点\n\n### arxiv\n\n- 适用：论文、研究、模型、算法、学术背景\n- 使用前先运行：`opencli arxiv -h`\n\n### stackoverflow\n\n- 适用：具体报错、API 用法、代码模式、实现细节\n- 使用前先运行：`opencli stackoverflow -h`\n\n### hackernews\n\n- 适用：技术社区讨论、开发者观点、创业和产品话题\n- 使用前先运行：`opencli hackernews -h`\n\n### reddit\n\n- 适用：英文社区问答、经验贴、推荐、对比讨论\n- 使用前先运行：`opencli reddit -h`\n\n### linux-do\n\n- 适用：人工智能、开源工具、中文技术社区\n- 使用前先运行：`opencli linux-do -h`\n\n### v2ex\n\n- 适用：中文技术社区\n- 使用前先运行：`opencli linux-do \n\n## 路由提示\n\n- 用户明确说“论文”“研究”时，优先 `arxiv`\n- 用户明确说“报错”“API 怎么用”时，优先 `stackoverflow`\n- 用户要“社区讨论”“开发者观点”时，优先 `hackernews` 或 `reddit`\n- 若用户没有明确指定站点，可先选 `gemini` 或 `grok`，内容不足时再补这些专用源\n","isInternal":false,"tokens":431,"sizeBytes":1171},{"name":"sources-travel.md","path":"skills/smart-search/references/sources-travel.md","rawUrl":"https://raw.githubusercontent.com/jackwener/OpenCLI/HEAD/skills/smart-search/references/sources-travel.md","title":"References Skill","category":"anthropic-skill","format":"markdown","content":"# 旅游\n\n用于目的地、景区、酒店联想和旅行线索。\n\n## 站点\n\n### ctrip\n\n- 适用：目的地、景区、酒店、旅行联想搜索\n- 使用前先运行：`opencli ctrip -h`\n\n### xiaohongshu\n\n- 适用：生活方式、穿搭、美妆、旅行、真实体验\n- 使用前先运行：`opencli xiaohongshu -h`\n\n## 路由提示\n\n- 用户明确说“携程”时直接使用 `ctrip`\n- 用户只说“旅行/目的地/景区”时，可先选 `doubao` 做中文语境粗检索，再补 `ctrip`\n","isInternal":false,"tokens":209,"sizeBytes":506},{"name":"SKILL.md","path":"skills/smart-search/SKILL.md","rawUrl":"https://raw.githubusercontent.com/jackwener/OpenCLI/HEAD/skills/smart-search/SKILL.md","title":"smart-search","category":"anthropic-skill","format":"markdown","content":"---\nname: smart-search\ndescription: 基于 opencli 命令的智能搜索路由器。当用户想要使用 OpenCLI、CLI 或 API 搜索、查询、查找或研究信息时，尤其是涉及指定网站、社交媒体、技术资料、新闻、购物、旅游、求职、金融或中文内容时，务必使用此 skill\n---\n\n# 智能搜索路由器\n\n根据话题和场景，将查询路由到最佳的 opencli 搜索源。此 skill 的核心目标不是记忆命令，而是先定位数据源，再让 Agent 通过 `opencli` 自己读取实时帮助，避免文档漂移。\n\n## 强制预检\n\n每次使用前，必须先做下面两步：\n\n- 运行 `opencli list -f yaml`\n- 用 live registry 确认候选站点是否存在，并检查 `strategy`、`browser`、`domain`\n\n选定站点后，必须再做下面两步：\n\n- 运行 `opencli <site> -h` 查看该站点有哪些子命令\n- 若已锁定某个子命令，再运行 `opencli <site> <command> -h` 查看参数、输出列、策略\n\n不要在 skill 文档里硬编码参数或假设命令签名；以 `opencli ... -h` 的实时输出为准。\n\n## 主路由规则\n\n只使用这一条规则，不再维护多套优先级：\n\n1. 当用户明确指定网站、平台或数据源时，直接使用对应网站。\n2. 当用户没有指定网站时，优先只选择一个 AI 源：`grok`、`doubao`、`gemini` 三选一。\n3. 当 AI 返回内容不足、缺少原始数据、需要权威佐证或需要垂直结果时，再补充 1-2 个专用源。\n\n## 单题预算与频率限制\n\n把“单个用户问题”理解为同一意图链路下的一次问题求解；同一轮追问、澄清、补充条件，若核心问题未变，仍算同一题。\n\n先建立一份站点调用台账。每次真正执行搜索命令后，立刻更新：\n\n- `site`\n- `query`\n- `count`\n- `status`\n\n计数规则：\n\n- `opencli list -f yaml`、`opencli <site> -h`、`opencli <site> <command> -h` 属于预检与帮助，不计入搜索次数\n- 一次真正的 `opencli <site> ...` 搜索/查询执行，计为该站点 1 次调用\n- 同站点因为报错、超时、验证码、反爬、登录态异常而失败，也算 1 次调用，不要无限重试\n\n频率上限：\n\n- AI 站点硬限制：同一题内，每个 AI 站点最多调用 1 次\n- 默认策略仍然是只选 1 个 AI 站点，不要把多个 AI 站点串成常规流程\n- 只有当用户明确要求比较多个 AI 站点时，才可以额外调用其他 AI 站点；但每个被点名的 AI 站点仍然最多 1 次\n- 非 AI 站点默认最多调用 2 次\n- 非 AI 站点第 2 次调用必须有明确理由，例如第一次结果过宽，需要加时间、地区、类别、排序或关键词限定\n- 非 AI 站点不要进行第 3 次调用；若信息仍不足，停止扩搜并明确说明缺口\n\n触发限频后的处理：\n\n- 记录：「已跳过：<site> 达到频率上限」\n- 优先改用其他同类站点\n- 若没有合适替代源，则直接基于已收集信息回答，并说明覆盖范围与缺口\n\n## 查询结束汇报\n\n每次查询结束后，回答末尾必须追加一段简短的“搜索摘要”，至少包含下面三项：\n\n- 使用了什么网站搜索\n- 每个网站搜了什么词\n- 每个网站搜了几次\n\n如果有被限频跳过的站点，也要明确写出。\n\n建议使用下面的固定格式：\n\n```md\n搜索摘要\n- 网站：<site1> | 查询词：<term1> | 次数：<n>\n- 网站：<site2> | 查询词：<term2>；<term3> | 次数：<n>\n- 已跳过：<site3>，原因：达到频率上限\n```\n\n## AI 源选择\n\n- `grok`\n  适合实时讨论、英文互联网舆论、Twitter/X 语境、热点追踪。\n- `doubao`\n  适合中文语境、字节抖音生态、生活方式内容、中文热点与泛中文问答。\n- `gemini`\n  适合全球网页、英文资料、通用信息检索、背景综述。\n\n如果用户没有指定网站，默认先判断语言和语境，再从这三个里只选一个。\n\n一旦某个 AI 站点已经执行过一次真实查询，就不要在同一题里改写关键词后再次调用该 AI 站点。若答案不足，优先补专用源，不要反复追打同一个 AI 站点。\n\n## AI 查询词建议\n\n当使用 AI 源时，不要只丢一个过短关键词。优先构造成“主题 + 目标 + 限定条件”的查询。\n\n- 主题\n  用户真正要查的对象、事件、产品、人物、公司、技术名词。\n- 目标\n  想要什么结果，例如总结、对比、原因、趋势、推荐、原始线索。\n- 限定条件\n  语言、地区、时间范围、平台范围、受众、价格带、岗位地点、是否要引用原始来源。\n\n优先使用下面这种表达方式：\n\n- `<主题> + <你要回答的问题>`\n- `<主题> + <时间范围/地区/语言>`\n- `<主题> + <平台或来源范围>`\n- `<主题> + <输出要求>`\n\n避免只输入：\n\n- 单个名词\n- 没有时间范围的热点问题\n- 没有地区限制的购物、求职、旅游问题\n- 没有平台限制的社交媒体问题\n\n## 专用源补充时机\n\n当出现以下任一情况时，再补充专用源：\n\n- AI 给出的是摘要，但你需要原始帖子、原始视频、原始商品或原始职位结果\n- AI 覆盖面不足，漏掉垂直站点信息\n- 需要更高权威性或更强领域相关性\n- 用户明确要求“从某个平台找”\n\n单次查询通常控制在 1 个 AI 源 + 1 到 2 个专用源，避免结果过载。\n\n## 处理不可用的源\n\n当站点不可用时：\n\n- 不要因为单个源失败而中止整个搜索\n- 记录：「已跳过：<site> 不可用」\n- 回退到同类其他站点，或回退到一个 AI 源\n- 始终以 `opencli list -f yaml` 与 `opencli <site> -h` 的实际结果为准\n\n不要假设任何站点“绝对可用”。即使是公开站点，也以当前环境中的 live help 和执行结果为准。\n\n## 参考文件\n\n根据需要读取对应文件：\n\n- **`references/sources-ai.md`** — AI 默认源\n- **`references/sources-tech.md`** — 技术 / 学术\n- **`references/sources-social.md`** — 社交媒体\n- **`references/sources-media.md`** — 媒体 / 娱乐\n- **`references/sources-info.md`** — 资讯 / 知识\n- **`references/sources-shopping.md`** — 购物\n- **`references/sources-travel.md`** — 旅游\n- **`references/sources-other.md`** — 其他垂直源\n\n只读与当前查询相关的文件，无需全部加载。\n","frontmatter":{"name":"smart-search","description":"基于 opencli 命令的智能搜索路由器。当用户想要使用 OpenCLI、CLI 或 API 搜索、查询、查找或研究信息时，尤其是涉及指定网站、社交媒体、技术资料、新闻、购物、旅游、求职、金融或中文内容时，务必使用此 skill"},"isInternal":false,"tokens":2364,"sizeBytes":6351}],"systemPromptSnippet":"<yakaai_skills repo=\"jackwener/OpenCLI\">\n<!-- File: llms.txt (Tokens: ~1073 | Category: root-instruction) -->\n# OpenCLI\n\n> General-purpose browser automation for AI agents, with reusable site commands.\n> An agent-browser alternative: navigate, inspect, click, fill, extract, and screenshot without a site adapter.\n\n## What OpenCLI does\n\nOpenCLI gives you one surface for three kinds of automation:\n\n1. **Built-in adapters** — 100+ sites pre-built with deterministic commands (Twitter/X, Xiaohongshu, Bilibili, Zhihu, LinkedIn, YouTube, Reddit, HackerNews, and more)\n2. **Browser automation for AI agents** — AI agents (Claude Code, Cursor, etc.) can navigate, click, type, fill, extract, and screenshot any page via `opencli browser` primitives and your logged-in Chrome session\n3. **Adapter authoring** — Write new site adapters end-to-end using `opencli-adapter-author` skill + `opencli browser recon/init/verify` workflow\n\n\n## OpenCLI as an agent-browser alternative\n\nOpenCLI supports general-purpose browser workflows without pre-built site adapters. The browser loop is: `opencli browser work open https://example.com`, `opencli browser work state`, then choose actions such as `click`, `fill`, or `extract`. Inspect state again after page changes; release the session with `opencli browser work close`.\n\nUse the same named session across calls. Element references, CLI arguments, outputs, and session lifecycle differ from agent-browser, so migrate and verify existing scripts rather than treating the CLIs as syntax-compatible. Adapters are optional shortcuts for recurring site tasks, not a prerequisite for browsing.\n\n- [OpenCLI vs agent-browser and migration](https://opencli.info/docs/guide/agent-browser-alternative.html)\n- [中文迁移指南](https://opencli.info/docs/zh/guide/agent-browser-alternative.html)\n- [Browser Bridge setup](https://opencli.info/docs/guide/browser-bridge.html)\n\n## Install\n\n```bash\nnpm install -g @jackwener/opencli\n# Install Browser Bridge extension from Chrome Web Store\n# Run: opencli doctor\n```\n\nRequires Node.js >= 20.\n\n## Key capabilities\n\n- **No built-in LLM dependency** — commands do not invoke an LLM; an AI agent orchestrating actions has its own model costs\n- **Account safe** — reuses Chrome's logged-in state, credentials never leave the browser\n- **Deterministic adapters** — site commands provide structured output schemas. Pipeable, CI-friendly\n- **Plugin system** — `opencli plugin install github:user/repo`\n- **Multiple output formats** — JSON, CSV, Markdown, YAML, table\n\n## Skills for AI agents\n\nInstall with: `npx skills add jackwener/opencli`\n\n- `opencli-browser` — give AI agents direct browser control (click, type, extract, screenshot)\n- `opencli-adapter-author` — write new adapters from recon to verification\n- `opencli-autofix` — repair broken adapters\n- `opencli-usage` — command and site reference\n\n## Built-in sites (selected)\n\nTwitter/X, Xiaohongshu, Bilibili, Zhihu, LinkedIn, YouTube, Reddit, HackerNews, Weibo, Amazon, Bloomberg, V2EX, Boss直聘, Douyin, Spotify, WeChat, Xiaoyuzhou, NotebookLM, Claude, Gemini, GeoGebra, xueqiu, zlibrary, upwork, 1688, pixiv, and 80+ more.\n\nFull list: https://github.com/jackwener/opencli/blob/main/docs/adapters/index.md\n\n## Configuration\n\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `OPENCLI_BROWSER_COMMAND_TIMEOUT` | `60` | Seconds to wait for a browser command |\n| `OPENCLI_PROFILE` | — | Browser Bridge profile alias for multi-profile setups |\n| `OPENCLI_VERBOSE` | `false` | Enable verbose logging |\n\n## Exit codes\n\n`0` success · `66` empty result · `69` Browser Bridge down · `75` timeout · `77` auth required · `78` config error · `130` Ctrl-C\n\n## Key links\n\n- Website: https://opencli.info\n- GitHub: https://github.com/jackwener/opencli\n- npm: https://www.npmjs.com/package/@jackwener/opencli\n- Chrome Extension: https://chromewebstore.google.com/detail/opencli/ildkmabpimmkaediidaifkhjpohdnifk\n- Docs: https://opencli.info\n- Plugins: https://opencli.info/plugins\n\n## Author\n\njackwener — https://github.com/jackwener\n\n## Related resources\n\n- Website llms.txt: https://opencli.info/llms.txt\n- Full adapter docs: https://github.com/jackwener/opencli/blob/main/docs/adapters/index.md\n- Extending guide: https://github.com/jackwener/opencli/blob/main/docs/guide/extending-opencli.md\n- Plugin guide: https://github.com/jackwener/opencli/blob/main/docs/guide/plugins.md\n\n<!-- File: skills/opencli-adapter-author/references/adapter-template.md (Tokens: ~6871 | Category: anthropic-skill) -->\n# Adapter Template\n\n一份 adapter 就是一次 `cli({...})` 调用。文件结构固定，三段：declaration、args、func。\n\n拿 `clis/eastmoney/convertible.js` 当活例子，对照拆解。\n\n---\n\n## 活例子：convertible.js\n\n> **注意（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(...)` 一起抄过去。\n\n```javascript\n// eastmoney convertible — on-market convertible bond listing.\nimport { cli, Strategy } from '@jackwener/opencli/registry';\nimport { CliError } from '@jackwener/opencli/errors';\n\nconst SORTS = {\n  change:   { fid: 'f3',   order: 'desc' },\n  drop:     { fid: 'f3',   order: 'asc' },\n  turnover: { fid: 'f6',   order: 'desc' },\n  price:    { fid: 'f2',   order: 'desc' },\n  premium:  { fid: 'f237', order: 'desc' },\n  value:    { fid: 'f236', order: 'desc' },\n  ytm:      { fid: 'f239', order: 'desc' },\n};\n\ncli({\n  site: 'eastmoney',\n  name: 'convertible',\n  description: '可转债行情列表（默认按成交额排序）',\n  domain: 'push2.eastmoney.com',\n  strategy: Strategy.PUBLIC,\n  browser: false,\n  args: [\n    { name: 'sort',  type: 'string', default: 'turnover', help: '排序：turnover / change / drop / price / premium' },\n    { name: 'limit', type: 'int',    default: 20,         help: '返回数量 (max 100)' },\n  ],\n  columns: ['rank', 'bondCode', 'bondName', 'bondPrice', 'bondChangePct',\n            'stockCode', 'stockName', 'stockPrice', 'stockChangePct',\n            'convPrice', 'convValue', 'convPremiumPct', 'remainingYears', 'ytm', 'listDate'],\n  func: async (args) => {\n    const sortKey = String(args.sort ?? 'turnover').toLowerCase();\n    const sort = SORTS[sortKey];\n    if (!sort) throw new CliError('INVALID_ARGUMENT', `Unknown sort \"${sortKey}\". Valid: ${Object.keys(SORTS).join(', ')}`);\n    const limit = Math.max(1, Math.min(Number(args.limit) || 20, 100));\n\n    const url = new URL('https://push2.eastmoney.com/api/qt/clist/get');\n    url.searchParams.set('pn', '1');\n    url.searchParams.set('pz', String(limit));\n    url.searchParams.set('po', sort.order === 'desc' ? '1' : '0');\n    url.searchParams.set('np', '1');\n    url.searchParams.set('fltt', '2');\n    url.searchParams.set('invt', '2');\n    url.searchParams.set('fid', sort.fid);\n    url.searchParams.set('fs', 'b:MK0354');\n    url.searchParams.set('fields', 'f12,f14,f2,f3,f6,f229,f230,f232,f234,f235,f236,f237,f238,f239,f243');\n    url.searchParams.set('ut', 'bd1d9ddb04089700cf9c27f6f7426281');\n\n    const resp = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0' } });\n    if (!resp.ok) throw new CliError('HTTP_ERROR', `convertible failed: HTTP ${resp.status}`);\n    const data = await resp.json();\n    const diff = Array.isArray(data?.data?.diff) ? data.data.diff : [];\n    if (diff.length === 0) throw new CliError('NO_DATA', 'eastmoney returned no convertible data');\n\n    return diff.slice(0, limit).map((it, i) => ({\n      rank: i + 1,\n      bondCode: it.f12,\n      bondName: it.f14,\n      bondPrice: it.f2,\n      bondChangePct: it.f3,\n      stockCode: it.f232,\n      stockName: it.f234,\n      stockPrice: it.f229,\n      stockChangePct: it.f230,\n      convPrice: it.f235,\n      convValue: it.f236,\n      convPremiumPct: it.f237,\n      remainingYears: it.f238,\n      ytm: it.f239,\n      listDate: String(it.f243 ?? ''),\n    }));\n  },\n});\n```\n\n---\n\n## 三段解剖\n\n### 1. Declaration — 标头\n\n```javascript\ncli({\n  site: 'eastmoney',          // 第一级命名空间，目录名一致\n  name: 'convertible',        // 第二级，CLI 上的子命令\n  description: '...',         // 一句话，出现在 `opencli list` 和 `opencli <site> -h`\n  domain: 'push2.eastmoney.com',  // 主要请求域名（诊断面板用）\n  strategy: Strategy.PUBLIC,  // PUBLIC / COOKIE / INTERCEPT / UI\n  browser: false,             // PUBLIC 几乎总是 false；COOKIE/INTERCEPT/UI 一律 true\n  ...\n});\n```\n\n### 2. Args & Columns\n\n```javascript\nargs: [\n  { name: 'sort',  type: 'string', default: 'turnover', help: '...' },\n  { name: 'limit', type: 'int',    default: 20,         help: '...' },\n],\ncolumns: ['rank', 'bondCode', 'bondName', /* ... */ ],\n```\n\n**规则**：\n\n- `type`: `string` / `int` / `float` / `bool`\n- `default` 必填（缺失的命令会拒绝启动）\n- `columns` 数组必须跟 `func` 返回的 object keys 完全对上，顺序也一致（决定表格列顺序）\n- 列名 camelCase，跟 `cli({...})` 其他 adapter 保持统一\n- **中间解析对象 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)）\n\n### 3. func — 主体\n\n```javascript\nimport { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';\n\nfunc: async (args) => {\n  // 1. 解析参数 — 越界一律抛，不要 silent clamp\n  const n = Number(args.limit ?? 20);\n  if (!Number.isInteger(n) || n <= 0) throw new ArgumentError('limit must be a positive integer');\n  if (n > 100) throw new ArgumentError('limit must be <= 100');\n  const limit = n;\n\n  // 2. 构造 URL / 请求\n  const url = new URL(...);\n  url.searchParams.set(...);\n\n  // 3. 发请求 — fetch 抛 / HTTP 非 2xx 都归 CommandExecutionError\n  let resp;\n  try {\n    resp = await fetch(url, { headers: { /* ... */ } });\n  } catch (error) {\n    throw new CommandExecutionError(`request failed: ${error?.message || error}`);\n  }\n  if (!resp.ok) throw new CommandExecutionError(`request failed: HTTP ${resp.status}`);\n\n  // 4. 解析 + 业务校验 — 业务空 → EmptyResultError，不要 sentinel row 也不要 return []\n  const data = await resp.json();\n  const diff = Array.isArray(data?.data?.diff) ? data.data.diff : [];\n  if (diff.length === 0) throw new EmptyResultError('site command', 'API returned no rows');\n\n  // 5. map 到 columns 同名 keys\n  return diff.slice(0, limit).map((it, i) => ({\n    rank: i + 1,\n    bondCode: it.f12,\n    // ...\n  }));\n},\n```\n\n**站点级 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。\n\n**参数形态**（**踩过最多次的坑**：搞反签名后 `args` 实际是 `debug` flag，所有 `args.foo` 静默 undefined → fallback 到 default。#1329 upstream 之前 8 个 non-browser adapter 写错过签名，全部 silently fallback 到默认参数）：\n\n- `browser: false`：`func: async (args, debug?) => { ... }` —— **单参 args**，不会收到 `page`\n- `browser: true`：`func: async (page, args, debug?) => { ... }` —— **双参 (page, args)**，第一参是浏览器上下文\n- `args`：所有 `args[]` 声明的参数解析后的 object\n\n**错误处理**：用 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）决定要不要重试。\n\n---\n\n## COOKIE adapter 骨架（需要登录态）\n\nPUBLIC 模式不够（接口 401 / 302 到 login / 响应是\"请登录\"页）就走这里。要点三条：\n\n1. 读 cookie 走 `page.getCookies(...)`，**不要读 `document.cookie`**。\n2. 拿 HTML 走 Node 端 `fetch` + 手动解码，**不要塞进 `page.evaluate` 里**。\n3. Declaration 加 `browser: true`；不需要真的打开目标页时 `navigateBefore: false`。\n\n```javascript\nimport { cli, Strategy } from '@jackwener/opencli/registry';\nimport { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';\n\nconst BASE = 'https://www.example.com';\nconst HOST = 'www.example.com';\nconst ROOT = '.example.com';  // 根域（auth 常在这里）\n\nasync function readCookie(page) {\n    const seen = new Map();\n    for (const opts of [{ domain: HOST }, { domain: ROOT }]) {\n        try {\n            const cookies = await page.getCookies(opts);\n            for (const c of cookies || []) {\n                if (!seen.has(c.name)) seen.set(c.name, c.value);\n            }\n        } catch { /* try next domain */ }\n    }\n    return [...seen].map(([k, v]) => `${k}=${v}`).join('; ');\n}\n\nasync function fetchHtml(url, { cookie, encoding = 'utf-8', headers = {} } = {}) {\n    let resp;\n    try {\n        resp = await fetch(url, {\n            headers: {\n                'User-Agent': 'Mozilla/5.0',\n                'Accept-Language': 'zh-CN,zh;q=0.9',\n                Referer: `${BASE}/`,\n                ...(cookie ? { Cookie: cookie } : {}),\n                ...headers,\n            },\n            redirect: 'follow',\n        });\n    } catch (error) {\n        throw new CommandExecutionError(`example request failed: ${error?.message || error}`);\n    }\n    if (!resp.ok) throw new CommandExecutionError(`example request failed: HTTP ${resp.status}`);\n    const buf = await resp.arrayBuffer();\n    return new TextDecoder(encoding).decode(buf);\n}\n\ncli({\n    site: 'example',\n    name: 'me',\n    access: 'read',\n    description: '示例：需要登录的私有页面',\n    domain: HOST,\n    strategy: Strategy.COOKIE,\n    browser: true,\n    navigateBefore: false,    // 本命令不需要先开目标页\n    args: [{ name: 'limit', type: 'int', default: 20, help: '返回条数' }],\n    columns: ['index', 'title', 'time'],\n    func: async (page, args) => {\n        const limit = Number(args.limit ?? 20);\n        if (!Number.isInteger(limit) || limit <= 0) throw new ArgumentError('limit must be a positive integer');\n\n        const cookie = await readCookie(page);\n        const html = await fetchHtml(`${BASE}/inbox`, { cookie, encoding: 'gbk' });\n\n        if (/请登录|需要登录|<title>Login/i.test(html)) {\n            throw new AuthRequiredError(HOST);\n        }\n\n        // parse html → rows\n        if (!rows.length) throw new EmptyResultError('example me', 'inbox is empty');\n        return rows.slice(0, limit);\n    },\n});\n```\n\n### JSON API 用 `page.fetchJson()`，不要手写 `page.evaluate(fetch(...))`\n\n如果接口必须在浏览器上下文里请求（依赖当前页面 cookie / CORS / origin），用内置 primitive：\n\n```javascript\nconst data = await page.fetchJson(`${BASE}/api/list`, {\n  method: 'POST',\n  headers: { 'X-Requested-With': 'XMLHttpRequest' },\n  body: { page: 1, size: limit },\n});\n```\n\n它固定 `credentials: 'include'`，带 timeout，HTTP 非 2xx / 非 JSON 会抛统一 runtime error。adapter 里不用再手写 `page.evaluate(fetch(...))`；如果你需要额外包一层业务语义，按 [`typed-errors.md`](./typed-errors.md) 映射到 `CommandExecutionError` / `AuthRequiredError` / `EmptyResultError`。\n\n### 页面内 DOM 逻辑用 `page.evaluate(fn, ...args)`\n\n新 adapter 优先写函数形式，外部变量通过参数传入：\n\n```javascript\nconst href = await page.evaluate((selector) => {\n  const link = document.querySelector(selector);\n  return link ? link.getAttribute('href') : null;\n}, 'a[data-testid=\"profile\"]');\n```\n\n`fn` 在浏览器页面上下文执行，不能读取 Node 侧闭包变量；参数必须能被 `JSON.stringify` 序列化。字符串形式 `page.evaluate('document.title')` 仍可用于简单表达式和既有代码，但不要再写依赖隐式 auto-IIFE 的模板字符串函数。\n\n### HTML 不走 browser fetch\n\n三个坑，踩一个就重写：\n\n- **HttpOnly cookie 看不见**：绝大多数登录站点把 auth cookie 标 `HttpOnly`，`document.cookie` 永远读不到它，只能通过 CDP 的 cookie jar 拿（`page.getCookies`）。塞到 `page.evaluate` 里就等于回到 `document.cookie` 那条路，必挂。\n- **`navigateBefore: false` 时当前 tab 不在目标站**：页面 origin 可能是 `about:blank` 或上一条命令留下的别处，从那儿发 fetch 到目标域就是 cross-origin，浏览器 CORS 一挡就是 \"Failed to fetch\"。\n- **非 UTF-8 编码解码麻烦**：GBK / Big5 / Shift-JIS 的站（Discuz / phpBB 老版 / 日站）在 `page.evaluate` 里用 `response.text()` 拿到的是乱码，`TextDecoder('gbk').decode(buf)` 的写法只在 Node 侧干净。\n\n**规则**：JSON 型浏览器接口用 `page.fetchJson()`；HTML 型 COOKIE adapter 一律 Node 侧 `fetch`，浏览器只当 cookie jar 用。\n\n### Selector 稳定性 — 不要 select 用户可见文本\n\nissue #1474 触发：同一个发送按钮在英文 Chrome 是 `aria-label=\"Submit\"`，在中文 Chrome（`chrome://settings/languages` 设中文）变 `aria-label=\"提交\"`，CSS 选择器 `button[aria-label=\"Submit\"]` 在中文环境下直接 0 匹配，silent empty result。\n\n根因不是 i18n bug，是**选择器的 anchor 选错了**。把页面 DOM 属性按 \"locale-stable vs locale-dependent\" 分两类：\n\n| 类 | 例子 | locale 切换会变吗 | 用作 primary selector？ |\n|----|------|----------------|---------------------|\n| **locale-stable 标识** | `data-testid`、`data-*`、稳定 `id` / `class` | 通常不变（开发者内部 ID） | ✅ 首选，但要先确认不是 hash / A-B test |\n| **semantic / scope anchor** | `role`、结构关系、邻近稳定容器 | 不按 locale 翻译，但常常不唯一 | ⚠️ 只作 scope/filter；不要单独用 `button[role=\"button\"]` |\n| **locale-dependent 文本** | `aria-label`、`title`、`placeholder`、`alt`、`textContent` | 变（被 i18n 框架翻译） | ❌ 仅当 stable 选择器全都不存在时的兜底 |\n\nChatGPT 的 web 端就是反例驱动的：有些 controls 暴露稳定 `data-testid`，有些 surfaces 只暴露 `aria-label` / `placeholder`。这种站必须先用 stable selector，再用多语言 fallback list：\n\n```javascript\n// clis/chatgpt/utils.js（简化活例）\nconst COMPOSER_SELECTORS = [\n  '#prompt-textarea',\n  '[data-testid=\"composer\"] [contenteditable=\"true\"]',\n  '[aria-label=\"Chat with ChatGPT\"]',   // en\n  '[aria-label=\"与 ChatGPT 聊天\"]',      // zh-CN\n  '[placeholder=\"Ask anything\"]',\n  '[placeholder=\"有问题，尽管问\"]',       // zh-CN\n];\nconst SEND_BUTTON_SELECTORS = [\n  'button[data-testid=\"send-button\"]:not([disabled])',\n  'button[aria-label=\"Send prompt\"]:not([disabled])',\n  'button[aria-label=\"发送提示\"]:not([disabled])',\n];\n```\n\n写 fallback list 的纪律：\n\n1. **stable selector 放最前**（`#prompt-textarea` / `[data-testid=\"send-button\"]`），locale-dependent 的放后面当兜底\n2. **`role` 只能当 semantic / scope filter**：`dialog [role=\"textbox\"]` 可以；裸 `button` / `[role=\"button\"]` 不够，因为同页可能有多个按钮\n3. **每种 locale 至少列一条**（en + zh-CN 是底线；扩到 ja / ko / ar 看站点用户分布）\n4. **commit 前 grep `aria-label=` / `placeholder=` / `title=` 看是不是漏了 fallback locale**——见 success-rate-pitfalls.md §11\n5. **失败要 typed fail-fast**：找不到 control 应该 `CommandExecutionError` / send-failed，不要返回空 rows 或假成功\n6. **不要给 framework 加 `--i18n \"zh:提交,ja:送信\"` 这种 flag** —— 等于把 fallback list 从 adapter 挪到 CLI，多一层 indirection 还要维护翻译字典。这是 over-engineering，已经在评审时被否\n\n为什么不在 daemon 端固定 Chrome locale？因为 opencli **不启动 Chrome**——daemon 是连用户已经在跑的 Chrome（CDP via extension），用户可能就是中文 UI / 中文资料检索需求。强制 en-US 会破坏用户的正当工作流。\n\n### Cookie 域的双查\n\n```javascript\nfor (const opts of [{ domain: HOST }, { domain: ROOT }]) { ... }\n```\n\n不是所有站都这么玄学，但下面这几类踩坑最多：\n\n| 站点类型 | 坑 |\n|---------|----|\n| Discuz!X / phpBB / vBulletin 论坛 | Auth cookie 设在 `.<root>.com`，HttpOnly；业务页在 `www.<root>.com`。只查 `www.` 会漏 |\n| 多子域账户体系（`account.x.com` vs `api.x.com`） | 登录时写在 account 域，API 域读取时拿不到 |\n| 新版 Chrome SameSite=Lax 默认 | 某些 cookie 查 `url:` 才给返，查 `domain:` 不给 |\n\n双查成本很低，不确定就两个都查，用 Map 去重第一次出现的 name。\n\n### 空态抛 `EmptyResultError`，**不**塞 sentinel 行\n\n历史上这里写的是\"返回一行说明 row 比 `return []` 安全\"。**这条已经反过来了**——见 PR #1329 R3 的 four anti-pattern fixes。现在的契约：\n\n```javascript\nimport { EmptyResultError } from '@jackwener/opencli/errors';\n\n// ❌ 老写法：sentinel 行污染 row 合同，让 listing→detail round-trip 拿到 tid='' 白跑\nif (/暂时没有提醒内容/.test(html)) {\n    return [{ index: 0, from: '', summary: '暂时没有提醒内容', time: '', threadUrl: '' }];\n}\n\n// ✅ 新写法：empty 是合法状态，但不是 row。exit code 66 让 agent 直接 branch\nif (/暂时没有提醒内容/.test(html)) {\n    throw new EmptyResultError('1point3acres notifications', '暂时没有提醒内容');\n}\n```\n\n更多反例和详细 routing 见 [`typed-errors.md` §3](./typed-errors.md)。\n\n---\n\n## 同类型 adapter 对照\n\n| 类型 | 代表 | 参考 |\n|------|------|-----|\n| clist 分页排行 | `convertible.js` / `rank.js` / `etf.js` / `sectors.js` | 都共享 `fs` + `fid` + `po` 结构 |\n| ulist 批量报价 | `quote.js` | `secids` 逗号拼接 |\n| K 线历史 | `kline.js` | `fields1 / fields2` 控列，CSV 解析 |\n| 报表（datacenter-web） | `longhu.js` / `holders.js` | `reportName` 驱动 |\n| 7x24 新闻 | `kuaixun.js` | `np-listapi` 栏目 id |\n| 公司公告 | `announcement.js` | `np-anotice-stock` |\n| 指数/北上 | `index-board.js` / `northbound.js` | push2 专用端点 |\n\n新写一条时，选最像的那类，复制后改 `name` / URL / fields / column 映射三处。\n\n---\n\n## Verify fixture（每个 adapter 配一份 `~/.opencli/sites/<site>/verify/<name>.json`）\n\nverify fixture 是\"adapter 产出长什么样\"的结构锚点。没有它，`opencli adapter test` 只能证\"adapter 能跑完不抛\"，证不出数据没错位。**必写**。\n\n详细 schema 见 `site-memory.md` 的 `verify/<cmd>.json` 节。这里只讲两个容易踩的地方：\n\n### args 形态：object vs array\n\n`args` 字段决定 verify 怎么调你的 adapter：\n\n- **对象形态** `{ \"limit\": 3 }` → 展开成 `--limit 3`，标准 named-flag adapter 用这个\n- **数组形态** `[\"123\", \"--limit\", \"3\"]` → 原样 append 到命令后，**positional 主语型** adapter 必须用这个\n\nrepo 约定\"主语优先 positional\"——thread 详情型、url 解析型、关键词搜索型都用 positional：\n\n```js\n// clis/1point3acres/thread.js — 接收 <tid> 作为主语\ncli({\n  site: '1point3acres',\n  name: 'thread',\n  args: [\n    { name: 'tid',   type: 'string', required: true, positional: true },\n    { name: 'limit', type: 'int',    default: 20 },\n  ],\n  // ...\n});\n```\n\n对应 fixture：\n\n```json\n{\n  \"args\": [\"1234567\", \"--limit\", \"3\"],\n  \"expect\": { \"rowCount\": { \"min\": 1, \"max\": 3 }, \"...\": \"...\" }\n}\n```\n\n**不要写成** `{ \"tid\": \"1234567\", \"limit\": 3 }`——这会被展开成 `--tid 1234567 --limit 3`，commander 把 `--tid` 当未知 flag 报错，或者 adapter 根本不认。\n\n### 种子 → 手改\n\nnamed-flag adapter（`hot` / `latest` 类）可以直接让工具生成种子：\n\n```bash\n# 1. 让 verify 先跑一遍，--write-fixture 生成种子（默认追加 --limit 3）\nopencli adapter test 1point3acres/hot --write-fixture\n\n# 2. 手改 ~/.opencli/sites/1point3acres/verify/hot.json\n#    - patterns: 加 URL / 日期 / ID 正则\n#    - notEmpty: 加核心字段（title / author / url）\n#    - rowCount: 收紧到业务合理区间\n\n# 3. 再跑 verify，fixture 吃得动就 OK\nopencli adapter test 1point3acres/hot\n```\n\npositional adapter 目前 `--write-fixture` 没法表达主语，**首份 fixture 要手写**：\n\n```bash\n# 1. 先直跑 adapter 看输出长啥样\nopencli 1point3acres thread 1173710 --limit 2 --format json | head\n\n# 2. 照着响应手写 ~/.opencli/sites/1point3acres/verify/thread.json\n#    （args 一定用数组: [\"1173710\", \"--limit\", \"2\"]）\n\n# 3. 跑 verify 核对\nopencli adapter test 1point3acres/thread\n```\n\n机器生成的种子只有 rowCount.min=1 / columns / types，挡不住字段值错位。**patterns + notEmpty 无论哪种情形都是肉写的**。\n\n---\n\n## 私人 adapter vs repo 贡献\n\n```\n~/.opencli/clis/<site>/<name>.js    # 私人\nclis/<site>/<name>.js               # repo 贡献\n```\n\n**两者在 `cli({...})` 层面完全一样**。差别只在运行入口：\n\n- 私人：写完立即可跑（`opencli <site> <name>`）\n- repo：要 `npm run build` 才被注册\n\n先在 `~/.opencli/clis/` 调通再拷贝到 `clis/`。\n\n<!-- File: skills/opencli-adapter-author/references/api-discovery.md (Tokens: ~5459 | Category: anthropic-skill) -->\n# API Discovery\n\n**寻找目标数据 endpoint。** 按当前证据选择需要的调查手段。\n\n下面提供五种调查手段。候选需要结合真实业务结果判断；复杂协议可以参考 [`deep-recon.md`](./deep-recon.md)，无需固定流程。\n\n---\n\n## Evidence from loaded scripts and network requests\n\n```bash\nopencli browser --session research open https://example.com\nopencli browser --session research network --static\nopencli browser --session research network --detail '<returned request key>'\n```\n\n`network --static` combines captured requests with AST candidates from `fetch`, axios, XHR and related calls. It reports method, URL, parameter names, script/line and request key. `network+static` means both sources support the same route; `static` alone is an unexecuted candidate. `EXPR` marks unresolved expressions. Check `scripts[].error`, `omittedScripts`, and cache provenance (`networkSource`, `networkSavedAt`). The bounded scan covers current document scripts, without following imports or proving authentication/pagination behavior.\n\nUse the evidence to choose the next investigation. Write or edit the normal JS adapter in place and run it through the existing CLI to inspect real business results; no draft directory or activation step is required.\n\n## §0 进入 §1 之前：先看两条红线\n\n这两条不看清楚，后面的 endpoint 验证会一直在错的前提下兜圈子。\n\n### 0.1 反爬厂商 → 决定 fetch 能不能从 Node 走\n\n`opencli browser state --diagnostics --session research` 的 `anti_bot` 字段给答案；手查看 cookies 也行：\n\n| cookie / body 信号 | 厂商 | 裸 Node fetch / curl 结果 | 策略 |\n|------------------|------|-----|-----|\n| `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()` |\n| `__cf_bm` / `cf_clearance` / `__cfduid`；body 含 `Cloudflare Ray ID` / `Checking your browser` | **Cloudflare** | TLS 指纹被标记，失败 | 同上：先 browser-context probe，最终 adapter 仍按模板选 fetch 路线 |\n| `_abck` / `bm_sz` / `bm_sv` | **Akamai** | 即使带 cookie 也常被挡 | 同上 |\n| body 含 `geetest` / `gt_captcha` | **Geetest** | 滑块/拼图，程序无解 | 超出 skill 范围，放弃或 UI 策略 |\n\n**规则**：看到上面四种任一个，先不要拿**裸** Node fetch 做 endpoint 验证。先用 browser-context probe 或目标 origin 页面确认接口能通；最终 adapter 的 fetch 路线仍按 `adapter-template.md` 选，HTML 型 COOKIE adapter 继续走 Node-side fetch + `page.getCookies()`。\n\n### 0.2 跨 subdomain = CORS 默认关\n\n`jobs.51job.com` 页面 fetch `cupid.51job.com` 的 API，默认会被浏览器 CORS 预检挡住——除非目标接口回了 `Access-Control-Allow-Origin`。\n\n判断：\n\n```bash\nopencli browser --session research eval \"fetch('https://<target-subdomain>/api/...', {credentials:'include'}).then(r=>r.status).catch(e=>'cors:'+e.message)\"\n```\n\n- 返回 status 数字 → CORS 通，继续\n- 返回 `cors:...` 或 `TypeError: Failed to fetch` → 挡住了\n\n**挡住时**：不要把 `credentials:'include'` 当万能药——这只解决\"带 cookie\"，不解决\"跨 origin\"。降级路径：\n\n1. 换同 origin 的 endpoint（同一个 subdomain 下的 API 往往更宽松）\n2. 用 `opencli browser --session research open https://<target-subdomain>/`，让页面在目标 subdomain 本身打开，再 fetch 相对路径\n3. 真跨域且无替代 → 走 `§5 intercept`，从页面自身发的请求里抓响应\n\n---\n\n## §1 network 精读（首选，Pattern A / D 命中率最高）\n\n### 拿候选\n\n```bash\nopencli browser --session research network\n```\n\n默认输出是 JSON，每个候选都带：\n- `key` — 稳定引用（GraphQL 的 `operationName` 或 `METHOD host+pathname`）\n- `shape` — response body 的路径→类型映射（不含原 body，省 token）\n- `status / url / method / ct / size`\n\n静态资源 / 埋点 / 追踪默认已过滤。默认会保留 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，而不是永久丢掉被隐藏的条目。\n\n如果是冷启动，先看 `opencli browser state --diagnostics --session research` 里的 `api_candidates`：\n\n- `verdict: \"likely_data\"`：优先 replay 这条，拿 status / content-type / sample shape 填 strategy note\n- `verdict: \"maybe_data\"`：可以试，但必须人工核对字段是否是目标业务数据\n- `verdict: \"noise\"`：多半是 analytics / beacon / personalization，不要因为 XHR 数量多就判 Pattern A\n- `verdict: \"blocked\"`：401/403；先排 cookie / token / CSRF，别直接退到 selector\n\n`real_data_score` 是证据，不是自动 strategy。最终仍要在 strategy note 里写 replay 结果和降级理由。\n\n### 按 shape 初筛\n\n挑 `key` 里含业务词（`list / detail / Timeline / User / Tweets / Quote`）的优先看 `shape`：\n\n- `$.data` 是 `object` 且下面出现 `array(N)` / `total` / `page` → 基本是它\n- 路径里出现 `nickname / avatar / title / price / tweets / items` → 就是它\n- shape 只有 `$: string` 或全是 HTML 噪音 → 下一条\n\n### 按期望字段反查（`--filter`）\n\n已经知道目标 body 该含哪些字段就直接让 CLI 把列表筛到只剩候选，不用自己 scroll 翻 shape：\n\n```bash\nopencli browser --session research network --filter author,text,likes\n```\n\n- 字段以英文逗号分隔；AND 语义，必须每个字段都作为 shape 路径的**任意一段**出现才保留（`$.data.items[0].author` 命中 `author`、`items`、`data` 都算）\n- 区分大小写（JSON key 本来就 case-sensitive）\n- 输出 envelope 新增 `filter` / `filter_dropped`，`count` 是过滤后数量\n- 0 命中不是 error，返回 `entries: []`；说明字段组合不对，换一组或去掉约束再试\n- 不要跟 `--detail` 一起用——`--detail` 按 key 取单条、`--filter` 是列表缩窄，组合会报 `invalid_args`\n- 空值 / `,,,` → `invalid_filter` 结构化错误\n- capture 依然按全量持久化，后续 `--detail <key>` 能找到被过滤掉的条目\n\n### 拉完整 body\n\n候选定了再拉完整 body（by key，不是 index — 数组顺序会随每次 capture 变）：\n\n```bash\nopencli browser --session research network --detail <key>\n```\n\ncapture 会持久化到 `~/.opencli/cache/browser-network/<session>.json`（默认 TTL 24h），所以 `--detail` 即使跨多条其他命令也还在。\n\n`--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——这说明请求合同仍不完整。\n\n这也意味着私有页面的 response 可能落在本地 cache。侦察结束要删除相关 session capture 并释放 browser session；不要依赖 24h TTL 代替清理。\n\n### 关键 request headers\n\n先用 `browser network --detail <key>` 看脱敏后的 request headers / body shape；不要打印或复制 credential 原值。旧 capture provider 若没有返回 `request`，再去 DevTools Network 面板核字段名，或用页面自然动作重新 capture，不能用 `browser eval` 猜造一份缺 header/body 的 URL-only 请求：\n\n| 看到 | 含义 | 对应策略 |\n|------|------|---------|\n| 只有 `Cookie` | 登录态靠 cookie | `Strategy.COOKIE` |\n| `Authorization: Bearer xxx` | token 鉴权 | 先找 token 来源（localStorage / cookie / bundle 硬编码） |\n| `X-Csrf-Token: xxx` 同时存在 cookie 里 | CSRF 防护 | `Strategy.COOKIE`，从 cookie 读 ct0 类字段拼头 |\n| `X-Workspace-Id / X-Tenant-Id` | 多租户业务头 | 先调 `/workspaces` 拿 ID，缓存下来 |\n| 啥自定义头都没有 | 匿名接口 | `Strategy.PUBLIC` |\n\n### 触发懒加载接口\n\n默认页加载完后滚动 / 点击才会出的接口不在首屏 network 里。需要：\n\n```bash\n# 滚到底（虚拟列表）\nopencli browser --session research eval \"window.scrollTo(0, document.body.scrollHeight)\"\nopencli browser --session research wait time 2\nopencli browser --session research network\n\n# 点某个按钮\nopencli browser --session research click <N>\nopencli browser --session research wait time 2\nopencli browser --session research network\n```\n\n---\n\n## §2 `__INITIAL_STATE__` / inline HTML（Pattern B）\n\n首屏数据常挂在这几个全局变量上：\n\n```bash\nopencli browser --session research eval \"Object.keys(window).filter(k=>k.startsWith('__'))\"\n```\n\n命中的常见名：\n\n| 全局 | 框架 |\n|------|-----|\n| `__NEXT_DATA__` | Next.js |\n| `__NUXT__` | Nuxt.js |\n| `__INITIAL_STATE__` | 自定义 Vue / React SSR |\n| `__PRELOADED_STATE__` | Redux SSR |\n| `__REMIX_CONTEXT__` | Remix |\n\n取数据：\n\n```bash\nopencli browser --session research eval \"JSON.stringify(window.__NEXT_DATA__).slice(0, 3000)\"\n```\n\n**关键**：inline state 只覆盖首屏的一部分（通常是 SEO 相关字段）。分页 / 评论 / 懒加载还是得回 §1 抓 API。\n\n把首屏 state 当作 adapter 的兜底数据源：公开访问时 state 里有 → 直接 parse；数据更新快 / 分页 → 回到 API。\n\n---\n\n## §3 JS bundle / script src 搜索（Pattern C，也是 A/D 的降级）\n\n### 扫 script src\n\n```bash\nopencli browser --session research 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))\"\n```\n\n看结果里的 hostname：\n\n- 明显像 API 的域名（`api.xxx / push.xxx / data.xxx / gateway.xxx`）→ 直接去试\n- 主 bundle（`main.js / app.js / index.xxx.js`）→ 继续下一步下载 bundle 搜 baseURL\n\n### 搜 bundle 里的 baseURL\n\n```bash\nopencli browser --session research 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})()\"\n```\n\n命中 `baseURL:\"https://api.foo.com\"` 直接拿 host 拼 endpoint。\n\n### 用 jsluice 扩大候选面（可选）\n\n手工搜 `baseURL` 只适合小 bundle。站点脚本多、压缩重或 endpoint 通过 `fetch` / XHR / 字符串拼接生成时，可以把**已经加载的脚本文本**通过 stdin 交给本机可选的 [jsluice](https://github.com/BishopFox/jsluice) 做语法感知扫描。\n\n```bash\n# bundle 只短暂落 /tmp；扫描后删除\njsluice urls < /tmp/example-bundle.js\n```\n\n边界：jsluice 输出是 candidate，不是 contract。`EXPR` 表示动态值未知；扫描无法证明 token、签名、CORS、权限、分页、字段语义或副作用。不要把命中 URL 直接写进 adapter，更不要把扫描到的疑似 secret 原值保存到 trace/site memory。\n\n每个候选至少记录：来源 bundle + 代码位置、method/path、触发它的可见动作、动态 network 是否发生、replay status/content-type/shape、选择或拒绝原因。复杂站直接转 [`deep-recon.md`](./deep-recon.md) 的 evidence ledger。\n\n### 直接试候选 endpoint\n\n像 eastmoney 这种经验 endpoint 可以直接喂：\n\n```bash\nopencli browser --session research 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())\"\n```\n\n200 只是 transport 成功。至少换一个输入再试，并核 content-type、目标 identity、非空 shape、分页和可见页面值；写入或复杂私有协议转 `deep-recon.md`，不能“数据看起来像”就认。\n\n### URL 后缀探测\n\n有些站直接在 URL 加 `.json` 就是 REST：\n\n- `https://www.reddit.com/r/rust.json` — Reddit 全覆盖\n- `https://xueqiu.com/S/SH600000.json` — 雪球部分页\n\n```bash\n# 当前页加 .json 试\nopencli browser --session research eval \"fetch(location.pathname.replace(/\\\\/$/,'')+'.json').then(r=>r.ok?r.json():'no')\"\n```\n\n---\n\n## §4 Token / CSRF 来源排查（Pattern D）\n\n已经在 network 里看到请求带自定义头，怎么拿到那个值：\n\n### Cookie 里\n\n```bash\nopencli browser --session research eval \"document.cookie.split('; ').map(x=>x.slice(0,x.indexOf('='))).filter(Boolean)\"\n```\n\n常见 token cookie 名：`ct0`（Twitter CSRF）、`xq_a_token`（雪球）、`SESSDATA`（B 站）、`_csrf / csrfToken`（通用）。\n\n**`document.cookie` 只能看到 non-HttpOnly 的 cookie。** 上面那条命令侦察阶段够用，真写 adapter 时 auth 经常是 HttpOnly，一定要用 `page.getCookies(...)` 从 CDP cookie jar 拿——见 `adapter-template.md` 的 \"COOKIE adapter 骨架\"。\n\n论坛 / BBS 引擎（Discuz!X / phpBB / vBulletin）还多一坑：auth cookie 设在**根域** `.example.com`（不是 `www.example.com`），且 HttpOnly。要查 `{ domain: '.<root>' }` **和** `{ domain: 'www.<root>' }` 两次，否则 adapter 在有 cookie 的前提下仍然 401。\n\n### localStorage / sessionStorage 里\n\n```bash\nopencli browser --session research eval \"Object.keys(localStorage).filter(k=>/token|auth|jwt|bearer|csrf/i.test(k))\"\n```\n\n先只列 key 名，找 `token / auth / jwt / bearer / csrf`。只有选定 production auth source 后才在页面内使用对应值；不要把值打印进聊天、trace、shell history 或 site memory。\n\n### Bundle 硬编码\n\n有些站的 Bearer 是全站一个常量（Twitter 的匿名 Bearer）。在 bundle 里搜：\n\n```bash\nopencli browser --session research 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)}})()\"\n```\n\n只返回数量/位置，不返回 token 原值。即使 bundle 中是公共匿名 Bearer，也先确认它是否是预期公开合同；不要复制未知 credential-shaped string。\n\n### 调用页面 runtime 让站点自己生成请求（只读、最后手段）\n\nVue + Pinia / Redux / React Context 有时能调用页面自己的只读 store method，让站点 runtime 自己生成签名和请求：\n\n```bash\n# Pinia\nopencli browser --session research eval \"typeof __pinia !== 'undefined' ? Object.keys(__pinia.state.value) : 'no pinia'\"\n\n# 只调用已证明是 read-only 的 store action（每个站点具体 action 名要查）\nopencli browser --session research eval \"window.__pinia.state.value.someStore.someMethod({...})\"\n```\n\n这不是“绕签名”，也不是 direct API contract：它仍依赖页面 controller/runtime，production strategy 通常是 `INTERCEPT`。只有动作语义被可见 UI 和动态请求证明为 read-only 才能在侦察中调用。未知 effect 或 write action 禁止自动调用；写入只观察用户明确授权的一次自然操作，按 `deep-recon.md` 处理。\n\n---\n\n## §5 让页面自然发请求并截获 response（最后降级）\n\n所有手段都试过还拿不到请求签名时，让页面自己自然发请求，adapter 用现有 Browser Bridge capture/interceptor 读取响应。优先 CDP network capture；只有已存在站点实现依赖 XHR interceptor 时才复用它，不要再写页面内 fetch/XHR monkey patch。\n\n```javascript\n// func 里：capture 必须先于触发动作安装，并先 drain stale entries\nawait page.startNetworkCapture('/api/foo');\nawait page.readNetworkCapture();\nawait page.goto('https://xxx.com/trigger-page');\n// 等页面自己发请求，再读取所有相关完整 response\nconst entries = await page.readNetworkCapture();\n```\n\ncapture queue 可能是破坏性 drain：过滤 relevant URL 后，只要看到 bodyless/truncated relevant entry 就拒绝 partial；多 response 要按业务 identity 合并，不能只取最后一个。分页、缓存和 no-partial 规则见 `deep-recon.md`。\n\n代价是要等页面真的触发请求，慢且依赖内部合同。只在 §1-4 都不行时用。\n\n---\n\n## 诊断不出来怎么办\n\n按这个顺序试到命中：\n\n```\n§1 network ──→ 命中？yes → 走\n      │        no\n      ↓\n§2 state  ──→ 命中？yes → 走\n      │        no\n      ↓\n§3 bundle ──→ 命中？yes → 走\n      │        no\n      ↓\n§4 token  ──→ 401 解除？yes → 走\n      │        no\n      ↓\n§5 intercept → 让页面自己发\n```\n\n**四条都命不中的站（罕见）**：多半是视觉化渲染（canvas / webgl），数据不以 HTTP/JSON 形式存在。这种放弃或换源。\n\n<!-- File: skills/opencli-adapter-author/references/coverage-matrix.md (Tokens: ~2019 | Category: anthropic-skill) -->\n# Coverage Matrix\n\nskill 明确承诺能搞定什么、搞不定什么。动手前先看一眼这张表，判断目标站落在哪一格。\n\n**状态标记**：\n- ✅ **已验证**：有可跑通的真实 adapter / dry-run 证据\n- 🟡 **已列招但未硬跑**：文档把方法写全了，但这一版没拿真实站点跑过；第一次遇到时按文档走，踩坑回来补 `site-memory`\n- ❌ **不支持**：skill 明确不碰，走绕开方案\n\n---\n\n## 支持（skill 里有对应的招）\n\n| 维度 | 支持 | 状态 | 走哪节 |\n|------|------|------|-------|\n| 页面形态 | 列表页 / 排行页 | ✅ | `adapter-template.md`（convertible.js / rank.js 类） |\n| | 详情页（单对象） | ✅ | `adapter-template.md`（stock.js / holders.js 类） |\n| | 时间序列（K 线 / 分钟线） | ✅ | `adapter-template.md`（kline.js） |\n| | 嵌套列表（列表里含列表） | 🟡 | `adapter-template.md` + `output-design.md` 打平规则 |\n| 站点类型 | SPA（React/Vue，JSON XHR） | ✅ | `site-recon.md` Pattern A + `api-discovery.md` §network |\n| | SSR（HTML with inline data） | 🟡 | `site-recon.md` Pattern B + `api-discovery.md` §state |\n| | JSONP / push/script[src] | ✅ | `site-recon.md` Pattern C + `api-discovery.md` §bundle（eastmoney / tonghuashun 已覆盖） |\n| | SPA + 独立 BFF domain | 🟡 | `api-discovery.md` §bundle §suffix |\n| Strategy（详见 `strategy-selection.md`） | 裸 `fetch()` 拿到 | ✅ | `PUBLIC_API`（一方文档化接口，最稳：fixes/adapter-year=1.18） |\n| | cookie 透传 | ✅ | `COOKIE_API`（官方 web 接口 + 用户登录态，fixes/adapter-year=2.01） |\n| | publish / upload / click / 表单 | ✅ | `UI_SELECTOR`（DOM 的 a11y / semantic 也是契约，fixes/adapter-year=1.92） |\n| | hydration state / inline JSON | 🟡 | `DOM_STATE`（fixes/adapter-year=0.91 小样本 N=11，按 UI_SELECTOR 同档） |\n| | page-context fetch（CORS / same-origin runtime） | 🟡 | `PAGE_FETCH`（无契约内部 endpoint，fixes/adapter-year=8.41，必须正向论证） |\n| | 触发 UI 拦截响应 | 🟡 | `INTERCEPT`（无契约，fixes/adapter-year=8.69，必须正向论证） |\n| 字段形态 | 自解释（`title / price / current`） | ✅ | 直接映射 |\n| | 已登记代号 | ✅ | `field-conventions.md` 查表 |\n| | 未登记代号 | 🟡 | `field-decode-playbook.md` 排序键对比法 |\n| | 嵌套路径 `data.diff[].f2` | ✅ | `field-decode-playbook.md` §3 结构差分 |\n| 分页 | `page` / `pn` / `pageNum` | ✅ | `adapter-template.md` 例子 |\n| | `cursor` / `next_cursor` | ✅ | adapter 里 while 循环，收集到 limit |\n| | `offset` / `start` | ✅ | 同上 |\n| 响应格式 | JSON | ✅ | 默认 |\n| | JSONP（`?callback=`） | ✅ | 去掉 callback 参数直接请求，返回仍是 JSON 字符串包裹 |\n| | CSV 字符串（eastmoney kline） | ✅ | `response.split(',')` 按列序解 |\n| | HTML 表格（tonghuashun） | 🟡 | `page.evaluate` 里用 `querySelectorAll` 拿 |\n\n🟡 的维度意思：方法在文档里，但这一版没拿真实站点跑过端到端。第一次遇到时按文档走，遇到和文档不一致的地方记到 `~/.opencli/sites/<site>/notes.md`，下一次再打开就是 ✅。\n\n---\n\n## 不支持（承认搞不定，skill 不教）\n\n| 场景 | 原因 | 绕开方案 |\n|------|------|---------|\n| 首次登录获取 token | 需要用户真实输入账密 | 让用户先在 browser session 里手动登录，adapter 拿 cookie 就行 |\n| 复杂 anti-bot（captcha） | 反爬拒流量 | 放弃，换同数据的其他站点 |\n| 加密字段（客户端 crypto） | 要破解 bundle 逆向 | 换 endpoint；实在不行发请求到 intercept 让页面自己解 |\n| WebSocket 流式数据 | 状态管理复杂 | 退回 HTTP 轮询版本（多数站都有） |\n| 私有 binary 协议 | 非 HTTP/WS | 不在 skill 范围 |\n| 视觉化图表（只有 canvas） | 数据埋在渲染层 | 找对应 API；找不到就放弃 |\n| 签名算法涉及静态密钥 | 需要长期跟踪 bundle 变更 | 走 `Strategy.INTERCEPT`，让页面自己发带签名的请求 |\n| 频控 / rate-limit 严格 | 多发几次就 429 | adapter 层控并发 + 加退避；但 skill 不解决 |\n\n---\n\n## 决定用不用 skill 的快速自测\n\n三个问题：\n\n1. **数据能在浏览器里看到吗？** 看不到（登录墙 / 付费墙）→ 先解决鉴权，再回来\n2. **数据来源是 HTTP/JSON/HTML 之一吗？** 不是（binary / 加密）→ 不在 skill 范围\n3. **需不需要每秒推送？** 需要 → 找同数据 HTTP 接口；没有就放弃\n\n三个都 yes 再往下走。\n\n---\n\n## 本轮硬验证 / 当前证据\n\n| 类型 | 证据 adapter | 覆盖维度 |\n|------|-------------|---------|\n| PUBLIC + 自解释字段 + SPA | `~/.opencli/clis/coingecko/top.js`（本轮 dry run） | `Strategy.PUBLIC` + REST JSON + 自解释字段 + 列表页 |\n| COOKIE + 代号字段 + JSONP | `clis/eastmoney/*.js` × 13（PR #1091 merged） | `Strategy.PUBLIC`（匿名 `ut=`）+ JSONP + f-代号 + 列表/详情/K 线 |\n| COOKIE + SPA | `clis/bilibili/*.js` × 10+（已存在） | `Strategy.COOKIE + browser:true` + wbi 签名 |\n\n**本 PR 新增的 skill 还未硬验证的维度**：🟡 行，尤其 SSR Pattern B + Bearer/CSRF + 未登记代号解码。这些放到第一批真实用户 adapter 写作中打磨，skill 文档先落，踩坑回来补 `site-memory`。合 PR 之前先拿 coingecko 跑第二轮（带着第一轮写出的 `~/.opencli/sites/coingecko/`）验证 memory 命中 → endpoint re-verify → 字段抽查 → 写 adapter 这条回路。\n\n<!-- File: skills/opencli-adapter-author/references/deep-recon.md (Tokens: ~2139 | Category: anthropic-skill) -->\n# Deep Recon: evidence-first discovery for undocumented sites\n\nUse 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.\n\n## 1. Freeze the command surface and mutation boundary\n\nBefore browsing, make an intent matrix:\n\n| Command | User intent | Read/write | Completeness | Exact target | Allowed live action |\n|---|---|---|---|---|---|\n| `search` | find matching entities | read | exact limit or upstream exhaustion | query | safe replay after proof |\n| `send` | create external side effect | write | one confirmed mutation | recipient + content | only with explicit authorization |\n\nGroup 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.\n\nWrite 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.\n\n## 2. Build an evidence ledger, not a traffic dump\n\nTrack one row per candidate:\n\n| Intent | Visible action | Source | Method/path | Effect | Auth/signing | Response shape | Replay result | Decision |\n|---|---|---|---|---|---|---|---|---|---|\n\n`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.\n\nNever paste credentials or response bodies into the ledger. Store structural facts: status, content type, arity/paths, row counts, cursor behavior, and source location.\n\n## 3. Triangulate three evidence planes\n\n1. **Visible truth**: page rows, counts, URLs, identity, and semantic controls.\n2. **Dynamic truth**: DevTools requests caused by one controlled action.\n3. **Static candidates**: loaded bundles scanned manually or with syntax-aware tools such as jsluice.\n\nDynamic 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.\n\nDo 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.\n\njsluice 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.\n\n## 4. Attribute requests with causal diffs\n\nFor each intent:\n\n1. establish a network/state baseline;\n2. perform exactly one visible action;\n3. inspect only newly added requests/state;\n4. repeat with one changed input;\n5. run a negative control when ambiguity remains.\n\nThe 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.\n\nFor 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.\n\n## 5. Rank candidates before decoding\n\nPrefer a candidate that:\n\n- contains the user-visible target data or mutation identity;\n- works across two inputs without copying ephemeral values;\n- has a safe, explainable auth source;\n- paginates to the requested completeness;\n- returns typed, distinguishable auth/HTTP/upstream failures;\n- is cheaper to maintain than the strongest UI/DOM alternative.\n\nPenalize 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.\n\n## 6. Pass the contract gate\n\nA read contract must prove all of these:\n\n1. **Occurrence**: the page naturally sends it, or it is documented/public.\n2. **Reproducibility**: a safe replay works across two non-empty inputs; if replay is impossible, `INTERCEPT` preserves the page-owned request.\n3. **Identity**: returned rows match visible target identity, not adjacent recommendations/ads/sidebars.\n4. **Completeness**: pagination reaches exact limit or proven upstream exhaustion.\n5. **Auth boundary**: cookies/CSRF/origin/runtime requirements are explicit and do not leak secrets.\n6. **Failure semantics**: auth, HTTP, malformed/truncated body, repeated cursor/page, timeout, and partial data fail typed.\n\nReplay 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.\n\nA direct API-backed write contract additionally must prove:\n\n1. target identity is deterministically bound in the request;\n2. auth/CSRF/signing can be reproduced without fabricating or exfiltrating runtime secrets;\n3. idempotency or duplicate-risk semantics are known;\n4. pre-write failure is distinguishable from post-write uncertainty;\n5. success is verified independently of the edited control/request echo;\n6. retries are disabled after an uncertain write unless the user first checks state.\n\n“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).\n\n## 7. Handle capture, pagination, and cache explicitly\n\nBrowser capture queues may be destructive drains. Before relying on them:\n\n- install capture before the action and drain stale entries;\n- cache the raw selected capture before applying display-only MIME, static-resource, or shape filters;\n- allow in-flight responses to settle;\n- treat bodyless or truncated relevant entries as possible data loss;\n- inspect non-JSON structured streams with request method, safely redacted headers, body shape, and size/truncation metadata;\n- merge all relevant completed responses in the action window;\n- identify pages/cursors by content, not arrival order alone;\n- deduplicate by stable entity ID;\n- reject repeated pages/cursors and page-cap exhaustion rather than return accumulated partial rows.\n\nNever 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.\n\nA 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.\n\n## 8. Choose strategy per command\n\nUse the repository strategy ladder after the contract gate. One site may legitimately mix strategies:\n\n- `PUBLIC_API` / `COOKIE_API` for reproducible contracts;\n- `PAGE_FETCH` for a verified same-origin read contract that must execute in page context;\n- `DOM_STATE` for stable hydration or cached state;\n- `INTERCEPT` when page-owned signing is unavoidable for reads;\n- `UI_SELECTOR` for user-visible writes only when that surface is allowed and safer;\n- no command when the requested contract cannot be proved.\n\nPreserve 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.\n\n## 9. Decode and test against invariants\n\nFor 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.\n\nValidation set:\n\n- two non-empty inputs plus one true empty result;\n- more than one page when pagination exists;\n- exact limit and upstream exhaustion;\n- cache/repeat behavior;\n- auth, HTTP, JSON/shape, truncated/bodyless capture, timeout, repeated cursor/page, and page cap;\n- every public column and target identity.\n\nTests 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.\n\n## 10. Deliver a clean, reviewable artifact set\n\nLeave durable outputs at the right layer:\n\n- **adapter**: executable contract, shared helpers, typed errors;\n- **tests**: production-path behavior and structural invariants;\n- **docs**: command surface, limits, auth, uncertainty, examples;\n- **site memory**: verified routes, triggers, fallbacks, rejected strategies, pitfalls, and verification date;\n- **recon conclusion**: evidence ledger without secrets or private bodies.\n\nBefore PR:\n\n1. regenerate manifest and validate the site;\n2. run focused/site tests, typecheck, build, docs coverage, typed-error and silent-column gates;\n3. scan the diff for live addresses, account IDs, tokens, message IDs, bodies, attachments, raw captures, and temporary dumps;\n4. delete raw capture/cache artifacts and release browser sessions;\n5. obtain independent exact-head review for writes, auth/signing, positional schemas, or no-partial pagination.\n\nRecord 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.\n\n<!-- File: skills/opencli-adapter-author/references/field-conventions.md (Tokens: ~2265 | Category: anthropic-skill) -->\n# Field Conventions\n\n响应字段代号在主要站点上的解码表。写 adapter 前先查一遍，查不到再用 SKILL.md 里 Step 2 的实测法推出来，推完补到本表。\n\n---\n\n## eastmoney（东方财富）\n\n域名：`push2.eastmoney.com` / `push2his.eastmoney.com` / `datacenter-web.eastmoney.com` / `np-listapi.eastmoney.com` / `np-anotice-stock.eastmoney.com`\n\n### 通用行情字段（`push2` clist/ulist/stock）\n\n| 代号 | 含义 | 备注 |\n|------|------|------|\n| `f1`  | 精度位数 | `fltt=2` 后可忽略 |\n| `f2`  | 最新价 | 要 `fltt=2` 才是格式化浮点 |\n| `f3`  | 涨跌幅 % | 同上 |\n| `f4`  | 涨跌额 | 同上 |\n| `f5`  | 成交量（手） | |\n| `f6`  | 成交额（元） | 接口默认是\"元\"，个别接口是\"万元\"，看 `f152` |\n| `f7`  | 振幅 % | |\n| `f8`  | 换手率 % | |\n| `f9`  | 市盈率（动态） | |\n| `f10` | 量比 | |\n| `f12` | 代码 | 股票/债券/ETF/指数统一 |\n| `f13` | market | 数字市场代号，见下表 |\n| `f14` | 名称 | |\n| `f15` | 最高 | |\n| `f16` | 最低 | |\n| `f17` | 今开 | |\n| `f18` | 昨收 | |\n| `f20` | 总市值 | |\n| `f21` | 流通市值 | |\n| `f23` | 市净率 | |\n| `f62` | 主力净流入 | **单位是\"元\"**，但有的接口返回\"万元\"，要核对 |\n| `f66` | 超大单净流入 | 同上 |\n| `f72` | 大单净流入 | |\n| `f78` | 中单净流入 | |\n| `f84` | 小单净流入 | |\n| `f100` | 所属板块 | |\n| `f152` | 精度（当 `fltt` 不传时要除以 10^f152） | 所以一律 `fltt=2` |\n\n### Convertible bond（可转债）专属\n\n| 代号 | 含义 |\n|------|------|\n| `f229` | 正股价 |\n| `f230` | 正股涨跌幅 |\n| `f232` | 正股代码 |\n| `f234` | 正股名称 |\n| `f235` | 转股价 |\n| `f236` | 转股价值 |\n| `f237` | 转股溢价率 |\n| `f238` | 剩余年限 |\n| `f239` | 到期收益率 (YTM) |\n| `f243` | 上市日期 (YYYYMMDD int) |\n\n### K-line `push2his` stock/kline/get\n\n返回形如 `data.klines: [\"2024-01-02,10.5,10.8,10.9,10.4,12345,...\"]` 的 CSV 字符串数组。用 `fields1 / fields2` 控制列。典型列序：\n\n```\ndate, open, close, high, low, volume, turnover, amplitude, changePct, changeAmt, turnoverRate\n```\n\n### Market 前缀（secid 格式 `<market>.<code>`）\n\n| 前缀 | 市场 |\n|------|------|\n| `1.`   | Shanghai (SSE) |\n| `0.`   | Shenzhen (SZSE) + Beijing (BSE) |\n| `116.` | Hong Kong (HKEX) |\n| `105.` | NASDAQ |\n| `106.` | NYSE |\n| `107.` | AMEX |\n| `100.` | 指数（HSI / SPX / DJIA / 各板块指数） |\n\n判断 symbol 是否已经是 secid 时，只在数字前缀属于上表时才认，否则视作普通 code（防止 `00700.HK` 这种 `<digits>.<alpha>` 误判）。\n\n### fs 市场/板块过滤码\n\n| 代码 | 含义 |\n|------|------|\n| `m:0+t:6,m:0+t:80,m:1+t:2,m:1+t:23,m:0+t:81+s:2048` | 沪深 A 股全集 |\n| `m:1+t:2,m:1+t:23` | 沪 A |\n| `m:0+t:6,m:0+t:80` | 深 A |\n| `m:0+t:81+s:2048` | 北证 A |\n| `m:0+t:80` | 创业板 |\n| `m:1+t:23` | 科创板 |\n| `m:116+t:3,m:116+t:4,m:116+t:1,m:116+t:2` | 港股 |\n| `m:105,m:106,m:107` | 美股 |\n| `b:MK0021` | ETF |\n| `b:MK0354` | 可转债 |\n| `m:90+t:2` | 行业板块 |\n| `m:90+t:3` | 概念板块 |\n| `m:90+t:1` | 地域板块 |\n\n### datacenter-web 报表 `reportName`\n\n| 名称 | 内容 |\n|------|------|\n| `RPT_DAILYBILLBOARD_DETAILS` | 龙虎榜 |\n| `RPT_F10_EH_FREEHOLDERS`     | 十大流通股东 |\n\n---\n\n## xueqiu（雪球）\n\n域名：`stock.xueqiu.com` / `xueqiu.com`\n\n行情 API 返回字段是人类可读，不需要词典：\n\n| 字段 | 含义 |\n|------|------|\n| `symbol` | SH600000 / 00700 / AAPL 样式 |\n| `name` | 名称 |\n| `current` | 最新价 |\n| `chg` | 涨跌额 |\n| `percent` | 涨跌幅 % |\n| `volume` | 成交量 |\n| `amount` | 成交额 |\n| `high52w / low52w` | 52 周高低 |\n| `market_capital` | 总市值 |\n| `pe_ttm` | 市盈率 TTM |\n| `pb` | 市净率 |\n\n鉴权：需要 `xq_a_token` cookie。走 `Strategy.COOKIE + browser: true`。\n\n---\n\n## bilibili\n\n域名：`api.bilibili.com` / `space.bilibili.com` / `passport.bilibili.com`\n\nB 站接口字段也大多人类可读。关键坑是 **wbi 签名**：\n\n- 凡 URL 含 `/wbi/` 的接口都需要 `w_rid + wts` 签名\n- 签名算法依赖每日轮换的 `img_key + sub_key`（从 `nav` 接口拿）\n- 平台 SDK 在 `clis/bilibili/utils.js`：`apiGet(page, path, { signed: true, params })` 自动签\n- 普通 cookie JSON 接口优先用 `page.fetchJson(url)`\n\n| 字段 | 含义 |\n|------|------|\n| `mid` | 用户 UID |\n| `aid / bvid` | 视频 ID（av 号 / bv 号） |\n| `cid` | 视频分 P ID |\n| `uname / upname` | up 主昵称 |\n| `view / danmaku / reply / favorite / coin / share / like` | 各类计数 |\n| `pubdate / ctime` | 发布/创建时间（秒级 unix） |\n\n---\n\n## tonghuashun（同花顺）\n\n域名：`q.10jqka.com.cn` / `d.10jqka.com.cn` / `data.10jqka.com.cn`\n\n同花顺多数接口返回 HTML 表格，需要 DOM 解析。JSONP 接口要设 `Referer: http://q.10jqka.com.cn/`，否则返回空。\n\n| 字段（API） | 含义 |\n|------|------|\n| `openPrice / closePrice` | 开/收盘 |\n| `zdf` | 涨跌幅 % |\n| `hsl` | 换手率 |\n| `zf`  | 振幅 |\n\n---\n\n## 字段不在词典时\n\n看 `field-decode-playbook.md`。里面是标准 SOP（排序键对比、结构差分、精度排查），10 分钟能推一条。\n\n推完一个代号补回本文件，下次直接查。\n\n<!-- File: skills/opencli-adapter-author/references/field-decode-playbook.md (Tokens: ~2712 | Category: anthropic-skill) -->\n# Field Decode Playbook\n\n响应里出现 `f237 / zdf / oc5 / x4` 这种看不懂的代号时走这套流程。目标：10 分钟内搞定一条未知字段，不靠猜。\n\n适用于 `field-conventions.md` 没收录的站点、没收录的代号、或者收录了但怀疑对不上的情况。\n\n---\n\n## 决策树\n\n```\n拿到一条响应，有字段看不懂？\n├── 字段值是字符串 / 时间 / URL → 走 §1 「肉眼对网页」\n├── 字段值是数字 → 走 §2 「排序键对比法」\n├── 字段值是数组 / 对象 → 走 §3 「结构差分法」\n└── 改参数响应不动 → 走 §4 「常量 / 精度位排查」\n```\n\n---\n\n## §1 肉眼对网页（字符串/时间/URL 类）\n\n最快。打开对应网页，响应的这条记录在哪行，把响应值跟页面上的文字一一对照。\n\n```bash\n# 举例：eastmoney convertible，响应里有 f243=20231215，页面上这支债写着\"上市日期 2023-12-15\"\n# 直接判断 f243 是上市日期（YYYYMMDD int）\n```\n\n**时间字段的判别**：\n\n| 观察到的形态 | 大概率是 |\n|-------------|---------|\n| 10 位整数 `1712345678` | unix 秒 |\n| 13 位整数 `1712345678000` | unix 毫秒 |\n| 8 位整数 `20240101` | YYYYMMDD int |\n| 6 位整数 `240101` | YYMMDD int |\n| 字符串 `2024-01-01` / `2024/01/01` | ISO 日期 |\n| 字符串 `01/01/2024` | 美式日期，小心月日顺序 |\n\n**URL 字段的判别**：\n\n```bash\n# 如果是相对路径，拼 domain 回浏览器验证\nopencli browser --session research eval \"window.location.origin + '/<path>'\"\n```\n\n---\n\n## §2 排序键对比法（数字类核心手段）\n\n数字字段是最容易踩坑的一类——看起来都是小数，但可能是涨跌幅、换手率、振幅、溢价率、市盈率……单位和量级也可能带陷阱。\n\n### 流程\n\n1. **找到一个能改变排序的参数**\n\n   | 站点 | 排序参数 | 值域 |\n   |------|---------|------|\n   | eastmoney (push2 clist) | `fid` + `po` | `fid` 是字段代号，`po` 是 `0=asc / 1=desc` |\n   | xueqiu | `order_by` | `percent / volume / amount / ...` |\n   | bilibili | `order` | `pubdate / click / stow / ...` |\n   | tonghuashun | `sort` | 数字代号 |\n   | 通用 | 页面点击表头切排序，抓包看新参数 | — |\n\n2. **用两个已知含义的值各抓一份响应**\n\n   比如 eastmoney，`fid=f2`（最新价）和 `fid=f3`（涨跌幅）：\n\n   ```bash\n   opencli browser --session research eval \"fetch('<url>&fid=f2&po=1').then(r=>r.json()).then(d=>d.data.diff.slice(0,3))\"\n   opencli browser --session research eval \"fetch('<url>&fid=f3&po=1').then(r=>r.json()).then(d=>d.data.diff.slice(0,3))\"\n   ```\n\n3. **对比两组数据**：\n\n   - 第一条记录的 `symbol/name` 变了吗？没变说明这个参数无效或者白名单窄\n   - 新的第一条里，哪个字段数量级 / 正负号 / 小数位数变化最大？那个字段就是和 `fid` 对应的业务语义\n   - 单调性：按 `desc` 取前 3 条，看新旧两组里目标字段是不是都单调下降\n\n4. **用第三个参数交叉验证**\n\n   ```bash\n   opencli browser --session research eval \"fetch('<url>&fid=f6&po=1').then(r=>r.json()).then(d=>d.data.diff.slice(0,3))\"  # 成交额\n   ```\n\n   对照网页上\"成交额排行\"的前三名。对得上就认。\n\n### 实例：推 f237（可转债溢价率）\n\n```bash\n# 按价格排，拿一条观察\nopencli browser --session research 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])\"\n# 返回 {f12:'123456', f14:'XX转债', f2:180.5, f3:2.1, f236:98.5, f237:83.2, f239:-4.1}\n\n# 按 f237 排\nopencli browser --session research eval \"fetch('...&fid=f237&po=1...').then(...)\"\n# 第一条变了，f237 值变成 400+\n\n# 打开 eastmoney 可转债页，切\"溢价率\"排序，第一条的溢价率确实是 400+ —— f237 就是溢价率 %\n```\n\n---\n\n## §3 结构差分法（数组 / 嵌套对象类）\n\n响应顶层是 `{data: {diff: [...]}}` 还是 `{list: [{...}, {...}]}` 还是 `{rows: [{k:v}, ...]}`，不同接口差别大。\n\n### 流程\n\n1. **先数一次嵌套路径**\n\n   ```bash\n   opencli browser --session research eval \"fetch('<url>').then(r=>r.json()).then(j=>({keys:Object.keys(j), type:Array.isArray(j)?'array':'object'}))\"\n   ```\n\n2. **一层一层剥**\n\n   ```bash\n   opencli browser --session research eval \"fetch('<url>').then(r=>r.json()).then(j=>{const d=j.data; return {keys:Object.keys(d), sample: d[Object.keys(d)[0]]}})\"\n   ```\n\n3. **数数组长度对照 pz / pageSize**\n\n   如果请求 `pz=20` 拿回的数组正好 20，那就是结果数组；如果是 1 那可能是 pagination meta。\n\n4. **同一字段在不同条目间是否变化**\n\n   ```bash\n   # 取前三条的 keys，看哪些 key 的值在变（业务数据），哪些不变（常量/配置）\n   opencli browser --session research 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})))\"\n   ```\n\n---\n\n## §4 常量 / 精度位排查\n\n有的字段每条都一样，是精度指示器或类型标记。\n\n| 典型现象 | 通常含义 | 处理 |\n|---------|---------|------|\n| 每条值都是 `2` 或 `3` | 精度位数（小数几位） | 除以 `10^n` 还原浮点 |\n| 每条值都是 `0` 或 `1` | 涨跌方向 / 停牌标记 | 枚举，查同类 adapter |\n| 每条值都是 `6` / `8` / `80` | 市场代号 / 分类 ID | 查 `field-conventions.md` 市场表 |\n| 每条值都是空字符串 | 接口不返回但字段有占位 | 请求里去掉 / 用别的字段 |\n\n**eastmoney 精度坑**：不传 `fltt=2` 时，价格字段是 `int * 10^f152`。所以一律加 `fltt=2`，避免所有除法问题。\n\n---\n\n## §5 写完要做的事\n\n推出一个新代号后：\n\n1. **补进 `references/field-conventions.md`**：找到对应站点的表格加一行。下次直接查。\n2. **在 adapter 代码里留一条注释**：如果是实测推出来的不常见代号，写一行 `// f237 = convertible premium rate (verified 2026-04-20 against page)` 方便复核。\n3. **通过 `opencli adapter test` 验一次**：字段值能对上网页上眼见的数字。\n\n---\n\n## §6 通用坑\n\n| 坑 | 症状 | 解 |\n|----|------|-----|\n| 单位\"万\"当\"元\"用 | 成交额少 4 个零 | eastmoney `f6` 是元、`f62 / f64` 等净流入是万，核对每条接口 |\n| 百分比没 × 100 | 涨跌幅显示 0.0XX | 响应已经乘过 100 或者站点用小数 —— 看一眼网页 |\n| 股票代码被截 | `600000` 变 `60000` | 某些接口返回 int，前导 0 掉了。永远用 `String(code).padStart(6, '0')` 处理 A 股代码 |\n| 时间区无前导 0 | `2024-1-1` 排序坏 | parse 后用 ISO 回写 |\n| JSON 里数字是字符串 | 无法 `.toFixed()` | `Number(x)` 显式转 |\n\n---\n\n## §7 真推不出来\n\n三条兜底：\n\n1. **找同站点已有的 adapter** — `ls clis/<site>/` 翻文件，别站点邻居的 adapter 通常共享字段命名\n2. **找开源实现** — GitHub 搜 `<域名> fields` 或 `<域名> api`，往往有 Python / Go 库已经解码过\n3. **灰度**：把这条字段先输出成 raw，命令仍可用，下一版再语义化\n\n不要猜。猜错了后面验证不到、线上跑出来用户看到乱码也会误导决策。\n\n<!-- File: skills/opencli-adapter-author/references/jsdom-fixture-pattern.md (Tokens: ~1965 | Category: anthropic-skill) -->\n# JSDOM-against-frozen-fixture pattern (for in-browser DOM extractors)\n\n## When this pattern applies\n\nYou're writing an adapter where the data extraction happens **inside the\nlive browser** via `page.evaluate(...)` — not in Node-side post-processing.\nTypical signal: the adapter has a function literal stringified into\n`page.evaluate('(' + fn.toString() + ')()')`, walking `document.querySelector`\nand other DOM APIs.\n\nThese extractors are invisible to mocked `page.evaluate` unit tests — those\ntests feed pre-baked results to the func, so the real DOM walk never runs.\nPR #1312 found two such silent in-browser bugs in dianping that only\nsurfaced on live verify:\n\n1. shop title fallback split on ASCII `[]` while the page renders\n   full-width `【】`, so `name` was always empty.\n2. `headText.replace(/\\s+/g, ' ')` collapsed rating \"4.8\" with reviews\n   \"21241条\", and a head-wide `/\\d+条/` regex captured `4.821241` → 5.\n\nIf either category looks plausible for your site, freeze a representative\nHTML snapshot and replay it through JSDOM in a unit test.\n\n## File layout\n\n- Test file: `clis/<site>/<site>.test.js` (alongside the adapter file)\n- Fixture file: `clis/<site>/__fixtures__/<command>.html`\n\nReference implementation: `clis/dianping/__fixtures__/{shop,search}.html`\n(see PR #1313 for the original test + PR #1318 for the whitespace-strip\nfollow-up that this doc grew out of).\n\n## Creating the HTML fixture\n\nThe whole point is to commit a **representative snapshot of the live\npage's DOM** — so the JSDOM unit test exercises the real selector paths\nthe live extractor walks.\n\n### Mandatory steps (in order)\n\n1. **Capture** the page's HTML from a live verify run:\n   ```bash\n   opencli browser --session research open https://www.example.com/<page>\n   # In another shell, dump page.content():\n   opencli browser --session research eval 'document.documentElement.outerHTML' \\\n     > /tmp/raw-<command>.html\n   ```\n\n2. **Strip noise blocks** that JSDOM doesn't need and that change every\n   page load (so committed fixtures wouldn't survive a re-capture diff\n   anyway):\n   - All `<script>...</script>` content\n   - All `<style>...</style>` content\n   - All `<iframe>...</iframe>` content\n   - All `<!-- ... -->` HTML comments\n   - All `<link rel=\"preload\" ...>` / tracking pixels\n\n3. **Replace `<img src=\"...\">`** with a placeholder\n   (`src=\"placeholder.png\"`) — real CDN URLs leak account-scoped tokens\n   and are noise.\n\n4. **Trim to the minimum subtree** that exercises the extractor and\n   triggers the bug you're guarding against. For dianping shop, that\n   was `.shop-head` + `.desc-info` + `.review-title`; for search,\n   3 of 15 result `<li>` cards (rank 1, 2, 3).\n\n5. **MANDATORY whitespace normalization step** — strip all\n   whitespace-only lines:\n   ```bash\n   awk 'NF>0' /tmp/raw-<command>.html > clis/<site>/__fixtures__/<command>.html\n   ```\n   JSDOM's HTML parser is whitespace-tolerant; blank lines have zero\n   semantic effect on the test, but they bloat the committed diff and\n   obscure the meaningful DOM subtree from reviewers.\n   **Skipping this step is the most common silent quality regression\n   in fixture creation.** PR #1318 cleaned 239 leftover blank lines\n   from dianping's two fixtures (84.6% / 54.8% of file content) and\n   the JSDOM tests still passed unchanged.\n\n6. **Commit** at `clis/<site>/__fixtures__/<command>.html`.\n\n### Anti-patterns to avoid\n\n- ❌ \"Strip script/style content\" but leave the surrounding newlines.\n  Removing inline script body without collapsing the now-blank line is\n  the source of the noise — Step 5 exists specifically to clean this up.\n- ❌ Trim to minimum subtree, skip Step 5. The fixture works for the\n  test but reviewers see hundreds of blank lines.\n- ❌ Pretty-print the **mega-line** (e.g. `<div>...</div>` collapsed\n  onto one giant line by the source page). Some bugs depend on\n  text-node adjacency without intervening whitespace (e.g. `4.8` and\n  `21241条` immediately adjacent → `headText` fusion bug). Pretty-print\n  inserts whitespace that masks the very condition you're testing for.\n  Step 5 only deletes empty lines — never re-flow content.\n- ❌ Re-capture from live and overwrite the committed fixture without\n  re-running Steps 2-5. The fixture is a **frozen** snapshot; if the\n  page layout changes, that's a separate decision (update test\n  expectations + re-trim + re-strip).\n\n## Writing the JSDOM unit test\n\n```js\nimport { describe, it, expect, beforeEach, afterEach } from 'vitest';\nimport { JSDOM } from 'jsdom';\nimport { readFileSync } from 'node:fs';\nimport { join, dirname } from 'node:path';\nimport { fileURLToPath } from 'node:url';\nimport { extractShopFields } from './shop.js';\n\nconst __dirname = dirname(fileURLToPath(import.meta.url));\nconst SHOP_FIXTURE = readFileSync(join(__dirname, '__fixtures__/shop.html'), 'utf8');\n\ndescribe('shop adapter — extractor against frozen HTML fixture', () => {\n    let originalDocument;\n    let originalLocation;\n\n    beforeEach(() => {\n        originalDocument = globalThis.document;\n        originalLocation = globalThis.location;\n    });\n\n    afterEach(() => {\n        globalThis.document = originalDocument;\n        globalThis.location = originalLocation;\n    });\n\n    function loadFixture(html, url) {\n        const dom = new JSDOM(html, { url });\n        globalThis.document = dom.window.document;\n        globalThis.location = dom.window.location;\n        return dom;\n    }\n\n    it('extracts the canonical fields and avoids known silent bugs', () => {\n        loadFixture(SHOP_FIXTURE, 'https://www.example.com/shop/123');\n\n        const data = extractShopFields();\n\n        expect(data.ok).toBe(true);\n        expect(data.name).toBe('...');\n        // Add explicit regression guards for each known silent bug.\n        expect(data.reviewsRaw).toBe('...');  // not the fused \"<rating><reviews>\" form\n    });\n});\n```\n\nFor this to work, the adapter's extractor must be a **top-level\nfunction** that uses bare `document` / `location` (not `window.document`),\nso the same code is exercised by:\n\n- live browser: injected via `${extractFn.toString()}` into\n  `page.evaluate`\n- JSDOM unit test: with `globalThis.document` swapped\n\nIf your adapter currently has the extractor as an IIFE inside a\ntemplate literal, refactor to a top-level `export function` first.\nReference: `clis/dianping/{shop,search}.js` extracts `extractShopFields()`\nand `extractSearchRows()` with bare `document`/`location`.\n\n## Reverse-validation (mandatory before claiming the test catches the bug)\n\nA test that \"passes 18/18\" doesn't prove it would have caught the original\nbug — only that it agrees with the current implementation. Before\ntrusting a regression guard:\n\n1. Make a backup of the adapter source.\n2. Reintroduce the buggy variant of the relevant extractor.\n3. Run the test. It MUST fail with an assertion that points at the\n   silent bug.\n4. Restore from backup.\n\nFor dianping bug #2 (rating/reviews fusion):\n```js\n// BUGGY VARIANT — replaces the .reviews selector path\nconst buggyMatch = headText.match(/(\\d+)条/);\nlet reviewsRaw = buggyMatch ? buggyMatch[0] : '';\n```\n\nIf after Step 5 of fixture creation the test still fails on this buggy\nvariant with `expected '21241条' to be '21241条'` actually receiving\n`'821241条'` (the fused digits), the regression guard is intact. If the\ntest still passes with the buggy variant, the fixture is too stripped /\nnormalization went too far / the assertion is too loose — go back and\ntighten.\n\nThis is the same discipline as `--write-fixture` Step 10 in the main\nrunbook (verify fixture catches what it should), applied to the JSDOM\nHTML fixture instead of the response JSON fixture.\n\n## See also\n\n- `references/adapter-template.md` — basic adapter file structure\n- `references/output-design.md` — column naming for the post-extract mapping\n- `references/success-rate-pitfalls.md` — broader \"verify can pass while\n  data is silently wrong\" catalog; the mocked-page.evaluate gap that\n  motivates this whole pattern is one entry there\n\n<!-- File: skills/opencli-adapter-author/references/output-design.md (Tokens: ~1802 | Category: anthropic-skill) -->\n# Output Design\n\nadapter 的 `columns` 不是随便列。要让下游（用户、其他 adapter 合并、agent 后续分析）都能直接读。\n\n---\n\n## 核心约定\n\n### 1. 命名\n\n**camelCase，全英文**：\n\n| 好 | 差 |\n|----|---|\n| `marketCap` | `market_cap` / `市值` / `MarketCap` |\n| `change24hPct` | `change_percentage_24h` / `涨跌幅24h` / `changePct_24h` |\n| `bondCode` | `bond_code` / `BOND_CODE` |\n| `pubTime` | `publish_time` / `pubdate` |\n\n原则：\n- 缩写：`pct`（百分比）/ `pe`（市盈率）/ `pb`（市净率）/ `ytm`（到期收益率）/ `id`\n- 时间后缀：`Time`（具体时刻）/ `Date`（日期）/ `Ts`（unix 秒）\n- 百分比一律 `Pct` 结尾，数值是\"已乘 100\"形式（`2.5` 表示 2.5%，不是 `0.025`）\n- 数量后缀：`Count`（整数计数）/ `Total`（累计值）\n\n### 2. 类型\n\n| 字段类 | JS 类型 | 格式 |\n|--------|---------|------|\n| 价格 / 金额 | `number` | 原始小数，别除 1000 别取整 |\n| 百分比 | `number` | 已 × 100（`2.5` = 2.5%） |\n| 计数 / rank | `number` | 正整数 |\n| 代码 / id / symbol | `string` | 股票代码 `'600000'` 保留前导 0 |\n| 名称 / 标题 | `string` | 去首尾空白 |\n| 时间 | `string` ISO（`'2024-01-15T10:30:00Z'`）或 `number` unix 秒 | 不要本地字符串 `'2024/1/15'` |\n| 布尔 | `boolean` | 不用 `0/1` |\n| URL | `string` | 绝对路径，相对路径要拼 host |\n\n**特殊**：\n- 缺失用 `null`，不用 `0` / `''`（0 和空字符串有业务含义，别混）\n- 枚举 → string，不要 int 代号（`'listed'` 比 `0` 清楚）\n\n### 3. 顺序\n\n固定三段：\n\n```\n[识别列 ...] [业务数字 ...] [metadata ...]\n```\n\n**识别列**（前 1-3 列）：`rank / symbol / code / bondCode / name / title / id`\n\n**业务数字**（中间）：价格、涨跌幅、成交量、市值等业务语义\n\n**metadata**（最后 1-3 列）：`pubTime / updateTime / source / url`\n\n### 4. 必有列（按类型）\n\n| adapter 类型 | 必须包含 |\n|-------------|---------|\n| 排行 / 列表 | `rank` + 识别列 + 业务数字 |\n| 时序 / K 线 | `date`（或 `ts`）+ 数值 |\n| 详情（单对象） | 识别列 + 业务字段 |\n| 新闻 / 公告 | `title` + `pubTime` + `url` |\n\n### 5. 控量\n\n**单条 ≤ 15 列**。超了就得考虑：\n- 拆成多个 adapter（列表版 + 详情版）\n- 次要字段合进 `extras: {...}` 对象\n- 只有少数用户关心的字段默认隐藏（靠参数开关）\n\n---\n\n## 对齐邻居 adapter\n\n写新 adapter 前先看同站点现有 adapter 怎么命名：\n\n```bash\ngrep -h \"columns:\" clis/<site>/*.js\n```\n\n复用同类列名。比如 `clis/eastmoney/convertible.js` 用 `bondCode / bondName / stockCode / stockName`，新写 eastmoney 某个涉及股票代码的 adapter 就沿用 `stockCode / stockName`，不要发明 `securityId / securityName`。\n\n---\n\n## 常见错误\n\n| 错 | 对 |\n|----|---|\n| `columns: ['id', 'name', 'data.price']`（点路径） | 把 `data.price` 在 func 里打平成 `price` |\n| `{date: '2024-01-15 10:30'}`（空格 + 非 ISO） | `'2024-01-15T10:30:00Z'` 或 `Date.toISOString()` |\n| `{pct: '2.5%'}`（字符串 + 单位） | `{changePct: 2.5}` 纯数字 |\n| `{volume: '1.2万'}` | `{volume: 12000}` |\n| `{code: 600000}`（整数丢前导 0） | `{code: '600000'}` string，或 `String(code).padStart(6, '0')` |\n| columns 和 func 返回的 keys 对不上 | 列出的每个 key 必须在返回对象里，顺序也一致 |\n\n---\n\n## description 字段\n\nadapter `description` 是用户第一眼看到的，写清楚：\n\n1. **数据是什么**：`\"A 股涨幅排行\"` vs `\"大盘指数分时\"`\n2. **默认行为**：`\"默认按涨幅排序，前 20 条\"`\n3. **重要参数**：`\"支持 market 参数切换沪深/北证\"`\n\n不要：\n- `\"get data\"` — 废话\n- `\"查询 xxx 数据\"` — 也是废话\n- 塞完整 URL / 字段代号列表 — 留给 help\n\n一行 30 字左右够了。\n\n---\n\n## args 命名\n\n- `limit` 而不是 `count / num / n / size`\n- `sort` 而不是 `sort_by / order_by / sortKey`\n- `market` 而不是 `exchange / platform / type`\n- `symbol` / `code` / `query` 根据业务选，保持和邻居 adapter 一致\n- 布尔参数用 `enableX / includeX`，默认 false 免得用户改变认知负担\n\n`help` 文案给**所有合法值**，别让用户猜：\n\n```javascript\n{ name: 'sort', type: 'string', default: 'turnover', help: '排序：turnover / change / drop / price / premium' }\n```\n\n---\n\n## 示例对比\n\n**差的**：\n\n```javascript\ncolumns: ['股票代码', 'name', 'PRICE', 'change%', 'vol', 'time']\n```\n\n问题：中英混、大小写乱、百分号字符串、缩写不统一。\n\n**好的**：\n\n```javascript\ncolumns: ['rank', 'stockCode', 'stockName', 'price', 'changePct', 'volume', 'updateTime']\n```\n\n识别列在前，metadata 在后，命名统一 camelCase。\n\n<!-- File: skills/opencli-adapter-author/references/site-memory.md (Tokens: ~3811 | Category: anthropic-skill) -->\n# Site Memory\n\n站点记忆分两层：**in-repo 种子**（skill 自带的已知站点公共知识）+ **本地工作目录**（每台机器跑过的站点累积产物）。\n\n---\n\n## 两层结构\n\n```\nskills/opencli-adapter-author/references/site-memory/<site>.md\n    — 公共种子。手写 + PR 审核进入。多 agent 共享的第一批起点。\n    — 已铺：eastmoney / xueqiu / bilibili / tonghuashun / gmail\n\n~/.opencli/sites/<site>/\n    — 本地累积。agent 跑 adapter 过程里自动写入，跨 session 复用。\n    — 不进 git，不进 PR。\n```\n\n用法：开头先读本地，命中 **不跳写 adapter**，仍要跑 Step 5 endpoint 验证 + Step 7 字段抽查（memory 可能过期或站点换版）；没命中读 in-repo；都没有走完整 recon。\n\n---\n\n## Layer 1 — In-repo 种子（`references/site-memory/<site>.md`）\n\n每个覆盖站点一个 `.md`，结构固定：\n\n```markdown\n# <site>\n\n## 域名\n主 API / 备 API / 登录 / 静态资源\n\n## 默认鉴权\n`Strategy.XXX` + 必需 cookie/header + 获取方式\n\n## 已知 endpoint（选最常用的 5-10 条）\n- `GET <url>` — 返回 X，分页参数 Y\n- ...\n\n## 字段（指向 `field-conventions.md` 的某一节）\n\n## 坑 / 陷阱\n- fltt=2 必传\n- 单位是\"万\"不是\"元\"\n- ...\n\n## 可参考的 adapter\n`clis/<site>/<name>.js` × N\n```\n\n审核门槛高，里面写的东西必须是\"多数人都会踩到\"的共识。一次性试错、站点局部怪癖放 Layer 2。\n\n---\n\n## Layer 2 — 本地工作目录（`~/.opencli/sites/<site>/`）\n\nagent 每跑一次相关 adapter 就可以自动写/读：\n\n```\n~/.opencli/sites/<site>/\n  notes.md               — 累积笔记（时间戳 + 写入人 + 发现）\n  endpoints.json         — 已验证的 endpoint 目录\n  field-map.json         — 字段代号 → 含义（key 为字段代号，value 为 {meaning, verified_at, source}）\n  verify/                — `opencli adapter test` 期望值（值级校验锚点，每个 adapter 一份）\n    <cmd>.json\n  fixtures/              — 公开/合成/已完成脱敏的响应样本（给字段对比 / 离线 replay；高敏私人响应不落盘）\n    <cmd>-<ts>.json\n  last-probe.log         — 最近一次侦察输出（下次接着用）\n```\n\n`verify/` vs `fixtures/` 别混：\n- `verify/<cmd>.json` 是**结构期望**（rowCount / columns / types / patterns / notEmpty），每 adapter 一份、会被 verify 读。\n- `fixtures/<cmd>-<ts>.json` 是**可安全持久化的响应样本**，给人 / 下一个 agent 做字段比对用，verify 不会读；高敏私人响应只用合成 fixture。\n\n### `endpoints.json` 格式（schema 锁死）\n\nkey = endpoint 的短名（`clist` / `kline` / `search` 等），不要用全 URL 当 key。\n\n```json\n{\n  \"clist\": {\n    \"url\": \"https://push2.eastmoney.com/api/qt/clist/get\",\n    \"method\": \"GET\",\n    \"params\": {\n      \"required\": [\"fs\", \"fields\"],\n      \"optional\": [\"pn\", \"pz\", \"fid\", \"po\", \"fltt\"]\n    },\n    \"response\": \"data.diff[] 数组\",\n    \"verified_at\": \"2026-04-20\",\n    \"notes\": \"fltt=2 必传\"\n  }\n}\n```\n\n字段说明：\n\n- `url` / `method`：原样存，query string 不入 `url`，都归 `params`\n- `params.required` / `params.optional`：参数名列表。**不存具体值**（值会变，记例子放 `notes`）\n- `response`：一句话写清响应形状入口（`data.diff[] 数组` / `result.data.items` / `纯数组`），而不是把整个响应贴进来\n- `verified_at`：`YYYY-MM-DD`。超过 30 天下次读到当作过期重验\n- `notes`：一两句关键坑（`fltt=2 必传` / `ms 单位 begin` 之类），不要写长文\n\n### `field-map.json` 格式（schema 锁死）\n\nkey = 字段代号（`f237` / `f152`），value 三件套：\n\n```json\n{\n  \"f237\": {\n    \"meaning\": \"convertible premium rate (%)\",\n    \"verified_at\": \"2026-04-20\",\n    \"source\": \"field-decode-playbook sort-key comparison vs page\"\n  }\n}\n```\n\n- `meaning`：人话 + 单位/精度（`%` / `元` / `万元` / `× 10^f152` 等）\n- `verified_at`：`YYYY-MM-DD`\n- `source`：怎么推出来的，让下次能复查（`field-decode-playbook sort-key` / `网页标签对照` / `bundle 搜索 var pricePct =`）\n- **已存在的 key 不要默默覆盖**。有冲突时先用 `fixtures/` 里的真实样本 + 网页肉眼值再确认一遍\n\n### `verify/<cmd>.json` 格式（schema 锁死）\n\n每个 adapter 一份，`opencli adapter test <site>/<cmd>` 会自动读。**没有这份 = verify 只能证\"能跑\"，证不出数据对**——可按任务需要维护 fixture。\n\n```json\n{\n  \"args\": { \"limit\": 3 },\n  \"expect\": {\n    \"rowCount\": { \"min\": 1, \"max\": 3 },\n    \"columns\": [\"rank\", \"tid\", \"title\", \"url\"],\n    \"types\": {\n      \"rank\": \"number\",\n      \"tid\": \"string|number\",\n      \"title\": \"string\",\n      \"url\": \"string\"\n    },\n    \"patterns\": {\n      \"url\": \"^https://www\\\\.1point3acres\\\\.com/bbs/thread-\"\n    },\n    \"notEmpty\": [\"title\", \"url\"]\n  }\n}\n```\n\n字段说明：\n\n- `args`：verify 调 adapter 时要带的参数。支持两种形态：\n  - **对象**：`{ \"limit\": 3 }` → 展开成 `--limit 3`，用于标准 named flag 适配器\n  - **数组**：`[\"123\", \"--limit\", \"3\"]` → **原样**追加到命令后，用于 positional 主语型适配器（`<tid>` / `<url>` / `<query>`）。repo 约定\"主语优先 positional\"，所以这类适配器只能用数组形态\n- `expect.rowCount.{min,max}`：包含边界。稳定列表接口收紧到 `[min, max]`，动态接口给一个宽区间\n- `expect.columns`：每行必须都有这些 key（严格要求——漏了就 fail）\n- `expect.types`：支持 `|` union（`string|null`）和 `any` 通配。写多少列类型看列的稳定性；波动大的列直接 `any` 比频繁改 fixture 好\n- `expect.patterns`：正则表达式 **字符串**（注意 `\\\\` 转义）。`null` / `undefined` 会被跳过，不要用正则校验可空字段\n- `expect.notEmpty`：trim 后不能为空的列。这是\"adapter 没吃掉核心业务字段\"的最后一道保险\n- `expect.mustNotContain`：`Record<col, string[]>`。列值里不允许出现这些子串。用来挡\"字段内容污染\"——比如 `description` 里混进了 `address:` / `category:` 的邻居节点文字、`title` 前面粘了面包屑前缀。`notEmpty` 挡不住这种软污染\n- `expect.mustBeTruthy`：列数组。列值必须是 JS truthy。用来挡\"silent `|| 0` / `|| false` 兜底\"——数值列返回 0 / 空字符串 / false 都会被 `notEmpty` 放过，但业务上通常是\"没抓到\"\n\n### 什么时候手写 vs `--write-fixture` 自动生成\n\n- `--write-fixture` 只是种子：生成 `rowCount.min=1` / `columns` / `types`，**没有** `patterns` / `notEmpty` / `mustNotContain` / `mustBeTruthy`——纯类型 fixture 挡不住数值错位 / 字段污染 / silent fallback。\n- 拿到种子后**必须手改**，四件套一起上：\n  - `patterns`：URL / 日期 / ID 等格式列\n  - `notEmpty`：核心业务字段\n  - `mustNotContain`：描述类文本列容易被兄弟节点污染时，把禁词（`address:` / `category:` 等）列出来\n  - `mustBeTruthy`：数值 / 布尔业务列，挡 `|| 0` / `|| false`\n- adapter 是 positional 主语型（`<tid>` / `<url>` / `<query>`）时，`--write-fixture` 的 `args` 要手写成数组形态。工具不会替你决定形态。\n- 站点换版导致 fixture 过时：`--update-fixture` 覆盖。改之前**先用肉眼核对一次网页值**，别闭着眼把错的响应固化下来。\n- **规避反模式：不要为了让 verify 通过去放松 pattern**。失败的 pattern 说明 adapter 输出有问题，要收紧 adapter，不是收紧 fixture。放松 fixture 等于默认把错数据接受下来。\n\n### `notes.md` 格式\n\n```markdown\n## 2026-04-20 by opencli-user\n写 `convertible.js` 时遇到：\n- f237 推断是溢价率（排序对比法，页面对照）\n- `fltt=2` 不加的话价格是整数 × 10^f152\n- `fs=b:MK0354` 过滤可转债\n```\n\n顶部追加新段落，老的不删。每段有日期 + 写入人。\n\n### `fixtures/<cmd>-<YYYYMMDDHHMM>.json` 格式\n\n一份该 endpoint 的**可安全持久化**响应样本。优先级：合成 fixture > 公开响应 > 可证明完成脱敏的真实响应。用途：\n\n- 未来字段代号再变时，拿样本和 `field-map.json` 做 regression 对比\n- 站点换版时，新响应和旧 fixture 做 diff 看哪个字段结构变了\n\n**存之前做数据分级**：\n\n- 公开列表/行情等无账户私有内容：可保存，仍需移除 cookie/token/header。\n- 低敏账号数据：只有完成字段级脱敏且不会从正文、snippet、URL、附件名反推个人时才保存。\n- 邮箱、私信、通讯录、支付、健康、草稿、上传文件等高敏内容：不要保存真实 response，即使“删了邮箱地址”也不够；用保持 shape/arity 的合成 fixture。\n\n原始 capture 只在 `/tmp/` 或受控本地 cache 中短暂存在，任务结束删除；不要把 raw private response 当成“完整 fixture”长期积累。\n\n---\n\n## runbook 里的读/写时机\n\n```\nStep 2 开始前 → 读  ~/.opencli/sites/<site>/\n                → 读  references/site-memory/<site>.md\n                命中后 → 不跳写 adapter，仍要跑 Step 5 (endpoint 验证) + Step 7 (字段抽查)\n                        verified_at 超 30 天 → 当作过期，按冷启动走 Step 3 → 4\n\nStep 10 verify 首轮通过后 → 写 ~/.opencli/sites/<site>/verify/<cmd>.json\n                            - 先 `--write-fixture` 拿种子，再手改 patterns / notEmpty / rowCount\n                            - 没这份后续 verify 挡不住数据错位，**必填**\n\nStep 11 肉眼对比通过后 → 写 ~/.opencli/sites/<site>/\n                        - endpoints.json：按 schema 追加或更新 verified_at\n                        - field-map.json：只追加新 key，已有的不默默覆盖\n                        - notes.md：顶部追加一段\n                        - fixtures/：按数据分级保存公开/合成/已脱敏样本（区别于 verify/）\n```\n\n**回写是 commit，不是 stash**：不过 Step 10 verify + Step 11 肉眼对比不写，防止把错的映射喂给下一轮。\n\n---\n\n## 不要写进 `~/.opencli/sites/` 的东西\n\n- 真实账户 cookie / token — 不要保存任何鉴权凭据\n- 用户私有数据：高敏内容一律不存；低敏内容只有完成字段级脱敏才可存\n- 过期超过 30 天的 last-probe.log（自动清）\n\n## 不要写进 **repo / adapter 目录** 的东西\n\n调试过程里的临时 dump（`.dbg-*.html` / `raw-*.json` / `sample-*` / `trace-*.txt`）**只能**落在系统 `/tmp/`；只有通过上面数据分级、准备长期保留的安全样本才进入 `~/.opencli/sites/<site>/fixtures/`。PR diff 会把 repo 根目录和 `clis/<site>/` 下的文件一起带走；任务结束还要删除原始 capture/cache。\n\n---\n\n## 没有 site-memory 时\n\n新站点没对应 `.md`，也没本地目录 → 完整走 recon + discovery，跑完直接写 `~/.opencli/sites/<site>/`，后面就有了。\n\n<!-- File: skills/opencli-adapter-author/references/site-memory/bilibili.md (Tokens: ~1363 | Category: anthropic-skill) -->\n# bilibili（B 站）\n\n## 域名\n\n| 用途 | 域名 |\n|------|------|\n| 主 API | `api.bilibili.com` |\n| 个人主页 / 空间 | `space.bilibili.com` |\n| 登录 / 鉴权 | `passport.bilibili.com` |\n| 动态 | `t.bilibili.com` / `api.vc.bilibili.com` |\n| 直播 | `api.live.bilibili.com` |\n\n## 默认鉴权\n\n- `Strategy.COOKIE + browser: true`\n- 核心 cookie：`SESSDATA`（登录态）、`bili_jct`（CSRF token，部分写接口要带到 header `Referer + csrf`）\n- **未登录也能调多数读接口**，但有 wbi 签名要求\n\n## 关键：wbi 签名\n\n- 凡 URL 含 `/wbi/` 的接口都要 `w_rid + wts` 签名\n- 签名算法依赖每日轮换的 `img_key / sub_key`，从 `api.bilibili.com/x/web-interface/nav` 的 `wbi_img` 字段拿\n- **不要自己重新实现**：`clis/bilibili/utils.js` 里的 `apiGet(page, path, { signed: true, params })` 已经封装好\n- 普通 cookie JSON 接口优先用 `page.fetchJson(url)`；站点级签名逻辑仍复用 `utils.js`\n\n## 已知 endpoint\n\n- `GET api.bilibili.com/x/web-interface/nav` — 登录态 + 拿 wbi key\n- `GET api.bilibili.com/x/space/wbi/arc/search?mid=<uid>` — 用户视频（需 wbi 签）\n- `GET api.bilibili.com/x/space/acc/info?mid=<uid>` — 用户资料\n- `GET api.bilibili.com/x/web-interface/view?bvid=BV...` — 视频详情\n- `GET api.bilibili.com/x/web-interface/popular?ps=20&pn=1` — 热门\n- `GET api.bilibili.com/x/web-interface/ranking/v2?rid=0` — 排行\n- `GET api.bilibili.com/x/v2/reply/wbi/main?type=1&oid=<aid>&mode=3` — 评论（需 wbi）\n- `GET api.bilibili.com/x/web-interface/search/all/v2?keyword=<q>` — 综合搜索\n- `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`）\n- `GET api.bilibili.com/x/v2/history/cursor` — 观看历史（需登录）\n- `GET api.bilibili.com/x/v3/fav/folder/created/list-all` — 收藏夹列表（需登录）\n\n## 字段\n\n字段基本人类可读，见 `../field-conventions.md` 的 bilibili 节（`mid / aid / bvid / cid / view / danmaku / reply / favorite / coin / share / like / pubdate / ctime`）。\n\n## 坑 / 陷阱\n\n1. **wbi 签名缓存 `img_key / sub_key`**：24 小时内有效，每次请求都重新 fetch `nav` 会被限频。utils.js 内部做了缓存\n2. **`mid` 和 `uid` 一回事**：接口不统一，`mid=` 居多，个别接口要 `host_mid=`\n3. **视频 ID 两种**：`aid`（老数字）和 `bvid`（BV1xxx），视频详情要用 `bvid`\n4. **`ps=` 最大 50**（popular/ranking），超过了多发几页拼\n5. **动态接口换版**：老的 `dynamic_svr` 已经废，新的走 `x/polymer/web-dynamic/v1/feed/space`，写新 adapter 直接用新接口\n6. **评论分页靠 `next` 游标**，不是页号\n7. **B 站限频 `-352 风控`**：短时间高频调会拦截，adapter 层加 500ms 间隔\n8. **搜索要先 \"cold start\"**：空 cookie 的 session 第一次搜会 412，先访问首页拿 `buvid3` cookie\n9. **直播接口在 `api.live.bilibili.com`**，不是主域名\n\n## 可参考的 adapter\n\n| 模板类型 | 参考文件 |\n|---------|---------|\n| 用户资料 / 视频列表 | `clis/bilibili/user-videos.js` / `me.js` |\n| 视频详情 / 字幕 | `clis/bilibili/download.js` / `subtitle.js` |\n| 评论 | `clis/bilibili/comments.js` |\n| 搜索 | `clis/bilibili/search.js` |\n| 热门 / 排行 | `clis/bilibili/hot.js` / `ranking.js` |\n| 动态 | `clis/bilibili/dynamic.js` |\n| 关注 | `clis/bilibili/following.js` |\n| 收藏 | `clis/bilibili/favorite.js` |\n| 观看历史 | `clis/bilibili/history.js` |\n\n通用工具：`clis/bilibili/utils.js`。新 adapter 先 `import { apiGet, fetchJson } from './utils.js'`，不要重写。\n\n<!-- File: skills/opencli-adapter-author/references/site-memory/eastmoney.md (Tokens: ~1522 | Category: anthropic-skill) -->\n# eastmoney（东方财富）\n\n## 域名\n\n| 用途 | 域名 |\n|------|------|\n| 行情列表 / 批量报价 | `push2.eastmoney.com` |\n| K 线历史 | `push2his.eastmoney.com` |\n| 报表类（龙虎榜 / 十大股东） | `datacenter-web.eastmoney.com` |\n| 7x24 快讯 | `np-listapi.eastmoney.com` |\n| 公司公告 | `np-anotice-stock.eastmoney.com` |\n| 静态页（网页端入口） | `quote.eastmoney.com` / `data.eastmoney.com` |\n\n## 默认鉴权\n\n- `Strategy.PUBLIC + browser: false`\n- 统一带 `ut=bd1d9ddb04089700cf9c27f6f7426281`（push2 系列的公共 token）\n- User-Agent 随意给个 `Mozilla/5.0` 就行，不加会偶发被拦\n\n## 已知 endpoint\n\n- `GET push2.eastmoney.com/api/qt/clist/get` — 列表 / 排行\n  - 必需：`fs`（市场/板块过滤码）、`fields`（字段清单）\n  - 可选：`pn`（页码，1-based）、`pz`（每页数量）、`fid`（排序字段）、`po`（0=asc / 1=desc）、`fltt`（2=浮点格式化）、`invt`（2=固定）、`np`（1=固定）\n  - 返回：`data.diff[]` 数组\n- `GET push2.eastmoney.com/api/qt/ulist.np/get` — 批量报价（给定 secids）\n  - 必需：`secids`（逗号拼接的 `<market>.<code>`）、`fields`\n  - 返回：`data.diff[]` 数组\n- `GET push2.eastmoney.com/api/qt/stock/get` — 单只详情\n  - 必需：`secid`、`fields`\n- `GET push2his.eastmoney.com/api/qt/stock/kline/get` — K 线历史\n  - 必需：`secid`、`klt`（周期：1 分 / 5 / 15 / 30 / 60 / 101 日 / 102 周 / 103 月）、`fqt`（0=不复权 / 1=前复权 / 2=后复权）、`fields1`、`fields2`\n  - 返回：`data.klines[]` — CSV 字符串数组\n- `GET datacenter-web.eastmoney.com/api/data/v1/get` — 报表类\n  - 必需：`reportName`（如 `RPT_DAILYBILLBOARD_DETAILS`）、`columns`、`pageSize`、`pageNumber`、`sortColumns`、`sortTypes`\n  - 返回：`result.data[]`\n- `GET np-listapi.eastmoney.com/nlist/api/list/get` — 7x24 快讯\n  - 必需：`client=web`、`column_id`、`limit`、`last_time`\n- `GET np-anotice-stock.eastmoney.com/api/security/ann` — 公司公告\n  - 必需：`sr=-1`、`page_size`、`page_index`、`ann_type`、`stock_list`\n\n## 字段\n\n字段代号词典见 `../field-conventions.md` 的 eastmoney 节（`f2 / f3 / f12 / f14 / f62 / f229-f243` 等全集）。\n\n市场前缀、fs 过滤码、secid 格式也都在那里。\n\n## 坑 / 陷阱\n\n1. **`fltt=2` 必传**：否则价格字段是 `int × 10^f152` 的整数表示，要自己除\n2. **资金流单位混乱**：`f6` 成交额是元，但 `f62 / f66 / f72 / f78 / f84` 等净流入大部分接口返回\"万元\"，核对单条接口后再乘\n3. **secid vs code**：`0.000001 / 1.600000` 是 secid（market + code），只有纯 code 时要先判断所属市场前缀再拼。用 `clis/eastmoney/_secid.js` 导出的 `resolveSecid(input)`（单股）或 `splitSymbols(s)`（批量参数拆分），不要自己硬拼前缀\n4. **港股代码 `00700.HK` 不是 secid**：只有前缀属于 `{0, 1, 105, 106, 107, 116, 100, 90}` 才当 secid\n5. **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`\n6. **datacenter-web 字段名大写**：`SECURITY_CODE` 不是 `security_code`\n\n## 可参考的 adapter\n\n| 模板类型 | 参考文件 |\n|---------|---------|\n| clist 分页排行 | `clis/eastmoney/convertible.js` / `rank.js` / `etf.js` / `sectors.js` |\n| ulist 批量报价 | `clis/eastmoney/quote.js` |\n| K 线历史 | `clis/eastmoney/kline.js` |\n| 报表类 | `clis/eastmoney/longhu.js` / `holders.js` |\n| 7x24 快讯 | `clis/eastmoney/kuaixun.js` |\n| 公司公告 | `clis/eastmoney/announcement.js` |\n| 指数 / 北上 | `clis/eastmoney/index-board.js` / `northbound.js` |\n| 资金流 | `clis/eastmoney/money-flow.js` |\n\n新写 eastmoney adapter 时，照最像的那条 copy + 改 `name` / URL 参数 / 字段映射三处。\n\n<!-- File: skills/opencli-adapter-author/references/site-memory/gmail.md (Tokens: ~694 | Category: anthropic-skill) -->\n# gmail\n\n## Domain and auth\n\n- Desktop UI: `mail.google.com/mail/u/<account>/`.\n- Requires Google session cookies and a signed-in Gmail surface.\n- The official Gmail API requires separate OAuth; browser cookies are not an official API credential.\n\n## Verified read paths\n\n- `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]`.\n- `POST /sync/u/<account>/i/fd`: natural thread/message detail response. Cached threads may render without a fresh request.\n- `POST /sync/u/<account>/i/s`: incremental synchronization; not needed by the initial adapter.\n- Legacy `POST /mail/u/<account>/?ui=2` still appears during bootstrap. Do not assume a single transport variant.\n\n## Positional fields verified in 2026-08\n\n- Thread: subject `[0]`, snippet `[1]`, timestamp `[2]`, sync id `[3]`, summaries `[4]`.\n- Summary: sender card `[1]`, label ids `[10]`.\n- FD thread: id `[0]`, message wrappers `[2]`; wrapper is `[messageId, record]`.\n- Message: to `[0]`, cc `[1]`, subject `[4]`, body `[5]`, snippet `[6]`, sender `[10]`, attachments `[13]`, date `[16]`, legacy id `[34]`.\n- Sender: display name `[14]`, address `[16]`.\n- Attachment node: id `[1]`, data `[3]`; data contains name `[2]`, MIME `[3]`, size `[4]`.\n\n## Strategy\n\n- Reads: `INTERCEPT`. Let Gmail submit the visible search/open action, then parse the natural `bv/fd` response. Never reconstruct private XSRF/BTAI request bodies.\n- Cached thread fallback: rendered message containers (`data-message-id`, `.a3s`, scoped attachment cards).\n- 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.\n\n## Durable pitfalls\n\n1. 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.\n2. `thread-f:<decimal>` converts to legacy hex with `BigInt(decimal).toString(16)`; never use Number.\n3. `bv` search responses may omit label definitions. The complete visible fallback is `#settings/labels`.\n4. A new `/fd` response is not guaranteed for cached messages. Treat rendered content as a legitimate visible-ui fallback.\n5. Responses are anti-XSSI-prefixed positional arrays. Enforce arity/required-field guards and fail typed on drift.\n6. Network capture is a destructive drain; a relevant bodyless/truncated entry means possible data loss, not an empty/short page.\n7. Never store live subjects, addresses, message bodies, attachment names, cookies, or account-specific ids in fixtures/site memory.\n8. 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.\n\n<!-- File: skills/opencli-adapter-author/references/site-memory/tonghuashun.md (Tokens: ~981 | Category: anthropic-skill) -->\n# tonghuashun（同花顺 / ths）\n\n## 域名\n\n| 用途 | 域名 |\n|------|------|\n| 行情页 / 热榜 | `q.10jqka.com.cn` |\n| 数据中心（多维筛选） | `data.10jqka.com.cn` |\n| 行情推送 | `d.10jqka.com.cn` / `dq.10jqka.com.cn` |\n| 基本面 / F10 | `basic.10jqka.com.cn` |\n| 资讯 / 新闻 | `news.10jqka.com.cn` |\n\n## 默认鉴权\n\n- `Strategy.COOKIE + browser: true`（多数接口有 cookie 风控）\n- `Referer: http://q.10jqka.com.cn/` 是关键 header，很多接口缺它直接返回空\n- 有些 JSONP 接口没 cookie 要求，但 Referer 还是要带\n- 不支持登录态扩展（加密/签名客户端逻辑复杂）\n\n## 已知 endpoint\n\n- `GET d.10jqka.com.cn/v6/line/hs_<code>/01/last1200.js` — K 线（JSONP，返回 `quotebridge_v6_line_...({...})`）\n- `GET q.10jqka.com.cn/thsq/quote/v6/<code>` — 实时行情快照（页面内 XHR）\n- `GET q.10jqka.com.cn/stock/attention/` — 热度榜（HTML，抽表）\n- `GET data.10jqka.com.cn/funds/ggzjl/field/zdf/order/desc/page/1/ajax/1/` — 资金流排行（HTML 表格 + JSONP 混合）\n- `GET news.10jqka.com.cn/tapp/news/push/stock/?tag=&page=1&limit=20` — 快讯\n- `GET data.10jqka.com.cn/ifinance/hotNews/` — 热点新闻\n\n## 字段\n\n见 `../field-conventions.md` 的 tonghuashun 节（`openPrice / closePrice / zdf / hsl / zf` 等）。\n\n数值字段多数是字符串（带百分号或\"万\"单位），解析时 `parseFloat` 并处理单位。\n\n## 坑 / 陷阱\n\n1. **`Referer` 必须带**，否则 302 到错误页\n2. **多数 JSONP 接口回调名固定**：`quotebridge_v6_...`，adapter 里直接 `.replace(/^[\\w_]+\\((.*)\\)$/, '$1')` 剥壳再 JSON.parse\n3. **HTML 表格接口**（如资金流）：`page.evaluate` 里 `document.querySelectorAll('table tr')` 抽，每列顺序在同类 adapter 之间复用\n4. **`<code>` 格式 `hs_600000`**：hs 是上海，sz 是深圳；港美股前缀不同\n5. **数据中心接口有日期快照切换**：`date=YYYYMMDD` 参数默认当天，历史日期要显式传\n6. **限频特别严**：连续 10 次会跳风控页，adapter 层 1s 间隔起\n7. **响应 gzip 强制**：某些接口不带 Accept-Encoding 会 406，`fetch` 默认 OK，node 原生 http 要显式加\n\n## 可参考的 adapter\n\n| 模板类型 | 参考文件 |\n|---------|---------|\n| 热度榜 | `clis/ths/hot-rank.js`（目前唯一一个，其他待补） |\n\nths 覆盖度低，新写 adapter 参考 eastmoney 同类型 adapter 的结构，把 URL / 解析逻辑换成 ths 版本。\n\n<!-- File: skills/opencli-adapter-author/references/site-memory/xueqiu.md (Tokens: ~1106 | Category: anthropic-skill) -->\n# xueqiu（雪球）\n\n## 域名\n\n| 用途 | 域名 |\n|------|------|\n| 行情 / 搜索 / 关注列表 | `stock.xueqiu.com` / `xueqiu.com` |\n| 动态 / 评论 / 热帖 | `xueqiu.com` |\n| 基金（蛋卷） | `danjuanfunds.com` |\n\n## 默认鉴权\n\n- `Strategy.COOKIE + browser: true`\n- 核心 cookie：`xq_a_token`（匿名也有，登录后含用户身份）\n- `page.evaluate` 里 `fetch(url, { credentials: 'include' })` 带 cookie\n- 浏览器先访问一次 `xueqiu.com` 触发 cookie 下发，再去调接口（否则 `xq_a_token` 不存在 → 400）\n\n## 已知 endpoint\n\n- `GET stock.xueqiu.com/v5/stock/quote.json?symbol=SH600000` — 单股详情\n- `GET stock.xueqiu.com/v5/stock/batch/quote.json?symbol=SH600000,SZ000001` — 批量报价\n- `GET stock.xueqiu.com/v5/stock/chart/kline.json?symbol=SH600000&begin=<ts>&period=day&type=before&count=-100` — K 线\n- `GET stock.xueqiu.com/v5/stock/screener/quote/list.json?market=CN&type=sh_sz` — 排行\n  - 可选：`order_by=percent`（涨幅）/ `volume`（成交量）/ `amount`（成交额）/ `market_capital`\n- `GET stock.xueqiu.com/v5/stock/search.json?keyword=<q>` — 搜索\n- `GET xueqiu.com/statuses/hot/list.json?since_id=-1&max_id=-1&size=20&type=stock` — 热门帖\n- `GET xueqiu.com/statuses/search.json?source=all&q=<q>` — 动态搜索\n- `GET xueqiu.com/service/v5/stock/portfolio/list.json` — 关注组合（需登录）\n- `GET xueqiu.com/v4/statuses/user_timeline.json?user_id=<uid>` — 用户动态\n\n## 字段\n\n字段基本人类可读（`symbol / name / current / chg / percent / volume / amount / market_capital / pe_ttm / pb`），详见 `../field-conventions.md` 的 xueqiu 节。\n\n## 坑 / 陷阱\n\n1. **必须先访问首页**：冷启动直接调 API 会 400 `cookie is invalid`，先 `page.goto('https://xueqiu.com/')` 再 fetch\n2. **`symbol` 前缀硬编码**：SH/SZ/HK/US —— 港股 `00700` 是 `'HK00700'`，美股 `AAPL` 是 `'AAPL'`（无前缀）\n3. **kline 的 `begin` 是毫秒 unix**，不是秒\n4. **screener 的 `type`**：`sh_sz / hk / us` 必传，漏了拿空数组\n5. **评论接口 `comments.json` 要传 `statusId`**，不是 `status_id`\n6. **限频明显**：同 symbol 短时间反复调会 403，adapter 层建议加 300ms 间隔\n7. **蛋卷基金（`danjuanfunds.com`）是独立子品牌**：cookie 域不同，要单独获取\n\n## 可参考的 adapter\n\n| 模板类型 | 参考文件 |\n|---------|---------|\n| 单股 / 批量报价 | `clis/xueqiu/stock.js` |\n| K 线 | `clis/xueqiu/kline.js` |\n| 排行 | `clis/xueqiu/hot-stock.js` |\n| 热帖 / feed | `clis/xueqiu/hot.js` / `feed.js` |\n| 关注 | `clis/xueqiu/watchlist.js` |\n| 搜索 | `clis/xueqiu/search.js` |\n| 评论 | `clis/xueqiu/comments.js` |\n| 基金持仓（蛋卷） | `clis/xueqiu/fund-holdings.js` |\n\n工具：`clis/xueqiu/utils.js` 有 `fetchWithRetry` + symbol normalize，直接用别重写。\n\n<!-- File: skills/opencli-adapter-author/references/site-recon.md (Tokens: ~1509 | Category: anthropic-skill) -->\n# Site Recon\n\n**Layer 1：这是哪种站？** 分类完直接进 `api-discovery.md` 找 endpoint。\n\n本文件**只做分类**，不讲 endpoint 怎么找。\n\n---\n\n## 页面与请求事实\n\n```bash\nopencli browser open <url> --session research\nopencli browser state --diagnostics --session research\nopencli browser network --static --session research\n```\n\n读取 success envelope 的 `data`。`state --diagnostics` 返回页面与可用 state globals；\n`network --static` 返回捕获请求和脚本候选。由 agent 判断最适合的数据源，CLI 不再给出\nPattern 分类或 `recommended_next_step`。下面的分类仅作为分析参考。\n\n看 `network` 输出判：\n\n| `network` 看到什么 | 站点类型 | 特征 |\n|------------------|---------|------|\n| 大量 `/api/...` JSON 请求，包含目标数据 | **A. SPA / JSON XHR** | React/Vue，数据走 fetch |\n| 有请求但都是广告 / 埋点，无目标数据 | **B. SSR / inline data** | 首屏在 HTML 里，深层再走 API |\n| 完全空 / 只有静态资源 | **C. JSONP / `<script src>` 驱动** | 老金融行情站常见 |\n| 有 API 但 401/403/签名错 | **D. Token / CSRF 鉴权型** | 在 A 基础上加鉴权 |\n| `Content-Type: text/event-stream` / WebSocket 握手 | **E. 流式** | 行情 tick / chat |\n\n分不清时参考下面五节的其他信号。\n\n**数据是 SPA / 异步加载时，`wait time 2` 可能不够**。改用 `opencli browser --session research wait xhr '/api/path-fragment'` 直接等具体接口到场，比盲 `wait time 5` 更稳。\n\n---\n\n## Pattern A — SPA / JSON XHR\n\n**代表**：xueqiu、linear、notion、大多数现代 SaaS\n\n**信号**：\n- URL 一访问就是 `/`，后续数据都在 network tab\n- `document.querySelector('main').childElementCount` 一开始为 0，后被 JS 填充\n- `window.React / window.Vue / window.__REACT_DEVTOOLS_GLOBAL_HOOK__` 存在\n\n**下一步**：`api-discovery.md` §1（network 精读）\n\n**注意 — Pattern A 命中不等于 strategy 选 `PAGE_FETCH`**：\n- 检查 network response shape 与业务目标是否一致；analytics / beacon / personalization 不能单独证明目标 API 可用。\n- booking #1680 反例：17 个 JSON XHR 看起来像 Pattern A，但全是 analytics side-channel，最终走 `DOM_STATE` / `UI_SELECTOR`\n- replay 候选 endpoint 后，按 `strategy-selection.md` 的契约模型选 strategy；`PUBLIC_API` / `COOKIE_API` 都不通才考虑 `PAGE_FETCH`\n\n---\n\n## Pattern B — SSR / inline data\n\n**代表**：bilibili 个人主页、小红书、微博、部分 Next.js / Nuxt 页\n\n**信号**：\n- 第一个请求（`document`）返回的 HTML 里已经含目标数据（`curl <url> | grep <某数字>`）\n- `window.__INITIAL_STATE__` / `window.__NEXT_DATA__` / `window.__NUXT__` 存在\n- 关 JS 仍能看到首屏数据\n\n**下一步**：`api-discovery.md` §2（state 抽取） + §1（深层数据回到 network）\n\n---\n\n## Pattern C — JSONP / `<script src>` 驱动\n\n**代表**：eastmoney、tonghuashun、老一代金融站\n\n**信号**：\n- `network` 空或只有 css/font\n- 页面上肯定有数据（价格、成交量等）\n- `document.querySelectorAll('script[src]')` 里有指向 `push / api / data` 域名的 src\n- 响应是 `jQuery123({...})` 这种回调包裹（JSONP）\n\n**下一步**：`api-discovery.md` §3（bundle / script src 搜索）\n\n---\n\n## Pattern D — Token / CSRF / Bearer\n\n**代表**：Twitter/X、部分企业 SaaS\n\n**信号**：\n- 已经是 Pattern A，但 `fetch(url, {credentials:'include'})` 返回 401/403\n- network 里请求头有 `X-Csrf-Token / Authorization: Bearer / X-Client-Id / X-Workspace-Id` 等自定义字段\n- 401 响应体带 `{\"code\":\"AUTH_REQUIRED\",\"csrf\":\"...\"}` 类提示\n\n**下一步**：`api-discovery.md` §4（token 来源排查） + §5（store action / intercept 降级）\n\n---\n\n## Pattern E — 流式\n\n**代表**：行情 tick、LLM chat\n\n**信号**：\n- `network` 里有 `101 Switching Protocols`（WebSocket 握手）\n- Response headers 含 `Content-Type: text/event-stream`\n- 请求一直 pending 不结束\n\n**下一步**：先找**同数据的 HTTP 轮询接口**（90% 概率有）。真没有再走 intercept 收 N 条。\n\n---\n\n## 识别失败怎么办\n\n诊断信号互相矛盾（比如 network 非空但目标数据不在里面），按优先级硬走：\n\n1. 先当 A，试 `api-discovery.md` §1\n2. 不行当 B，试 §2\n3. 还不行当 C，试 §3\n4. 401 出现了切 D，试 §4\n5. 所有手段都试过，启动 intercept（§5）\n\n不要纠结分类。分类是帮忙定第一步，没命中就按顺序降级。\n\n<!-- File: skills/opencli-adapter-author/references/strategy-selection.md (Tokens: ~448 | Category: anthropic-skill) -->\n# Choosing an adapter strategy\n\nUse the site's available contract and the task's needs to choose an implementation.\nThis reference is optional; no strategy note or classification artifact is required.\n\n| Source | Useful when | Check |\n| --- | --- | --- |\n| Public API | A documented endpoint covers the task | Inputs, authentication, pagination and error semantics |\n| Cookie-authenticated API | The endpoint works with the current account | Cookie/CSRF scope and real returned identity |\n| Page state / SSR | The page already contains the required data | Field meaning, completeness and hydration timing |\n| Semantic UI | The visible controls express the operation reliably | Stable selectors, target identity and observable result |\n| Browser fetch | The request needs page origin or runtime context | Actual request parameters and response meaning |\n| Network interception | The page produces a request that is difficult to reproduce | The captured response belongs to the intended action and input |\n\nThere is no universal API-over-DOM rule. A stable visible control can be easier to\nmaintain than an undocumented internal endpoint. Reuse an existing adapter or\nshared helper when it already models the same operation.\n\n## Gather facts as needed\n\n```sh\nopencli browser state --diagnostics --session research\nopencli browser network --static --session research\nopencli browser network --detail RequestKey --session research\n```\n\nRead `data` from the success envelope. Page diagnostics expose title, URL and\ninitial-state globals. Network evidence provides captured request keys, response\nshapes and static source locations. `static` is a candidate; `network+static`\nmeans both sources support the route. Neither proves business correctness.\nThe CLI does not assign a strategy score or prescribe the next action.\n\nCheck representative values against the visible page. For lists, distinguish a\ntrue end of pagination from a failed or partial response. For writes, bind the\noperation to the requested target and inspect its result; do not replay an action\nwhose previous outcome is unknown.\n\nIf an endpoint needs a runtime-generated signature, consider observing the page's\nown request or using semantic UI. Use `references/api-discovery.md` for techniques\nand `references/deep-recon.md` when the protocol requires further investigation.\n\n<!-- File: skills/opencli-adapter-author/references/success-rate-pitfalls.md (Tokens: ~3365 | Category: anthropic-skill) -->\n# Success-Rate Pitfalls\n\n11 个**静默失败**（adapter 看起来能跑、verify 能过，但数据是错的）的坑。每条给：现象 → 根因 → 防御手段。\n\n不是风格建议。每条都对应过一次真实翻车。\n\n---\n\n## 1. fixture pattern 被放松以过 verify\n\n**现象**：`verify` 报 `pattern \"url\" does not match /^https?:\\/\\/.*\\.com\\/bbs\\/thread-/`。agent 的\"修法\"是把 fixture 里的 pattern 改宽（`^https?://`），verify 一下就通过了。\n\n**根因**：adapter 丢了 URL 前缀 / 拼错了路径 / 吃到了相对路径。pattern 失败不是 fixture 太严，是 adapter 输出真的破了。\n\n**防御**：\n- `autofix` skill 现有纪律：**verify pattern 失败 = 收紧 adapter，不是收紧 fixture**（`opencli-autofix` SKILL.md §Rules for Patching 第 6 条）\n- 要改 fixture 的唯一合法理由：**站点本身换了格式**（例如 URL 规范迁移）。这种情况下在 `~/.opencli/sites/<site>/notes.md` 顶部写一段说明\n\n---\n\n## 2. 字段内容污染但 `notEmpty` / `columns` 都过\n\n**现象**：`description` 字段不为空，`verify` 通过。肉眼看输出发现描述里混了 `\"address: 上海 category: IT\"` 之类明显不属于描述的片段——兄弟 DOM 节点或父节点文字被一起 `textContent` 了。\n\n**根因**：`.container` 的 `textContent` 包括所有后代文字。调 `innerText` 仅好一点；用 `querySelector('.desc').textContent` 时，如果 `.desc` 里嵌套了 `.tag.address`，一样吃进去。\n\n**防御**：\n- fixture 用 `mustNotContain`：`{ \"description\": [\"address:\", \"category:\", \"工作年限:\"] }` 把已踩过的污染词列出来\n- adapter 侧：定位更精确的 selector，或抓到后 `.replace(/address:[^\\n]*/g, '').trim()`\n- 别信 `textContent.trim()` 就完事\n\n---\n\n## 3. 字段语义分歧（两个字段看起来都对）\n\n**现象**：51job 列表有 `updatedate` 和 `publishDate`，eastmoney 债券有 `f10`（发行日）和 `f26`（上市日）。adapter 随便选一个，verify 通过，用户一对照发现时间错位 1 个月。\n\n**根因**：两个字段都是合法日期，format 也对，只是含义不同。`notEmpty` / `types` 都挡不住。\n\n**防御**：\n- Step 7 字段解码**必须和网页肉眼对至少一条已知记录**（\"这条债券首页写的上市日是 2025-02-14\"，看 adapter 输出对不对得上）\n- 字段写进 `field-map.json` 时 `meaning` 要精确到\"上市日\"而不是\"日期\"\n\n---\n\n## 4. 字段单位混淆（数值量级错）\n\n**现象**：eastmoney 返回 `totalMarketCap: 128`（单位：亿元），adapter 直接写进 `marketCap`，用户对照 K 线页看是 128 元。常见错位：\n\n| 接口返回 | 网页显示 | 实际单位 | 错误写法 |\n|---------|---------|----------|---------|\n| `0.025` | `2.5%` | 小数 | adapter 再 × 100 → 显示 250% |\n| `12800` | `1.28 万` | 元 | adapter 不除 10000 → 显示 12800 万 |\n| `f152 = 2` | `9.27` | 价格 = 原值 ÷ 10^f152 | adapter 忽略 f152 → 显示 927 |\n\n**防御**：\n- fixture 加 `mustBeTruthy` 挡 `|| 0` / `|| false` silent fallback（数值列应该有值，不是 0）\n- `field-map.json` 的 `meaning` 写单位：`\"premium pct (0-1 fraction, NOT already × 100)\"`\n- Step 11 肉眼比对不能只比\"有没有数字\"，要比数量级\n\n---\n\n## 5. JSON-in-attribute vs 渲染后 innerText\n\n**现象**：51job 把完整 JSON 塞在 `<div data-sensorsdata='{\"job_title\":\"...\"}'>` 里，用 innerText 取的是渲染后的截断显示文字（\"Lead... ↩ 上海...\"），字段边界丢了。\n\n**根因**：现代站点常把结构化数据放在 `data-*` 属性里，渲染层只挑部分显示。取 innerText 相当于丢掉了结构。\n\n**防御**：\n- 看到 `data-sensorsdata` / `data-ng-state` / `data-page-props` / `data-track` 类属性先读属性，不读 innerText\n- 先在 `browser eval` 里检查：`document.querySelector('.item').dataset` 看有没有 JSON 串\n- 搜 bundle 时也搜 `JSON.parse(el.dataset.*)` 模式看 vendor 把数据塞哪了\n\n---\n\n## 6. cookie 域 / origin 不一致的隐式假设\n\n**现象**：在 `jobs.51job.com` 页面调 `cupid.51job.com` 的接口，headless 里带了 cookie 也跨不过去——`credentials:'include'` 只管带 cookie，不管 CORS。\n\n**根因**：浏览器 CORS 预检默认关闭跨 subdomain 请求。`credentials:'include'` 不是万能药。\n\n**防御**：参见 `api-discovery.md §0.2`。判断：`fetch(target).catch(e=>'cors:'+e.message)` 看是不是 TypeError。降级路径：改用 same-origin endpoint / 改在目标 subdomain 上打开页面 / 走 `§5 intercept`。\n\n---\n\n## 7. 等不够就抓导致空 DOM / 空 network\n\n**现象**：`open url && wait time 2 && network` 看到 0 条业务 API。agent 以为是 Pattern C（静态），去 bundle 里找 baseURL，找不到就卡住。真相：SPA 3.5 秒才发出第一个 API。\n\n**根因**：`wait time N` 是盲等。不同站点 JS 执行速度差很多。\n\n**防御**：\n- 数据是异步加载时**不用 `wait time`**，用 `opencli browser --session research wait xhr '/api/path-fragment'`，等具体 XHR 到场再 `network`\n- 不确定 endpoint 路径时：先 `wait time 2 && network`，看到候选路径再转 `wait xhr` 确认\n\n---\n\n## 8. adapter 里的 falsy `|| 0` 兜底静默\n\n**现象**：`likes: data.likes || 0`。接口偶尔返回 `likes: null`（可能因字段名改了、权限问题等），adapter 写成 0，verify `types: {likes: 'number'}` 通过，用户看到的是\"所有帖子 0 赞\"。\n\n**根因**：`||` 兜底把\"没抓到\"变成\"是 0\"。`notEmpty` 挡不住 0，`types` 也不挡。\n\n**防御**：\n- fixture 用 `mustBeTruthy: [\"likes\", \"count\", ...]`——业务数值列必须 truthy\n- adapter 侧 prefer `?.` 而不是 `||`；真的想兜底就兜 `undefined`，让 verify 能看见\n- 全部 `|| 0` 要过一遍眼：这个 0 是合法值还是漏抓 fallback\n\n---\n\n## 9. 跨 session cookie 污染 / 登录态漂移\n\n**现象**：本地开发时用自己的登录态验 endpoint 能通，PR 一合 verify fixture 跑在 CI 环境里立刻 401——顺手把样本数据也固化进了 fixture，看起来\"一切正常\"。\n\n**根因**：fixture 样本是带登录态跑出来的。存 `~/.opencli/sites/<site>/fixtures/*.json` 没脱敏，把 cookie / token / 自己的 uid / 昵称存了进去。\n\n**防御**：\n- `site-memory.md` 的脱敏规则：存 fixtures 前去掉 cookie / token / 用户私有字段（手机号 / 邮箱 / 昵称 / uid）\n- 需要登录态的接口：adapter 用 `Strategy.COOKIE`，adapter 代码里**不写任何具体 cookie 值**，只声明\"我需要 domain X 的 cookie\"\n- verify 样本里看到 `Bearer ...` 或 32 位 hex token → 先删再存\n\n---\n\n## 10. adapter 默认 timeout 不统一\n\n**现象**：同一个站两个 adapter，一个默认 15s timeout，另一个默认 60s。慢接口在一个命令里 ok，在另一个一样的慢接口却 timeout 了。\n\n**根因**：模板没统一 timeout；agent 依赖\"最像的邻居\"复制，复制到的邻居选了短 timeout。\n\n**防御**：\n- 邻居 adapter 的 `requestTimeoutMs` / `browser.wait` 配置**不能盲抄**。每个 adapter 应该结合自己的接口特性设一个\n- 真实接口延迟：Step 5 endpoint 验证时用 `time curl`（或 `performance.now()` 包 fetch）量一下 p50 / p95，timeout 设 p95 × 2 比较安全\n- 出现偶发 timeout 别 retry 掩盖；记到 `notes.md`，下次就知道这接口 p95 偏高\n\n---\n\n## 11. `aria-label` / `placeholder` / `title` 是 locale-dependent 文本\n\n**现象**：你本地英文 Chrome 测 `button[aria-label=\"Submit\"]` 一切正常，verify fixture 也是英文环境抓的。用户把 `chrome://settings/languages` 切中文，同一个按钮变 `aria-label=\"提交\"`，adapter silent 0 匹配——退化成 `notEmpty` / `types` 都没法 fire 的\"adapter 跑完返 0 行\"。\n\n**根因**：`aria-label` / `title` / `placeholder` / `alt` / `textContent` 都是页面的**用户可见文本**，被站点 i18n 框架翻译。用它们当 selector anchor 等于 \"select by visible text\"，locale 一动整个选择器就废。\n\n**防御**：\n- 优先用 locale-stable 标识：`data-testid` / `data-*` / 稳定 `id` / `class`。先确认不是 hash / A-B test 产物\n- `role` 不按 locale 翻译，但通常不唯一；只能当 semantic / scope filter，不能用裸 `[role=\"button\"]` 当 primary\n- 站点只暴露 `aria-label`（典型如 ChatGPT web 某些 control）时，写 fallback list，至少 en + zh-CN：`'[aria-label=\"Send\"], [aria-label=\"发送\"]'`\n- commit 前 grep `aria-label=` / `placeholder=` / `title=` 的硬编码字符串，确认每条都有兜底 locale\n- 找不到 control 要 typed fail-fast（例如 `CommandExecutionError` / send-failed），不要把 selector miss 变成空 rows 或假成功\n- 详细 framework + 活例见 `adapter-template.md §Selector 稳定性`\n\n不要去给 framework 加 `--i18n` flag 自动展开——多一层 indirection 还要维护翻译字典，纯 over-engineering。\n\n---\n\n## 总结：静默失败的共同特征\n\n1. **verify 绿 ≠ 数据对**。verify 只能证\"结构没坏\"，证不出\"值对不对\"。Step 11 肉眼比对是必须的。\n2. **\"字段有值\"是个比\"字段为空\"更危险的失败态**。空你会去查，有值你会 fallthrough。\n3. **fixture 四件套一起上**：`patterns` + `notEmpty` + `mustNotContain` + `mustBeTruthy`——每件挡一类问题，缺一个就漏。\n\n回写 `notes.md` 时把你踩的新坑写进去。下次就有第 12 条了。\n\n<!-- File: skills/opencli-adapter-author/references/typed-errors.md (Tokens: ~4236 | Category: anthropic-skill) -->\n# Typed Error Conventions\n\nOpenCLI 用 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 只能减不能加，新违例必须立刻收掉）。\n\n每条 rule 都挂了真实 anti-pattern 反例（PR #1329 三轮迭代是主要素材库）。\n\n---\n\n## 1. 5-classification 落点表\n\n| 场景 | 抛 | code | exit |\n|------|-----|------|------|\n| 参数不合法（包括越界、格式错、缺主语 positional） | `ArgumentError(message)` | `ARGUMENT` | 2 |\n| 业务无数据 / 资源不存在 | `EmptyResultError(command, hint?)` | `EMPTY_RESULT` | 66 |\n| HTTP 非 2xx / fetch throw / JSON parse 错 / 接口业务报错 | `CommandExecutionError(message)` | `COMMAND_EXEC` | 1 |\n| 需要登录（cookie 缺 / 401 / 302 → /login / \"请登录\" 页） | `AuthRequiredError(domain, message?)` | `AUTH_REQUIRED` | 77 |\n| 等响应超时（CDP / page.evaluate / 流式回复） | `TimeoutError(label, seconds)` | `TIMEOUT` | 75 |\n\n类签名定义见 [`src/errors.ts`](../../../src/errors.ts)。\n\n**禁止泛用 `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。\n\n---\n\n## 2. Anti-pattern A：silent-clamp 不止 limit\n\n**反例**（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)）：\n\n```js\n// ❌ silent clamp — 200 当 100，0/-1 当 1，timeout=5 当 15\nconst limit = Math.max(1, Math.min(Number(args.limit) || 20, 100));\nconst timeout = Math.max(15, parseInt(kwargs.timeout, 10) || 120);\nconst contentLimit = Math.max(50, Number(args.contentLimit) || 400);\nconst page = Math.max(1, Number(args.page) || 1);\n```\n\n**修法**（[`clis/1point3acres/utils.js`](../../../clis/1point3acres/utils.js) 的 `normalizePositiveInteger` / `normalizeLimit`）：\n\n```js\nimport { ArgumentError } from '@jackwener/opencli/errors';\n\n/** Validate a positive integer arg without silently flooring/clamping. */\nexport function normalizePositiveInteger(value, defaultValue, label = 'value', { min = 1 } = {}) {\n    const raw = value ?? defaultValue;\n    const n = Number(raw);\n    if (!Number.isInteger(n) || n <= 0) throw new ArgumentError(`${label} must be a positive integer`);\n    if (n < min) throw new ArgumentError(`${label} must be >= ${min}`);\n    return n;\n}\n\n/** With both lower bound and an explicit ceiling. */\nexport function normalizeLimit(value, defaultValue, maxValue, label = 'limit') {\n    const n = normalizePositiveInteger(value, defaultValue, label);\n    if (n > maxValue) throw new ArgumentError(`${label} must be <= ${maxValue}`);\n    return n;\n}\n```\n\n**用法**（[`clis/1point3acres/thread.js#L37-L39`](../../../clis/1point3acres/thread.js)）：\n\n```js\nconst page = normalizePositiveInteger(args.page, 1, 'page');\nconst limit = normalizePositiveInteger(args.limit, 10, 'limit');\nconst contentLimit = normalizePositiveInteger(args.contentLimit, 400, 'contentLimit', { min: 50 });\n```\n\n**注意**：当前 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**。\n\n### 何时抽 site-level helper\n\n≥ 2 个同站 adapter 都要做同样的 limit / page 校验时，把 `normalizeLimit` / `normalizePositiveInteger` 抽到 `clis/<site>/utils.js`。1 个 adapter 用就直接 inline，别为 1 处单点抽 helper。\n\n---\n\n## 3. Anti-pattern B：sentinel row / scalar sentinel 吞 empty / unknown / failure\n\n**反例 1**（PR #1329 R3 修掉的，[`clis/1point3acres/notifications.js` before](https://github.com/jackwener/OpenCLI/blob/2b8609b82fccaf98505c5b1b1859e3ffdaa1a55c/clis/1point3acres/notifications.js#L35-L37)）：\n\n```js\n// ❌ \"暂时没有提醒内容\" 是 empty result，不是一行业务数据\nif (/暂时没有提醒内容/.test(html)) {\n    return [{ index: 0, from: '', summary: '暂时没有提醒内容', time: '', threadUrl: '' }];\n}\n```\n\n**反例 2**（[`clis/1point3acres/search.js` before](https://github.com/jackwener/OpenCLI/blob/2b8609b82fccaf98505c5b1b1859e3ffdaa1a55c/clis/1point3acres/search.js#L52-L60)）：\n\n```js\n// ❌ \"抱歉\" hint 是搜索无结果，不是 rank=0 的伪结果行\nif (items.length === 0) {\n    const hint = html.match(/<p>([^<]*?抱歉[^<]*?)<\\/p>/);\n    if (hint) {\n        return [{ rank: 0, tid: '', title: hint[1].trim(), forum: '', author: '', replies: 0, views: 0, postTime: '', url: '' }];\n    }\n    return [];\n}\n```\n\n**反例 3**（[`clis/qwen/history.js` before](https://github.com/jackwener/OpenCLI/blob/2b8609b82fccaf98505c5b1b1859e3ffdaa1a55c/clis/qwen/history.js#L44-L51)）：\n\n```js\n// ❌ API failure 不是 conversation 的一行\nif (!result.ok && !result.sessions.length) {\n    return [{ Index: 0, Title: `API failed (status=${result.status})`, Updated: '', Url: '' }];\n}\n```\n\n**反例 4**（[`clis/qwen/image.js` before](https://github.com/jackwener/OpenCLI/blob/2b8609b82fccaf98505c5b1b1859e3ffdaa1a55c/clis/qwen/image.js#L160-L164)）：\n\n```js\n// ❌ 单张图片 fetch 失败，伪装成 \"⚠️ fetch-failed\" 状态行继续\nif (!asset?.ok) {\n    results.push({ Status: `⚠️ fetch-failed(${asset?.status || '?'})`, File: '-', Link: link });\n    continue;\n}\n```\n\n**修法**：\n\n```js\nimport { EmptyResultError, CommandExecutionError } from '@jackwener/opencli/errors';\n\nif (/暂时没有提醒内容/.test(html)) {\n    throw new EmptyResultError('1point3acres notifications', '暂时没有提醒内容');\n}\nif (items.length === 0) {\n    throw new EmptyResultError('1point3acres search', `No results for \"${query}\"`);\n}\nif (!result.ok && !result.sessions.length) {\n    throw new CommandExecutionError(`Qianwen history API failed (status=${result.status})`);\n}\nif (!asset?.ok) {\n    throw new CommandExecutionError(`Failed to fetch image: status=${asset?.status || '?'}`);\n}\n```\n\n**反例 5：scalar sentinel**（PR #1329 R2 修掉的，[`clis/qwen/status.js` before](https://github.com/jackwener/OpenCLI/blob/42e5303c792d9f71d9a30dde2e391405e03661e7/clis/qwen/status.js#L24-L29)）：\n\n```js\n// ❌ '-' 会被下游当成真实 model/session id 字符串\nreturn [{\n    Status: 'Connected',\n    Login: loggedIn ? 'Yes' : 'No (guest mode)',\n    Model: model || '-',\n    SessionId: sessionId || '-',\n}];\n\n// ✅ unknown 用 null，agent 可以 if (row.Model === null) 干净分支\nreturn [{\n    Status: 'Connected',\n    Login: loggedIn ? 'Yes' : 'No (guest mode)',\n    Model: model ? model : null,\n    SessionId: sessionId ? sessionId : null,\n}];\n```\n\n**根因**：success row 是\"业务数据\"的合同。把 empty / failure 塞进 row 会破坏：\n\n1. **round-trip pipeline**：listing → detail 类下游 adapter 拿到 `tid: ''` 会去查 `thread `（\"\"），白跑一轮\n2. **exit code semantics**：empty 应该 exit 66、API fail 应该 exit 1，混进 row 后两者都 exit 0，agent 没法 branch\n3. **fixture rowCount.min=1**：sentinel row 让 verify 永远过 → 真的接口挂了也看不见\n\n**对比 #1290 twitter trending**：当时也踩过 silent N/A row 的坑，drop 掉那行比留个 silent-wrong 字段诚实——这是 2026-05-04 的早期教训。typed-errors 把\"drop\"升级成\"throw EmptyResultError\"，下游 agent 能直接从 exit code 66 分辨 empty vs 真实数据错位。\n\n---\n\n## 4. Anti-pattern C：`return []` 当 silent fallback\n\n`adapter` 跑完什么都没拿到不能 `return []`：\n\n```js\n// ❌\nif (items.length === 0) return [];\n\n// ✅\nif (items.length === 0) throw new EmptyResultError('site command', `optional context`);\n```\n\n`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> 在该论坛没有发过帖子\"）。\n\n---\n\n## 5. Anti-pattern D：generic `CliError('CODE')` 隐藏分类\n\n**反例**（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)）：\n\n```js\n// ❌ 都是 exit 1，agent 无法区分服务失败 vs 空结果 vs bad args\nif (!resp.ok) throw new CliError('HTTP_ERROR', `HTTP ${resp.status}`);\nif (!Array.isArray(data) || data.length === 0) throw new CliError('NO_DATA', 'no data');\nif (!/id=\"postlist\"/.test(html)) throw new CliError('THREAD_NOT_FOUND', `帖子 ${tid} 不存在`);\n```\n\n**修法**：\n\n```js\n// HTTP / fetch / JSON / in-band API error → runtime failure\nif (!resp.ok) throw new CommandExecutionError(`request failed: HTTP ${resp.status}`);\nif (!Array.isArray(data)) throw new CommandExecutionError('unexpected response shape');\n\n// valid empty / resource missing → empty result\nif (data.length === 0) throw new EmptyResultError('coingecko top', 'no market data');\nif (!/id=\"postlist\"/.test(html)) throw new EmptyResultError('1point3acres thread', `帖子 ${tid} 不存在`);\n```\n\n`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.\n\n---\n\n## 6. Verify fixture 怎么挡这三类\n\n新写 fixture 时（`~/.opencli/sites/<site>/verify/<cmd>.json`）：\n\n- **rowCount.min ≥ 1**：保证 sentinel-row 不能伪装通过\n- **patterns**：核心 id 列加 `^\\d+$` 类正则，挡 `tid: ''` / `pid: ''` 之类空字符串污染\n- **mustBeTruthy**：业务数值列（`replies` / `views` / `count`）必须 truthy，挡 `|| 0` silent fallback\n- **预期 EmptyResultError 是合法返回时**（例如某条 fixture 故意搜不出来）用 `expect.exitCode: 66` 让\"空态合法\"和\"adapter 崩了\"在 fixture 层就分开（参见 [`site-memory.md`](./site-memory.md) verify schema）\n\n---\n\n## 7. 已经 grandfathered 的旧 adapter\n\nrepo 里仍有相当数量的 `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 不会卡。\n\n---\n\n## 8. 自查清单（PR push 前）\n\n- [ ] adapter 没有 `Math.max(1, Math.min(...))` / `Math.max(N, ...)` clamp 在外部参数上\n- [ ] adapter 没有 `return []` / `return [{...sentinel...}]` 当 empty / failure 兜底\n- [ ] adapter 没有 `'-'` / `'N/A'` 这类 scalar sentinel 冒充 real value；语义可空就返回 `null`\n- [ ] adapter 抛的不是 `CliError('XXX')`，而是 5 类 typed error 之一\n- [ ] verify fixture 的 `rowCount.min ≥ 1`，sentinel row 过不了\n- [ ] `npm run check:typed-error-lint` 跑过 → baseline 没增加\n\n<!-- File: skills/opencli-adapter-author/SKILL.md (Tokens: ~880 | Category: anthropic-skill) -->\n---\nname: opencli-adapter-author\ndescription: Build or improve an OpenCLI JS adapter using observed site behavior, existing adapters and browser API evidence. For ad-hoc browser interaction, see opencli-browser.\nallowed-tools: Bash(opencli:*), Bash(jsluice:*), Read, Edit, Write, Grep\n---\n\n# Adapter authoring\n\nChoose the simplest implementation that reliably performs the requested task.\nUse an existing site adapter when it already covers the capability:\n\n```sh\nopencli list search --site example\nopencli example search --help -f json\n```\n\n`list` returns `{ok:true,data:{commands,total,nextOffset?}}`; use `--offset` to\ncontinue, `--limit` to adjust page size. Detailed arguments belong in command help.\n\n## Evidence available to you\n\n```sh\nopencli browser open https://example.com --session research\nopencli browser state --diagnostics --session research\nopencli browser network --session research\nopencli browser network --static --session research\nopencli browser network --detail RequestKey --session research\n```\n\nNetwork captures describe observed requests. Static analysis adds candidates\nfrom loaded scripts without executing them. Inspect source locations, unresolved\nexpressions and capture provenance. Replay read requests when useful and compare\nbusiness data with the page. Write commands have real effects, including when run\nthrough `adapter test`; use a suitable test account or a supported dry-run.\n\nPrefer public APIs with a stable contract when available. Browser fetch, captured\nresponses, page state and semantic UI interactions are also valid choices. Select\nbased on the task and observed reliability. Do not guess endpoint parameters or\nhide failed operations behind empty success results.\n\n## JS interface\n\nAdapters are ordinary files under `~/.opencli/clis/<site>/<command>.js` or repository\n`clis/<site>/`. There is no draft directory, promotion step or required memory file.\n\n```sh\nopencli adapter init example/search\n```\n\nThe existing `cli({...})` interface is unchanged: `func(args)` for Node execution,\n`func(page, args)` with `browser: true`. Reuse neighboring adapters and shared helpers.\nReturn business data; the CLI adds the JSON success envelope. Throw a typed error\nwith an actionable hint for failures. Use `access: 'read'|'write'` accurately.\n`columns` controls human table exports; JSON preserves nested results.\n\n```js\nimport { cli, Strategy } from '@jackwener/opencli/registry';\n\ncli({\n  site: 'example', name: 'search', description: 'Search example items',\n  access: 'read', strategy: Strategy.PUBLIC, browser: false,\n  args: [{ name: 'query', positional: true, required: true }],\n  func: async (args) => {\n    const response = await fetch(`https://example.com/api/search?q=${encodeURIComponent(args.query)}`);\n    if (!response.ok) throw new Error(`Search failed: HTTP ${response.status}`);\n    return response.json();\n  },\n});\n```\n\n## Check and exercise\n\n```sh\nopencli adapter check example/search\nopencli example search 'query'\nopencli adapter test example/search --args '{\"query\":\"query\"}'\n```\n\n`check` validates definitions without executing adapters. `test` executes the normal\nCLI and optionally compares an existing fixture. `--write-fixture`, `--update-fixture`\nand `--no-fixture` control optional expectations; no row-shape or site-memory gate\nis imposed. `--trace retain-on-failure` helps diagnose browser execution.\n\nFor repository changes, cover the core behavior and failure paths with focused\nproduction-path tests, typecheck and build. Verify IDs, fields, units, pagination\nand empty/error semantics relevant to the task. Do not automatically replay a\nwrite with an unknown outcome; inspect state first.\n\n## Optional references\n\nRead only what is useful for the current task: `references/adapter-template.md`\nfor adapter patterns, `api-discovery.md` for request investigation,\n`output-design.md` for result fields, `field-decode-playbook.md` for opaque fields,\nand `deep-recon.md` for difficult private protocols. Site notes and fixtures are\noptional knowledge aids; older reference checklists are not mandatory workflows.\n\n<!-- File: skills/opencli-autofix/SKILL.md (Tokens: ~3122 | Category: anthropic-skill) -->\n---\nname: opencli-autofix\ndescription: Automatically fix broken OpenCLI adapters when commands fail. Load this skill when an opencli command fails — it guides you through collecting a trace artifact, patching the adapter, retrying, and filing an upstream GitHub issue after a verified fix. Works with any AI agent.\nallowed-tools: Bash(opencli:*), Bash(gh:*), Read, Edit, Write\n---\n\n# OpenCLI AutoFix — Automatic Adapter Self-Repair\n\nWhen an `opencli` command fails because a website changed its DOM, API, or response schema, **automatically diagnose, fix the adapter, and retry** — don't just report the error.\n\n## Safety Boundaries\n\n**Before starting any repair, check these hard stops:**\n\n- **`AUTH_REQUIRED`** (exit code 77) — **STOP.** Do not modify code. Tell the user to log into the site in Chrome.\n- **`BROWSER_CONNECT`** (exit code 69) — **STOP.** Do not modify code. Tell the user to run `opencli doctor`.\n- **CAPTCHA / rate limiting** — **STOP.** Not an adapter issue.\n\n**Scope constraint:**\n- **Only modify the file at `adapterSourcePath` in the trace `summary.md` front matter** — this is the authoritative adapter location (may be `clis/<site>/` in repo or `~/.opencli/clis/<site>/` for npm installs)\n- **Never modify** `src/`, `extension/`, `tests/`, `package.json`, or `tsconfig.json`\n\n**Retry budget:** Max **3 repair rounds** per failure. If 3 rounds of diagnose → fix → retry don't resolve it, stop and report what was tried.\n\n## Prerequisites\n\n```bash\nopencli doctor    # Verify extension + daemon connectivity\n```\n\n## When to Use This Skill\n\nUse when `opencli <site> <command>` fails with repairable errors:\n- **SELECTOR** — element not found (DOM changed)\n- **EMPTY_RESULT** — no data returned (API response changed)\n- **API_ERROR** / **NETWORK** — endpoint moved or broke\n- **PAGE_CHANGED** — page structure no longer matches\n- **COMMAND_EXEC** — runtime error in adapter logic\n- **TIMEOUT** — page loads differently, adapter waits for wrong thing\n\n## Before Entering Repair: \"Empty\" ≠ \"Broken\"\n\n`EMPTY_RESULT` — and sometimes a structurally-valid `SELECTOR` that returns nothing — is often **not an adapter bug**. Platforms actively degrade results under anti-scrape heuristics, and a \"not found\" response from the site doesn't mean the content is actually missing. Rule this out **before** committing to a repair round:\n\n- **Retry with an alternative query or entry point.** If `opencli xiaohongshu search \"X\"` returns 0 but `opencli xiaohongshu search \"X 攻略\"` returns 20, the adapter is fine — the platform was shaping results for the first query.\n- **Spot-check in a normal Chrome tab.** If the data is visible in the user's own browser but the adapter comes back empty, the issue is usually authentication state, rate limiting, or a soft block — not a code bug. The fix is `opencli doctor` / re-login, not editing source.\n- **Look for soft 404s.** Sites like xiaohongshu / weibo / douyin return HTTP 200 with an empty payload instead of a real 404 when an item is hidden or deleted. The snapshot will look structurally correct. A retry 2-3 seconds later often distinguishes \"temporarily hidden\" from \"actually gone\".\n- **\"0 results\" from a search is an answer.** If the adapter successfully reached the search endpoint, got an HTTP 200, and the platform returned `results: []`, that is a valid answer — report it to the user as \"no matches for this query\" rather than patching the adapter.\n\nOnly proceed to Step 1 if the empty/selector-missing result is **reproducible across retries and alternative entry points**. Otherwise you're patching a working adapter to chase noise, and the patched version will break the next working path.\n\n## Step 1: Collect Trace Context\n\nRun the failing command with failure-retained trace enabled:\n\n```bash\nopencli <site> <command> [args...] --trace retain-on-failure 2>trace-error.yaml\n```\n\nOn failure, stderr contains the normal error envelope plus a small `trace` block:\n\n```yaml\nok: false\nerror:\n  code: SELECTOR\n  message: \"Could not find element: .old-selector\"\ntrace:\n  schemaVersion: 1\n  opencliVersion: \"...\"\n  traceId: \"...\"\n  dir: \"/path/to/.opencli/profiles/default/traces/...\"\n  summaryPath: \"/path/to/.opencli/profiles/default/traces/.../summary.md\"\n  receiptPath: \"/path/to/.opencli/profiles/default/traces/.../receipt.json\"\n```\n\nRead `summaryPath` first. It is the LLM-oriented entry point and includes front matter:\n\n```yaml\n---\nschemaVersion: 1\nopencliVersion: \"...\"\ntraceId: \"...\"\nstatus: failure\nsite: \"example\"\ncommand: \"example/search\"\nadapterSourcePath: \"/path/to/clis/example/search.js\"\nerrorCode: \"SELECTOR\"\nerrorMessage: \"Could not find element: .old-selector\"\n---\n```\n\nThe artifact directory contains:\n\n```text\nsummary.md      # start here\nreceipt.json    # machine-readable trace receipt\ntrace.jsonl     # full redacted timeline\nnetwork.jsonl   # redacted network events\nconsole.jsonl   # redacted console events\nstate/          # final snapshots when available\nscreenshots/    # final screenshots when available\n```\n\nIf you redirected stderr to a file, read that file and copy `trace.summaryPath`.\n\nDo not ask the user to rerun with legacy diagnostic env vars. Trace is the repair evidence path.\n\n## Step 2: Analyze the Failure\n\nRead the trace summary and the adapter source. Classify the root cause:\n\n| Error Code | Likely Cause | Repair Strategy |\n|-----------|-------------|-----------------|\n| SELECTOR | DOM restructured, class/id renamed | Explore current DOM → find new selector |\n| EMPTY_RESULT | API response schema changed, or data moved | Check network → find new response path |\n| API_ERROR | Endpoint URL changed, new params required | Discover new API via network intercept |\n| AUTH_REQUIRED | Login flow changed, cookies expired | **STOP** — tell user to log in, do not modify code |\n| TIMEOUT | Page loads differently, spinner/lazy-load | Add/update wait conditions |\n| PAGE_CHANGED | Major redesign | May need full adapter rewrite |\n\n**Key questions to answer:**\n1. What is the adapter trying to do? (Read the file at `adapterSourcePath`)\n2. What did the page look like when it failed? (Read `summary.md`, then `state/` if needed)\n3. What network requests happened? (Read `Failed Network` in `summary.md`, then `network.jsonl` if needed)\n4. What's the gap between what the adapter expects and what the page provides?\n\n## Step 3: Explore the Current Website\n\nUse `opencli browser` to inspect the live website. **Never use the broken adapter** — it will just fail again.\n\n### DOM changed (SELECTOR errors)\n\n```bash\n# Open the page and inspect current DOM\nopencli browser open https://example.com/target-page && opencli browser --session research state\n\n# Look for elements that match the adapter's intent\n# Compare the snapshot with what the adapter expects\n```\n\n### API changed (API_ERROR, EMPTY_RESULT)\n\n```bash\n# Open page with network interceptor, then trigger the action manually\nopencli browser open https://example.com/target-page && opencli browser --session research state\n\n# Interact to trigger API calls\nopencli browser click <N> && opencli browser --session research network\n\n# Narrow to the request you care about by the fields its body should have\nopencli browser --session research network --filter author,text,likes\n\n# Inspect specific API response (key is the `key` field from the default JSON output)\nopencli browser --session research network --detail <key>\n```\n\n## Step 4: Patch the Adapter\n\nRead the adapter source file at `adapterSourcePath` from the trace summary front matter and make targeted fixes. This path is authoritative — it may be in the repo (`clis/`) or user-local (`~/.opencli/clis/`).\n\nUse the `Read` tool on the exact path from summary.md front matter.\n\n### Common Fixes\n\n**Selector update:**\n```typescript\n// Before: page.evaluate('document.querySelector(\".old-class\")...')\n// After:  page.evaluate('document.querySelector(\".new-class\")...')\n```\n\n**API endpoint change:**\n```typescript\n// Before: const resp = await page.evaluate(`fetch('/api/v1/old-endpoint')...`)\n// After:  const resp = await page.evaluate(`fetch('/api/v2/new-endpoint')...`)\n```\n\n**Response schema change:**\n```typescript\n// Before: const items = data.results\n// After:  const items = data.data.items  // API now nests under \"data\"\n```\n\n**Wait condition update:**\n```typescript\n// Before: await page.wait({ selector: '.loading-spinner', hidden: true })\n// After:  await page.wait({ selector: '[data-loaded=\"true\"]' })\n```\n\n### Rules for Patching\n\n1. **Make minimal changes** — fix only what's broken, don't refactor\n2. **Keep the same output structure** — `columns` and return format must stay compatible\n3. **Prefer API over DOM scraping** — if you discover a JSON API during exploration, switch to it\n4. **Use `@jackwener/opencli/*` imports only** — never add third-party package imports\n5. **Test after patching** — run the command again to verify\n6. **Never relax `verify/<cmd>.json` fixtures to silence a failure.** A failing `patterns` / `notEmpty` / `mustNotContain` / `mustBeTruthy` rule means the adapter's output is broken. Tighten the adapter so it produces correct values; do not loosen the fixture to accept the broken values. The one legitimate reason to edit a fixture during repair is when the **site itself** changed shape (e.g. URL format migration) — in that case update the fixture and note the change in `~/.opencli/sites/<site>/notes.md`. Otherwise editing the fixture is covering up a silent correctness regression.\n\n## Step 5: Verify the Fix\n\n```bash\n# Run the command normally\nopencli <site> <command> [args...]\n```\n\nIf it still fails, go back to Step 1 and collect a fresh trace. You have a budget of **3 repair rounds** (trace → fix → retry). If the same error persists after a fix, try a different approach. After 3 rounds, stop and report what was tried.\n\n## Step 6: File an Upstream Issue\n\nIf the retry **passes**, the local adapter has drifted from upstream. File a GitHub issue so the fix flows back to `jackwener/OpenCLI`.\n\n**Do NOT file for:**\n- `AUTH_REQUIRED`, `BROWSER_CONNECT`, `ARGUMENT`, `CONFIG` — environment/usage issues, not adapter bugs\n- CAPTCHA or rate limiting — not fixable upstream\n- Failures you couldn't actually fix (3 rounds exhausted)\n\n**Only file after a verified local fix** — the retry must pass first.\n\n**Procedure:**\n\n1. Prepare the issue content from the trace summary you already have:\n   - **Title:** `[autofix] <site>/<command>: <error_code>` (e.g. `[autofix] zhihu/hot: SELECTOR`)\n   - **Body** (use this template):\n\n```markdown\n## Summary\nOpenCLI autofix repaired this adapter locally, and the retry passed.\n\n## Adapter\n- Site: `<site>`\n- Command: `<command>`\n- OpenCLI version: `<version from opencli --version>`\n\n## Original failure\n- Error code: `<error_code>`\n\n~~~\n<error_message>\n~~~\n\n## Local fix summary\n\n~~~\n<1-2 sentence description of what you changed and why>\n~~~\n\n_Issue filed by OpenCLI autofix after a verified local repair._\n```\n\n2. **Ask the user before filing.** Show them the draft title and body. Only proceed if they confirm.\n\n3. If the user approves and `gh auth status` succeeds:\n\n```bash\ngh issue create --repo jackwener/OpenCLI \\\n  --title \"[autofix] <site>/<command>: <error_code>\" \\\n  --body \"<the body above>\"\n```\n\nIf `gh` is not installed or not authenticated, tell the user and skip — do not error out.\n\n## When to Stop\n\n**Hard stops (do not modify code):**\n- **AUTH_REQUIRED / BROWSER_CONNECT** — environment issue, not adapter bug\n- **Site requires CAPTCHA** — can't automate this\n- **Rate limited / IP blocked** — not an adapter issue\n\n**Soft stops (report after attempting):**\n- **3 repair rounds exhausted** — stop, report what was tried and what failed\n- **Feature completely removed** — the data no longer exists\n- **Major redesign** — needs full adapter rewrite via `opencli-adapter-author` skill\n\nIn all stop cases, clearly communicate the situation to the user rather than making futile patches.\n\n## Example Repair Session\n\n```\n1. User runs: opencli zhihu hot\n   → Fails: SELECTOR \"Could not find element: .HotList-item\"\n\n2. AI runs: opencli zhihu hot --trace retain-on-failure 2>trace-error.yaml\n   → Gets trace summary with final state and failed action evidence\n\n3. AI reads summary/state: page loaded but uses \".HotItem\" instead of \".HotList-item\"\n\n4. AI explores: opencli browser open https://www.zhihu.com/hot && opencli browser --session research state\n   → Confirms new class name \".HotItem\" with child \".HotItem-content\"\n\n5. AI patches: Edit adapter at `adapterSourcePath` — replace \".HotList-item\" with \".HotItem\"\n\n6. AI verifies: opencli zhihu hot\n   → Success: returns hot topics\n\n7. AI prepares upstream issue draft, shows it to the user\n\n8. User approves → AI runs: gh issue create --repo jackwener/OpenCLI --title \"[autofix] zhihu/hot: SELECTOR\" --body \"...\"\n```\n\n<!-- File: skills/opencli-browser/SKILL.md (Tokens: ~1020 | Category: anthropic-skill) -->\n---\nname: opencli-browser\ndescription: Drive Chrome with OpenCLI to inspect pages, interact with logged-in sites, capture API evidence, and manage native tabs, groups and windows. For reusable adapters, see opencli-adapter-author.\nallowed-tools: Bash(opencli:*), Read, Edit, Write\n---\n\n# Browser tools for agents\n\nUse the capability that fits the task. Discover commands with `opencli list <query>`;\nread exact arguments with `opencli browser <command> --help -f json`.\nRun `opencli doctor` when diagnosing a connection problem.\n\n## Scope and results\n\nPage commands require `--session <name>`. Reuse that name across a task; different\nsessions isolate page work. `--profile <name>` selects a Chrome profile when needed.\nOptions can follow command arguments:\n\n```sh\nopencli browser open https://example.com --session research\nopencli browser state --session research\nopencli browser click --role button --name Search --session research\nopencli browser close --session research\n```\n\nDefault output is one JSON value: `{ \"ok\": true, \"data\": ... }`.\nFailures return `{ \"ok\": false, \"error\": { \"code\": \"...\", \"message\": \"...\", \"hint\": \"...\" } }`\nand a nonzero exit code. Diagnostic logs go to stderr. `network --follow` and\n`console --follow` stream one envelope per line. Help, version and shell completion\nare inspection/export surfaces; structured help uses `--help -f json`.\n\nRead the returned data and error fields. When `error.outcome` is `unknown`, inspect\nthe affected state before retrying a write.\n\n## Page targets and interaction\n\n- `state` returns URL and interactive element refs. `--diagnostics` also reports\n  page title and available initial-state globals; `--source ax` selects the AX tree.\n- `find` locates elements. Interaction targets accept a ref, CSS selector, or\n  semantic flags such as `--role`, `--name`, `--label`, `--text`, `--testid`.\n- Use `--nth` to resolve deliberate multiple matches. On stale refs, read fresh\n  state or locate the element again; refs describe the observed page.\n- `fill` replaces a field and verifies its value; `type` types text. Other actions\n  include `click`, `select`, `check`, `upload`, `drag`, `keys` and `dialog`.\n- `get text|value|attributes|html`, `extract`, `screenshot` and `eval` expose page data.\n  `eval` executes JavaScript in the page and can mutate state.\n- `wait xhr|selector|text|download` observes an expected condition. Consult help for\n  timeouts and matching rules.\n- `tab list|new|select|close` manages session tabs. `--tab` accepts the page identity\n  returned by these commands; these are different from numeric native Chrome IDs.\n- `frames` lists frame targets; `eval --frame` evaluates in a selected frame.\n\n`bind --session research` attaches the current user tab. Bound sessions permit\nnavigation but block tab creation, selection and closure. `unbind` releases the\nbinding without closing the user tab. Owned sessions keep leased tabs until\n`close` or idle expiry. `--window foreground|background` controls owned windows.\n\n## Network and scripts\n\n```sh\nopencli browser network --session research\nopencli browser network --detail RequestKey --session research\nopencli browser network --static --session research\n```\n\n`network` returns captured request keys and response shapes. `--detail` reads a\ncached request; `--filter` selects shapes; `--raw` exposes response bodies.\n`--static` adds Acorn AST candidates from loaded scripts, with source locations,\nmethods, query/body parameter names and observed request evidence. `EXPR` means\nan unresolved expression. Static candidates are not executed or proven APIs.\nCheck `analysisComplete`, script errors, omitted scripts and `networkSource`.\nAn empty live capture can reuse a recent cache; reported provenance distinguishes it.\n\n## Native Chrome management\n\nNative calls need no session and do not attach a page:\n\n```sh\nopencli browser chrome describe\nopencli browser chrome describe tabs.group\nopencli browser chrome call tabs.query '[{}]'\nopencli browser chrome call tabs.group '[{\"tabIds\":[123,456],\"createProperties\":{\"windowId\":10}}]'\nopencli browser chrome call tabGroups.update '[789,{\"title\":\"Research\",\"color\":\"blue\"}]'\n```\n\nUse numeric IDs returned by Chrome, replacing the example IDs. `describe` lists\nsupported methods, signatures, live permissions and official documentation.\nNative-created tabs/windows persist. Mutations touching active OpenCLI session\ntabs or managed groups return `managed_resource_conflict`; ordinary groups can be\ncreated, renamed, recolored, moved and dissolved with `tabs.ungroup`.\n\n<!-- File: skills/opencli-usage/SKILL.md (Tokens: ~575 | Category: anthropic-skill) -->\n---\nname: opencli-usage\ndescription: Discover and invoke OpenCLI capabilities for websites and Chrome. Find commands, inspect schemas, interpret results and choose browser or adapter tools.\nallowed-tools: Bash(opencli:*), Read\n---\n\n# OpenCLI\n\nOpenCLI exposes reusable website adapters and direct Chrome tools to agents.\n\n```sh\nopencli list search\nopencli list --site github --limit 20\nopencli github search --help -f json\n```\n\n`list [query]` searches command names and descriptions across built-ins and adapters.\nResults are compact and paginated: `{ok:true,data:{commands,total,nextOffset?}}`.\nPass `--offset <nextOffset>` to continue. Inspect exact inputs using command help.\nDo not hard-code the adapter catalog or dump all commands into context.\n\n## Results\n\nCommands default to JSON: `{ok:true,data:...}`. Failures produce a nonzero exit\ncode and `{ok:false,error:{code,message,hint?,...}}` on stdout. Logs use stderr.\nExplicit `-f table|yaml|csv|md|plain` exports are available where command help lists\nthem. Help, version, completion and explicit plain exports retain their own formats.\nBrowser follow modes stream one JSON envelope per line.\n\nUse error codes and supplied candidates/keys to recover. Unknown write outcomes\nrequire inspecting state before repeating an action. An empty successful result\nis distinct from an execution failure.\n\n## Browser\n\n```sh\nopencli browser open https://example.com --session research\nopencli browser state --session research\nopencli browser close --session research\nopencli browser chrome describe\n```\n\nPage commands use `--session`; native Chrome methods need no session. `--profile`\nselects a connected Chrome profile. `profile list` discovers profiles and\n`profile use <name>` selects the default. Use `doctor` for connection diagnosis.\nSee `opencli-browser` for interaction, API evidence and tab/group management.\n\n## Adapters and installation\n\n```sh\nopencli adapter init example/search\nopencli adapter check example/search\nopencli adapter test example/search --args '{\"query\":\"hello\"}'\nopencli adapter status\n```\n\nSee `opencli-adapter-author` for implementation. `adapter eject` makes an official\nsite editable locally; `adapter reset` removes that local override. Adapter tests\nexecute real commands and can perform writes.\n\n`plugin list|install|update|uninstall|create` manages plugin packages. `auth status`\nreports website login facts; `auth refresh` touches sessions. `daemon status|restart|stop`\nmanages the bridge process. Read help for the selected capability.\n\nBundled skill content is available through `skills list` and\n`skills read opencli-browser -f plain` (omit `-f plain` for JSON).\n\n<!-- File: skills/smart-search/references/sources-ai.md (Tokens: ~1107 | Category: anthropic-skill) -->\n# AI 默认源\n\n当用户没有明确指定网站时，先在 `grok`、`doubao`、`gemini` 中选择一个，不要一开始并行跑多个 AI 源。\n\n## 使用规则\n\n1. 先运行 `opencli list -f yaml`\n2. 确认 `grok`、`doubao`、`gemini` 哪些在当前 registry 中可用\n3. 运行 `opencli <site> -h`\n4. 锁定具体子命令后，再运行 `opencli <site> <command> -h`\n\n## 路由建议\n\n### grok\n\n- 适用：实时热点、Twitter/X 语境、英文互联网讨论、舆论与趋势\n- 补充源常见去向：`twitter`、`reddit`、`reuters`、`google`\n- 搜索词建议：\n  - 加上时间范围，例如“today / this week”\n  - 加上平台范围，例如“on X”, “from social posts”\n  - 加上目标，例如“latest reactions”, “main viewpoints”, “key claims”\n  - 示例：\n    - `OpenAI latest reactions on X this week`\n    - `TSLA earnings main viewpoints on social media April 2026`\n    - `Nintendo Switch 2 rumors latest discussion on X`\n\n### doubao\n\n- 适用：中文语境、国内热点、生活方式、字节生态、中文泛问答\n- 补充源常见去向：`xiaohongshu`、`weibo`、`zhihu`、`bilibili`、`36kr`\n- 搜索词建议：\n  - 加上中文场景限定，例如“中文讨论”“国内用户”“小红书/微博语境”\n  - 加上需求目标，例如“帮我总结”“给我对比”“提取推荐理由”\n  - 加上人群或用途，例如“适合新手”“预算 500 元内”“上海求职”\n  - 示例：\n    - `2026年MacBook Air值得买吗 中文讨论里主要观点是什么`\n    - `上海 AI 产品经理招聘趋势 近一个月中文信息总结`\n    - `敏感肌防晒推荐 中文用户常提到的优缺点`\n\n### gemini\n\n- 适用：全球网页、英文资料、背景综述、通用检索\n- 补充源常见去向：`google`、`wikipedia`、`arxiv`、`stackoverflow`\n- 搜索词建议：\n  - 加上主题类型，例如“overview”, “comparison”, “background”, “best sources”\n  - 加上范围限定，例如地区、时间、语言、行业\n  - 加上结果形式，例如“with sources”, “compare pros and cons”, “official guidance”\n  - 示例：\n    - `MCP overview and official guidance with sources`\n    - `best budget travel destinations in Japan April 2026 compare pros and cons`\n    - `TypeScript decorators current status official sources`\n\n## 补充原则\n\n- 先用一个 AI 源拿到初步答案\n- 若答案缺少原始数据、垂直结果或权威来源，再补 1-2 个专用源\n- 不要把 AI 默认源当成命令签名的事实来源；命令细节始终以 `opencli ... -h` 为准\n\n## 通用写法模板\n\n可直接按下面模板构造 AI 查询：\n\n- 热点/新闻：\n  `<事件> + 最新进展 + <时间范围> + <地区/平台>`\n- 对比/推荐：\n  `<对象A> vs <对象B> + <关注维度> + <人群/预算/用途>`\n- 中文社区：\n  `<主题> + 中文讨论里主要观点 + <时间范围>`\n- 全球资料：\n  `<主题> + overview/background + with sources`\n- 求职：\n  `<岗位> + <城市/国家> + market trends/hiring + <时间范围>`\n- 购物：\n  `<商品> + reviews/price/value + <地区/预算>`\n\n<!-- File: skills/smart-search/references/sources-info.md (Tokens: ~378 | Category: anthropic-skill) -->\n# 资讯 / 知识\n\n用于新闻、百科、通用知识、中文科技资讯。\n\n## 站点\n\n### google\n\n- 适用：通用网页搜索、跨站点兜底\n- 使用前先运行：`opencli google -h`\n\n### wikipedia\n\n- 适用：名词解释、背景知识、历史事实\n- 使用前先运行：`opencli wikipedia -h`\n\n### reuters\n\n- 适用：国际新闻、事实性报道\n- 使用前先运行：`opencli reuters -h`\n\n### weibo\n\n- 适用：微博热点、话题、中文舆论\n- 使用前先运行：`opencli weibo -h`\n\n### 36kr\n\n- 适用：中文科技、创业、融资资讯\n- 使用前先运行：`opencli 36kr -h`\n\n### substack\n\n- 适用：newsletter、作者订阅内容、长文\n- 使用前先运行：`opencli substack -h`\n\n## 路由提示\n\n- 背景知识与定义，优先 `wikipedia`\n- 通用网页与跨站信息，优先 `google`\n- 国际新闻优先 `reuters`\n- 中文科技资讯优先 `36kr`\n- 当 AI 回答过于泛化时，用这些源补充事实和链接\n\n<!-- File: skills/smart-search/references/sources-media.md (Tokens: ~441 | Category: anthropic-skill) -->\n# 媒体 / 娱乐\n\n用于视频、电影、剧集、音乐、播客、内容平台搜索。\n\n## 站点\n\n### bilibili\n\n- 适用：中文视频、教程、评测、UP 主内容\n- 使用前先运行：`opencli bilibili -h`\n\n### youtube\n\n- 适用：英文视频、教程、评测、访谈\n- 使用前先运行：`opencli youtube -h`\n\n### tiktok\n\n- 适用：短视频、热点、娱乐内容\n- 使用前先运行：`opencli tiktok -h`\n\n### imdb\n\n- 适用：电影、剧集、演员、评分\n- 使用前先运行：`opencli imdb -h`\n\n### douban\n\n- 适用：中文电影、书籍、音乐口碑与评分\n- 使用前先运行：`opencli douban -h`\n\n### spotify\n\n- 适用：歌曲、歌手、专辑\n- 使用前先运行：`opencli spotify -h`\n\n### apple-podcasts\n\n- 适用：播客节目、播客搜索\n- 使用前先运行：`opencli apple-podcasts -h`\n\n### medium\n\n- 适用：长文、技术博客、设计与产品文章\n- 使用前先运行：`opencli medium -h`\n\n## 路由提示\n\n- 用户只说“找视频/影评/播客/文章”，先按内容形态选站点\n- 用户没有指定站点时，可先用 `doubao`、`grok` 或 `gemini` 做一轮粗检索，再补媒体站点拿原始结果\n\n<!-- File: skills/smart-search/references/sources-other.md (Tokens: ~369 | Category: anthropic-skill) -->\n# 其他垂直源\n\n用于求职、金融、书籍、词典等不适合归入前述分类的场景。\n\n## 站点\n\n### linkedin\n\n- 适用：全球职位、英文岗位、跨国公司招聘\n- 使用前先运行：`opencli linkedin -h`\n\n### boss\n\n- 适用：国内职位搜索、招聘与岗位信息\n- 使用前先运行：`opencli boss -h`\n\n### xueqiu\n\n- 适用：股票、金融讨论、行情相关线索\n- 使用前先运行：`opencli xueqiu -h`\n\n### weread\n\n- 适用：中文书籍搜索与书单线索\n- 使用前先运行：`opencli weread -h`\n\n### dictionary\n\n- 适用：英文词义、基础词典查询\n- 使用前先运行：`opencli dictionary -h`\n\n### sinablog\n\n- 适用：较旧的中文博客内容\n- 使用前先运行：`opencli sinablog -h`\n\n## 路由提示\n\n- 求职：全球优先 `linkedin`，国内优先 `boss`\n- 金融：优先 `xueqiu`\n- 书籍：中文优先 `weread`\n- 英文单词定义：优先 `dictionary`\n\n<!-- File: skills/smart-search/references/sources-shopping.md (Tokens: ~296 | Category: anthropic-skill) -->\n# 购物\n\n用于商品搜索、价格、好价、口碑与区域电商结果。\n\n## 站点\n\n### amazon\n\n- 适用：全球商品搜索、价格参考、英文电商\n- 使用前先运行：`opencli amazon -h`\n\n### smzdm\n\n- 适用：国内好价、优惠、导购、商品讨论\n- 使用前先运行：`opencli smzdm -h`\n\n### coupang\n\n- 适用：韩国电商商品搜索\n- 使用前先运行：`opencli coupang -h`\n\n### douban\n\n- 适用：图书、影视、音乐类口碑补充，不是标准电商，但可用于消费决策补充\n- 使用前先运行：`opencli douban -h`\n\n## 路由提示\n\n- 用户指定平台时直接使用该平台\n- 未指定平台时，全球商品优先 `amazon`，国内好价优先 `smzdm`\n- 若先用 AI 做产品调研，后续可补电商站点拿实际商品结果\n\n<!-- File: skills/smart-search/references/sources-social.md (Tokens: ~463 | Category: anthropic-skill) -->\n# 社交媒体\n\n用于需要原始帖子、原始用户结果、中文或英文社区讨论时。\n\n## 站点\n\n### twitter\n\n- 适用：Twitter/X 原始帖子、作者、实时讨论\n- 使用前先运行：`opencli twitter -h`\n\n### weibo\n\n- 适用：微博热点、话题、中文舆论\n- 使用前先运行：`opencli weibo -h`\n\n### xiaohongshu\n\n- 适用：生活方式、穿搭、美妆、旅行、真实体验\n- 使用前先运行：`opencli xiaohongshu -h`\n\n### zhihu\n\n- 适用：中文深度问答、专业解释、行业经验\n- 使用前先运行：`opencli zhihu -h`\n\n### tieba\n\n- 适用：兴趣圈子、历史帖子、粉丝社区\n- 使用前先运行：`opencli tieba -h`\n\n### instagram\n\n- 适用：账号搜索、图片社交线索\n- 使用前先运行：`opencli instagram -h`\n\n### facebook\n\n- 适用：主页、人物、帖子线索\n- 使用前先运行：`opencli facebook -h`\n\n### bluesky\n\n- 适用：Bluesky 用户与账号搜索\n- 使用前先运行：`opencli bluesky -h`\n\n## 路由提示\n\n- 用户明确指定某个平台时，直接用该平台\n- 用户只说“社交媒体上怎么看”时，可先选 `grok` 或 `doubao`\n- AI 只给摘要而没有原始帖子时，再切到对应社交站点\n\n<!-- File: skills/smart-search/references/sources-tech.md (Tokens: ~431 | Category: anthropic-skill) -->\n# 技术 / 学术\n\n用于技术问题、研究论文、开发者讨论、开源社区信息。\n\n## 站点\n\n### arxiv\n\n- 适用：论文、研究、模型、算法、学术背景\n- 使用前先运行：`opencli arxiv -h`\n\n### stackoverflow\n\n- 适用：具体报错、API 用法、代码模式、实现细节\n- 使用前先运行：`opencli stackoverflow -h`\n\n### hackernews\n\n- 适用：技术社区讨论、开发者观点、创业和产品话题\n- 使用前先运行：`opencli hackernews -h`\n\n### reddit\n\n- 适用：英文社区问答、经验贴、推荐、对比讨论\n- 使用前先运行：`opencli reddit -h`\n\n### linux-do\n\n- 适用：人工智能、开源工具、中文技术社区\n- 使用前先运行：`opencli linux-do -h`\n\n### v2ex\n\n- 适用：中文技术社区\n- 使用前先运行：`opencli linux-do \n\n## 路由提示\n\n- 用户明确说“论文”“研究”时，优先 `arxiv`\n- 用户明确说“报错”“API 怎么用”时，优先 `stackoverflow`\n- 用户要“社区讨论”“开发者观点”时，优先 `hackernews` 或 `reddit`\n- 若用户没有明确指定站点，可先选 `gemini` 或 `grok`，内容不足时再补这些专用源\n\n<!-- ... +2 more modular skills available via /api/skills/jackwener/OpenCLI?path={path} -->\n</yakaai_skills>"}