@jackwener/opencli 1.7.19 → 1.7.21
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 +11 -9
- package/README.zh-CN.md +9 -10
- package/cli-manifest.json +239 -249
- 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 +240 -11
- 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 +5 -4
- package/clis/twitter/bookmark-folder.test.js +59 -1
- package/clis/twitter/bookmark-folders.js +0 -1
- package/clis/twitter/bookmarks.js +9 -4
- package/clis/twitter/bookmarks.test.js +205 -0
- 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/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/browser/daemon-client.d.ts +1 -0
- package/dist/src/browser/daemon-client.js +3 -0
- package/dist/src/browser/daemon-client.test.js +20 -0
- package/dist/src/cli.js +8 -3
- package/dist/src/cli.test.js +1 -0
- package/dist/src/daemon-utils.d.ts +18 -0
- package/dist/src/daemon-utils.js +37 -0
- package/dist/src/daemon.d.ts +1 -1
- package/dist/src/daemon.js +44 -13
- package/dist/src/daemon.test.js +42 -1
- package/dist/src/electron-apps.js +0 -1
- package/dist/src/electron-apps.test.js +1 -0
- package/dist/src/external-clis.yaml +12 -3
- package/dist/src/external.d.ts +4 -0
- package/dist/src/external.js +3 -0
- package/dist/src/external.test.js +24 -1
- package/dist/src/help.d.ts +5 -1
- package/dist/src/help.js +4 -3
- package/dist/src/help.test.js +5 -1
- package/package.json +1 -1
- package/clis/notion/export.js +0 -32
- package/clis/notion/favorites.js +0 -85
- package/clis/notion/new.js +0 -35
- package/clis/notion/read.js +0 -31
- package/clis/notion/search.js +0 -47
- package/clis/notion/sidebar.js +0 -42
- package/clis/notion/status.js +0 -17
- package/clis/notion/write.js +0 -41
package/clis/twitter/trending.js
CHANGED
package/clis/twitter/tweets.js
CHANGED
|
@@ -221,7 +221,6 @@ cli({
|
|
|
221
221
|
domain: 'x.com',
|
|
222
222
|
strategy: Strategy.COOKIE,
|
|
223
223
|
browser: true,
|
|
224
|
-
siteSession: 'persistent',
|
|
225
224
|
args: [
|
|
226
225
|
{ name: 'username', type: 'string', positional: true, help: 'Twitter screen name (with or without @). Defaults to the logged-in user when omitted.' },
|
|
227
226
|
{ name: 'limit', type: 'int', default: 20, help: 'Max tweets to return' },
|
|
@@ -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
|
+
});
|
|
@@ -105,6 +105,9 @@ async function sendCommandRaw(action, params) {
|
|
|
105
105
|
});
|
|
106
106
|
const result = (await res.json());
|
|
107
107
|
if (!result.ok) {
|
|
108
|
+
if (result.errorCode === 'command_result_unknown') {
|
|
109
|
+
throw new BrowserCommandError(result.error ?? 'Browser command result is unknown', result.errorCode, result.errorHint);
|
|
110
|
+
}
|
|
108
111
|
const isDuplicateCommandId = res.status === 409
|
|
109
112
|
|| (result.error ?? '').includes('Duplicate command id');
|
|
110
113
|
if (isDuplicateCommandId && attempt < maxRetries) {
|
|
@@ -176,4 +176,24 @@ describe('daemon-client', () => {
|
|
|
176
176
|
});
|
|
177
177
|
expect(ids[0]).not.toBe(ids[1]);
|
|
178
178
|
});
|
|
179
|
+
it('sendCommand does not retry command_result_unknown even when the message looks transient', async () => {
|
|
180
|
+
const fetchMock = vi.mocked(fetch);
|
|
181
|
+
fetchMock.mockResolvedValue({
|
|
182
|
+
ok: false,
|
|
183
|
+
status: 503,
|
|
184
|
+
json: () => Promise.resolve({
|
|
185
|
+
id: 'server',
|
|
186
|
+
ok: false,
|
|
187
|
+
errorCode: 'command_result_unknown',
|
|
188
|
+
error: 'Extension disconnected after command timeout',
|
|
189
|
+
errorHint: 'Inspect state before retrying.',
|
|
190
|
+
}),
|
|
191
|
+
});
|
|
192
|
+
await expect(sendCommand('exec', { code: 'window.__mutate = true' })).rejects.toMatchObject({
|
|
193
|
+
name: 'BrowserCommandError',
|
|
194
|
+
code: 'command_result_unknown',
|
|
195
|
+
hint: 'Inspect state before retrying.',
|
|
196
|
+
});
|
|
197
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
198
|
+
});
|
|
179
199
|
});
|
package/dist/src/cli.js
CHANGED
|
@@ -15,7 +15,7 @@ import { serializeCommand, formatArgSummary } from './serialization.js';
|
|
|
15
15
|
import { render as renderOutput } from './output.js';
|
|
16
16
|
import { PKG_VERSION } from './version.js';
|
|
17
17
|
import { printCompletionScript } from './completion.js';
|
|
18
|
-
import { loadExternalClis, executeExternalCli, installExternalCli, registerExternalCli, isBinaryInstalled } from './external.js';
|
|
18
|
+
import { loadExternalClis, executeExternalCli, installExternalCli, registerExternalCli, isBinaryInstalled, formatExternalCliLabel } from './external.js';
|
|
19
19
|
import { registerAllCommands } from './commanderAdapter.js';
|
|
20
20
|
import { classifyAdapter, formatRootAdapterHelpText, installCommanderNamespaceStructuredHelp, installStructuredHelp, leadingPositionalFromUsage, rootHelpData } from './help.js';
|
|
21
21
|
import { EXIT_CODES, getErrorMessage, BrowserConnectError } from './errors.js';
|
|
@@ -3005,6 +3005,7 @@ cli({
|
|
|
3005
3005
|
.action((opts) => {
|
|
3006
3006
|
const rows = loadExternalClis().map((ext) => ({
|
|
3007
3007
|
name: ext.name,
|
|
3008
|
+
package: ext.package ?? '',
|
|
3008
3009
|
binary: ext.binary,
|
|
3009
3010
|
installed: isBinaryInstalled(ext.binary),
|
|
3010
3011
|
description: ext.description ?? '',
|
|
@@ -3013,7 +3014,7 @@ cli({
|
|
|
3013
3014
|
}));
|
|
3014
3015
|
renderOutput(rows, {
|
|
3015
3016
|
fmt: opts.format,
|
|
3016
|
-
columns: ['name', 'binary', 'installed', 'description', 'homepage', 'tags'],
|
|
3017
|
+
columns: ['name', 'package', 'binary', 'installed', 'description', 'homepage', 'tags'],
|
|
3017
3018
|
title: 'opencli/external/list',
|
|
3018
3019
|
source: 'opencli external list',
|
|
3019
3020
|
});
|
|
@@ -3067,6 +3068,10 @@ cli({
|
|
|
3067
3068
|
// Classification derives from each adapter's `domain` field — see classifyAdapter.
|
|
3068
3069
|
// External CLIs are taken from the externalClis registry (passthrough binaries).
|
|
3069
3070
|
const externalNames = externalClis.map(ext => ext.name);
|
|
3071
|
+
const externalHelpEntries = externalClis.map(ext => ({
|
|
3072
|
+
name: ext.name,
|
|
3073
|
+
label: formatExternalCliLabel(ext),
|
|
3074
|
+
}));
|
|
3070
3075
|
const siteDomains = new Map();
|
|
3071
3076
|
for (const [, cmd] of getRegistry()) {
|
|
3072
3077
|
if (!siteDomains.has(cmd.site))
|
|
@@ -3080,7 +3085,7 @@ cli({
|
|
|
3080
3085
|
else
|
|
3081
3086
|
sites.push(site);
|
|
3082
3087
|
}
|
|
3083
|
-
const adapterGroups = { external:
|
|
3088
|
+
const adapterGroups = { external: externalHelpEntries, apps, sites };
|
|
3084
3089
|
const adapterNameSet = new Set([...externalNames, ...siteNames]);
|
|
3085
3090
|
installCommanderNamespaceStructuredHelp(browser, { globalCommand: program, description: originalBrowserDescription });
|
|
3086
3091
|
installCommanderNamespaceStructuredHelp(daemonCmd, { globalCommand: program, description: originalDaemonDescription });
|
package/dist/src/cli.test.js
CHANGED
|
@@ -168,6 +168,7 @@ describe('createProgram root help descriptions', () => {
|
|
|
168
168
|
expect(data.site_adapters.sites).toEqual(['bilibili']);
|
|
169
169
|
expect(data.external_clis.count).toBeGreaterThanOrEqual(0);
|
|
170
170
|
expect(Array.isArray(data.external_clis.clis)).toBe(true);
|
|
171
|
+
expect(Array.isArray(data.external_clis.display)).toBe(true);
|
|
171
172
|
// Adapters must NOT leak into the core commands list
|
|
172
173
|
const commandNames = data.commands.map((cmd) => cmd.name);
|
|
173
174
|
expect(commandNames).not.toContain('bilibili');
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export declare const COMMAND_RESULT_UNKNOWN_CODE = "command_result_unknown";
|
|
2
|
+
export declare const COMMAND_RESULT_UNKNOWN_HINT = "Inspect the browser/session state before retrying. Do not blindly retry write commands such as navigate, click, type, or eval.";
|
|
3
|
+
export declare const PROFILE_DISCONNECTED_HINT = "Open that Chrome profile and make sure the OpenCLI extension is enabled, or choose another profile with opencli profile use <name>.";
|
|
4
|
+
export type DaemonFailureContract = {
|
|
5
|
+
message: string;
|
|
6
|
+
errorCode: string;
|
|
7
|
+
errorHint: string;
|
|
8
|
+
status: number;
|
|
9
|
+
countAsCommandResultUnknown: boolean;
|
|
10
|
+
};
|
|
11
|
+
export declare function commandResultUnknownMessage(action: string): string;
|
|
12
|
+
export declare function buildExtensionDisconnectFailure(input: {
|
|
13
|
+
contextId: string;
|
|
14
|
+
action: string;
|
|
15
|
+
dispatched: boolean;
|
|
16
|
+
}): DaemonFailureContract;
|
|
17
|
+
export declare function buildCommandDispatchFailure(contextId: string): DaemonFailureContract;
|
|
18
|
+
export declare function getResponseCorsHeaders(pathname: string, origin?: string): Record<string, string> | undefined;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export const COMMAND_RESULT_UNKNOWN_CODE = 'command_result_unknown';
|
|
2
|
+
export const COMMAND_RESULT_UNKNOWN_HINT = 'Inspect the browser/session state before retrying. Do not blindly retry write commands such as navigate, click, type, or eval.';
|
|
3
|
+
export const PROFILE_DISCONNECTED_HINT = 'Open that Chrome profile and make sure the OpenCLI extension is enabled, or choose another profile with opencli profile use <name>.';
|
|
4
|
+
export function commandResultUnknownMessage(action) {
|
|
5
|
+
return `Browser connection dropped after the ${action} command was dispatched; it may have completed.`;
|
|
6
|
+
}
|
|
7
|
+
export function buildExtensionDisconnectFailure(input) {
|
|
8
|
+
if (input.dispatched) {
|
|
9
|
+
return {
|
|
10
|
+
message: commandResultUnknownMessage(input.action),
|
|
11
|
+
errorCode: COMMAND_RESULT_UNKNOWN_CODE,
|
|
12
|
+
errorHint: COMMAND_RESULT_UNKNOWN_HINT,
|
|
13
|
+
status: 503,
|
|
14
|
+
countAsCommandResultUnknown: true,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
return buildCommandDispatchFailure(input.contextId);
|
|
18
|
+
}
|
|
19
|
+
export function buildCommandDispatchFailure(contextId) {
|
|
20
|
+
return {
|
|
21
|
+
message: `Browser profile "${contextId}" disconnected before command dispatch`,
|
|
22
|
+
errorCode: 'profile_disconnected',
|
|
23
|
+
errorHint: PROFILE_DISCONNECTED_HINT,
|
|
24
|
+
status: 503,
|
|
25
|
+
countAsCommandResultUnknown: false,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export function getResponseCorsHeaders(pathname, origin) {
|
|
29
|
+
if (pathname !== '/ping')
|
|
30
|
+
return undefined;
|
|
31
|
+
if (!origin || !origin.startsWith('chrome-extension://'))
|
|
32
|
+
return undefined;
|
|
33
|
+
return {
|
|
34
|
+
'Access-Control-Allow-Origin': origin,
|
|
35
|
+
Vary: 'Origin',
|
|
36
|
+
};
|
|
37
|
+
}
|
package/dist/src/daemon.d.ts
CHANGED
package/dist/src/daemon.js
CHANGED
|
@@ -27,9 +27,11 @@ import { log } from './logger.js';
|
|
|
27
27
|
import { PKG_VERSION } from './version.js';
|
|
28
28
|
import { DEFAULT_CONTEXT_ID } from './browser/profile.js';
|
|
29
29
|
import { recordExtensionVersion } from './update-check.js';
|
|
30
|
+
import { buildCommandDispatchFailure, buildExtensionDisconnectFailure, getResponseCorsHeaders, } from './daemon-utils.js';
|
|
30
31
|
const PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
|
|
31
32
|
const extensionProfiles = new Map();
|
|
32
33
|
const pending = new Map();
|
|
34
|
+
let commandResultUnknownCount = 0;
|
|
33
35
|
const LOG_BUFFER_SIZE = 200;
|
|
34
36
|
const logBuffer = [];
|
|
35
37
|
class DaemonCommandFailure extends Error {
|
|
@@ -110,7 +112,16 @@ function unregisterExtensionConnection(ws) {
|
|
|
110
112
|
if (p.contextId !== contextId)
|
|
111
113
|
continue;
|
|
112
114
|
clearTimeout(p.timer);
|
|
113
|
-
|
|
115
|
+
const failure = buildExtensionDisconnectFailure({
|
|
116
|
+
contextId,
|
|
117
|
+
action: p.action,
|
|
118
|
+
dispatched: p.dispatched,
|
|
119
|
+
});
|
|
120
|
+
if (failure.countAsCommandResultUnknown) {
|
|
121
|
+
commandResultUnknownCount++;
|
|
122
|
+
log.warn(`[daemon] Command result unknown after extension disconnect (id=${id}, action=${p.action}, context=${contextId})`);
|
|
123
|
+
}
|
|
124
|
+
p.reject(new DaemonCommandFailure(failure.message, failure.errorCode, failure.errorHint, failure.status));
|
|
114
125
|
pending.delete(id);
|
|
115
126
|
}
|
|
116
127
|
}
|
|
@@ -142,16 +153,6 @@ function jsonResponse(res, status, data, extraHeaders) {
|
|
|
142
153
|
res.writeHead(status, { 'Content-Type': 'application/json', ...extraHeaders });
|
|
143
154
|
res.end(JSON.stringify(data));
|
|
144
155
|
}
|
|
145
|
-
export function getResponseCorsHeaders(pathname, origin) {
|
|
146
|
-
if (pathname !== '/ping')
|
|
147
|
-
return undefined;
|
|
148
|
-
if (!origin || !origin.startsWith('chrome-extension://'))
|
|
149
|
-
return undefined;
|
|
150
|
-
return {
|
|
151
|
-
'Access-Control-Allow-Origin': origin,
|
|
152
|
-
Vary: 'Origin',
|
|
153
|
-
};
|
|
154
|
-
}
|
|
155
156
|
async function handleRequest(req, res) {
|
|
156
157
|
// ─── Security: Origin & custom-header check ──────────────────────
|
|
157
158
|
// Block browser-based CSRF: browsers always send an Origin header on
|
|
@@ -219,6 +220,7 @@ async function handleRequest(req, res) {
|
|
|
219
220
|
profileDisconnected: route.errorCode === 'profile_disconnected',
|
|
220
221
|
profiles,
|
|
221
222
|
pending: pending.size,
|
|
223
|
+
commandResultUnknown: commandResultUnknownCount,
|
|
222
224
|
memoryMB: Math.round(mem.rss / 1024 / 1024 * 10) / 10,
|
|
223
225
|
port: PORT,
|
|
224
226
|
});
|
|
@@ -277,8 +279,37 @@ async function handleRequest(req, res) {
|
|
|
277
279
|
pending.delete(body.id);
|
|
278
280
|
reject(new Error(`Command timeout (${timeoutMs / 1000}s)`));
|
|
279
281
|
}, timeoutMs);
|
|
280
|
-
|
|
281
|
-
|
|
282
|
+
const entry = {
|
|
283
|
+
contextId: route.connection.contextId,
|
|
284
|
+
action: typeof body.action === 'string' ? body.action : 'unknown',
|
|
285
|
+
dispatched: false,
|
|
286
|
+
resolve,
|
|
287
|
+
reject,
|
|
288
|
+
timer,
|
|
289
|
+
};
|
|
290
|
+
pending.set(body.id, entry);
|
|
291
|
+
const failBeforeDispatch = (err) => {
|
|
292
|
+
if (pending.get(body.id) !== entry)
|
|
293
|
+
return;
|
|
294
|
+
const failure = buildCommandDispatchFailure(entry.contextId);
|
|
295
|
+
clearTimeout(timer);
|
|
296
|
+
pending.delete(body.id);
|
|
297
|
+
reject(new DaemonCommandFailure(failure.message, failure.errorCode, failure.errorHint, failure.status));
|
|
298
|
+
log.warn(`[daemon] Failed to dispatch command ${body.id}: ${err instanceof Error ? err.message : String(err)}`);
|
|
299
|
+
};
|
|
300
|
+
try {
|
|
301
|
+
route.connection.ws.send(JSON.stringify(body), (err) => {
|
|
302
|
+
if (err && !entry.dispatched)
|
|
303
|
+
failBeforeDispatch(err);
|
|
304
|
+
});
|
|
305
|
+
// Once ws accepts the frame, the command may execute even if the
|
|
306
|
+
// result is later lost; do not downgrade later disconnects to a
|
|
307
|
+
// pre-dispatch failure just because no result/ack has arrived yet.
|
|
308
|
+
entry.dispatched = true;
|
|
309
|
+
}
|
|
310
|
+
catch (err) {
|
|
311
|
+
failBeforeDispatch(err);
|
|
312
|
+
}
|
|
282
313
|
});
|
|
283
314
|
jsonResponse(res, 200, result);
|
|
284
315
|
}
|