Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion clis/barchart/quote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* Auth: CSRF token from <meta name="csrf-token"> + session cookies.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { CommandExecutionError } from '@jackwener/opencli/errors';

cli({
site: 'barchart',
Expand Down Expand Up @@ -111,7 +112,7 @@ cli({
})()
`);

if (!data || data.error) return [];
if (!data || data.error) throw new CommandExecutionError(data?.error || `Failed to fetch quote for ${symbol}`);

const r = data.row || {};
// API returns formatted strings like "+1.41" and "+0.56%"; use raw if available
Expand Down
4 changes: 2 additions & 2 deletions clis/xueqiu/earnings-date.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { cli } from '@jackwener/opencli/registry';
import { EmptyResultError } from '@jackwener/opencli/errors';
import { fetchXueqiuJson } from './utils.js';

cli({
Expand All @@ -23,8 +24,7 @@ cli({
const symbol = String(kwargs.symbol).toUpperCase();
const url = `https://stock.xueqiu.com/v5/stock/screener/event/list.json?symbol=${encodeURIComponent(symbol)}&page=1&size=100`;
const d = await fetchXueqiuJson(page, url);
if ('error' in d) return [d];
if (!d.data?.items) return [{ error: '获取失败: ' + symbol, help: '请确认股票代码是否正确' }];
if (!d.data?.items) throw new EmptyResultError('xueqiu/earnings-date', '请确认股票代码是否正确: ' + symbol);

// subtype 2 = 预计财报发布
const now = Date.now();
Expand Down
2 changes: 1 addition & 1 deletion clis/xueqiu/feed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ cli({
await page.goto('https://xueqiu.com');
const url = `https://xueqiu.com/v4/statuses/home_timeline.json?page=${kwargs.page}&count=${kwargs.limit}`;
const d = await fetchXueqiuJson(page, url);
if ('error' in d) return [d];

return ((d.home_timeline || d.list || []) as any[]).slice(0, kwargs.limit as number).map((item: any) => {
const user = item.user || {};
return {
Expand Down
4 changes: 2 additions & 2 deletions clis/xueqiu/groups.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { cli } from '@jackwener/opencli/registry';
import { AuthRequiredError } from '@jackwener/opencli/errors';
import { fetchXueqiuJson } from './utils.js';

cli({
Expand All @@ -11,8 +12,7 @@ cli({
func: async (page, _kwargs) => {
await page.goto('https://xueqiu.com');
const d = await fetchXueqiuJson(page, 'https://stock.xueqiu.com/v5/stock/portfolio/list.json?category=1&size=20');
if ('error' in d) return [d];
if (!d.data?.stocks) return [{ error: '获取失败', help: '请确认已登录雪球(https://xueqiu.com)' }];
if (!d.data?.stocks) throw new AuthRequiredError('xueqiu.com');
return ((d.data.stocks || []) as any[]).map((g: any) => ({
pid: String(g.id),
name: g.name,
Expand Down
4 changes: 2 additions & 2 deletions clis/xueqiu/hot-stock.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { cli } from '@jackwener/opencli/registry';
import { AuthRequiredError } from '@jackwener/opencli/errors';
import { fetchXueqiuJson } from './utils.js';

cli({
Expand All @@ -16,8 +17,7 @@ cli({
await page.goto('https://xueqiu.com');
const url = `https://stock.xueqiu.com/v5/stock/hot_stock/list.json?size=${kwargs.limit}&type=${kwargs.type}`;
const d = await fetchXueqiuJson(page, url);
if ('error' in d) return [d];
if (!d.data?.items) return [{ error: '获取失败', help: '请确认已登录雪球(https://xueqiu.com)' }];
if (!d.data?.items) throw new AuthRequiredError('xueqiu.com');
return ((d.data.items || []) as any[]).map((s: any, i: number) => ({
rank: i + 1,
symbol: s.symbol,
Expand Down
2 changes: 1 addition & 1 deletion clis/xueqiu/hot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ cli({
func: async (page, kwargs) => {
await page.goto('https://xueqiu.com');
const d = await fetchXueqiuJson(page, 'https://xueqiu.com/statuses/hot/listV3.json?source=hot&page=1');
if ('error' in d) return [d];

return ((d.list || []) as any[]).slice(0, kwargs.limit as number).map((item: any, i: number) => {
const user = item.user || {};
return {
Expand Down
5 changes: 3 additions & 2 deletions clis/xueqiu/kline.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { cli } from '@jackwener/opencli/registry';
import { EmptyResultError } from '@jackwener/opencli/errors';
import { fetchXueqiuJson } from './utils.js';

cli({
Expand All @@ -24,8 +25,8 @@ cli({
const beginTs = Date.now();
const url = `https://stock.xueqiu.com/v5/stock/chart/kline.json?symbol=${encodeURIComponent(symbol)}&begin=${beginTs}&period=day&type=before&count=-${days}`;
const d = await fetchXueqiuJson(page, url);
if ('error' in d) return [d];
if (!d.data?.item?.length) return [];

if (!d.data?.item?.length) throw new EmptyResultError('xueqiu/kline', '请确认股票代码是否正确: ' + symbol);

const columns: string[] = d.data.column || [];
const colIdx: Record<string, number> = {};
Expand Down
2 changes: 1 addition & 1 deletion clis/xueqiu/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ cli({
await page.goto('https://xueqiu.com');
const url = `https://xueqiu.com/stock/search.json?code=${encodeURIComponent(String(kwargs.query))}&size=${kwargs.limit}`;
const d = await fetchXueqiuJson(page, url);
if ('error' in d) return [d];

return ((d.stocks || []) as any[]).slice(0, kwargs.limit as number).map((s: any) => {
let symbol = '';
if (s.exchange === 'SH' || s.exchange === 'SZ' || s.exchange === 'BJ') {
Expand Down
4 changes: 2 additions & 2 deletions clis/xueqiu/stock.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { cli } from '@jackwener/opencli/registry';
import { EmptyResultError } from '@jackwener/opencli/errors';
import { fetchXueqiuJson } from './utils.js';

function fmtAmount(v: number | null | undefined): string | null {
Expand Down Expand Up @@ -29,8 +30,7 @@ cli({
const symbol = String(kwargs.symbol).toUpperCase();
const url = `https://stock.xueqiu.com/v5/stock/batch/quote.json?symbol=${encodeURIComponent(symbol)}`;
const d = await fetchXueqiuJson(page, url);
if ('error' in d) return [d];
if (!d.data?.items?.length) return [{ error: '未找到股票: ' + symbol, help: '请确认股票代码是否正确,如 SH600519、AAPL' }];
if (!d.data?.items?.length) throw new EmptyResultError('xueqiu/stock', '请确认股票代码是否正确: ' + symbol);
const item = d.data.items[0];
const q = item.quote || {};
const m = item.market || {};
Expand Down
13 changes: 6 additions & 7 deletions clis/xueqiu/utils.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import type { IPage } from '@jackwener/opencli/types';

export interface XueqiuError { error: string; help: string; }
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';

/**
* Fetch a xueqiu JSON API from inside the browser context (credentials included).
* Page must already be navigated to xueqiu.com before calling this function.
* Returns { error, help } on HTTP errors; otherwise returns the parsed JSON.
* Throws CliError on HTTP errors; otherwise returns the parsed JSON.
*/
export async function fetchXueqiuJson(page: IPage, url: string): Promise<any | XueqiuError> {
export async function fetchXueqiuJson(page: IPage, url: string): Promise<any> {
const result = await page.evaluate(`(async () => {
const res = await fetch(${JSON.stringify(url)}, { credentials: 'include' });
if (!res.ok) return { __xqErr: res.status };
Expand All @@ -22,12 +21,12 @@ export async function fetchXueqiuJson(page: IPage, url: string): Promise<any | X
if (r?.__xqErr !== undefined) {
const code = r.__xqErr;
if (code === 401 || code === 403) {
return { error: '未登录或登录已过期', help: '在浏览器中打开 https://xueqiu.com 并登录,然后重试' };
throw new AuthRequiredError('xueqiu.com', '未登录或登录已过期');
}
if (code === 'parse') {
return { error: '响应不是有效 JSON', help: '可能触发了风控,请检查登录状态或稍后重试' };
throw new CommandExecutionError('响应不是有效 JSON', '可能触发了风控,请检查登录状态或稍后重试');
}
return { error: `HTTP ${code}`, help: '请检查网络连接或登录状态' };
throw new CommandExecutionError(`HTTP ${code}`, '请检查网络连接或登录状态');
}
return result;
}
4 changes: 2 additions & 2 deletions clis/xueqiu/watchlist.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { cli } from '@jackwener/opencli/registry';
import { AuthRequiredError } from '@jackwener/opencli/errors';
import { fetchXueqiuJson } from './utils.js';

cli({
Expand All @@ -21,8 +22,7 @@ cli({
const pid = String(kwargs.pid || '-1');
const url = `https://stock.xueqiu.com/v5/stock/portfolio/stock/list.json?size=100&category=1&pid=${encodeURIComponent(pid)}`;
const d = await fetchXueqiuJson(page, url);
if ('error' in d) return [d];
if (!d.data?.stocks) return [{ error: '获取失败', help: '请确认已登录雪球(https://xueqiu.com)' }];
if (!d.data?.stocks) throw new AuthRequiredError('xueqiu.com');
return ((d.data.stocks || []) as any[]).slice(0, kwargs.limit as number).map((s: any) => ({
symbol: s.symbol,
name: s.name,
Expand Down
3 changes: 2 additions & 1 deletion clis/yahoo-finance/quote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* Yahoo Finance stock quote — multi-strategy API fallback.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { CommandExecutionError } from '@jackwener/opencli/errors';

cli({
site: 'yahoo-finance',
Expand Down Expand Up @@ -66,7 +67,7 @@ cli({
return {error: 'Could not fetch quote for ' + sym};
})()
`);
if (!data || data.error) return [];
if (!data || data.error) throw new CommandExecutionError(data?.error || `Failed to fetch quote for ${symbol}`);
return [data];
},
});
4 changes: 2 additions & 2 deletions skills/opencli-explorer/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ cat clis/<site>/feed.ts # 读最相似的那个

所有适配器统一使用 TypeScript `cli()` API,放入 `clis/<site>/<name>.ts` 即自动注册。

完整模板(Tier 1~4)、分页模式、错误处理规范(`{ error, help }` 格式)→ **[adapter-templates.md](references/adapter-templates.md)**
完整模板(Tier 1~4)、分页模式、错误处理规范(`throw CliError` + YAML envelope)→ **[adapter-templates.md](references/adapter-templates.md)**

**最简结构**(Tier 2 Cookie):

Expand Down Expand Up @@ -241,7 +241,7 @@ git add clis/mysite/ && git commit -m "feat(mysite): add mycommand" && git push
|------|------|---------|
| 缺少 `navigate` | `Target page context` 错误 | 在 evaluate 前加 `page.goto()` |
| 缺少 `strategy: public` | 公开 API 也启动浏览器 | 加 `strategy: Strategy.PUBLIC` + `browser: false` |
| **风控被拦截(伪 200)** | JSON 里核心数据是空串 | 必须断言!返回 `{ error, help }` 提示重新登录 |
| **风控被拦截(伪 200)** | JSON 里核心数据是空串 | 必须断言!`throw new AuthRequiredError(domain)` 提示重新登录 |
| **SPA 返回 HTML** | `fetch('/api/xxx')` 返回 `<!DOCTYPE html>` | 页面 host 是 `app.xxx.com`,真实 API 在 `api.xxx.com`;搜 JS bundle 找 baseURL |
| **400 缺少上下文 Header** | 带了 Bearer 仍然 400,报 `Missing X-Server-Id` | 先调 `/servers` 拿业务上下文 ID,加进 headers |
| **文件写错目录** | `opencli list` 找不到命令 | Repo 贡献放 `clis/<site>/` + build;私人 adapter 放 `~/.opencli/clis/<site>/` |
Expand Down
86 changes: 50 additions & 36 deletions skills/opencli-explorer/references/adapter-templates.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ cli({
```typescript
// clis/slock/channels.ts
import { cli, Strategy } from '@jackwener/opencli/registry';
import { AuthRequiredError } from '@jackwener/opencli/errors';

cli({
site: 'slock',
Expand All @@ -119,7 +120,7 @@ cli({
await page.goto('https://app.slock.ai');
const data = await page.evaluate(`(async () => {
const token = localStorage.getItem('slock_access_token');
if (!token) return { error: 'Not logged in', help: 'Open https://app.slock.ai and log in, then retry' };
if (!token) return { error: 'Not logged in' };

// 多租户 SaaS:先拿工作空间列表
const slug = ${JSON.stringify(kwargs.server || null)} || localStorage.getItem('slock_last_server_slug');
Expand All @@ -134,7 +135,7 @@ cli({
});
return res.json();
})()`);
if ((data as any).error) return [data as any];
if ((data as any).error) throw new AuthRequiredError('app.slock.ai');
return (data as any[]).slice(0, kwargs.limit).map((ch: any, i: number) => ({
rank: i + 1,
name: ch.name || '',
Expand All @@ -156,6 +157,7 @@ cli({
```typescript
// clis/twitter/lists.ts
import { cli, Strategy } from '@jackwener/opencli/registry';
import { AuthRequiredError } from '@jackwener/opencli/errors';

cli({
site: 'twitter',
Expand All @@ -172,7 +174,7 @@ cli({
await page.goto('https://x.com');
const data = await page.evaluate(`(async () => {
const ct0 = document.cookie.match(/ct0=([^;]+)/)?.[1];
if (!ct0) return { error: 'Not logged in', help: 'Open https://x.com and log in, then retry' };
if (!ct0) return { error: 'Not logged in' };
const bearer = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
const res = await fetch('/i/api/graphql/QUERY_ID/ListsManagePinTimeline', {
headers: {
Expand All @@ -188,7 +190,7 @@ cli({
?.filter(e => e.content?.itemContent?.list)
?.map(e => e.content.itemContent.list) || [];
})()`);
if ((data as any).error) return [data as any];
if ((data as any).error) throw new AuthRequiredError('x.com');
return (data as any[]).slice(0, kwargs.limit).map((l: any, i: number) => ({
rank: i + 1,
name: l.name || '',
Expand Down Expand Up @@ -349,7 +351,7 @@ const server = servers.find(s => s.slug === slug) || servers[0];
// clis/mysite/utils.ts
export async function getServerContext(slug: string | null): Promise<{ token: string; server: any }> {
const token = localStorage.getItem('mysite_access_token');
if (!token) throw { error: 'Not logged in', help: 'Open https://app.mysite.com and log in, then retry' };
if (!token) return { error: 'Not logged in' };
const servers = await fetch('https://api.mysite.com/api/servers', {
headers: { 'Authorization': 'Bearer ' + token }
}).then(r => r.json());
Expand All @@ -365,9 +367,12 @@ import { getServerContext } from './utils.js';
func: async (page, kwargs) => {
await page.goto('https://app.mysite.com');
const data = await page.evaluate(`(async () => {
const { token, server } = await (${getServerContext.toString()})(${JSON.stringify(kwargs.server || null)});
const ctx = await (${getServerContext.toString()})(${JSON.stringify(kwargs.server || null)});
if (ctx.error) return ctx; // bubble error sentinel to func() body
const { token, server } = ctx;
// ...
})()`);
if (data?.error) throw new AuthRequiredError('app.mysite.com', data.error);
}
```

Expand All @@ -380,42 +385,51 @@ func: async (page, kwargs) => {

## 错误处理规范

### 返回 `{ error, help }` 而非 throw
### 使用 `throw CliError` 子类

```typescript
// ❌ 不推荐:throw 导致 CLI 打印 stack trace,用户不知道怎么修复
if (!token) throw new Error('Not logged in');

// ✅ 推荐:返回结构化错误,help 告诉 AI Agent 或用户下一步怎么做
if (!token) return [{ error: 'Not logged in', help: 'Open https://site.com and log in, then retry' }];
```

**字段约定**:

| 字段 | 类型 | 说明 |
|------|------|------|
| `error` | `string` | 问题描述,事实性,不带感叹号 |
| `help` | `string` | 具体的修复动作,可直接执行 |

**常用 help 模板**:
所有错误都通过 `throw` 类型化的 `CliError` 子类来表达。框架层统一捕获并输出 YAML Error Envelope 到 stderr + 非零 exit code。**不要用 `return [{error, help}]`**——stdout 是纯数据通道。

```typescript
// 未登录
{ error: 'Not logged in', help: 'Open https://site.com in the browser and log in, then retry' }
import { AuthRequiredError, EmptyResultError, CommandExecutionError } from '@jackwener/opencli/errors';

// 找不到资源
{ error: `Channel not found: ${kwargs.channel}`, help: 'Run `opencli site channels` to see available channels' }
// ❌ 不推荐:错误伪装成数据混入 stdout
if (!token) return [{ error: 'Not logged in', help: '...' }];

// 权限不足
{ error: 'Forbidden (403)', help: 'Check that your account has access to this resource' }
// ✅ 推荐:throw 类型化错误,框架自动输出 YAML envelope 到 stderr
if (!token) throw new AuthRequiredError('site.com');
```

// API 结构变更
{ error: 'Unexpected response structure', help: 'Run `opencli browser network --detail N` to inspect the current API response' }
**注意 `page.evaluate()` 内部**:browser 环境没有 CliError,在 evaluate 内返回 `{ error, help }` 后,在 `func()` 体内检查并 throw:

// 风控降级(伪 200)
{ error: 'Core data is empty — possible risk-control block', help: 'Re-login to the site in the browser, then retry' }
```typescript
const data = await page.evaluate(`(async () => {
const token = localStorage.getItem('token');
if (!token) return { error: 'Not logged in' };
// ...
})()`);
if ((data as any).error) throw new AuthRequiredError('site.com', (data as any).error);
```

**何时 throw vs 返回 error 对象**:
- 程序错误(参数类型错、配置缺失)→ `throw`,这是 bug
- 运行时用户可修复的情况(未登录、找不到资源、API 变更)→ 返回 `{ error, help }`
**可用的 CliError 子类**:

| 子类 | code | 场景 | exit code |
|------|------|------|-----------|
| `AuthRequiredError` | AUTH_REQUIRED | 未登录、Cookie 过期 | 77 |
| `EmptyResultError` | EMPTY_RESULT | API 返回空数据 | 66 |
| `CommandExecutionError` | COMMAND_EXEC | 通用执行失败 | 1 |
| `TimeoutError` | TIMEOUT | 超时 | 75 |
| `ArgumentError` | ARGUMENT | 参数错误 | 2 |
| `SelectorError` | SELECTOR | DOM 元素找不到 | 1 |
| `BrowserConnectError` | BROWSER_CONNECT | 浏览器连接失败 | 69 |
| `ConfigError` | CONFIG | 配置缺失 | 78 |

**错误输出示例**(YAML envelope → stderr):

```yaml
ok: false
error:
code: AUTH_REQUIRED
message: Not logged in to site.com
help: Please open Chrome or Chromium and log in to https://site.com
exitCode: 77
```
3 changes: 2 additions & 1 deletion skills/opencli-explorer/references/advanced-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

```typescript
import { cli, Strategy } from '@jackwener/opencli/registry';
import { AuthRequiredError } from '@jackwener/opencli/errors';
import type { IPage } from '@jackwener/opencli/types';
import { apiGet } from './utils.js'; // 复用平台 SDK

Expand Down Expand Up @@ -40,7 +41,7 @@ cli({
// Step 4: 断言风控降级(空值断言)
const subtitles = payload.data?.subtitle?.subtitles || [];
const url = subtitles[0]?.subtitle_url;
if (!url) return [{ error: 'subtitle_url is empty — possible risk-control block', help: 'Re-login to Bilibili, then retry' }];
if (!url) throw new AuthRequiredError('bilibili.com', 'subtitle_url is empty — possible risk-control block');

// Step 5: 拉取最终数据(CDN JSON)
const items = await page.evaluate(`(async () => {
Expand Down
2 changes: 2 additions & 0 deletions skills/opencli-oneshot/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,7 @@ cli({

```typescript
import { cli, Strategy } from '@jackwener/opencli/registry';
import { AuthRequiredError } from '@jackwener/opencli/errors';

cli({
site: 'twitter',
Expand Down Expand Up @@ -274,6 +275,7 @@ cli({
});
return res.json();
})()`);
if ((data as any).error) throw new AuthRequiredError('x.com');
// 解析 data...
return [];
},
Expand Down
Loading