All skills
jackwener avatar

/opencli-adapter-author

@2413694
by jakevinjackwener/opencli30k stars
2,903

Use when writing an OpenCLI adapter for a new site or adding a new command to an existing site. Guides end-to-end from first recon through field decoding, adapter coding, and verify. Replaces opencli-oneshot / opencli-explorer. For ad-hoc browser driving (no adapter), see opencli-browser instead; for a top-level orientation to opencli, see opencli-usage.

Use this Skill: https://skilld.dev/gh/jackwener/opencli/opencli-adapter-author

This session only. Nothing lands on disk.

referencesadapter-template.md

≈5.4k tokens on demand. Your agent reads this file only when SKILL.md points to it.

Adapter Template

一份 adapter 就是一次 cli({...}) 调用。文件结构固定,三段:declaration、args、func。

拿 clis/eastmoney/convertible.js 当活例子,对照拆解。


活例子:convertible.js

注意(2026-05 起):下面这份 convertible.js 的 limit clamp 和 CliError('HTTP_ERROR' / 'NO_DATA') 是 grandfathered 写法(在 scripts/typed-error-lint-baseline.json 里)。结构布局(cli 声明 / args / columns / map)仍然是好范本,但 error 处理 + limit 校验请按下文 §3 + typed-errors.md 写。新写 adapter 抄这个文件别连 Math.max(1, Math.min(...)) 和 CliError(...) 一起抄过去。

// eastmoney convertible — on-market convertible bond listing.
import { cli, Strategy } from '@jackwener/opencli/registry';
import { CliError } from '@jackwener/opencli/errors';

const SORTS = {
  change:   { fid: 'f3',   order: 'desc' },
  drop:     { fid: 'f3',   order: 'asc' },
  turnover: { fid: 'f6',   order: 'desc' },
  price:    { fid: 'f2',   order: 'desc' },
  premium:  { fid: 'f237', order: 'desc' },
  value:    { fid: 'f236', order: 'desc' },
  ytm:      { fid: 'f239', order: 'desc' },
};

cli({
  site: 'eastmoney',
  name: 'convertible',
  description: '可转债行情列表(默认按成交额排序)',
  domain: 'push2.eastmoney.com',
  strategy: Strategy.PUBLIC,
  browser: false,
  args: [
    { name: 'sort',  type: 'string', default: 'turnover', help: '排序:turnover / change / drop / price / premium' },
    { name: 'limit', type: 'int',    default: 20,         help: '返回数量 (max 100)' },
  ],
  columns: ['rank', 'bondCode', 'bondName', 'bondPrice', 'bondChangePct',
            'stockCode', 'stockName', 'stockPrice', 'stockChangePct',
            'convPrice', 'convValue', 'convPremiumPct', 'remainingYears', 'ytm', 'listDate'],
  func: async (args) => {
    const sortKey = String(args.sort ?? 'turnover').toLowerCase();
    const sort = SORTS[sortKey];
    if (!sort) throw new CliError('INVALID_ARGUMENT', `Unknown sort "${sortKey}". Valid: ${Object.keys(SORTS).join(', ')}`);
    const limit = Math.max(1, Math.min(Number(args.limit) || 20, 100));

    const url = new URL('https://push2.eastmoney.com/api/qt/clist/get');
    url.searchParams.set('pn', '1');
    url.searchParams.set('pz', String(limit));
    url.searchParams.set('po', sort.order === 'desc' ? '1' : '0');
    url.searchParams.set('np', '1');
    url.searchParams.set('fltt', '2');
    url.searchParams.set('invt', '2');
    url.searchParams.set('fid', sort.fid);
    url.searchParams.set('fs', 'b:MK0354');
    url.searchParams.set('fields', 'f12,f14,f2,f3,f6,f229,f230,f232,f234,f235,f236,f237,f238,f239,f243');
    url.searchParams.set('ut', 'bd1d9ddb04089700cf9c27f6f7426281');

    const resp = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0' } });
    if (!resp.ok) throw new CliError('HTTP_ERROR', `convertible failed: HTTP ${resp.status}`);
    const data = await resp.json();
    const diff = Array.isArray(data?.data?.diff) ? data.data.diff : [];
    if (diff.length === 0) throw new CliError('NO_DATA', 'eastmoney returned no convertible data');

    return diff.slice(0, limit).map((it, i) => ({
      rank: i + 1,
      bondCode: it.f12,
      bondName: it.f14,
      bondPrice: it.f2,
      bondChangePct: it.f3,
      stockCode: it.f232,
      stockName: it.f234,
      stockPrice: it.f229,
      stockChangePct: it.f230,
      convPrice: it.f235,
      convValue: it.f236,
      convPremiumPct: it.f237,
      remainingYears: it.f238,
      ytm: it.f239,
      listDate: String(it.f243 ?? ''),
    }));
  },
});

