Compare commits

...

10 Commits

Author SHA1 Message Date
jackwener 460206001b chore: remove slock adapters from this PR
Slock adapters should be in a separate PR, not bundled with the
adapter refactor and skill docs improvements.
2026-04-10 02:20:34 +08:00
jackwener 3a4eb582ad fix(output): show error rows in table/csv/markdown when columns declared
When a command declares columns (e.g. ['rank', 'title', 'value']) but
returns an error row ({ error, help }), the declared columns would
render empty cells. Now resolveColumns detects the error key and falls
back to the row's actual keys, making diagnostics visible in all output
formats.
2026-04-10 02:18:39 +08:00
jackwener 30b79d4813 refactor(adapters): migrate pipeline adapters to func() with { error, help } pattern
- slock: agents, channels, messages, servers now use getSlockContext/resolveChannelId
  from utils.ts; error handling uses { error, help } return instead of bare throws
- linux-do: export fetchLinuxDoJson from feed.ts; migrate search, topic, categories,
  tags, user-posts, user-topics from pipeline+throw to func() using fetchLinuxDoJson
- xueqiu: add utils.ts with fetchXueqiuJson helper; migrate hot, feed, search, stock,
  watchlist, hot-stock, groups, kline, earnings-date from pipeline+throw to func()
2026-04-10 02:18:39 +08:00
jackwener fceb7fca37 feat(slock): extract utils.ts + apply { error, help } pattern; docs: remedy→help
slock/utils.ts: new — getSlockContext(), resolveChannelId()
  - Shared token + workspace resolution, no more 4-line duplication
  - UUID regex (/^[0-9a-f]{8}-...$/) replaces fragile !includes('-')
  - Returns { error, help } instead of throwing

tasks.ts / members.ts / send.ts:
  - Import from utils.ts, remove all duplicated auth boilerplate
  - All errors return [{ error, help }], no more throw
  - members.ts: add limit arg (was unbounded before)

docs: rename remedy → help across all skill references
2026-04-10 02:18:39 +08:00
jackwener 7de086e7d8 docs(skills): add decision matrix, stop conditions, sync comments
explorer: add path decision matrix before core workflow
oneshot: add explicit stop/switch conditions (when to escalate to explorer)
both: add keep-in-sync comment on the two-scenario verify block
2026-04-10 02:18:39 +08:00
jackwener fd47a713ae docs(adapter-templates): add utils.ts extraction pattern for same-site adapters 2026-04-10 02:18:39 +08:00
jackwener 4266c17513 docs(skills): fix verify command — split by dev scenario
browser verify only reads ~/.opencli/clis/, not repo's clis/.
Split all verify instructions:
- Repo 贡献: npm run build + opencli <site> <cmd>
- 私人 adapter: opencli browser verify <site>/<name>

Fixes blocker in explorer:L209, L224 and oneshot:L286, L298
2026-04-10 02:18:19 +08:00
jackwener 5442205c9d docs(explorer): split SKILL.md into reference sub-documents
- Shrink main SKILL.md from 994 to 270 lines — core workflow only
- Extract all TS templates (Tier 1~4, pagination) to references/adapter-templates.md
- Add error handling standard: { error, remedy } pattern (remedy > hint)
- Add Tier 2.5 localStorage Bearer template with multi-tenant X-Server-Id example
- Extract cascading requests, tap debug, verbose mode, anti-change patterns to references/advanced-patterns.md
- Extract record workflow to references/record-workflow.md
2026-04-10 02:18:19 +08:00
jackwener e83a09654e docs(skills): fix path conflict + add anti-change patterns from real adapters
Fix reviewer blocking issue:
- Remove the contradictory "~/.opencli/clis/" note that mixed user-local and
  repo-contributor workflows; replace with explicit two-scenario callout in
  Step 4, Step 5, pitfall table, and oneshot test section
- Template comments in oneshot restored to clis/<site>/<name>.ts (repo path)

Add "抗变更模式" section to explorer, based on opencli's own production code:
- Pattern 1: dynamic queryId discovery (twitter/shared.ts resolveTwitterQueryId)
  — scan loaded JS bundle by operationName (stable) to find queryId (unstable)
- Pattern 2: semantic DOM priority fallback (web/read.ts)
  — article > [role=main] > main > class-hint > body, pick largest text block
- Pattern 3: ordered selector array + timestamp comments (xiaohongshu/publish.ts)
  — first-match wins, comment records UI version and observed attribute values
- Pattern 4: nullish-coalescing field multi-path (xiaohongshu/user-helpers.ts)
  — covers camelCase/snake_case variants without assuming fixed key name
2026-04-10 02:17:36 +08:00
jackwener 56a7885ab8 docs(skills): add Tier 2.5 localStorage Bearer, SPA discovery, and test standards
From real-world experience building slock.ai CLI adapters:

- oneshot: add network-empty diagnosis, SPA baseURL bundle search, Tier 2.5
  localStorage Bearer template (with multi-tenant X-Server-Id pattern),
  updated auth quick-reference, file path note, opencli browser verify test flow
- explorer: add Tier 2.5 to decision tree and strategy table, update test section
  with opencli browser verify + Done standard, fix Step 5 path to ~/.opencli/clis/,
  add 4 new pitfall rows (SPA HTML, 400 context header, empty network, wrong dir)
