@jackwener/opencli 1.7.1 → 1.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/cli-manifest.json +386 -1
- package/clis/binance/asks.js +21 -0
- package/clis/binance/commands.test.js +70 -0
- package/clis/binance/depth.js +21 -0
- package/clis/binance/gainers.js +22 -0
- package/clis/binance/klines.js +21 -0
- package/clis/binance/losers.js +22 -0
- package/clis/binance/pairs.js +21 -0
- package/clis/binance/price.js +18 -0
- package/clis/binance/prices.js +19 -0
- package/clis/binance/ticker.js +21 -0
- package/clis/binance/top.js +21 -0
- package/clis/binance/trades.js +20 -0
- package/clis/twitter/lists-parser.js +77 -0
- package/clis/twitter/lists.d.ts +5 -0
- package/clis/twitter/lists.js +62 -0
- package/clis/twitter/lists.test.js +50 -0
- package/clis/weibo/feed.js +18 -5
- package/clis/zsxq/topic.js +5 -3
- package/clis/zsxq/topic.test.js +4 -3
- package/clis/zsxq/utils.js +1 -1
- package/dist/src/cli.js +108 -0
- package/dist/src/discovery.d.ts +5 -2
- package/dist/src/discovery.js +7 -35
- package/dist/src/engine.test.js +29 -1
- package/dist/src/main.js +6 -5
- package/package.json +3 -3
- package/scripts/fetch-adapters.js +59 -28
- package/dist/src/clis/binance/asks.d.ts +0 -1
- package/dist/src/clis/binance/asks.js +0 -20
- package/dist/src/clis/binance/commands.test.d.ts +0 -3
- package/dist/src/clis/binance/commands.test.js +0 -58
- package/dist/src/clis/binance/depth.d.ts +0 -1
- package/dist/src/clis/binance/depth.js +0 -20
- package/dist/src/clis/binance/gainers.d.ts +0 -1
- package/dist/src/clis/binance/gainers.js +0 -21
- package/dist/src/clis/binance/klines.d.ts +0 -1
- package/dist/src/clis/binance/klines.js +0 -20
- package/dist/src/clis/binance/losers.d.ts +0 -1
- package/dist/src/clis/binance/losers.js +0 -21
- package/dist/src/clis/binance/pairs.d.ts +0 -1
- package/dist/src/clis/binance/pairs.js +0 -20
- package/dist/src/clis/binance/price.d.ts +0 -1
- package/dist/src/clis/binance/price.js +0 -17
- package/dist/src/clis/binance/prices.d.ts +0 -1
- package/dist/src/clis/binance/prices.js +0 -18
- package/dist/src/clis/binance/ticker.d.ts +0 -1
- package/dist/src/clis/binance/ticker.js +0 -20
- package/dist/src/clis/binance/top.d.ts +0 -1
- package/dist/src/clis/binance/top.js +0 -20
- package/dist/src/clis/binance/trades.d.ts +0 -1
- package/dist/src/clis/binance/trades.js +0 -19
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
|
|
3
|
+
cli({
|
|
4
|
+
site: 'binance',
|
|
5
|
+
name: 'prices',
|
|
6
|
+
description: 'Latest prices for all trading pairs',
|
|
7
|
+
domain: 'data-api.binance.vision',
|
|
8
|
+
strategy: Strategy.PUBLIC,
|
|
9
|
+
browser: false,
|
|
10
|
+
args: [
|
|
11
|
+
{ name: 'limit', type: 'int', default: 20, help: 'Number of prices' },
|
|
12
|
+
],
|
|
13
|
+
columns: ['rank', 'symbol', 'price'],
|
|
14
|
+
pipeline: [
|
|
15
|
+
{ fetch: { url: 'https://data-api.binance.vision/api/v3/ticker/price' } },
|
|
16
|
+
{ map: { rank: '${{ index + 1 }}', symbol: '${{ item.symbol }}', price: '${{ item.price }}' } },
|
|
17
|
+
{ limit: '${{ args.limit }}' },
|
|
18
|
+
],
|
|
19
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
|
|
3
|
+
cli({
|
|
4
|
+
site: 'binance',
|
|
5
|
+
name: 'ticker',
|
|
6
|
+
description: '24h ticker statistics for top trading pairs by volume',
|
|
7
|
+
domain: 'data-api.binance.vision',
|
|
8
|
+
strategy: Strategy.PUBLIC,
|
|
9
|
+
browser: false,
|
|
10
|
+
args: [
|
|
11
|
+
{ name: 'limit', type: 'int', default: 20, help: 'Number of tickers' },
|
|
12
|
+
],
|
|
13
|
+
columns: ['symbol', 'price', 'change_pct', 'high', 'low', 'volume', 'quote_vol', 'trades'],
|
|
14
|
+
pipeline: [
|
|
15
|
+
{ fetch: { url: 'https://data-api.binance.vision/api/v3/ticker/24hr' } },
|
|
16
|
+
{ map: { symbol: '${{ item.symbol }}', price: '${{ item.lastPrice }}', change_pct: '${{ item.priceChangePercent }}', high: '${{ item.highPrice }}', low: '${{ item.lowPrice }}', volume: '${{ item.volume }}', quote_vol: '${{ item.quoteVolume }}', trades: '${{ item.count }}', sort_volume: '${{ Number(item.quoteVolume) }}' } },
|
|
17
|
+
{ sort: { by: 'sort_volume', order: 'desc' } },
|
|
18
|
+
{ map: { symbol: '${{ item.symbol }}', price: '${{ item.lastPrice }}', change_pct: '${{ item.priceChangePercent }}', high: '${{ item.highPrice }}', low: '${{ item.lowPrice }}', volume: '${{ item.volume }}', quote_vol: '${{ item.quoteVolume }}', trades: '${{ item.count }}' } },
|
|
19
|
+
{ limit: '${{ args.limit }}' },
|
|
20
|
+
],
|
|
21
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
|
|
3
|
+
cli({
|
|
4
|
+
site: 'binance',
|
|
5
|
+
name: 'top',
|
|
6
|
+
description: 'Top trading pairs by 24h volume on Binance',
|
|
7
|
+
domain: 'data-api.binance.vision',
|
|
8
|
+
strategy: Strategy.PUBLIC,
|
|
9
|
+
browser: false,
|
|
10
|
+
args: [
|
|
11
|
+
{ name: 'limit', type: 'int', default: 20, help: 'Number of trading pairs' },
|
|
12
|
+
],
|
|
13
|
+
columns: ['rank', 'symbol', 'price', 'change_24h', 'high', 'low', 'volume'],
|
|
14
|
+
pipeline: [
|
|
15
|
+
{ fetch: { url: 'https://data-api.binance.vision/api/v3/ticker/24hr' } },
|
|
16
|
+
{ map: { symbol: '${{ item.symbol }}', price: '${{ item.lastPrice }}', change_24h: '${{ item.priceChangePercent }}', high: '${{ item.highPrice }}', low: '${{ item.lowPrice }}', volume: '${{ item.quoteVolume }}', sort_volume: '${{ Number(item.quoteVolume) }}' } },
|
|
17
|
+
{ sort: { by: 'sort_volume', order: 'desc' } },
|
|
18
|
+
{ map: { rank: '${{ index + 1 }}', symbol: '${{ item.symbol }}', price: '${{ item.lastPrice }}', change_24h: '${{ item.priceChangePercent }}', high: '${{ item.highPrice }}', low: '${{ item.lowPrice }}', volume: '${{ item.quoteVolume }}' } },
|
|
19
|
+
{ limit: '${{ args.limit }}' },
|
|
20
|
+
],
|
|
21
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
|
|
3
|
+
cli({
|
|
4
|
+
site: 'binance',
|
|
5
|
+
name: 'trades',
|
|
6
|
+
description: 'Recent trades for a trading pair',
|
|
7
|
+
domain: 'data-api.binance.vision',
|
|
8
|
+
strategy: Strategy.PUBLIC,
|
|
9
|
+
browser: false,
|
|
10
|
+
args: [
|
|
11
|
+
{ name: 'symbol', type: 'str', required: true, positional: true, help: 'Trading pair symbol (e.g. BTCUSDT, ETHUSDT)' },
|
|
12
|
+
{ name: 'limit', type: 'int', default: 20, help: 'Number of trades (max 1000)' },
|
|
13
|
+
],
|
|
14
|
+
columns: ['id', 'price', 'qty', 'quote_qty', 'buyer_maker'],
|
|
15
|
+
pipeline: [
|
|
16
|
+
{ fetch: { url: 'https://data-api.binance.vision/api/v3/trades?symbol=${{ args.symbol }}&limit=${{ args.limit }}' } },
|
|
17
|
+
{ map: { id: '${{ item.id }}', price: '${{ item.price }}', qty: '${{ item.qty }}', quote_qty: '${{ item.quoteQty }}', buyer_maker: '${{ item.isBuyerMaker }}' } },
|
|
18
|
+
{ limit: '${{ args.limit }}' },
|
|
19
|
+
],
|
|
20
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
const MEMBER_PATTERNS = [
|
|
2
|
+
/([\d.,]+(?:\s?[KMB千萬万亿])?)\s*members?/i,
|
|
3
|
+
/([\d.,]+(?:\s?[KMB千萬万亿])?)\s*位成员/,
|
|
4
|
+
];
|
|
5
|
+
const FOLLOWER_PATTERNS = [
|
|
6
|
+
/([\d.,]+(?:\s?[KMB千萬万亿])?)\s*followers?/i,
|
|
7
|
+
/([\d.,]+(?:\s?[KMB千萬万亿])?)\s*位关注者/,
|
|
8
|
+
];
|
|
9
|
+
const PRIVATE_PATTERNS = [/\bprivate\b/i, /锁定列表/];
|
|
10
|
+
const EMPTY_STATE_PATTERNS = [
|
|
11
|
+
/hasn't created any lists/i,
|
|
12
|
+
/has not created any lists/i,
|
|
13
|
+
/no lists yet/i,
|
|
14
|
+
/没有创建任何列表/,
|
|
15
|
+
/还没有创建任何列表/,
|
|
16
|
+
];
|
|
17
|
+
function normalizeText(text) {
|
|
18
|
+
return String(text || '').replace(/\s+/g, ' ').trim();
|
|
19
|
+
}
|
|
20
|
+
function matchMetric(text, patterns) {
|
|
21
|
+
for (const pattern of patterns) {
|
|
22
|
+
const match = text.match(pattern);
|
|
23
|
+
if (match)
|
|
24
|
+
return normalizeText(match[1]);
|
|
25
|
+
}
|
|
26
|
+
return '0';
|
|
27
|
+
}
|
|
28
|
+
function looksLikeMetadata(line) {
|
|
29
|
+
const text = normalizeText(line);
|
|
30
|
+
if (!text)
|
|
31
|
+
return true;
|
|
32
|
+
if (text.startsWith('@'))
|
|
33
|
+
return true;
|
|
34
|
+
if (MEMBER_PATTERNS.some((pattern) => pattern.test(text)))
|
|
35
|
+
return true;
|
|
36
|
+
if (FOLLOWER_PATTERNS.some((pattern) => pattern.test(text)))
|
|
37
|
+
return true;
|
|
38
|
+
if (PRIVATE_PATTERNS.some((pattern) => pattern.test(text)))
|
|
39
|
+
return true;
|
|
40
|
+
if (/^(public|pinned)$/i.test(text))
|
|
41
|
+
return true;
|
|
42
|
+
if (/^(lists?|你的列表)$/i.test(text))
|
|
43
|
+
return true;
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
export function parseListCards(cards) {
|
|
47
|
+
const seen = new Set();
|
|
48
|
+
const results = [];
|
|
49
|
+
for (const card of cards || []) {
|
|
50
|
+
const href = normalizeText(card?.href);
|
|
51
|
+
const rawText = String(card?.text || '');
|
|
52
|
+
if (!href || seen.has(href))
|
|
53
|
+
continue;
|
|
54
|
+
seen.add(href);
|
|
55
|
+
const text = normalizeText(rawText);
|
|
56
|
+
if (!text)
|
|
57
|
+
continue;
|
|
58
|
+
const lines = rawText
|
|
59
|
+
.split('\n')
|
|
60
|
+
.map((line) => normalizeText(line))
|
|
61
|
+
.filter(Boolean);
|
|
62
|
+
const name = lines.find((line) => !looksLikeMetadata(line));
|
|
63
|
+
if (!name)
|
|
64
|
+
continue;
|
|
65
|
+
results.push({
|
|
66
|
+
name,
|
|
67
|
+
members: matchMetric(text, MEMBER_PATTERNS),
|
|
68
|
+
followers: matchMetric(text, FOLLOWER_PATTERNS),
|
|
69
|
+
mode: PRIVATE_PATTERNS.some((pattern) => pattern.test(text)) ? 'private' : 'public',
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
return results;
|
|
73
|
+
}
|
|
74
|
+
export function isEmptyListsState(text) {
|
|
75
|
+
const normalized = normalizeText(text);
|
|
76
|
+
return EMPTY_STATE_PATTERNS.some((pattern) => pattern.test(normalized));
|
|
77
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { AuthRequiredError, SelectorError } from '@jackwener/opencli/errors';
|
|
2
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
3
|
+
import { isEmptyListsState, parseListCards } from './lists-parser.js';
|
|
4
|
+
|
|
5
|
+
cli({
|
|
6
|
+
site: 'twitter',
|
|
7
|
+
name: 'lists',
|
|
8
|
+
description: 'Get Twitter/X lists for a user',
|
|
9
|
+
domain: 'x.com',
|
|
10
|
+
strategy: Strategy.COOKIE,
|
|
11
|
+
browser: true,
|
|
12
|
+
args: [
|
|
13
|
+
{ name: 'user', positional: true, type: 'string', required: false },
|
|
14
|
+
{ name: 'limit', type: 'int', default: 50 },
|
|
15
|
+
],
|
|
16
|
+
columns: ['name', 'members', 'followers', 'mode'],
|
|
17
|
+
func: async (page, kwargs) => {
|
|
18
|
+
let targetUser = kwargs.user;
|
|
19
|
+
if (!targetUser) {
|
|
20
|
+
await page.goto('https://x.com/home');
|
|
21
|
+
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
|
22
|
+
const href = await page.evaluate(`() => {
|
|
23
|
+
const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
|
|
24
|
+
return link ? link.getAttribute('href') : null;
|
|
25
|
+
}`);
|
|
26
|
+
if (!href) {
|
|
27
|
+
throw new AuthRequiredError('x.com', 'Could not find logged-in user profile link. Are you logged in?');
|
|
28
|
+
}
|
|
29
|
+
targetUser = href.replace('/', '');
|
|
30
|
+
}
|
|
31
|
+
await page.goto(`https://x.com/${targetUser}/lists`);
|
|
32
|
+
await page.wait(3);
|
|
33
|
+
const pageData = await page.evaluate(`() => {
|
|
34
|
+
const cards = [];
|
|
35
|
+
const seen = new Set();
|
|
36
|
+
for (const anchor of Array.from(document.querySelectorAll('a[href*="/i/lists/"]'))) {
|
|
37
|
+
const href = anchor.getAttribute('href') || '';
|
|
38
|
+
if (!/\\/i\\/lists\\/\\d+/.test(href) || seen.has(href)) continue;
|
|
39
|
+
seen.add(href);
|
|
40
|
+
const container = anchor.closest('[data-testid="cellInnerDiv"]') || anchor;
|
|
41
|
+
const text = (container.innerText || anchor.innerText || '').trim();
|
|
42
|
+
if (!text) continue;
|
|
43
|
+
cards.push({ href, text });
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
cards,
|
|
47
|
+
pageText: document.body.innerText || '',
|
|
48
|
+
};
|
|
49
|
+
}`);
|
|
50
|
+
if (!pageData?.pageText) {
|
|
51
|
+
throw new SelectorError('Twitter lists', 'Empty page text');
|
|
52
|
+
}
|
|
53
|
+
const results = parseListCards(pageData.cards);
|
|
54
|
+
if (results.length === 0) {
|
|
55
|
+
if (isEmptyListsState(pageData.pageText)) {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
throw new SelectorError('Twitter lists', `Could not parse list data`);
|
|
59
|
+
}
|
|
60
|
+
return results.slice(0, kwargs.limit);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { isEmptyListsState, parseListCards } from './lists-parser.js';
|
|
3
|
+
|
|
4
|
+
describe('twitter lists parser', () => {
|
|
5
|
+
it('parses english list cards without relying on page locale', () => {
|
|
6
|
+
const result = parseListCards([
|
|
7
|
+
{
|
|
8
|
+
href: '/i/lists/123',
|
|
9
|
+
text: `AI Researchers
|
|
10
|
+
@jack
|
|
11
|
+
124 Members 3.4K Followers
|
|
12
|
+
Private`,
|
|
13
|
+
},
|
|
14
|
+
]);
|
|
15
|
+
expect(result).toEqual([
|
|
16
|
+
{
|
|
17
|
+
name: 'AI Researchers',
|
|
18
|
+
members: '124',
|
|
19
|
+
followers: '3.4K',
|
|
20
|
+
mode: 'private',
|
|
21
|
+
},
|
|
22
|
+
]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('parses chinese list cards without scanning document.body.innerText', () => {
|
|
26
|
+
const result = parseListCards([
|
|
27
|
+
{
|
|
28
|
+
href: '/i/lists/456',
|
|
29
|
+
text: `AI观察
|
|
30
|
+
@jack
|
|
31
|
+
321 位成员 8.8K 位关注者
|
|
32
|
+
锁定列表`,
|
|
33
|
+
},
|
|
34
|
+
]);
|
|
35
|
+
expect(result).toEqual([
|
|
36
|
+
{
|
|
37
|
+
name: 'AI观察',
|
|
38
|
+
members: '321',
|
|
39
|
+
followers: '8.8K',
|
|
40
|
+
mode: 'private',
|
|
41
|
+
},
|
|
42
|
+
]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('detects empty state text in english and chinese', () => {
|
|
46
|
+
expect(isEmptyListsState(`@jack hasn't created any Lists yet`)).toBe(true);
|
|
47
|
+
expect(isEmptyListsState('这个账号还没有创建任何列表')).toBe(true);
|
|
48
|
+
expect(isEmptyListsState('AI Researchers 124 Members')).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
});
|
package/clis/weibo/feed.js
CHANGED
|
@@ -1,20 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Weibo feed —
|
|
2
|
+
* Weibo feed — for-you or following timeline.
|
|
3
3
|
*/
|
|
4
4
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
5
5
|
import { getSelfUid } from './utils.js';
|
|
6
|
+
const TIMELINE_ENDPOINTS = {
|
|
7
|
+
'for-you': 'unreadfriendstimeline',
|
|
8
|
+
following: 'friendstimeline',
|
|
9
|
+
};
|
|
6
10
|
cli({
|
|
7
11
|
site: 'weibo',
|
|
8
12
|
name: 'feed',
|
|
9
|
-
description: 'Weibo
|
|
13
|
+
description: 'Fetch Weibo timeline (for-you or following)',
|
|
10
14
|
domain: 'weibo.com',
|
|
11
15
|
strategy: Strategy.COOKIE,
|
|
12
16
|
args: [
|
|
17
|
+
{
|
|
18
|
+
name: 'type',
|
|
19
|
+
default: 'for-you',
|
|
20
|
+
choices: ['for-you', 'following'],
|
|
21
|
+
help: 'Timeline type: for-you (algorithmic) or following (chronological)',
|
|
22
|
+
},
|
|
13
23
|
{ name: 'limit', type: 'int', default: 15, help: 'Number of posts (max 50)' },
|
|
14
24
|
],
|
|
15
25
|
columns: ['author', 'text', 'reposts', 'comments', 'likes', 'time', 'url'],
|
|
16
26
|
func: async (page, kwargs) => {
|
|
17
27
|
const count = Math.min(kwargs.limit || 15, 50);
|
|
28
|
+
const timelineType = kwargs.type === 'following' ? 'following' : 'for-you';
|
|
29
|
+
const endpoint = TIMELINE_ENDPOINTS[timelineType];
|
|
18
30
|
await page.goto('https://weibo.com');
|
|
19
31
|
await page.wait(2);
|
|
20
32
|
const uid = await getSelfUid(page);
|
|
@@ -22,13 +34,14 @@ cli({
|
|
|
22
34
|
(async () => {
|
|
23
35
|
const uid = ${JSON.stringify(uid)};
|
|
24
36
|
const count = ${count};
|
|
37
|
+
const endpoint = ${JSON.stringify(endpoint)};
|
|
25
38
|
const listId = '10001' + uid;
|
|
26
39
|
const strip = (html) => (html || '').replace(/<[^>]+>/g, '').replace(/ /g, ' ').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&').trim();
|
|
27
40
|
|
|
28
|
-
const resp = await fetch('/ajax/feed/
|
|
29
|
-
if (!resp.ok) return {error: 'HTTP ' + resp.status};
|
|
41
|
+
const resp = await fetch('/ajax/feed/' + endpoint + '?list_id=' + listId + '&refresh=4&since_id=0&count=' + count, { credentials: 'include' });
|
|
42
|
+
if (!resp.ok) return { error: 'HTTP ' + resp.status };
|
|
30
43
|
const data = await resp.json();
|
|
31
|
-
if (!data.ok) return {error: 'API error: ' + (data.msg || 'unknown')};
|
|
44
|
+
if (!data.ok) return { error: 'API error: ' + (data.msg || 'unknown') };
|
|
32
45
|
|
|
33
46
|
return (data.statuses || []).slice(0, count).map(s => {
|
|
34
47
|
const u = s.user || {};
|
package/clis/zsxq/topic.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
2
|
import { CliError } from '@jackwener/opencli/errors';
|
|
3
|
-
import { browserJsonRequest, ensureZsxqAuth, ensureZsxqPage, fetchFirstJson, getCommentsFromResponse, getTopicFromResponse, getTopicUrl, summarizeComments, toTopicRow, } from './utils.js';
|
|
3
|
+
import { getActiveGroupId, browserJsonRequest, ensureZsxqAuth, ensureZsxqPage, fetchFirstJson, getCommentsFromResponse, getTopicFromResponse, getTopicUrl, summarizeComments, toTopicRow, } from './utils.js';
|
|
4
4
|
cli({
|
|
5
5
|
site: 'zsxq',
|
|
6
6
|
name: 'topic',
|
|
@@ -10,6 +10,7 @@ cli({
|
|
|
10
10
|
browser: true,
|
|
11
11
|
args: [
|
|
12
12
|
{ name: 'id', required: true, positional: true, help: 'Topic ID' },
|
|
13
|
+
{ name: 'group_id', help: 'Group ID (optional; defaults to active group in Chrome)' },
|
|
13
14
|
{ name: 'comment_limit', type: 'int', default: 20, help: 'Number of comments to fetch' },
|
|
14
15
|
],
|
|
15
16
|
columns: ['topic_id', 'type', 'author', 'title', 'comments', 'likes', 'comment_preview', 'url'],
|
|
@@ -17,8 +18,9 @@ cli({
|
|
|
17
18
|
await ensureZsxqPage(page);
|
|
18
19
|
await ensureZsxqAuth(page);
|
|
19
20
|
const topicId = String(kwargs.id);
|
|
21
|
+
const groupId = String(kwargs.group_id || await getActiveGroupId(page));
|
|
20
22
|
const commentLimit = Math.max(1, Number(kwargs.comment_limit) || 20);
|
|
21
|
-
const detailUrl = `https://api.zsxq.com/v2/topics/${topicId}`;
|
|
23
|
+
const detailUrl = `https://api.zsxq.com/v2/groups/${groupId}/topics/${topicId}`;
|
|
22
24
|
const detailResp = await browserJsonRequest(page, detailUrl);
|
|
23
25
|
if (detailResp.status === 404) {
|
|
24
26
|
throw new CliError('NOT_FOUND', `Topic ${topicId} not found`);
|
|
@@ -27,7 +29,7 @@ cli({
|
|
|
27
29
|
throw new CliError('FETCH_ERROR', detailResp.error || `Failed to fetch topic ${topicId}`, `Checked endpoint: ${detailUrl}`);
|
|
28
30
|
}
|
|
29
31
|
const commentsResp = await fetchFirstJson(page, [
|
|
30
|
-
`https://api.zsxq.com/v2/topics/${topicId}/comments?sort=asc&count=${commentLimit}`,
|
|
32
|
+
`https://api.zsxq.com/v2/groups/${groupId}/topics/${topicId}/comments?sort=asc&count=${commentLimit}`,
|
|
31
33
|
]);
|
|
32
34
|
const topic = getTopicFromResponse(detailResp.data);
|
|
33
35
|
if (!topic)
|
package/clis/zsxq/topic.test.js
CHANGED
|
@@ -11,11 +11,12 @@ describe('zsxq topic command', () => {
|
|
|
11
11
|
const mockPage = {
|
|
12
12
|
goto: vi.fn().mockResolvedValue(undefined),
|
|
13
13
|
evaluate: vi.fn()
|
|
14
|
-
.mockResolvedValueOnce(true)
|
|
14
|
+
.mockResolvedValueOnce(true) // ensureZsxqAuth
|
|
15
|
+
.mockResolvedValueOnce('12345') // getActiveGroupId
|
|
15
16
|
.mockResolvedValueOnce({
|
|
16
17
|
ok: true,
|
|
17
18
|
status: 404,
|
|
18
|
-
url: 'https://api.zsxq.com/v2/topics/404',
|
|
19
|
+
url: 'https://api.zsxq.com/v2/groups/12345/topics/404',
|
|
19
20
|
data: null,
|
|
20
21
|
}),
|
|
21
22
|
};
|
|
@@ -24,6 +25,6 @@ describe('zsxq topic command', () => {
|
|
|
24
25
|
message: 'Topic 404 not found',
|
|
25
26
|
});
|
|
26
27
|
expect(mockPage.goto).toHaveBeenCalledWith('https://wx.zsxq.com');
|
|
27
|
-
expect(mockPage.evaluate).toHaveBeenCalledTimes(
|
|
28
|
+
expect(mockPage.evaluate).toHaveBeenCalledTimes(3);
|
|
28
29
|
});
|
|
29
30
|
});
|
package/clis/zsxq/utils.js
CHANGED
|
@@ -168,7 +168,7 @@ export function getTopicFromResponse(payload) {
|
|
|
168
168
|
const data = unwrapRespData(payload);
|
|
169
169
|
if (Array.isArray(data))
|
|
170
170
|
return data[0] ?? null;
|
|
171
|
-
if (typeof data.topic_id === 'number')
|
|
171
|
+
if (typeof data.topic_id === 'number' || typeof data.topic_id === 'string')
|
|
172
172
|
return data;
|
|
173
173
|
const record = asRecord(data);
|
|
174
174
|
if (!record)
|
package/dist/src/cli.js
CHANGED
|
@@ -891,6 +891,114 @@ cli({
|
|
|
891
891
|
process.exitCode = EXIT_CODES.GENERIC_ERROR;
|
|
892
892
|
}
|
|
893
893
|
});
|
|
894
|
+
// ── Built-in: adapter management ─────────────────────────────────────────
|
|
895
|
+
const adapterCmd = program.command('adapter').description('Manage CLI adapters');
|
|
896
|
+
adapterCmd
|
|
897
|
+
.command('status')
|
|
898
|
+
.description('Show which sites have local overrides vs using official baseline')
|
|
899
|
+
.action(async () => {
|
|
900
|
+
const os = await import('node:os');
|
|
901
|
+
const userClisDir = path.join(os.homedir(), '.opencli', 'clis');
|
|
902
|
+
const builtinClisDir = BUILTIN_CLIS;
|
|
903
|
+
try {
|
|
904
|
+
const userEntries = await fs.promises.readdir(userClisDir, { withFileTypes: true });
|
|
905
|
+
const userSites = userEntries.filter(e => e.isDirectory()).map(e => e.name).sort();
|
|
906
|
+
let builtinSites = [];
|
|
907
|
+
try {
|
|
908
|
+
const builtinEntries = await fs.promises.readdir(builtinClisDir, { withFileTypes: true });
|
|
909
|
+
builtinSites = builtinEntries.filter(e => e.isDirectory()).map(e => e.name).sort();
|
|
910
|
+
}
|
|
911
|
+
catch { /* no builtin dir */ }
|
|
912
|
+
if (userSites.length === 0) {
|
|
913
|
+
console.log('No local adapter overrides. All sites use the official baseline.');
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
console.log(`Local overrides in ~/.opencli/clis/ (${userSites.length} sites):\n`);
|
|
917
|
+
for (const site of userSites) {
|
|
918
|
+
const isOfficial = builtinSites.includes(site);
|
|
919
|
+
const label = isOfficial ? 'override' : 'custom';
|
|
920
|
+
console.log(` ${site} [${label}]`);
|
|
921
|
+
}
|
|
922
|
+
console.log(`\nOfficial baseline: ${builtinSites.length} sites in package`);
|
|
923
|
+
}
|
|
924
|
+
catch {
|
|
925
|
+
console.log('No local adapter overrides. All sites use the official baseline.');
|
|
926
|
+
}
|
|
927
|
+
});
|
|
928
|
+
adapterCmd
|
|
929
|
+
.command('eject')
|
|
930
|
+
.description('Copy an official adapter to ~/.opencli/clis/ for local editing')
|
|
931
|
+
.argument('<site>', 'Site name (e.g. twitter, bilibili)')
|
|
932
|
+
.action(async (site) => {
|
|
933
|
+
const os = await import('node:os');
|
|
934
|
+
const userClisDir = path.join(os.homedir(), '.opencli', 'clis');
|
|
935
|
+
const builtinSiteDir = path.join(BUILTIN_CLIS, site);
|
|
936
|
+
const userSiteDir = path.join(userClisDir, site);
|
|
937
|
+
try {
|
|
938
|
+
await fs.promises.access(builtinSiteDir);
|
|
939
|
+
}
|
|
940
|
+
catch {
|
|
941
|
+
console.error(styleText('red', `Error: Site "${site}" not found in official adapters.`));
|
|
942
|
+
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
try {
|
|
946
|
+
await fs.promises.access(userSiteDir);
|
|
947
|
+
console.error(styleText('yellow', `Site "${site}" already exists in ~/.opencli/clis/. Use "opencli adapter reset ${site}" first to restore official version.`));
|
|
948
|
+
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
catch { /* good, doesn't exist yet */ }
|
|
952
|
+
fs.cpSync(builtinSiteDir, userSiteDir, { recursive: true });
|
|
953
|
+
console.log(styleText('green', `✅ Ejected "${site}" to ~/.opencli/clis/${site}/`));
|
|
954
|
+
console.log('You can now edit the adapter files. Changes take effect immediately.');
|
|
955
|
+
console.log(styleText('yellow', 'Note: Official updates to this adapter will overwrite your changes.'));
|
|
956
|
+
});
|
|
957
|
+
adapterCmd
|
|
958
|
+
.command('reset')
|
|
959
|
+
.description('Remove local override and restore official adapter version')
|
|
960
|
+
.argument('[site]', 'Site name (e.g. twitter, bilibili)')
|
|
961
|
+
.option('--all', 'Reset all local overrides')
|
|
962
|
+
.action(async (site, opts) => {
|
|
963
|
+
const os = await import('node:os');
|
|
964
|
+
const userClisDir = path.join(os.homedir(), '.opencli', 'clis');
|
|
965
|
+
if (opts.all) {
|
|
966
|
+
try {
|
|
967
|
+
const userEntries = await fs.promises.readdir(userClisDir, { withFileTypes: true });
|
|
968
|
+
const dirs = userEntries.filter(e => e.isDirectory());
|
|
969
|
+
if (dirs.length === 0) {
|
|
970
|
+
console.log('No local sites to reset.');
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
for (const dir of dirs) {
|
|
974
|
+
fs.rmSync(path.join(userClisDir, dir.name), { recursive: true, force: true });
|
|
975
|
+
}
|
|
976
|
+
console.log(styleText('green', `✅ Reset ${dirs.length} site(s). All adapters now use official baseline.`));
|
|
977
|
+
}
|
|
978
|
+
catch {
|
|
979
|
+
console.log('No local sites to reset.');
|
|
980
|
+
}
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
if (!site) {
|
|
984
|
+
console.error(styleText('red', 'Error: Please specify a site name or use --all.'));
|
|
985
|
+
process.exitCode = EXIT_CODES.USAGE_ERROR;
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
const userSiteDir = path.join(userClisDir, site);
|
|
989
|
+
try {
|
|
990
|
+
await fs.promises.access(userSiteDir);
|
|
991
|
+
}
|
|
992
|
+
catch {
|
|
993
|
+
console.error(styleText('yellow', `Site "${site}" has no local override.`));
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
const isOfficial = fs.existsSync(path.join(BUILTIN_CLIS, site));
|
|
997
|
+
fs.rmSync(userSiteDir, { recursive: true, force: true });
|
|
998
|
+
console.log(styleText('green', isOfficial
|
|
999
|
+
? `✅ Reset "${site}". Now using official baseline.`
|
|
1000
|
+
: `✅ Removed custom site "${site}".`));
|
|
1001
|
+
});
|
|
894
1002
|
// ── Built-in: daemon ──────────────────────────────────────────────────────
|
|
895
1003
|
const daemonCmd = program.command('daemon').description('Manage the opencli daemon');
|
|
896
1004
|
daemonCmd
|
package/dist/src/discovery.d.ts
CHANGED
|
@@ -23,8 +23,11 @@ export declare const PLUGINS_DIR: string;
|
|
|
23
23
|
*/
|
|
24
24
|
export declare function ensureUserCliCompatShims(baseDir?: string): Promise<void>;
|
|
25
25
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
26
|
+
* Ensure the user adapters directory exists.
|
|
27
|
+
*
|
|
28
|
+
* With smart sync, ~/.opencli/clis/ only holds files that differ from the
|
|
29
|
+
* package baseline (upstream-synced cache + autofix output + user overrides).
|
|
30
|
+
* Built-in adapters are loaded directly from the installed package.
|
|
28
31
|
*/
|
|
29
32
|
export declare function ensureUserAdapters(): Promise<void>;
|
|
30
33
|
/**
|
package/dist/src/discovery.js
CHANGED
|
@@ -14,7 +14,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
14
14
|
import { Strategy, registerCommand } from './registry.js';
|
|
15
15
|
import { getErrorMessage } from './errors.js';
|
|
16
16
|
import { log } from './logger.js';
|
|
17
|
-
import { findPackageRoot, getCliManifestPath
|
|
17
|
+
import { findPackageRoot, getCliManifestPath } from './package-paths.js';
|
|
18
18
|
/** User runtime directory: ~/.opencli */
|
|
19
19
|
export const USER_OPENCLI_DIR = path.join(os.homedir(), '.opencli');
|
|
20
20
|
/** User CLIs directory: ~/.opencli/clis */
|
|
@@ -77,43 +77,15 @@ export async function ensureUserCliCompatShims(baseDir = USER_OPENCLI_DIR) {
|
|
|
77
77
|
log.warn(`Could not create symlink at ${symlinkPath}: ${getErrorMessage(err)}`);
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
|
-
const ADAPTER_MANIFEST_PATH = path.join(USER_OPENCLI_DIR, 'adapter-manifest.json');
|
|
81
80
|
/**
|
|
82
|
-
*
|
|
83
|
-
*
|
|
81
|
+
* Ensure the user adapters directory exists.
|
|
82
|
+
*
|
|
83
|
+
* With smart sync, ~/.opencli/clis/ only holds files that differ from the
|
|
84
|
+
* package baseline (upstream-synced cache + autofix output + user overrides).
|
|
85
|
+
* Built-in adapters are loaded directly from the installed package.
|
|
84
86
|
*/
|
|
85
87
|
export async function ensureUserAdapters() {
|
|
86
|
-
|
|
87
|
-
try {
|
|
88
|
-
await fs.promises.access(ADAPTER_MANIFEST_PATH);
|
|
89
|
-
return;
|
|
90
|
-
}
|
|
91
|
-
catch {
|
|
92
|
-
// No manifest — first run or postinstall was skipped
|
|
93
|
-
}
|
|
94
|
-
// Check if clis dir has any content (could be manually populated)
|
|
95
|
-
try {
|
|
96
|
-
const entries = await fs.promises.readdir(USER_CLIS_DIR);
|
|
97
|
-
if (entries.length > 0)
|
|
98
|
-
return;
|
|
99
|
-
}
|
|
100
|
-
catch {
|
|
101
|
-
// Dir doesn't exist — needs fetch
|
|
102
|
-
}
|
|
103
|
-
log.info('First run detected — copying adapters (one-time setup)...');
|
|
104
|
-
try {
|
|
105
|
-
const { execFileSync } = await import('node:child_process');
|
|
106
|
-
const scriptPath = getFetchAdaptersScriptPath(PACKAGE_ROOT);
|
|
107
|
-
execFileSync(process.execPath, [scriptPath], {
|
|
108
|
-
stdio: 'inherit',
|
|
109
|
-
env: { ...process.env, _OPENCLI_FIRST_RUN: '1' },
|
|
110
|
-
timeout: 120_000,
|
|
111
|
-
});
|
|
112
|
-
}
|
|
113
|
-
catch (err) {
|
|
114
|
-
log.warn(`Could not fetch adapters on first run: ${getErrorMessage(err)}`);
|
|
115
|
-
log.warn('Built-in adapters from the package will be used.');
|
|
116
|
-
}
|
|
88
|
+
await fs.promises.mkdir(USER_CLIS_DIR, { recursive: true });
|
|
117
89
|
}
|
|
118
90
|
/**
|
|
119
91
|
* Discover and register CLI commands.
|
package/dist/src/engine.test.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import { discoverClis, discoverPlugins, ensureUserCliCompatShims, PLUGINS_DIR } from './discovery.js';
|
|
2
|
+
import { discoverClis, discoverPlugins, ensureUserCliCompatShims, ensureUserAdapters, PLUGINS_DIR } from './discovery.js';
|
|
3
3
|
import { executeCommand } from './execution.js';
|
|
4
4
|
import { getRegistry, cli, Strategy } from './registry.js';
|
|
5
5
|
import { clearAllHooks, onAfterExecute } from './hooks.js';
|
|
@@ -103,6 +103,34 @@ cli({
|
|
|
103
103
|
}
|
|
104
104
|
});
|
|
105
105
|
});
|
|
106
|
+
describe('ensureUserAdapters', () => {
|
|
107
|
+
it('creates user clis directory without triggering full copy', async () => {
|
|
108
|
+
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'opencli-ensure-'));
|
|
109
|
+
const clisDir = path.join(tempDir, 'clis');
|
|
110
|
+
try {
|
|
111
|
+
// Patch USER_CLIS_DIR is not easy, so we test the function behavior indirectly:
|
|
112
|
+
// ensureUserAdapters should not throw and should be very fast (no fetch script)
|
|
113
|
+
const start = Date.now();
|
|
114
|
+
await ensureUserAdapters();
|
|
115
|
+
const elapsed = Date.now() - start;
|
|
116
|
+
// Should complete quickly (< 1s) since it only creates a directory
|
|
117
|
+
expect(elapsed).toBeLessThan(1000);
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
await fs.promises.rm(tempDir, { recursive: true, force: true });
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
it('discoverClis handles empty user directory gracefully', async () => {
|
|
124
|
+
const emptyDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'opencli-empty-'));
|
|
125
|
+
try {
|
|
126
|
+
// Should not throw for an empty directory (no adapters to discover)
|
|
127
|
+
await expect(discoverClis(emptyDir)).resolves.not.toThrow();
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
await fs.promises.rm(emptyDir, { recursive: true, force: true });
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
});
|
|
106
134
|
describe('discoverPlugins', () => {
|
|
107
135
|
const testPluginDir = path.join(PLUGINS_DIR, '__test-plugin__');
|
|
108
136
|
const yamlPath = path.join(testPluginDir, 'greeting.yaml');
|