三段解剖

1. Declaration — 标头

cli({
  site: 'eastmoney',          // 第一级命名空间,目录名一致
  name: 'convertible',        // 第二级,CLI 上的子命令
  description: '...',         // 一句话,出现在 `opencli list` 和 `opencli <site> -h`
  domain: 'push2.eastmoney.com',  // 主要请求域名(诊断面板用)
  strategy: Strategy.PUBLIC,  // PUBLIC / COOKIE / INTERCEPT / UI
  browser: false,             // PUBLIC 几乎总是 false;COOKIE/INTERCEPT/UI 一律 true
  ...
});

2. Args & Columns

args: [
  { name: 'sort',  type: 'string', default: 'turnover', help: '...' },
  { name: 'limit', type: 'int',    default: 20,         help: '...' },
],
columns: ['rank', 'bondCode', 'bondName', /* ... */ ],

规则:

  • type: string / int / float / bool
  • default 必填(缺失的命令会拒绝启动)
  • columns 数组必须跟 func 返回的 object keys 完全对上,顺序也一致(决定表格列顺序)
  • 列名 camelCase,跟 cli({...}) 其他 adapter 保持统一
  • 中间解析对象 key 不能跟 columns 任一项重叠 —— 否则 silent-column-drop audit 会把它当 row 候选误判。{pid, html, start} 这类中间结构改成 {postId, body, offset},最后在 push row 时再 destructure aliasing 回 column 命名。背景:PR #1329 R1 codex-mini0 catch 的(before → after)

3. func — 主体

import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';

func: async (args) => {
  // 1. 解析参数 — 越界一律抛,不要 silent clamp
  const n = Number(args.limit ?? 20);
  if (!Number.isInteger(n) || n <= 0) throw new ArgumentError('limit must be a positive integer');
  if (n > 100) throw new ArgumentError('limit must be <= 100');
  const limit = n;

  // 2. 构造 URL / 请求
  const url = new URL(...);
  url.searchParams.set(...);

  // 3. 发请求 — fetch 抛 / HTTP 非 2xx 都归 CommandExecutionError
  let resp;
  try {
    resp = await fetch(url, { headers: { /* ... */ } });
  } catch (error) {
    throw new CommandExecutionError(`request failed: ${error?.message || error}`);
  }
  if (!resp.ok) throw new CommandExecutionError(`request failed: HTTP ${resp.status}`);

  // 4. 解析 + 业务校验 — 业务空 → EmptyResultError,不要 sentinel row 也不要 return []
  const data = await resp.json();
  const diff = Array.isArray(data?.data?.diff) ? data.data.diff : [];
  if (diff.length === 0) throw new EmptyResultError('site command', 'API returned no rows');

  // 5. map 到 columns 同名 keys
  return diff.slice(0, limit).map((it, i) => ({
    rank: i + 1,
    bondCode: it.f12,
    // ...
  }));
},