2026-04-10 02:17:36 +08:00
21 changed files with 430 additions and 560 deletions
+23 -40
View File
@@ -1,4 +1,5 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { fetchLinuxDoJson } from './feed.js';
cli({
site: 'linux-do',
@@ -12,33 +13,27 @@ cli({
{ name: 'limit', type: 'int', default: 20, help: 'Number of categories' },
],
columns: ['name', 'slug', 'id', 'topics', 'description'],
pipeline: [
{ navigate: 'https://linux.do' },
{ evaluate: `(async () => {
const res = await fetch('/categories.json', { credentials: 'include' });
if (!res.ok) throw new Error('HTTP ' + res.status + ' - 请先登录 linux.do');
let data;
try { data = await res.json(); } catch { throw new Error('响应不是有效 JSON - 请先登录 linux.do'); }
const cats = data?.category_list?.categories || [];
const showSub = \${{ args.subcategories }};
const results = [];
const limit = \${{ args.limit }};
for (const c of cats.slice(0, \${{ args.limit }})) {
results.push({
name: c.name,
slug: c.slug,
id: c.id,
topics: c.topic_count,
description: (c.description_text || '').slice(0, 80),
});
if (results.length >= limit) break;
if (showSub && c.subcategory_ids && c.subcategory_ids.length > 0) {
const subRes = await fetch('/categories.json?parent_category_id=' + c.id, { credentials: 'include' });
if (subRes.ok) {
let subData;
try { subData = await subRes.json(); } catch { continue; }
const subCats = subData?.category_list?.categories || [];
func: async (page, kwargs) => {
const data = await fetchLinuxDoJson(page, '/categories.json');
const cats = (data?.category_list?.categories || []) as any[];
const showSub = !!kwargs.subcategories;
const limit = kwargs.limit as number;
const results: any[] = [];
for (const c of cats) {
if (results.length >= limit) break;
results.push({
name: c.name,
slug: c.slug,
id: c.id,
topics: c.topic_count,
description: (c.description_text || '').slice(0, 80),
});
if (showSub && Array.isArray(c.subcategory_ids) && c.subcategory_ids.length > 0) {
const subData = await fetchLinuxDoJson(page, `/categories.json?parent_category_id=${c.id}`, { skipNavigate: true });
const subCats = (subData?.category_list?.categories || []) as any[];
for (const sc of subCats) {
if (results.length >= limit) break;
results.push({
name: c.name + ' / ' + sc.name,
slug: sc.slug,
@@ -46,21 +41,9 @@ cli({
topics: sc.topic_count,
description: (sc.description_text || '').slice(0, 80),
});
if (results.length >= limit) break;
}
}
}
if (results.length >= limit) break;
}
return results;
})()
` },
{ map: {
name: '${{ item.name }}',
slug: '${{ item.slug }}',
id: '${{ item.id }}',
topics: '${{ item.topics }}',
description: '${{ item.description }}',
} },
],
return results;
},
});
+1 -1
View File
@@ -140,7 +140,7 @@ async function ensureLinuxDoHome(page: IPage | null): Promise<void> {
await page.wait(2);
}
async function fetchLinuxDoJson(page: IPage | null, apiPath: string, options: FetchJsonOptions = {}): Promise<any> {
export async function fetchLinuxDoJson(page: IPage | null, apiPath: string, options: FetchJsonOptions = {}): Promise<any> {
if (!options.skipNavigate) {
await ensureLinuxDoHome(page);
}
+15 -28
View File
@@ -1,42 +1,29 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { fetchLinuxDoJson } from './feed.js';
cli({
site: 'linux-do',
name: 'search',
description: '搜索 linux.do',
domain: 'linux.do',
strategy: Strategy.COOKIE,
browser: true,
args: [
{ name: 'query', required: true, positional: true, help: 'Search query' },
{ name: 'limit', type: 'int', default: 20, help: 'Number of results' },
],
columns: ['rank', 'title', 'views', 'likes', 'replies', 'url'],
pipeline: [
{ navigate: 'https://linux.do' },
{ evaluate: `(async () => {
const keyword = \${{ args.query | json }};
const res = await fetch('/search.json?q=' + encodeURIComponent(keyword), { credentials: 'include' });
if (!res.ok) throw new Error('HTTP ' + res.status + ' - 请先登录 linux.do');
let data;
try { data = await res.json(); } catch { throw new Error('响应不是有效 JSON - 请先登录 linux.do'); }
const topics = data?.topics || [];
return topics.slice(0, \${{ args.limit }}).map(t => ({
title: t.title,
views: t.views,
likes: t.like_count,
replies: (t.posts_count || 1) - 1,
url: 'https://linux.do/t/topic/' + t.id,
}));
})()
` },
{ map: {
rank: '${{ index + 1 }}',
title: '${{ item.title }}',
views: '${{ item.views }}',
likes: '${{ item.likes }}',
replies: '${{ item.replies }}',
url: '${{ item.url }}',
} },
{ limit: '${{ args.limit }}' },
],
func: async (page, kwargs) => {
const query = encodeURIComponent(String(kwargs.query));
const data = await fetchLinuxDoJson(page, `/search.json?q=${query}`);
const topics = (data?.topics || []) as any[];
return topics.slice(0, kwargs.limit as number).map((t: any, i: number) => ({
rank: i + 1,
title: t.title,
views: t.views,
likes: t.like_count,
replies: (t.posts_count || 1) - 1,
url: 'https://linux.do/t/topic/' + t.id,
}));
},
});
+14 -26
View File
@@ -1,4 +1,5 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { fetchLinuxDoJson } from './feed.js';
cli({
site: 'linux-do',
@@ -11,30 +12,17 @@ cli({
{ name: 'limit', type: 'int', default: 30, help: 'Number of tags' },
],
columns: ['rank', 'name', 'count', 'url'],
pipeline: [
{ navigate: 'https://linux.do' },
{ evaluate: `(async () => {
const res = await fetch('/tags.json', { credentials: 'include' });
if (!res.ok) throw new Error('HTTP ' + res.status + ' - 请先登录 linux.do');
let data;
try { data = await res.json(); } catch { throw new Error('响应不是有效 JSON - 请先登录 linux.do'); }
let tags = data?.tags || [];
tags.sort((a, b) => (b.count || 0) - (a.count || 0));
return tags.slice(0, \${{ args.limit }}).map(t => ({
id: t.id,
name: t.name || t.id,
slug: t.slug,
count: t.count || 0,
}));
})()
` },
{ map: {
rank: '${{ index + 1 }}',
name: '${{ item.name }}',
count: '${{ item.count }}',
slug: '${{ item.slug }}',
id: '${{ item.id }}',
url: 'https://linux.do/tag/${{ item.slug }}',
} },
],
func: async (page, kwargs) => {
const data = await fetchLinuxDoJson(page, '/tags.json');
const tags = (data?.tags || []) as any[];
tags.sort((a: any, b: any) => (b.count || 0) - (a.count || 0));
return tags.slice(0, kwargs.limit as number).map((t: any, i: number) => ({
rank: i + 1,
name: t.name || t.id,
count: t.count || 0,
slug: t.slug,
id: t.id,
url: 'https://linux.do/tag/' + t.slug,
}));
},
});
+34 -42
View File
@@ -1,4 +1,28 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { fetchLinuxDoJson } from './feed.js';
function toLocalTime(utcStr: string): string {
if (!utcStr) return '';
const date = new Date(utcStr);
return Number.isNaN(date.getTime()) ? utcStr : date.toLocaleString();
}
function strip(html: string): string {
return (html || '')
.replace(/<br\s*\/?>/gi, ' ')
.replace(/<\/(p|div|li|blockquote|h[1-6])>/gi, ' ')
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#(?:(\d+)|x([0-9a-fA-F]+));/g, (_, dec, hex) => {
try { return String.fromCodePoint(dec !== undefined ? Number(dec) : parseInt(hex, 16)); } catch { return ''; }
})
.replace(/\s+/g, ' ')
.trim();
}
cli({
site: 'linux-do',
@@ -12,46 +36,14 @@ cli({
{ name: 'limit', type: 'int', default: 20, help: 'Number of posts' },
],
columns: ['author', 'content', 'likes', 'created_at'],
pipeline: [
{ navigate: 'https://linux.do' },
{ evaluate: `(async () => {
const toLocalTime = (utcStr) => {
if (!utcStr) return '';
const date = new Date(utcStr);
return Number.isNaN(date.getTime()) ? utcStr : date.toLocaleString();
};
const res = await fetch('/t/\${{ args.id }}.json', { credentials: 'include' });
if (!res.ok) throw new Error('HTTP ' + res.status + ' - 请先登录 linux.do');
let data;
try { data = await res.json(); } catch { throw new Error('响应不是有效 JSON - 请先登录 linux.do'); }
const strip = (html) => (html || '')
.replace(/<br\\s*\\/?>/gi, ' ')
.replace(/<\\/(p|div|li|blockquote|h[1-6])>/gi, ' ')
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#(?:(\\d+)|x([0-9a-fA-F]+));/g, (_, dec, hex) => {
try { return String.fromCodePoint(dec !== undefined ? Number(dec) : parseInt(hex, 16)); } catch { return ''; }
})
.replace(/\\s+/g, ' ')
.trim();
const posts = data?.post_stream?.posts || [];
return posts.slice(0, \${{ args.limit }}).map(p => ({
author: p.username,
content: strip(p.cooked).slice(0, 200),
likes: p.like_count,
created_at: toLocalTime(p.created_at),
}));
})()
` },
{ map: {
author: '${{ item.author }}',
content: '${{ item.content }}',
likes: '${{ item.likes }}',
created_at: '${{ item.created_at }}',
} },
],
func: async (page, kwargs) => {
const data = await fetchLinuxDoJson(page, `/t/${kwargs.id}.json`);
const posts = (data?.post_stream?.posts || []) as any[];
return posts.slice(0, kwargs.limit as number).map((p: any) => ({
author: p.username,
content: strip(p.cooked).slice(0, 200),
likes: p.like_count,
created_at: toLocalTime(p.created_at),
}));
},
});
+41 -47
View File
@@ -1,4 +1,28 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { fetchLinuxDoJson } from './feed.js';
function toLocalTime(utcStr: string): string {
if (!utcStr) return '';
const date = new Date(utcStr);
return Number.isNaN(date.getTime()) ? utcStr : date.toLocaleString();
}
function strip(html: string): string {
return (html || '')
.replace(/<br\s*\/?>/gi, ' ')
.replace(/<\/(p|div|li|blockquote|h[1-6])>/gi, ' ')
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#(?:(\d+)|x([0-9a-fA-F]+));/g, (_, dec, hex) => {
try { return String.fromCodePoint(dec !== undefined ? Number(dec) : parseInt(hex, 16)); } catch { return ''; }
})
.replace(/\s+/g, ' ')
.trim();
}
cli({
site: 'linux-do',
@@ -12,51 +36,21 @@ cli({
{ name: 'limit', type: 'int', default: 20, help: 'Number of posts' },
],
columns: ['index', 'topic_user', 'topic', 'reply', 'time', 'url'],
pipeline: [
{ navigate: 'https://linux.do' },
{ evaluate: `(async () => {
const username = \${{ args.username | json }};
const toLocalTime = (utcStr) => {
if (!utcStr) return '';
const date = new Date(utcStr);
return Number.isNaN(date.getTime()) ? utcStr : date.toLocaleString();
};
const strip = (html) => (html || '')
.replace(/<br\\s*\\/?>/gi, ' ')
.replace(/<\\/(p|div|li|blockquote|h[1-6])>/gi, ' ')
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#(?:(\\d+)|x([0-9a-fA-F]+));/g, (_, dec, hex) => {
try { return String.fromCodePoint(dec !== undefined ? Number(dec) : parseInt(hex, 16)); } catch { return ''; }
})
.replace(/\\s+/g, ' ')
.trim();
const limit = \${{ args.limit | default(20) }};
const res = await fetch('/user_actions.json?username=' + encodeURIComponent(username) + '&filter=5&offset=0&limit=' + limit, { credentials: 'include' });
if (!res.ok) throw new Error('HTTP ' + res.status + ' - 请先登录 linux.do');
let data;
try { data = await res.json(); } catch { throw new Error('响应不是有效 JSON - 请先登录 linux.do'); }
const actions = data?.user_actions || [];
return actions.slice(0, limit).map(a => ({
author: a.acting_username || a.username || '',
title: a.title || '',
content: strip(a.excerpt).slice(0, 200),
created_at: toLocalTime(a.created_at),
url: 'https://linux.do/t/topic/' + a.topic_id + '/' + a.post_number,
}));
})()
` },
{ map: {
index: '${{ index + 1 }}',
topic_user: '${{ item.author }}',
topic: '${{ item.title }}',
reply: '${{ item.content }}',
time: '${{ item.created_at }}',
url: '${{ item.url }}',
} },
],
func: async (page, kwargs) => {
const username = String(kwargs.username);
const limit = kwargs.limit as number;
const data = await fetchLinuxDoJson(
page,
`/user_actions.json?username=${encodeURIComponent(username)}&filter=5&offset=0&limit=${limit}`,
);
const actions = (data?.user_actions || []) as any[];
return actions.slice(0, limit).map((a: any, i: number) => ({
index: i + 1,
topic_user: a.acting_username || a.username || '',
topic: a.title || '',
reply: strip(a.excerpt).slice(0, 200),
time: toLocalTime(a.created_at),
url: 'https://linux.do/t/topic/' + a.topic_id + '/' + a.post_number,
}));
},
});
+22 -34
View File
@@ -1,4 +1,11 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { fetchLinuxDoJson } from './feed.js';
function toLocalTime(utcStr: string): string {
if (!utcStr) return '';
const date = new Date(utcStr);
return Number.isNaN(date.getTime()) ? utcStr : date.toLocaleString();
}
cli({
site: 'linux-do',
@@ -12,38 +19,19 @@ cli({
{ name: 'limit', type: 'int', default: 20, help: 'Number of topics' },
],
columns: ['rank', 'title', 'replies', 'created_at', 'likes', 'views', 'url'],
pipeline: [
{ navigate: 'https://linux.do' },
{ evaluate: `(async () => {
const username = \${{ args.username | json }};
const toLocalTime = (utcStr) => {
if (!utcStr) return '';
const date = new Date(utcStr);
return Number.isNaN(date.getTime()) ? utcStr : date.toLocaleString();
};
const res = await fetch('/topics/created-by/' + encodeURIComponent(username) + '.json', { credentials: 'include' });
if (!res.ok) throw new Error('HTTP ' + res.status + ' - 请先登录 linux.do');
let data;
try { data = await res.json(); } catch { throw new Error('响应不是有效 JSON - 请先登录 linux.do'); }
const topics = data?.topic_list?.topics || [];
return topics.slice(0, \${{ args.limit }}).map(t => ({
title: t.fancy_title || t.title || '',
replies: t.posts_count || 0,
created_at: toLocalTime(t.created_at),
likes: t.like_count || 0,
views: t.views || 0,
url: 'https://linux.do/t/topic/' + t.id,
}));
})()
` },
{ map: {
rank: '${{ index + 1 }}',
title: '${{ item.title }}',
replies: '${{ item.replies }}',
created_at: '${{ item.created_at }}',
likes: '${{ item.likes }}',
views: '${{ item.views }}',
url: '${{ item.url }}',
} },
],
func: async (page, kwargs) => {
const username = String(kwargs.username);
const limit = kwargs.limit as number;
const data = await fetchLinuxDoJson(page, `/topics/created-by/${encodeURIComponent(username)}.json`);
const topics = (data?.topic_list?.topics || []) as any[];
return topics.slice(0, limit).map((t: any, i: number) => ({
rank: i + 1,
title: t.fancy_title || t.title || '',
replies: t.posts_count || 0,
created_at: toLocalTime(t.created_at),
likes: t.like_count || 0,
views: t.views || 0,
url: 'https://linux.do/t/topic/' + t.id,
}));
},
});
+25 -40
View File
@@ -1,4 +1,5 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { cli } from '@jackwener/opencli/registry';
import { fetchXueqiuJson } from './utils.js';
cli({
site: 'xueqiu',
@@ -17,46 +18,30 @@ cli({
{ name: 'limit', type: 'int', default: 10, help: '返回数量,默认 10' },
],
columns: ['date', 'report', 'status'],
pipeline: [
{ navigate: 'https://xueqiu.com' },
{ evaluate: `(async () => {
const symbol = (\${{ args.symbol | json }} || '').toUpperCase();
const onlyNext = \${{ args.next }};
if (!symbol) throw new Error('Missing argument: symbol');
const resp = await fetch(
\`https://stock.xueqiu.com/v5/stock/screener/event/list.json?symbol=\${encodeURIComponent(symbol)}&page=1&size=100\`,
{ credentials: 'include' }
);
if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?');
const d = await resp.json();
if (!d.data || !d.data.items) throw new Error('获取失败: ' + JSON.stringify(d));
func: async (page, kwargs) => {
await page.goto('https://xueqiu.com');
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: '请确认股票代码是否正确' }];
// subtype 2 = 预计财报发布
let items = d.data.items.filter(item => item.subtype === 2);
// subtype 2 = 预计财报发布
const now = Date.now();
let results = (d.data.items as any[])
.filter((item: any) => item.subtype === 2)
.map((item: any) => {
const ts = item.timestamp;
const dateStr = ts ? new Date(ts).toISOString().split('T')[0] : null;
const isFuture = ts && ts > now;
return { date: dateStr, report: item.message, status: isFuture ? '⏳ 未发布' : '✅ 已发布', _ts: ts, _future: isFuture };
});
const now = Date.now();
let results = items.map(item => {
const ts = item.timestamp;
const dateStr = ts ? new Date(ts).toISOString().split('T')[0] : null;
const isFuture = ts && ts > now;
return {
date: dateStr,
report: item.message,
status: isFuture ? '⏳ 未发布' : '✅ 已发布',
_ts: ts,
_future: isFuture
};
});
if (kwargs.next) {
const future = results.filter((r: any) => r._future).sort((a: any, b: any) => a._ts - b._ts);
results = future.length ? [future[0]] : [];
}
if (onlyNext) {
const future = results.filter(r => r._future).sort((a, b) => a._ts - b._ts);
results = future.length ? [future[0]] : [];
}
return results;
})()
` },
{ map: { date: '${{ item.date }}', report: '${{ item.report }}', status: '${{ item.status }}' } },
{ limit: '${{ args.limit }}' },
],
return results.slice(0, kwargs.limit as number).map(({ date, report, status }: any) => ({ date, report, status }));
},
});
+28 -36
View File
@@ -1,4 +1,15 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { cli } from '@jackwener/opencli/registry';
import { fetchXueqiuJson } from './utils.js';
function strip(html: string): string {
return (html || '')
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.trim();
}
cli({
site: 'xueqiu',
@@ -11,39 +22,20 @@ cli({
{ name: 'limit', type: 'int', default: 20, help: '每页数量,默认 20' },
],
columns: ['author', 'text', 'likes', 'replies', 'url'],
pipeline: [
{ navigate: 'https://xueqiu.com' },
{ evaluate: `(async () => {
const page = \${{ args.page }};
const count = \${{ args.limit }};
const resp = await fetch(\`https://xueqiu.com/v4/statuses/home_timeline.json?page=\${page}&count=\${count}\`, {credentials: 'include'});
if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?');
const d = await resp.json();
const strip = (html) => (html || '').replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').trim();
const list = d.home_timeline || d.list || [];
return list.map(item => {
const user = item.user || {};
return {
id: item.id,
text: strip(item.description).substring(0, 200),
url: 'https://xueqiu.com/' + user.id + '/' + item.id,
author: user.screen_name,
likes: item.fav_count,
retweets: item.retweet_count,
replies: item.reply_count,
created_at: item.created_at ? new Date(item.created_at).toISOString() : null
};
});
})()
` },
{ map: {
author: '${{ item.author }}',
text: '${{ item.text }}',
likes: '${{ item.likes }}',
replies: '${{ item.replies }}',
url: '${{ item.url }}',
} },
{ limit: '${{ args.limit }}' },
],
func: async (page, kwargs) => {
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 {
author: user.screen_name,
text: strip(item.description).substring(0, 200),
likes: item.fav_count,
replies: item.reply_count,
url: 'https://xueqiu.com/' + user.id + '/' + item.id,
};
});
},
});
+13 -17
View File
@@ -1,4 +1,5 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { cli } from '@jackwener/opencli/registry';
import { fetchXueqiuJson } from './utils.js';
cli({
site: 'xueqiu',
@@ -7,20 +8,15 @@ cli({
domain: 'xueqiu.com',
browser: true,
columns: ['pid', 'name', 'count'],
pipeline: [
{ navigate: 'https://xueqiu.com' },
{ evaluate: `(async () => {
const resp = await fetch('https://stock.xueqiu.com/v5/stock/portfolio/list.json?category=1&size=20', {credentials: 'include'});
if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?');
const d = await resp.json();
if (!d.data || !d.data.stocks) throw new Error('获取失败,可能未登录');
return d.data.stocks.map(g => ({
pid: String(g.id),
name: g.name,
count: g.symbol_count || 0
}));
})()
` },
],
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' }];
return ((d.data.stocks || []) as any[]).map((g: any) => ({
pid: String(g.id),
name: g.name,
count: g.symbol_count || 0,
}));
},
});
+18 -32
View File
@@ -1,4 +1,5 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { cli } from '@jackwener/opencli/registry';
import { fetchXueqiuJson } from './utils.js';
cli({
site: 'xueqiu',
@@ -11,35 +12,20 @@ cli({
{ name: 'type', default: '10', help: '榜单类型 10=人气榜(默认) 12=关注榜' },
],
columns: ['rank', 'symbol', 'name', 'price', 'changePercent', 'heat'],
pipeline: [
{ navigate: 'https://xueqiu.com' },
{ evaluate: `(async () => {
const count = \${{ args.limit }};
const type = \${{ args.type | json }};
const resp = await fetch(\`https://stock.xueqiu.com/v5/stock/hot_stock/list.json?size=\${count}&type=\${type}\`, {credentials: 'include'});
if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?');
const d = await resp.json();
if (!d.data || !d.data.items) throw new Error('获取失败');
return d.data.items.map((s, i) => ({
rank: i + 1,
symbol: s.symbol,
name: s.name,
price: s.current,
changePercent: s.percent != null ? s.percent.toFixed(2) + '%' : null,
heat: s.value,
rank_change: s.rank_change,
url: 'https://xueqiu.com/S/' + s.symbol
}));
})()
` },
{ map: {
rank: '${{ item.rank }}',
symbol: '${{ item.symbol }}',
name: '${{ item.name }}',
price: '${{ item.price }}',
changePercent: '${{ item.changePercent }}',
heat: '${{ item.heat }}',
} },
{ limit: '${{ args.limit }}' },
],
func: async (page, kwargs) => {
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' }];
return ((d.data.items || []) as any[]).map((s: any, i: number) => ({
rank: i + 1,
symbol: s.symbol,
name: s.name,
price: s.current,
changePercent: s.percent != null ? s.percent.toFixed(2) + '%' : null,
heat: s.value,
url: 'https://xueqiu.com/S/' + s.symbol,
}));
},
});
+27 -33
View File
@@ -1,4 +1,15 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { cli } from '@jackwener/opencli/registry';
import { fetchXueqiuJson } from './utils.js';
function strip(html: string): string {
return (html || '')
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.trim();
}
cli({
site: 'xueqiu',
@@ -10,36 +21,19 @@ cli({
{ name: 'limit', type: 'int', default: 20, help: '返回数量,默认 20,最大 50' },
],
columns: ['rank', 'author', 'text', 'likes', 'url'],
pipeline: [
{ navigate: 'https://xueqiu.com' },
{ evaluate: `(async () => {
const resp = await fetch('https://xueqiu.com/statuses/hot/listV3.json?source=hot&page=1', {credentials: 'include'});
if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?');
const d = await resp.json();
const list = d.list || [];
const strip = (html) => (html || '').replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').trim();
return list.map((item, i) => {
const user = item.user || {};
return {
rank: i + 1,
text: strip(item.description).substring(0, 200),
url: 'https://xueqiu.com/' + user.id + '/' + item.id,
author: user.screen_name,
likes: item.fav_count,
retweets: item.retweet_count,
replies: item.reply_count
};
});
})()
` },
{ map: {
rank: '${{ item.rank }}',
author: '${{ item.author }}',
text: '${{ item.text }}',
likes: '${{ item.likes }}',
url: '${{ item.url }}',
} },
{ limit: '${{ args.limit }}' },
],
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 {
rank: i + 1,
author: user.screen_name,
text: strip(item.description).substring(0, 200),
likes: item.fav_count,
url: 'https://xueqiu.com/' + user.id + '/' + item.id,
};
});
},
});
+25 -45
View File
@@ -1,4 +1,5 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { cli } from '@jackwener/opencli/registry';
import { fetchXueqiuJson } from './utils.js';
cli({
site: 'xueqiu',
@@ -16,50 +17,29 @@ cli({
{ name: 'days', type: 'int', default: 14, help: '回溯天数(默认14天)' },
],
columns: ['date', 'open', 'high', 'low', 'close', 'volume'],
pipeline: [
{ navigate: 'https://xueqiu.com' },
{ evaluate: `(async () => {
const symbol = (\${{ args.symbol | json }} || '').toUpperCase();
const days = parseInt(\${{ args.days | json }}) || 14;
if (!symbol) throw new Error('Missing argument: symbol');
func: async (page, kwargs) => {
await page.goto('https://xueqiu.com');
const symbol = String(kwargs.symbol).toUpperCase();
const days = kwargs.days as number;
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 [];
// begin = now minus days (for count=-N, returns N items ending at begin)
const beginTs = Date.now();
const resp = await fetch('https://stock.xueqiu.com/v5/stock/chart/kline.json?symbol=' + encodeURIComponent(symbol) + '&begin=' + beginTs + '&period=day&type=before&count=-' + days, {credentials: 'include'});
if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?');
const d = await resp.json();
const columns: string[] = d.data.column || [];
const colIdx: Record<string, number> = {};
columns.forEach((name: string, i: number) => { colIdx[name] = i; });
if (!d.data || !d.data.item || d.data.item.length === 0) return [];
const columns = d.data.column || [];
const items = d.data.item || [];
const colIdx = {};
columns.forEach((name, i) => { colIdx[name] = i; });
function fmt(v) { return v == null ? null : v; }
return items.map(row => ({
date: colIdx.timestamp != null ? new Date(row[colIdx.timestamp]).toISOString().split('T')[0] : null,
open: fmt(row[colIdx.open]),
high: fmt(row[colIdx.high]),
low: fmt(row[colIdx.low]),
close: fmt(row[colIdx.close]),
volume: fmt(row[colIdx.volume]),
amount: fmt(row[colIdx.amount]),
chg: fmt(row[colIdx.chg]),
percent: fmt(row[colIdx.percent]),
symbol: symbol
}));
})()
` },
{ map: {
date: '${{ item.date }}',
open: '${{ item.open }}',
high: '${{ item.high }}',
low: '${{ item.low }}',
close: '${{ item.close }}',
volume: '${{ item.volume }}',
percent: '${{ item.percent }}',
} },
],
return (d.data.item as any[][]).map(row => ({
date: colIdx.timestamp != null ? new Date(row[colIdx.timestamp]).toISOString().split('T')[0] : null,
open: row[colIdx.open] ?? null,
high: row[colIdx.high] ?? null,
low: row[colIdx.low] ?? null,
close: row[colIdx.close] ?? null,
volume: row[colIdx.volume] ?? null,
percent: row[colIdx.percent] ?? null,
symbol,
}));
},
});
+24 -37
View File
@@ -1,4 +1,5 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { cli } from '@jackwener/opencli/registry';
import { fetchXueqiuJson } from './utils.js';
cli({
site: 'xueqiu',
@@ -11,40 +12,26 @@ cli({
{ name: 'limit', type: 'int', default: 10, help: '返回数量,默认 10' },
],
columns: ['symbol', 'name', 'exchange', 'price', 'changePercent', 'url'],
pipeline: [
{ navigate: 'https://xueqiu.com' },
{ evaluate: `(async () => {
const query = \${{ args.query | json }};
const count = \${{ args.limit }};
const resp = await fetch(\`https://xueqiu.com/stock/search.json?code=\${encodeURIComponent(query)}&size=\${count}\`, {credentials: 'include'});
if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?');
const d = await resp.json();
return (d.stocks || []).map(s => {
let symbol = '';
if (s.exchange === 'SH' || s.exchange === 'SZ' || s.exchange === 'BJ') {
symbol = s.code.startsWith(s.exchange) ? s.code : s.exchange + s.code;
} else {
symbol = s.code;
}
return {
symbol: symbol,
name: s.name,
exchange: s.exchange,
price: s.current,
changePercent: s.percentage != null ? s.percentage.toFixed(2) + '%' : null,
url: 'https://xueqiu.com/S/' + symbol
};
});
})()
` },
{ map: {
symbol: '${{ item.symbol }}',
name: '${{ item.name }}',
exchange: '${{ item.exchange }}',
price: '${{ item.price }}',
changePercent: '${{ item.changePercent }}',
url: '${{ item.url }}',
} },
{ limit: '${{ args.limit }}' },
],
func: async (page, kwargs) => {
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') {
symbol = s.code.startsWith(s.exchange) ? s.code : s.exchange + s.code;
} else {
symbol = s.code;
}
return {
symbol,
name: s.name,
exchange: s.exchange,
price: s.current,
changePercent: s.percentage != null ? s.percentage.toFixed(2) + '%' : null,
url: 'https://xueqiu.com/S/' + symbol,
};
});
},
});
+44 -56
View File
@@ -1,4 +1,13 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { cli } from '@jackwener/opencli/registry';
import { fetchXueqiuJson } from './utils.js';
function fmtAmount(v: number | null | undefined): string | null {
if (v == null) return null;
if (Math.abs(v) >= 1e12) return (v / 1e12).toFixed(2) + '万亿';
if (Math.abs(v) >= 1e8) return (v / 1e8).toFixed(2) + '亿';
if (Math.abs(v) >= 1e4) return (v / 1e4).toFixed(2) + '万';
return String(v);
}
cli({
site: 'xueqiu',
@@ -15,59 +24,38 @@ cli({
},
],
columns: ['name', 'symbol', 'price', 'changePercent', 'marketCap'],
pipeline: [
{ navigate: 'https://xueqiu.com' },
{ evaluate: `(async () => {
const symbol = (\${{ args.symbol | json }} || '').toUpperCase();
if (!symbol) throw new Error('Missing argument: symbol');
const resp = await fetch(\`https://stock.xueqiu.com/v5/stock/batch/quote.json?symbol=\${encodeURIComponent(symbol)}\`, {credentials: 'include'});
if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?');
const d = await resp.json();
if (!d.data || !d.data.items || d.data.items.length === 0) throw new Error('未找到股票: ' + symbol);
function fmtAmount(v) {
if (v == null) return null;
if (Math.abs(v) >= 1e12) return (v / 1e12).toFixed(2) + '万亿';
if (Math.abs(v) >= 1e8) return (v / 1e8).toFixed(2) + '亿';
if (Math.abs(v) >= 1e4) return (v / 1e4).toFixed(2) + '万';
return v.toString();
}
const item = d.data.items[0];
const q = item.quote || {};
const m = item.market || {};
return [{
name: q.name,
symbol: q.symbol,
exchange: q.exchange,
currency: q.currency,
price: q.current,
change: q.chg,
changePercent: q.percent != null ? q.percent.toFixed(2) + '%' : null,
open: q.open,
high: q.high,
low: q.low,
prevClose: q.last_close,
amplitude: q.amplitude != null ? q.amplitude.toFixed(2) + '%' : null,
volume: q.volume,
amount: fmtAmount(q.amount),
turnover_rate: q.turnover_rate != null ? q.turnover_rate.toFixed(2) + '%' : null,
marketCap: fmtAmount(q.market_capital),
floatMarketCap: fmtAmount(q.float_market_capital),
ytdPercent: q.current_year_percent != null ? q.current_year_percent.toFixed(2) + '%' : null,
market_status: m.status || null,
time: q.timestamp ? new Date(q.timestamp).toISOString() : null,
url: 'https://xueqiu.com/S/' + q.symbol
}];
})()
` },
{ map: {
name: '${{ item.name }}',
symbol: '${{ item.symbol }}',
price: '${{ item.price }}',
changePercent: '${{ item.changePercent }}',
marketCap: '${{ item.marketCap }}',
} },
],
func: async (page, kwargs) => {
await page.goto('https://xueqiu.com');
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' }];
const item = d.data.items[0];
const q = item.quote || {};
const m = item.market || {};
return [{
name: q.name,
symbol: q.symbol,
exchange: q.exchange,
currency: q.currency,
price: q.current,
change: q.chg,
changePercent: q.percent != null ? q.percent.toFixed(2) + '%' : null,
open: q.open,
high: q.high,
low: q.low,
prevClose: q.last_close,
amplitude: q.amplitude != null ? q.amplitude.toFixed(2) + '%' : null,
volume: q.volume,
amount: fmtAmount(q.amount),
turnover_rate: q.turnover_rate != null ? q.turnover_rate.toFixed(2) + '%' : null,
marketCap: fmtAmount(q.market_capital),
floatMarketCap: fmtAmount(q.float_market_capital),
ytdPercent: q.current_year_percent != null ? q.current_year_percent.toFixed(2) + '%' : null,
market_status: m.status || null,
time: q.timestamp ? new Date(q.timestamp).toISOString() : null,
url: 'https://xueqiu.com/S/' + q.symbol,
}];
},
});
+33
View File
@@ -0,0 +1,33 @@
import type { IPage } from '@jackwener/opencli/types';
export interface XueqiuError { error: string; help: string; }
/**
* 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.
*/
export async function fetchXueqiuJson(page: IPage, url: string): Promise<any | XueqiuError> {
const result = await page.evaluate(`(async () => {
const res = await fetch(${JSON.stringify(url)}, { credentials: 'include' });
if (!res.ok) return { __xqErr: res.status };
try {
return await res.json();
} catch {
return { __xqErr: 'parse' };
}
})()`);
const r = result as any;
if (r?.__xqErr !== undefined) {
const code = r.__xqErr;
if (code === 401 || code === 403) {
return { error: '未登录或登录已过期', help: '在浏览器中打开 https://xueqiu.com 并登录,然后重试' };
}
if (code === 'parse') {
return { error: '响应不是有效 JSON', help: '可能触发了风控,请检查登录状态或稍后重试' };
}
return { error: `HTTP ${code}`, help: '请检查网络连接或登录状态' };
}
return result;
}
+19 -29
View File
@@ -1,4 +1,5 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { cli } from '@jackwener/opencli/registry';
import { fetchXueqiuJson } from './utils.js';
cli({
site: 'xueqiu',
@@ -15,32 +16,21 @@ cli({
{ name: 'limit', type: 'int', default: 100, help: '默认 100' },
],
columns: ['symbol', 'name', 'price', 'changePercent'],
pipeline: [
{ navigate: 'https://xueqiu.com' },
{ evaluate: `(async () => {
const pid = \${{ args.pid | json }} || '-1';
const resp = await fetch(\`https://stock.xueqiu.com/v5/stock/portfolio/stock/list.json?size=100&category=1&pid=\${encodeURIComponent(pid)}\`, {credentials: 'include'});
if (!resp.ok) throw new Error('HTTP ' + resp.status + ' Hint: Not logged in?');
const d = await resp.json();
if (!d.data || !d.data.stocks) throw new Error('获取失败,可能未登录');
return d.data.stocks.map(s => ({
symbol: s.symbol,
name: s.name,
price: s.current,
change: s.chg,
changePercent: s.percent != null ? s.percent.toFixed(2) + '%' : null,
volume: s.volume,
url: 'https://xueqiu.com/S/' + s.symbol
}));
})()
` },
{ map: {
symbol: '${{ item.symbol }}',
name: '${{ item.name }}',
price: '${{ item.price }}',
changePercent: '${{ item.changePercent }}',
} },
{ limit: '${{ args.limit }}' },
],
func: async (page, kwargs) => {
await page.goto('https://xueqiu.com');
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' }];
return ((d.data.stocks || []) as any[]).slice(0, kwargs.limit as number).map((s: any) => ({
symbol: s.symbol,
name: s.name,
price: s.current,
change: s.chg,
changePercent: s.percent != null ? s.percent.toFixed(2) + '%' : null,
volume: s.volume,
url: 'https://xueqiu.com/S/' + s.symbol,
}));
},
});
+2 -2
View File
@@ -167,7 +167,7 @@ cat clis/<site>/feed.ts # 读最相似的那个
所有适配器统一使用 TypeScript `cli()` API,放入 `clis/<site>/<name>.ts` 即自动注册。
完整模板(Tier 1~4)、分页模式、错误处理规范(`{ error, remedy }` 格式)→ **[adapter-templates.md](references/adapter-templates.md)**
完整模板(Tier 1~4)、分页模式、错误处理规范(`{ error, help }` 格式)→ **[adapter-templates.md](references/adapter-templates.md)**
**最简结构**Tier 2 Cookie):
@@ -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, remedy }` 提示重新登录 |
| **风控被拦截(伪 200** | JSON 里核心数据是空串 | 必须断言!返回 `{ error, help }` 提示重新登录 |
| **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>/` |
@@ -119,7 +119,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', remedy: 'Open https://app.slock.ai and log in, then retry' };
if (!token) return { error: 'Not logged in', help: 'Open https://app.slock.ai and log in, then retry' };
// 多租户 SaaS:先拿工作空间列表
const slug = ${JSON.stringify(kwargs.server || null)} || localStorage.getItem('slock_last_server_slug');
@@ -172,7 +172,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', remedy: 'Open https://x.com and log in, then retry' };
if (!ct0) return { error: 'Not logged in', help: 'Open https://x.com and log in, then retry' };
const bearer = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
const res = await fetch('/i/api/graphql/QUERY_ID/ListsManagePinTimeline', {
headers: {
@@ -349,7 +349,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', remedy: 'Open https://app.mysite.com and log in, then retry' };
if (!token) throw { error: 'Not logged in', help: 'Open https://app.mysite.com and log in, then retry' };
const servers = await fetch('https://api.mysite.com/api/servers', {
headers: { 'Authorization': 'Bearer ' + token }
}).then(r => r.json());
@@ -376,16 +376,18 @@ func: async (page, kwargs) => {
---
## 错误处理规范
### 返回 `{ error, remedy }` 而非 throw
### 返回 `{ error, help }` 而非 throw
```typescript
// ❌ 不推荐:throw 导致 CLI 打印 stack trace,用户不知道怎么修复
if (!token) throw new Error('Not logged in');
// ✅ 推荐:返回结构化错误,remedy 告诉 AI Agent 或用户下一步怎么做
if (!token) return [{ error: 'Not logged in', remedy: 'Open https://site.com and log in, then retry' }];
// ✅ 推荐:返回结构化错误,help 告诉 AI Agent 或用户下一步怎么做
if (!token) return [{ error: 'Not logged in', help: 'Open https://site.com and log in, then retry' }];
```
**字段约定**
@@ -393,27 +395,27 @@ if (!token) return [{ error: 'Not logged in', remedy: 'Open https://site.com and
| 字段 | 类型 | 说明 |
|------|------|------|
| `error` | `string` | 问题描述,事实性,不带感叹号 |
| `remedy` | `string` | 具体的修复动作,可直接执行 |
| `help` | `string` | 具体的修复动作,可直接执行 |
**常用 remedy 模板**
**常用 help 模板**
```typescript
// 未登录
{ error: 'Not logged in', remedy: 'Open https://site.com in the browser and log in, then retry' }
{ error: 'Not logged in', help: 'Open https://site.com in the browser and log in, then retry' }
// 找不到资源
{ error: `Channel not found: ${kwargs.channel}`, remedy: 'Run `opencli site channels` to see available channels' }
{ error: `Channel not found: ${kwargs.channel}`, help: 'Run `opencli site channels` to see available channels' }
// 权限不足
{ error: 'Forbidden (403)', remedy: 'Check that your account has access to this resource' }
{ error: 'Forbidden (403)', help: 'Check that your account has access to this resource' }
// API 结构变更
{ error: 'Unexpected response structure', remedy: 'Run `opencli browser network --detail N` to inspect the current API response' }
{ error: 'Unexpected response structure', help: 'Run `opencli browser network --detail N` to inspect the current API response' }
// 风控降级(伪 200
{ error: 'Core data is empty — possible risk-control block', remedy: 'Re-login to the site in the browser, then retry' }
{ error: 'Core data is empty — possible risk-control block', help: 'Re-login to the site in the browser, then retry' }
```
**何时 throw vs 返回 error 对象**
- 程序错误(参数类型错、配置缺失)→ `throw`,这是 bug
- 运行时用户可修复的情况(未登录、找不到资源、API 变更)→ 返回 `{ error, remedy }`
- 运行时用户可修复的情况(未登录、找不到资源、API 变更)→ 返回 `{ error, help }`
@@ -40,7 +40,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', remedy: 'Re-login to Bilibili, then retry' }];
if (!url) return [{ error: 'subtitle_url is empty — possible risk-control block', help: 'Re-login to Bilibili, then retry' }];
// Step 5: 拉取最终数据(CDN JSON
const items = await page.evaluate(`(async () => {
+5
View File
@@ -24,6 +24,11 @@ function normalizeRows(data: unknown): Record<string, unknown>[] {
}
function resolveColumns(rows: Record<string, unknown>[], opts: RenderOptions): string[] {
// When a command returns an error row ({ error, help }), override the declared
// columns so the error is visible in table/csv/markdown output.
if (opts.columns && rows.length > 0 && 'error' in rows[0] && !opts.columns.includes('error')) {
return Object.keys(rows[0]);
}
return opts.columns ?? Object.keys(rows[0] ?? {});
}