@jackwener/opencli 1.7.20 → 1.7.22
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 +2 -1
- package/README.zh-CN.md +2 -1
- package/cli-manifest.json +233 -72
- package/clis/_shared/search-adapter.js +70 -0
- package/clis/boss/chatlist.js +96 -14
- package/clis/boss/chatlist.test.js +211 -0
- package/clis/boss/chatmsg.js +98 -24
- package/clis/boss/chatmsg.test.js +230 -0
- package/clis/boss/utils.js +257 -12
- package/clis/boss/utils.test.js +34 -0
- package/clis/brave/search.js +80 -0
- package/clis/brave/search.test.js +76 -0
- package/clis/duckduckgo/search.js +131 -0
- package/clis/duckduckgo/search.test.js +128 -0
- package/clis/duckduckgo/suggest.js +45 -0
- package/clis/duckduckgo/suggest.test.js +66 -0
- package/clis/facebook/feed.js +301 -56
- package/clis/facebook/feed.test.js +169 -0
- package/clis/reddit/comment.js +0 -1
- package/clis/reddit/frontpage.js +0 -1
- package/clis/reddit/home.js +0 -1
- package/clis/reddit/popular.js +0 -1
- package/clis/reddit/read.js +0 -1
- package/clis/reddit/read.test.js +2 -2
- package/clis/reddit/save.js +0 -1
- package/clis/reddit/saved.js +0 -1
- package/clis/reddit/search.js +0 -1
- package/clis/reddit/subreddit-info.js +0 -1
- package/clis/reddit/subreddit.js +0 -1
- package/clis/reddit/subscribe.js +0 -1
- package/clis/reddit/upvote.js +0 -1
- package/clis/reddit/upvoted.js +0 -1
- package/clis/reddit/user-comments.js +0 -1
- package/clis/reddit/user-posts.js +0 -1
- package/clis/reddit/user.js +0 -1
- package/clis/reddit/whoami.js +0 -1
- package/clis/rednote/rednote.test.js +65 -0
- package/clis/rednote/search.js +11 -5
- package/clis/twitter/article.js +0 -1
- package/clis/twitter/bookmark-folder.js +0 -1
- package/clis/twitter/bookmark-folders.js +0 -1
- package/clis/twitter/bookmarks.js +0 -1
- package/clis/twitter/download.js +0 -1
- package/clis/twitter/followers.js +0 -1
- package/clis/twitter/following.js +0 -1
- package/clis/twitter/likes.js +0 -1
- package/clis/twitter/list-tweets.js +0 -1
- package/clis/twitter/lists.js +0 -1
- package/clis/twitter/notifications.js +0 -1
- package/clis/twitter/profile.js +0 -1
- package/clis/twitter/search.js +0 -1
- package/clis/twitter/thread.js +0 -1
- package/clis/twitter/timeline.js +0 -1
- package/clis/twitter/trending.js +0 -1
- package/clis/twitter/tweets.js +0 -1
- package/clis/weibo/comments.js +3 -4
- package/clis/weibo/envelope.test.js +85 -0
- package/clis/weibo/favorites.js +4 -4
- package/clis/weibo/feed.js +3 -5
- package/clis/weibo/hot.js +3 -4
- package/clis/weibo/me.js +3 -5
- package/clis/weibo/post.js +3 -4
- package/clis/weibo/search.js +4 -3
- package/clis/weibo/user.js +3 -4
- package/clis/weibo/utils.js +34 -5
- package/clis/weibo/utils.test.js +36 -0
- package/clis/xiaohongshu/search.js +34 -16
- package/clis/xiaohongshu/search.test.js +66 -11
- package/clis/yahoo/search.js +92 -0
- package/clis/yahoo/search.test.js +94 -0
- package/dist/src/cli.js +1 -1
- package/dist/src/external-clis.yaml +12 -0
- package/dist/src/external.d.ts +6 -1
- package/dist/src/external.test.js +19 -0
- package/package.json +1 -1
package/clis/weibo/utils.js
CHANGED
|
@@ -1,10 +1,39 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared Weibo utilities — uid extraction.
|
|
3
3
|
*/
|
|
4
|
-
import { AuthRequiredError } from '@jackwener/opencli/errors';
|
|
4
|
+
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
5
|
+
/**
|
|
6
|
+
* `page.evaluate` may return either the raw IIFE value or a
|
|
7
|
+
* `{ session, data }` envelope depending on the browser-bridge version.
|
|
8
|
+
* Adapter code that inspected the payload directly (e.g. `Array.isArray`,
|
|
9
|
+
* truthiness checks on uid strings) silently received the envelope wrapper
|
|
10
|
+
* instead of the inner value. This helper normalizes both shapes so callers
|
|
11
|
+
* can keep their existing checks unchanged.
|
|
12
|
+
*/
|
|
13
|
+
export function unwrapEvaluateResult(payload) {
|
|
14
|
+
if (payload && !Array.isArray(payload) && typeof payload === 'object' && 'session' in payload && 'data' in payload) {
|
|
15
|
+
return payload.data;
|
|
16
|
+
}
|
|
17
|
+
return payload;
|
|
18
|
+
}
|
|
19
|
+
export function requireArrayEvaluateResult(payload, label) {
|
|
20
|
+
if (!Array.isArray(payload)) {
|
|
21
|
+
if (payload && typeof payload === 'object' && 'error' in payload) {
|
|
22
|
+
throw new CommandExecutionError(`${label}: ${String(payload.error)}`);
|
|
23
|
+
}
|
|
24
|
+
throw new CommandExecutionError(`${label} returned malformed extraction payload`);
|
|
25
|
+
}
|
|
26
|
+
return payload;
|
|
27
|
+
}
|
|
28
|
+
export function requireObjectEvaluateResult(payload, label) {
|
|
29
|
+
if (!payload || Array.isArray(payload) || typeof payload !== 'object') {
|
|
30
|
+
throw new CommandExecutionError(`${label} returned malformed extraction payload`);
|
|
31
|
+
}
|
|
32
|
+
return payload;
|
|
33
|
+
}
|
|
5
34
|
/** Get the currently logged-in user's uid from Vue store or config API. */
|
|
6
35
|
export async function getSelfUid(page) {
|
|
7
|
-
const uid = await page.evaluate(`
|
|
36
|
+
const uid = unwrapEvaluateResult(await page.evaluate(`
|
|
8
37
|
(() => {
|
|
9
38
|
const app = document.querySelector('#app')?.__vue_app__;
|
|
10
39
|
const store = app?.config?.globalProperties?.$store;
|
|
@@ -12,18 +41,18 @@ export async function getSelfUid(page) {
|
|
|
12
41
|
if (uid) return String(uid);
|
|
13
42
|
return null;
|
|
14
43
|
})()
|
|
15
|
-
`);
|
|
44
|
+
`));
|
|
16
45
|
if (uid)
|
|
17
46
|
return uid;
|
|
18
47
|
// Fallback: config API
|
|
19
|
-
const config = await page.evaluate(`
|
|
48
|
+
const config = unwrapEvaluateResult(await page.evaluate(`
|
|
20
49
|
(async () => {
|
|
21
50
|
const resp = await fetch('/ajax/config/get_config', {credentials: 'include'});
|
|
22
51
|
if (!resp.ok) return null;
|
|
23
52
|
const data = await resp.json();
|
|
24
53
|
return data.ok && data.data?.uid ? String(data.data.uid) : null;
|
|
25
54
|
})()
|
|
26
|
-
`);
|
|
55
|
+
`));
|
|
27
56
|
if (config)
|
|
28
57
|
return config;
|
|
29
58
|
throw new AuthRequiredError('weibo.com');
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
|
3
|
+
import { requireArrayEvaluateResult, requireObjectEvaluateResult, unwrapEvaluateResult } from './utils.js';
|
|
4
|
+
|
|
5
|
+
describe('unwrapEvaluateResult (browser-bridge envelope normalization)', () => {
|
|
6
|
+
it('returns the raw array unchanged when payload is already an array', () => {
|
|
7
|
+
const arr = [{ id: '1' }, { id: '2' }];
|
|
8
|
+
expect(unwrapEvaluateResult(arr)).toBe(arr);
|
|
9
|
+
});
|
|
10
|
+
it('unwraps { session, data: [...] } envelope to the inner array', () => {
|
|
11
|
+
const arr = [{ id: '1' }];
|
|
12
|
+
const env = { session: 'site:weibo:abc', data: arr };
|
|
13
|
+
expect(unwrapEvaluateResult(env)).toBe(arr);
|
|
14
|
+
});
|
|
15
|
+
it('unwraps primitive data (e.g. uid string) from Browser Bridge envelopes', () => {
|
|
16
|
+
expect(unwrapEvaluateResult({ session: 'site:weibo:abc', data: '1234567890' })).toBe('1234567890');
|
|
17
|
+
});
|
|
18
|
+
it('unwraps null payload data so getSelfUid fallback can trigger', () => {
|
|
19
|
+
expect(unwrapEvaluateResult({ session: 'site:weibo:abc', data: null })).toBe(null);
|
|
20
|
+
});
|
|
21
|
+
it('passes non-envelope objects through unchanged (e.g. profile result)', () => {
|
|
22
|
+
const obj = { screen_name: 'alice', uid: '42' };
|
|
23
|
+
expect(unwrapEvaluateResult(obj)).toBe(obj);
|
|
24
|
+
});
|
|
25
|
+
it('handles null and undefined safely', () => {
|
|
26
|
+
expect(unwrapEvaluateResult(null)).toBe(null);
|
|
27
|
+
expect(unwrapEvaluateResult(undefined)).toBe(undefined);
|
|
28
|
+
});
|
|
29
|
+
it('keeps malformed array/object payloads as typed command failures after unwrap', () => {
|
|
30
|
+
expect(requireArrayEvaluateResult([{ id: '1' }], 'weibo feed')).toEqual([{ id: '1' }]);
|
|
31
|
+
expect(() => requireArrayEvaluateResult({ error: 'API error' }, 'weibo feed')).toThrow(CommandExecutionError);
|
|
32
|
+
expect(() => requireArrayEvaluateResult({ error: 'API error' }, 'weibo feed')).toThrow('weibo feed: API error');
|
|
33
|
+
expect(requireObjectEvaluateResult({ uid: '42' }, 'weibo me')).toEqual({ uid: '42' });
|
|
34
|
+
expect(() => requireObjectEvaluateResult([{ uid: '42' }], 'weibo me')).toThrow(CommandExecutionError);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Ref: https://github.com/jackwener/opencli/issues/10
|
|
7
7
|
*/
|
|
8
8
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
9
|
-
import { ArgumentError, AuthRequiredError } from '@jackwener/opencli/errors';
|
|
9
|
+
import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
10
10
|
/**
|
|
11
11
|
* Wait for search results or login wall using MutationObserver (max 5s).
|
|
12
12
|
* Returns 'content' if note items appeared, 'login_wall' if login gate
|
|
@@ -60,6 +60,26 @@ export function stripXhsAuthorDateSuffix(value) {
|
|
|
60
60
|
const stripped = text.replace(/\s*(?:\d{1,2}天前|\d+小时前|\d+分钟前|\d+秒前|刚刚|昨天|前天|\d+周前|\d+个月前|\d{1,2}-\d{1,2}|\d{4}-\d{1,2}-\d{1,2})$/u, '').trim();
|
|
61
61
|
return stripped || text;
|
|
62
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* `page.evaluate` may return either the raw IIFE value or a
|
|
65
|
+
* `{ session, data }` envelope depending on the browser-bridge version.
|
|
66
|
+
* Adapter code that called `Array.isArray(payload)` directly on the
|
|
67
|
+
* envelope silently received [] for every search. This helper normalizes
|
|
68
|
+
* both shapes so callers can keep their Array.isArray checks unchanged.
|
|
69
|
+
*/
|
|
70
|
+
export function unwrapEvaluateResult(payload) {
|
|
71
|
+
if (payload && !Array.isArray(payload) && typeof payload === 'object' && 'session' in payload && 'data' in payload) {
|
|
72
|
+
return payload.data;
|
|
73
|
+
}
|
|
74
|
+
return payload;
|
|
75
|
+
}
|
|
76
|
+
function requireSearchRows(payload, phase) {
|
|
77
|
+
const rows = unwrapEvaluateResult(payload);
|
|
78
|
+
if (!Array.isArray(rows)) {
|
|
79
|
+
throw new CommandExecutionError(`Unexpected Xiaohongshu search ${phase} payload shape; expected an array of rows.`);
|
|
80
|
+
}
|
|
81
|
+
return rows;
|
|
82
|
+
}
|
|
63
83
|
export function parseLimit(raw) {
|
|
64
84
|
const parsed = Number(raw ?? 20);
|
|
65
85
|
if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) {
|
|
@@ -267,7 +287,7 @@ export const command = cli({
|
|
|
267
287
|
// Wait for search results to render (or login wall to appear).
|
|
268
288
|
// Uses MutationObserver to resolve as soon as content appears,
|
|
269
289
|
// instead of a fixed delay + blind retry.
|
|
270
|
-
const waitResult = await page.evaluate(WAIT_FOR_CONTENT_JS);
|
|
290
|
+
const waitResult = unwrapEvaluateResult(await page.evaluate(WAIT_FOR_CONTENT_JS));
|
|
271
291
|
if (waitResult === 'login_wall') {
|
|
272
292
|
throw new AuthRequiredError('www.xiaohongshu.com', 'Xiaohongshu search results are blocked behind a login wall');
|
|
273
293
|
}
|
|
@@ -275,25 +295,23 @@ export const command = cli({
|
|
|
275
295
|
// layout, so scrolling to the bottom can evict the initially visible
|
|
276
296
|
// note cards from the DOM and make extraction return [] even though the
|
|
277
297
|
// browser rendered results correctly.
|
|
278
|
-
const initialPayload = await page.evaluate(buildSearchExtractJs('www.xiaohongshu.com'));
|
|
279
|
-
|
|
298
|
+
const initialPayload = requireSearchRows(await page.evaluate(buildSearchExtractJs('www.xiaohongshu.com')), 'initial extraction');
|
|
299
|
+
const payload = [...initialPayload];
|
|
280
300
|
if (payload.length < limit) {
|
|
281
301
|
// Scroll until enough rows are rendered or the lazy-load plateaus.
|
|
282
302
|
// Replaces the previous fixed `autoScroll({ times: 2 })` which capped
|
|
283
303
|
// extraction at ~13 notes regardless of `--limit` (#1471).
|
|
284
304
|
await page.evaluate(buildScrollUntilJs(limit));
|
|
285
|
-
const scrolledPayload = await page.evaluate(buildSearchExtractJs('www.xiaohongshu.com'));
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
break;
|
|
296
|
-
}
|
|
305
|
+
const scrolledPayload = requireSearchRows(await page.evaluate(buildSearchExtractJs('www.xiaohongshu.com')), 'post-scroll extraction');
|
|
306
|
+
const seen = new Set(payload.map((item) => item.url).filter(Boolean));
|
|
307
|
+
for (const item of scrolledPayload) {
|
|
308
|
+
if (item?.url && seen.has(item.url))
|
|
309
|
+
continue;
|
|
310
|
+
if (item?.url)
|
|
311
|
+
seen.add(item.url);
|
|
312
|
+
payload.push(item);
|
|
313
|
+
if (payload.length >= limit)
|
|
314
|
+
break;
|
|
297
315
|
}
|
|
298
316
|
}
|
|
299
317
|
const data = payload;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from 'vitest';
|
|
2
2
|
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
3
|
import { JSDOM } from 'jsdom';
|
|
4
|
-
import { __test__, buildScrollUntilJs, noteIdToDate } from './search.js';
|
|
4
|
+
import { __test__, buildScrollUntilJs, noteIdToDate, unwrapEvaluateResult } from './search.js';
|
|
5
5
|
|
|
6
6
|
function markVisible(el) {
|
|
7
7
|
el.getBoundingClientRect = () => ({ width: 100, height: 100 });
|
|
@@ -57,24 +57,37 @@ describe('xiaohongshu search', () => {
|
|
|
57
57
|
expect(page.evaluate).toHaveBeenCalledTimes(1);
|
|
58
58
|
expect(page.autoScroll).not.toHaveBeenCalled();
|
|
59
59
|
});
|
|
60
|
+
it('unwraps a browser-bridge envelope before handling login-wall wait result', async () => {
|
|
61
|
+
const cmd = getRegistry().get('xiaohongshu/search');
|
|
62
|
+
const page = createPageMock([
|
|
63
|
+
{ session: 'site:xiaohongshu', data: 'login_wall' },
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
await expect(cmd.func(page, { query: '特斯拉', limit: 5 })).rejects.toMatchObject({
|
|
67
|
+
code: 'AUTH_REQUIRED',
|
|
68
|
+
message: expect.stringContaining('blocked behind a login wall'),
|
|
69
|
+
});
|
|
70
|
+
expect(page.evaluate).toHaveBeenCalledTimes(1);
|
|
71
|
+
});
|
|
60
72
|
it('returns ranked results with search_result url and author_url preserved', async () => {
|
|
61
73
|
const cmd = getRegistry().get('xiaohongshu/search');
|
|
62
74
|
expect(cmd?.func).toBeTypeOf('function');
|
|
63
75
|
const detailUrl = 'https://www.xiaohongshu.com/search_result/68e90be80000000004022e66?xsec_token=test-token&xsec_source=';
|
|
64
76
|
const authorUrl = 'https://www.xiaohongshu.com/user/profile/635a9c720000000018028b40?xsec_token=user-token&xsec_source=pc_search';
|
|
77
|
+
const rows = [
|
|
78
|
+
{
|
|
79
|
+
title: '某鱼买FSD被坑了4万',
|
|
80
|
+
author: '随风',
|
|
81
|
+
likes: '261',
|
|
82
|
+
url: detailUrl,
|
|
83
|
+
author_url: authorUrl,
|
|
84
|
+
},
|
|
85
|
+
];
|
|
65
86
|
const page = createPageMock([
|
|
66
87
|
// First evaluate: MutationObserver wait (content appeared)
|
|
67
88
|
'content',
|
|
68
|
-
// Second evaluate: initial DOM extraction (already enough results)
|
|
69
|
-
|
|
70
|
-
{
|
|
71
|
-
title: '某鱼买FSD被坑了4万',
|
|
72
|
-
author: '随风',
|
|
73
|
-
likes: '261',
|
|
74
|
-
url: detailUrl,
|
|
75
|
-
author_url: authorUrl,
|
|
76
|
-
},
|
|
77
|
-
],
|
|
89
|
+
// Second evaluate: initial DOM extraction (already enough results) through Browser Bridge envelope.
|
|
90
|
+
{ session: 'site:xiaohongshu', data: rows },
|
|
78
91
|
]);
|
|
79
92
|
const result = await cmd.func(page, { query: '特斯拉', limit: 1 });
|
|
80
93
|
// Should only do one goto (the search page itself), no per-note detail navigation
|
|
@@ -91,6 +104,18 @@ describe('xiaohongshu search', () => {
|
|
|
91
104
|
},
|
|
92
105
|
]);
|
|
93
106
|
});
|
|
107
|
+
it('fails typed instead of silently returning [] for malformed extraction payloads', async () => {
|
|
108
|
+
const cmd = getRegistry().get('xiaohongshu/search');
|
|
109
|
+
const page = createPageMock([
|
|
110
|
+
'content',
|
|
111
|
+
{ session: 'site:xiaohongshu', data: { rows: [] } },
|
|
112
|
+
]);
|
|
113
|
+
|
|
114
|
+
await expect(cmd.func(page, { query: '测试', limit: 1 })).rejects.toMatchObject({
|
|
115
|
+
code: 'COMMAND_EXEC',
|
|
116
|
+
message: expect.stringContaining('payload shape'),
|
|
117
|
+
});
|
|
118
|
+
});
|
|
94
119
|
it('filters out results with no title and respects the limit', async () => {
|
|
95
120
|
const cmd = getRegistry().get('xiaohongshu/search');
|
|
96
121
|
expect(cmd?.func).toBeTypeOf('function');
|
|
@@ -135,6 +160,10 @@ describe('xiaohongshu search', () => {
|
|
|
135
160
|
'content',
|
|
136
161
|
// Second evaluate: initial extraction (no rows rendered)
|
|
137
162
|
[],
|
|
163
|
+
// Third evaluate: scroll-until row count
|
|
164
|
+
0,
|
|
165
|
+
// Fourth evaluate: post-scroll extraction (still no rows)
|
|
166
|
+
[],
|
|
138
167
|
]);
|
|
139
168
|
const result = (await cmd.func(page, { query: '测试等待', limit: 5 }));
|
|
140
169
|
expect(result).toHaveLength(0);
|
|
@@ -268,3 +297,29 @@ describe('noteIdToDate (ObjectID timestamp parsing)', () => {
|
|
|
268
297
|
expect(noteIdToDate('https://www.xiaohongshu.com/search_result/000000000000000000000000')).toBe('');
|
|
269
298
|
});
|
|
270
299
|
});
|
|
300
|
+
describe('unwrapEvaluateResult (browser-bridge envelope normalization)', () => {
|
|
301
|
+
it('returns the raw array unchanged when payload is already an array', () => {
|
|
302
|
+
const arr = [{ title: 'a' }, { title: 'b' }];
|
|
303
|
+
expect(unwrapEvaluateResult(arr)).toBe(arr);
|
|
304
|
+
});
|
|
305
|
+
it('unwraps { session, data: [...] } envelope to the inner array', () => {
|
|
306
|
+
const arr = [{ title: 'a' }];
|
|
307
|
+
const env = { session: 'site:xiaohongshu:abc', data: arr };
|
|
308
|
+
expect(unwrapEvaluateResult(env)).toBe(arr);
|
|
309
|
+
});
|
|
310
|
+
it('unwraps primitive data from Browser Bridge envelopes', () => {
|
|
311
|
+
expect(unwrapEvaluateResult({ session: 'site:xiaohongshu:abc', data: 'login_wall' })).toBe('login_wall');
|
|
312
|
+
});
|
|
313
|
+
it('passes non-envelope objects through unchanged', () => {
|
|
314
|
+
const obj = { results: [], loginWall: true };
|
|
315
|
+
expect(unwrapEvaluateResult(obj)).toBe(obj);
|
|
316
|
+
});
|
|
317
|
+
it('handles null and undefined safely', () => {
|
|
318
|
+
expect(unwrapEvaluateResult(null)).toBe(null);
|
|
319
|
+
expect(unwrapEvaluateResult(undefined)).toBe(undefined);
|
|
320
|
+
});
|
|
321
|
+
it('unwraps non-array envelope data so callers can validate the payload shape', () => {
|
|
322
|
+
const env = { session: 'x', data: { not: 'an array' } };
|
|
323
|
+
expect(unwrapEvaluateResult(env)).toEqual({ not: 'an array' });
|
|
324
|
+
});
|
|
325
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
import {
|
|
3
|
+
emptySearchResults,
|
|
4
|
+
requireBoundedInteger,
|
|
5
|
+
requireRows,
|
|
6
|
+
requireSearchQuery,
|
|
7
|
+
runBrowserStep,
|
|
8
|
+
toHttpsUrl,
|
|
9
|
+
} from '../_shared/search-adapter.js';
|
|
10
|
+
|
|
11
|
+
function decodeYahooUrl(href) {
|
|
12
|
+
if (!href) return '';
|
|
13
|
+
if (href.indexOf('RU=') !== -1 && href.indexOf('/RK=') !== -1) {
|
|
14
|
+
var match = href.match(/RU=([^/]+)\/RK=/);
|
|
15
|
+
if (match && match[1]) {
|
|
16
|
+
try {
|
|
17
|
+
return toHttpsUrl(decodeURIComponent(match[1]), 'https://search.yahoo.com');
|
|
18
|
+
} catch {
|
|
19
|
+
return toHttpsUrl(href, 'https://search.yahoo.com');
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return toHttpsUrl(href, 'https://search.yahoo.com');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function buildExtractorJs(limit) {
|
|
27
|
+
return `
|
|
28
|
+
(function() {
|
|
29
|
+
var results = [];
|
|
30
|
+
var seen = {};
|
|
31
|
+
var items = document.querySelectorAll('.algo');
|
|
32
|
+
for (var i = 0; i < items.length; i++) {
|
|
33
|
+
if (results.length >= ${limit}) break;
|
|
34
|
+
var el = items[i];
|
|
35
|
+
var h3 = el.querySelector('h3');
|
|
36
|
+
var linkEl = el.querySelector('.compTitle a');
|
|
37
|
+
var snippetEl = el.querySelector('.compText');
|
|
38
|
+
if (!h3 || !linkEl) continue;
|
|
39
|
+
var title = h3.textContent.trim();
|
|
40
|
+
var href = linkEl.getAttribute('href') || '';
|
|
41
|
+
var snippet = snippetEl ? snippetEl.textContent.trim() : '';
|
|
42
|
+
if (!title || !href || seen[href]) continue;
|
|
43
|
+
seen[href] = true;
|
|
44
|
+
results.push([title, href, snippet]);
|
|
45
|
+
}
|
|
46
|
+
return results;
|
|
47
|
+
})()`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const command = cli({
|
|
51
|
+
site: 'yahoo',
|
|
52
|
+
name: 'search',
|
|
53
|
+
access: 'read',
|
|
54
|
+
description: 'Search Yahoo (powered by Bing)',
|
|
55
|
+
domain: 'search.yahoo.com',
|
|
56
|
+
strategy: Strategy.PUBLIC,
|
|
57
|
+
browser: true,
|
|
58
|
+
args: [
|
|
59
|
+
{ name: 'keyword', positional: true, required: true, help: 'Search query' },
|
|
60
|
+
{ name: 'limit', type: 'int', default: 7, help: 'Number of results per page (max 7)' },
|
|
61
|
+
{ name: 'page', type: 'int', default: 1, help: 'Page number (1, 2, 3...). Yahoo returns ~7 results per page' },
|
|
62
|
+
],
|
|
63
|
+
columns: ['rank', 'title', 'url', 'snippet'],
|
|
64
|
+
func: async (page, kwargs) => {
|
|
65
|
+
const limit = requireBoundedInteger(kwargs.limit, 7, 1, 7, '--limit');
|
|
66
|
+
const query = requireSearchQuery(kwargs.keyword);
|
|
67
|
+
const keyword = encodeURIComponent(query);
|
|
68
|
+
const pageNum = requireBoundedInteger(kwargs.page, 1, 1, 100, '--page');
|
|
69
|
+
var url = `https://search.yahoo.com/search?p=${keyword}`;
|
|
70
|
+
if (pageNum > 1) url += `&b=${(pageNum - 1) * 7 + 1}`;
|
|
71
|
+
await runBrowserStep('yahoo search navigation', () => page.goto(url));
|
|
72
|
+
try {
|
|
73
|
+
await page.wait({ selector: '.algo', timeout: 10 });
|
|
74
|
+
} catch {
|
|
75
|
+
await page.wait(3).catch(function() {});
|
|
76
|
+
}
|
|
77
|
+
const raw = await runBrowserStep('yahoo search extraction', () => page.evaluate(buildExtractorJs(limit)));
|
|
78
|
+
const results = requireRows(raw, 'yahoo search');
|
|
79
|
+
if (results.length === 0) {
|
|
80
|
+
throw emptySearchResults('Yahoo', query);
|
|
81
|
+
}
|
|
82
|
+
const rows = results
|
|
83
|
+
.map(function(r, index) {
|
|
84
|
+
return { rank: index + 1 + (pageNum - 1) * 7, title: r[0], url: decodeYahooUrl(r[1]), snippet: r[2] };
|
|
85
|
+
})
|
|
86
|
+
.filter((row) => row.url);
|
|
87
|
+
if (rows.length === 0) throw emptySearchResults('Yahoo', query);
|
|
88
|
+
return rows;
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
export const __test__ = { command };
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const { __test__ } = await import('./search.js');
|
|
4
|
+
const command = __test__.command;
|
|
5
|
+
|
|
6
|
+
function createPageMock(evaluateResult = []) {
|
|
7
|
+
return {
|
|
8
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
9
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
10
|
+
evaluate: vi.fn().mockResolvedValue(evaluateResult),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('yahoo search', () => {
|
|
15
|
+
it('should register as a valid command', () => {
|
|
16
|
+
expect(command).toBeDefined();
|
|
17
|
+
expect(command.site).toBe('yahoo');
|
|
18
|
+
expect(command.name).toBe('search');
|
|
19
|
+
expect(command.access).toBe('read');
|
|
20
|
+
expect(command.browser).toBe(true);
|
|
21
|
+
expect(command.strategy).toBe('public');
|
|
22
|
+
expect(command.domain).toBe('search.yahoo.com');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should define keyword positional arg', () => {
|
|
26
|
+
const kwArg = command.args.find(a => a.name === 'keyword');
|
|
27
|
+
expect(kwArg).toBeDefined();
|
|
28
|
+
expect(kwArg.positional).toBe(true);
|
|
29
|
+
expect(kwArg.required).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('should define limit arg with default 7', () => {
|
|
33
|
+
const limitArg = command.args.find(a => a.name === 'limit');
|
|
34
|
+
expect(limitArg).toBeDefined();
|
|
35
|
+
expect(limitArg.type).toBe('int');
|
|
36
|
+
expect(limitArg.default).toBe(7);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should define output columns', () => {
|
|
40
|
+
expect(command.columns).toContain('rank');
|
|
41
|
+
expect(command.columns).toContain('title');
|
|
42
|
+
expect(command.columns).toContain('url');
|
|
43
|
+
expect(command.columns).toContain('snippet');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('rejects empty query, invalid limit, and invalid page before navigation', async () => {
|
|
47
|
+
const page = createPageMock();
|
|
48
|
+
await expect(command.func(page, { keyword: ' ', limit: 5 })).rejects.toMatchObject({ code: 'ARGUMENT' });
|
|
49
|
+
await expect(command.func(page, { keyword: 'opencli', limit: 8 })).rejects.toMatchObject({ code: 'ARGUMENT' });
|
|
50
|
+
await expect(command.func(page, { keyword: 'opencli', limit: 5, page: 0 })).rejects.toMatchObject({ code: 'ARGUMENT' });
|
|
51
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('decodes Yahoo redirect URLs and assigns listing rank', async () => {
|
|
55
|
+
const page = createPageMock({
|
|
56
|
+
session: 'site:yahoo',
|
|
57
|
+
data: [[
|
|
58
|
+
'OpenCLI',
|
|
59
|
+
'https://r.search.yahoo.com/_ylt=x/RU=https%3A%2F%2Fgithub.com%2Fjackwener%2FOpenCLI/RK=2/RS=x',
|
|
60
|
+
'CLI browser tooling',
|
|
61
|
+
]],
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
await expect(command.func(page, { keyword: 'opencli', limit: 1, page: 2 })).resolves.toEqual([{
|
|
65
|
+
rank: 8,
|
|
66
|
+
title: 'OpenCLI',
|
|
67
|
+
url: 'https://github.com/jackwener/OpenCLI',
|
|
68
|
+
snippet: 'CLI browser tooling',
|
|
69
|
+
}]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('drops decoded Yahoo redirect targets that are not http(s) URLs', async () => {
|
|
73
|
+
const page = createPageMock([
|
|
74
|
+
[
|
|
75
|
+
'Bad redirect',
|
|
76
|
+
'https://r.search.yahoo.com/_ylt=x/RU=javascript%3Aalert(1)/RK=2/RS=x',
|
|
77
|
+
'should not be emitted',
|
|
78
|
+
],
|
|
79
|
+
]);
|
|
80
|
+
|
|
81
|
+
await expect(command.func(page, { keyword: 'opencli', limit: 1 })).rejects.toMatchObject({
|
|
82
|
+
code: 'EMPTY_RESULT',
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('fails typed instead of silently returning [] for malformed extraction payloads', async () => {
|
|
87
|
+
const page = createPageMock({ rows: [] });
|
|
88
|
+
|
|
89
|
+
await expect(command.func(page, { keyword: 'opencli', limit: 1 })).rejects.toMatchObject({
|
|
90
|
+
code: 'COMMAND_EXEC',
|
|
91
|
+
message: expect.stringContaining('payload shape'),
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
});
|
package/dist/src/cli.js
CHANGED
|
@@ -545,7 +545,7 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
|
|
|
545
545
|
for (const ext of externalClis) {
|
|
546
546
|
const isInstalled = isBinaryInstalled(ext.binary);
|
|
547
547
|
const tag = isInstalled ? '[installed]' : '[auto-install]';
|
|
548
|
-
console.log(` ${ext
|
|
548
|
+
console.log(` ${formatExternalCliLabel(ext)} ${tag}${ext.description ? ` — ${ext.description}` : ''}`);
|
|
549
549
|
}
|
|
550
550
|
console.log();
|
|
551
551
|
}
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
- name: ntn
|
|
18
18
|
binary: ntn
|
|
19
|
+
package: notion
|
|
19
20
|
description: "Notion CLI — official Notion API CLI for pages, databases, blocks, search, comments"
|
|
20
21
|
homepage: "https://ntn.dev"
|
|
21
22
|
tags: [notion, notes, knowledge, productivity]
|
|
@@ -36,8 +37,18 @@
|
|
|
36
37
|
install:
|
|
37
38
|
default: "npm install -g @larksuite/cli"
|
|
38
39
|
|
|
40
|
+
- name: longbridge
|
|
41
|
+
binary: longbridge
|
|
42
|
+
description: "Longbridge CLI — AI-native market data, account management and trading commands for Longbridge OpenAPI"
|
|
43
|
+
homepage: "https://open.longbridge.com/zh-CN/docs/cli/"
|
|
44
|
+
tags: [longbridge, finance, trading, market-data, openapi, ai-agent]
|
|
45
|
+
install:
|
|
46
|
+
mac: "brew install --cask longbridge/tap/longbridge-terminal"
|
|
47
|
+
windows: "scoop install https://open.longbridge.com/longbridge/longbridge-terminal/longbridge.json"
|
|
48
|
+
|
|
39
49
|
- name: dws
|
|
40
50
|
binary: dws
|
|
51
|
+
package: DingTalk Workspace
|
|
41
52
|
description: "DingTalk Workspace CLI — messages, docs, calendar, contacts and more for humans and AI agents"
|
|
42
53
|
homepage: "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
|
43
54
|
tags: [dingtalk, collaboration, productivity, ai-agent]
|
|
@@ -47,6 +58,7 @@
|
|
|
47
58
|
|
|
48
59
|
- name: wecom-cli
|
|
49
60
|
binary: wecom-cli
|
|
61
|
+
package: 企业微信
|
|
50
62
|
description: "WeCom/企业微信 CLI — contacts, todos, meetings, messages, calendar, docs and smart sheets for AI agents"
|
|
51
63
|
homepage: "https://github.com/WecomTeam/wecom-cli"
|
|
52
64
|
tags: [wecom, wechat-work, collaboration, productivity, ai-agent]
|
package/dist/src/external.d.ts
CHANGED
|
@@ -8,7 +8,12 @@ export interface ExternalCliConfig {
|
|
|
8
8
|
/** User-facing OpenCLI subcommand and, by default, the executable name. */
|
|
9
9
|
name: string;
|
|
10
10
|
binary: string;
|
|
11
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* Display alias rendered alongside `name` in help/listing as `name(package)`.
|
|
13
|
+
* Use either the upstream distribution/project name (e.g. `tg-cli`, `discord-cli`)
|
|
14
|
+
* or a human-readable brand label (e.g. `notion`, `企业微信`) when the bare
|
|
15
|
+
* executable name is ambiguous.
|
|
16
|
+
*/
|
|
12
17
|
package?: string;
|
|
13
18
|
description?: string;
|
|
14
19
|
homepage?: string;
|
|
@@ -44,6 +44,21 @@ describe('parseCommand', () => {
|
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
46
|
});
|
|
47
|
+
it('registers Longbridge with safe package-manager installers only', () => {
|
|
48
|
+
const raw = fs.readFileSync(path.join(__dirname, 'external-clis.yaml'), 'utf8');
|
|
49
|
+
const entries = (yaml.load(raw) || []);
|
|
50
|
+
const longbridge = entries.find((entry) => entry.name === 'longbridge');
|
|
51
|
+
expect(longbridge).toMatchObject({
|
|
52
|
+
binary: 'longbridge',
|
|
53
|
+
homepage: 'https://open.longbridge.com/zh-CN/docs/cli/',
|
|
54
|
+
install: {
|
|
55
|
+
mac: 'brew install --cask longbridge/tap/longbridge-terminal',
|
|
56
|
+
windows: 'scoop install https://open.longbridge.com/longbridge/longbridge-terminal/longbridge.json',
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
expect(longbridge?.install?.linux).toBeUndefined();
|
|
60
|
+
expect(longbridge?.install?.default).toBeUndefined();
|
|
61
|
+
});
|
|
47
62
|
});
|
|
48
63
|
describe('formatExternalCliLabel', () => {
|
|
49
64
|
it('shows the package name when the executable name differs', () => {
|
|
@@ -52,6 +67,10 @@ describe('formatExternalCliLabel', () => {
|
|
|
52
67
|
it('keeps the label compact when package and name match', () => {
|
|
53
68
|
expect(formatExternalCliLabel({ name: 'docker', binary: 'docker', package: 'docker' })).toBe('docker');
|
|
54
69
|
});
|
|
70
|
+
it('renders a human-readable brand alias for ambiguous executable names', () => {
|
|
71
|
+
expect(formatExternalCliLabel({ name: 'ntn', binary: 'ntn', package: 'notion' })).toBe('ntn(notion)');
|
|
72
|
+
expect(formatExternalCliLabel({ name: 'wecom-cli', binary: 'wecom-cli', package: '企业微信' })).toBe('wecom-cli(企业微信)');
|
|
73
|
+
});
|
|
55
74
|
});
|
|
56
75
|
describe('installExternalCli', () => {
|
|
57
76
|
const cli = {
|