站点级 helper:≥ 2 个同站 adapter 都做相同 limit / page 校验时,把校验抽成 clis/<site>/utils.js 的 normalizeLimit(value, default, max, label) / normalizePositiveInteger(value, default, label, { min }),避免每个 adapter 都 inline 一遍。模板见 typed-errors.md §2 和 clis/1point3acres/utils.js。1 个 adapter 用就直接 inline,不要为 1 处单点抽 helper。

参数形态(踩过最多次的坑:搞反签名后 args 实际是 debug flag,所有 args.foo 静默 undefined → fallback 到 default。#1329 upstream 之前 8 个 non-browser adapter 写错过签名,全部 silently fallback 到默认参数):

  • browser: false:func: async (args, debug?) => { ... } —— 单参 args,不会收到 page
  • browser: true:func: async (page, args, debug?) => { ... } —— 双参 (page, args),第一参是浏览器上下文
  • args:所有 args[] 声明的参数解析后的 object

错误处理:用 typed error 5-classification(参见 typed-errors.md),不要 CliError('XXX', ...) 直传,不要 return [] 了事,不要 return [{sentinel}] 装一行业务数据冒充 empty。autofix skill 靠 typed error 的 exit code(66 = empty / 77 = auth / 75 = timeout / 2 = argument / 1 = exec)决定要不要重试。


COOKIE adapter 骨架(需要登录态)

PUBLIC 模式不够(接口 401 / 302 到 login / 响应是"请登录"页)就走这里。要点三条:

  1. 读 cookie 走 page.getCookies(...),不要读 document.cookie。
  2. 拿 HTML 走 Node 端 fetch + 手动解码,不要塞进 page.evaluate 里。
  3. Declaration 加 browser: true;不需要真的打开目标页时 navigateBefore: false。
import { cli, Strategy } from '@jackwener/opencli/registry';
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';

const BASE = 'https://www.example.com';
const HOST = 'www.example.com';
const ROOT = '.example.com';  // 根域(auth 常在这里)

async function readCookie(page) {
    const seen = new Map();
    for (const opts of [{ domain: HOST }, { domain: ROOT }]) {
        try {
            const cookies = await page.getCookies(opts);
            for (const c of cookies || []) {
                if (!seen.has(c.name)) seen.set(c.name, c.value);
            }
        } catch { /* try next domain */ }
    }
    return [...seen].map(([k, v]) => `${k}=${v}`).join('; ');
}

async function fetchHtml(url, { cookie, encoding = 'utf-8', headers = {} } = {}) {
    let resp;
    try {
        resp = await fetch(url, {
            headers: {
                'User-Agent': 'Mozilla/5.0',
                'Accept-Language': 'zh-CN,zh;q=0.9',
                Referer: `${BASE}/`,
                ...(cookie ? { Cookie: cookie } : {}),
                ...headers,
            },
            redirect: 'follow',
        });
    } catch (error) {
        throw new CommandExecutionError(`example request failed: ${error?.message || error}`);
    }
    if (!resp.ok) throw new CommandExecutionError(`example request failed: HTTP ${resp.status}`);
    const buf = await resp.arrayBuffer();
    return new TextDecoder(encoding).decode(buf);
}

cli({
    site: 'example',
    name: 'me',
    access: 'read',
    description: '示例:需要登录的私有页面',
    domain: HOST,
    strategy: Strategy.COOKIE,
    browser: true,
    navigateBefore: false,    // 本命令不需要先开目标页
    args: [{ name: 'limit', type: 'int', default: 20, help: '返回条数' }],
    columns: ['index', 'title', 'time'],
    func: async (page, args) => {
        const limit = Number(args.limit ?? 20);
        if (!Number.isInteger(limit) || limit <= 0) throw new ArgumentError('limit must be a positive integer');

        const cookie = await readCookie(page);
        const html = await fetchHtml(`${BASE}/inbox`, { cookie, encoding: 'gbk' });

        if (/请登录|需要登录|<title>Login/i.test(html)) {
            throw new AuthRequiredError(HOST);
        }

        // parse html → rows
        if (!rows.length) throw new EmptyResultError('example me', 'inbox is empty');
        return rows.slice(0, limit);
    },
});

JSON API 用 page.fetchJson(),不要手写 page.evaluate(fetch(...))

如果接口必须在浏览器上下文里请求(依赖当前页面 cookie / CORS / origin),用内置 primitive:

const data = await page.fetchJson(`${BASE}/api/list`, {
  method: 'POST',
  headers: { 'X-Requested-With': 'XMLHttpRequest' },
  body: { page: 1, size: limit },
});

它固定 credentials: 'include',带 timeout,HTTP 非 2xx / 非 JSON 会抛统一 runtime error。adapter 里不用再手写 page.evaluate(fetch(...));如果你需要额外包一层业务语义,按 typed-errors.md 映射到 CommandExecutionError / AuthRequiredError / EmptyResultError。

页面内 DOM 逻辑用 page.evaluate(fn, ...args)

新 adapter 优先写函数形式,外部变量通过参数传入:

const href = await page.evaluate((selector) => {
  const link = document.querySelector(selector);
  return link ? link.getAttribute('href') : null;
}, 'a[data-testid="profile"]');

fn 在浏览器页面上下文执行,不能读取 Node 侧闭包变量;参数必须能被 JSON.stringify 序列化。字符串形式 page.evaluate('document.title') 仍可用于简单表达式和既有代码,但不要再写依赖隐式 auto-IIFE 的模板字符串函数。

HTML 不走 browser fetch

三个坑,踩一个就重写:

  • HttpOnly cookie 看不见:绝大多数登录站点把 auth cookie 标 HttpOnly,document.cookie 永远读不到它,只能通过 CDP 的 cookie jar 拿(page.getCookies)。塞到 page.evaluate 里就等于回到 document.cookie 那条路,必挂。
  • navigateBefore: false 时当前 tab 不在目标站:页面 origin 可能是 about:blank 或上一条命令留下的别处,从那儿发 fetch 到目标域就是 cross-origin,浏览器 CORS 一挡就是 "Failed to fetch"。
  • 非 UTF-8 编码解码麻烦:GBK / Big5 / Shift-JIS 的站(Discuz / phpBB 老版 / 日站)在 page.evaluate 里用 response.text() 拿到的是乱码,TextDecoder('gbk').decode(buf) 的写法只在 Node 侧干净。

规则:JSON 型浏览器接口用 page.fetchJson();HTML 型 COOKIE adapter 一律 Node 侧 fetch,浏览器只当 cookie jar 用。

Selector 稳定性 — 不要 select 用户可见文本

issue #1474 触发:同一个发送按钮在英文 Chrome 是 aria-label="Submit",在中文 Chrome(chrome://settings/languages 设中文)变 aria-label="提交",CSS 选择器 button[aria-label="Submit"] 在中文环境下直接 0 匹配,silent empty result。

根因不是 i18n bug,是选择器的 anchor 选错了。把页面 DOM 属性按 "locale-stable vs locale-dependent" 分两类:

类 例子 locale 切换会变吗 用作 primary selector?
locale-stable 标识 data-testid、data-*、稳定 id / class 通常不变(开发者内部 ID) ✅ 首选,但要先确认不是 hash / A-B test
semantic / scope anchor role、结构关系、邻近稳定容器 不按 locale 翻译,但常常不唯一 ⚠️ 只作 scope/filter;不要单独用 button[role="button"]
locale-dependent 文本 aria-label、title、placeholder、alt、textContent 变(被 i18n 框架翻译) ❌ 仅当 stable 选择器全都不存在时的兜底

ChatGPT 的 web 端就是反例驱动的:有些 controls 暴露稳定 data-testid,有些 surfaces 只暴露 aria-label / placeholder。这种站必须先用 stable selector,再用多语言 fallback list:

// clis/chatgpt/utils.js(简化活例)
const COMPOSER_SELECTORS = [
  '#prompt-textarea',
  '[data-testid="composer"] [contenteditable="true"]',
  '[aria-label="Chat with ChatGPT"]',   // en
  '[aria-label="与 ChatGPT 聊天"]',      // zh-CN
  '[placeholder="Ask anything"]',
  '[placeholder="有问题,尽管问"]',       // zh-CN
];
const SEND_BUTTON_SELECTORS = [
  'button[data-testid="send-button"]:not([disabled])',
  'button[aria-label="Send prompt"]:not([disabled])',
  'button[aria-label="发送提示"]:not([disabled])',
];

写 fallback list 的纪律:

  1. stable selector 放最前(#prompt-textarea / [data-testid="send-button"]),locale-dependent 的放后面当兜底
  2. role 只能当 semantic / scope filter:dialog [role="textbox"] 可以;裸 button / [role="button"] 不够,因为同页可能有多个按钮
  3. 每种 locale 至少列一条(en + zh-CN 是底线;扩到 ja / ko / ar 看站点用户分布)
  4. commit 前 grep aria-label= / placeholder= / title= 看是不是漏了 fallback locale——见 success-rate-pitfalls.md §11
  5. 失败要 typed fail-fast:找不到 control 应该 CommandExecutionError / send-failed,不要返回空 rows 或假成功
  6. 不要给 framework 加 --i18n "zh:提交,ja:送信" 这种 flag —— 等于把 fallback list 从 adapter 挪到 CLI,多一层 indirection 还要维护翻译字典。这是 over-engineering,已经在评审时被否

为什么不在 daemon 端固定 Chrome locale?因为 opencli 不启动 Chrome——daemon 是连用户已经在跑的 Chrome(CDP via extension),用户可能就是中文 UI / 中文资料检索需求。强制 en-US 会破坏用户的正当工作流。

Cookie 域的双查

for (const opts of [{ domain: HOST }, { domain: ROOT }]) { ... }

不是所有站都这么玄学,但下面这几类踩坑最多:

站点类型 坑
Discuz!X / phpBB / vBulletin 论坛 Auth cookie 设在 .<root>.com,HttpOnly;业务页在 www.<root>.com。只查 www. 会漏
多子域账户体系(account.x.com vs api.x.com) 登录时写在 account 域,API 域读取时拿不到
新版 Chrome SameSite=Lax 默认 某些 cookie 查 url: 才给返,查 domain: 不给

双查成本很低,不确定就两个都查,用 Map 去重第一次出现的 name。

空态抛 EmptyResultError,不塞 sentinel 行

历史上这里写的是"返回一行说明 row 比 return [] 安全"。这条已经反过来了——见 PR #1329 R3 的 four anti-pattern fixes。现在的契约:

import { EmptyResultError } from '@jackwener/opencli/errors';

// ❌ 老写法:sentinel 行污染 row 合同,让 listing→detail round-trip 拿到 tid='' 白跑
if (/暂时没有提醒内容/.test(html)) {
    return [{ index: 0, from: '', summary: '暂时没有提醒内容', time: '', threadUrl: '' }];
}

// ✅ 新写法:empty 是合法状态,但不是 row。exit code 66 让 agent 直接 branch
if (/暂时没有提醒内容/.test(html)) {
    throw new EmptyResultError('1point3acres notifications', '暂时没有提醒内容');
}

更多反例和详细 routing 见 typed-errors.md §3。


同类型 adapter 对照

类型 代表 参考
clist 分页排行 convertible.js / rank.js / etf.js / sectors.js 都共享 fs + fid + po 结构
ulist 批量报价 quote.js secids 逗号拼接
K 线历史 kline.js fields1 / fields2 控列,CSV 解析
报表(datacenter-web) longhu.js / holders.js reportName 驱动
7x24 新闻 kuaixun.js np-listapi 栏目 id
公司公告 announcement.js np-anotice-stock
指数/北上 index-board.js / northbound.js push2 专用端点

新写一条时,选最像的那类,复制后改 name / URL / fields / column 映射三处。


Verify fixture(每个 adapter 配一份 ~/.opencli/sites/<site>/verify/<name>.json)

verify fixture 是"adapter 产出长什么样"的结构锚点。没有它,opencli browser verify 只能证"adapter 能跑完不抛",证不出数据没错位。必写。

详细 schema 见 site-memory.md 的 verify/<cmd>.json 节。这里只讲两个容易踩的地方:

args 形态:object vs array

args 字段决定 verify 怎么调你的 adapter:

  • 对象形态 { "limit": 3 } → 展开成 --limit 3,标准 named-flag adapter 用这个
  • 数组形态 ["123", "--limit", "3"] → 原样 append 到命令后,positional 主语型 adapter 必须用这个

repo 约定"主语优先 positional"——thread 详情型、url 解析型、关键词搜索型都用 positional:

// clis/1point3acres/thread.js — 接收 <tid> 作为主语
cli({
  site: '1point3acres',
  name: 'thread',
  args: [
    { name: 'tid',   type: 'string', required: true, positional: true },
    { name: 'limit', type: 'int',    default: 20 },
  ],
  // ...
});

对应 fixture:

{
  "args": ["1234567", "--limit", "3"],
  "expect": { "rowCount": { "min": 1, "max": 3 }, "...": "..." }
}

不要写成 { "tid": "1234567", "limit": 3 }——这会被展开成 --tid 1234567 --limit 3,commander 把 --tid 当未知 flag 报错,或者 adapter 根本不认。

种子 → 手改

named-flag adapter(hot / latest 类)可以直接让工具生成种子:

# 1. 让 verify 先跑一遍,--write-fixture 生成种子(默认追加 --limit 3)
opencli browser verify 1point3acres/hot --write-fixture

# 2. 手改 ~/.opencli/sites/1point3acres/verify/hot.json
#    - patterns: 加 URL / 日期 / ID 正则
#    - notEmpty: 加核心字段(title / author / url)
#    - rowCount: 收紧到业务合理区间

# 3. 再跑 verify,fixture 吃得动就 OK
opencli browser verify 1point3acres/hot

positional adapter 目前 --write-fixture 没法表达主语,首份 fixture 要手写:

# 1. 先直跑 adapter 看输出长啥样
opencli 1point3acres thread 1173710 --limit 2 --format json | head

# 2. 照着响应手写 ~/.opencli/sites/1point3acres/verify/thread.json
#    (args 一定用数组: ["1173710", "--limit", "2"])

# 3. 跑 verify 核对
opencli browser verify 1point3acres/thread

机器生成的种子只有 rowCount.min=1 / columns / types,挡不住字段值错位。patterns + notEmpty 无论哪种情形都是肉写的。


私人 adapter vs repo 贡献

~/.opencli/clis/<site>/<name>.js    # 私人
clis/<site>/<name>.js               # repo 贡献

两者在 cli({...}) 层面完全一样。差别只在运行入口:

  • 私人:写完立即可跑(opencli <site> <name>)
  • repo:要 npm run build 才被注册

先在 ~/.opencli/clis/ 调通再拷贝到 clis/。

Source: SKILL.md on GitHub

No alerts6d3 checks · Risk SAFE
  • Gen Agent Trust Hub6d

    This skill provides a comprehensive development framework for creating website adapters for the OpenCLI tool. It guides the agent through site reconnaissance, API discovery, and field decoding, with specific emphasis on technical accuracy and safe handling of PII/credentials. The primary security consideration is the processing of untrusted web content to generate local code.

  • Socket6d

    No alerts

  • Snyk6d

    Risk: LOW · No issues

Signed by skilld at 2413694. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub last week.

Activeupdated last week
What it can do
Runs commands Reads files Edits files
All 6 allowed tools
Bash(opencli:*)Bash(jsluice:*)ReadEditWriteGrep

README badge

README badge for jackwener/opencli/opencli-adapter-author