@jackwener/opencli 1.7.17 → 1.7.18
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 +3 -0
- package/README.zh-CN.md +2 -0
- package/cli-manifest.json +280 -0
- package/clis/doubao/utils.js +17 -0
- package/clis/doubao/utils.test.js +61 -0
- package/clis/reddit/reply.js +182 -0
- package/clis/reddit/reply.test.js +89 -0
- package/clis/rednote/comments.js +76 -0
- package/clis/rednote/download.js +59 -0
- package/clis/rednote/feed.js +95 -0
- package/clis/rednote/navigation.test.js +26 -0
- package/clis/rednote/note.js +68 -0
- package/clis/rednote/notifications.js +139 -0
- package/clis/rednote/rednote.test.js +157 -0
- package/clis/rednote/search.js +97 -0
- package/clis/rednote/user.js +55 -0
- package/clis/xiaohongshu/comments.js +34 -24
- package/clis/xiaohongshu/download.js +32 -23
- package/clis/xiaohongshu/feed.js +23 -15
- package/clis/xiaohongshu/note-helpers.js +16 -6
- package/clis/xiaohongshu/note.js +26 -20
- package/clis/xiaohongshu/notifications.js +26 -19
- package/clis/xiaohongshu/search.js +37 -28
- package/clis/xiaohongshu/user-helpers.js +13 -4
- package/clis/xiaohongshu/user-helpers.test.js +20 -0
- package/clis/xiaohongshu/user.js +9 -4
- package/clis/youtube/transcript.js +28 -3
- package/clis/youtube/transcript.test.js +90 -1
- package/dist/src/cli.js +3 -3
- package/dist/src/cli.test.js +8 -3
- package/dist/src/doctor.js +6 -1
- package/dist/src/doctor.test.js +2 -0
- package/package.json +1 -1
|
@@ -1,23 +1,11 @@
|
|
|
1
1
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
browser: true,
|
|
10
|
-
args: [
|
|
11
|
-
{
|
|
12
|
-
name: 'type',
|
|
13
|
-
default: 'mentions',
|
|
14
|
-
help: 'Notification type: mentions, likes, or connections',
|
|
15
|
-
},
|
|
16
|
-
{ name: 'limit', type: 'int', default: 20, help: 'Number of notifications to return' },
|
|
17
|
-
],
|
|
18
|
-
columns: ['rank', 'user', 'action', 'content', 'note', 'time'],
|
|
19
|
-
pipeline: [
|
|
20
|
-
{ navigate: 'https://www.xiaohongshu.com/notification' },
|
|
2
|
+
/**
|
|
3
|
+
* Build the notifications pipeline for the given web host. Exported so the
|
|
4
|
+
* rednote adapter can register the same pipeline against www.rednote.com.
|
|
5
|
+
*/
|
|
6
|
+
export function buildNotificationsPipeline(webHost) {
|
|
7
|
+
return [
|
|
8
|
+
{ navigate: `https://${webHost}/notification` },
|
|
21
9
|
{ tap: {
|
|
22
10
|
store: 'notification',
|
|
23
11
|
action: 'getNotification',
|
|
@@ -35,5 +23,24 @@ cli({
|
|
|
35
23
|
time: '${{ item.time }}',
|
|
36
24
|
} },
|
|
37
25
|
{ limit: '${{ args.limit | default(20) }}' },
|
|
26
|
+
];
|
|
27
|
+
}
|
|
28
|
+
export const command = cli({
|
|
29
|
+
site: 'xiaohongshu',
|
|
30
|
+
name: 'notifications',
|
|
31
|
+
access: 'read',
|
|
32
|
+
description: '小红书通知 (mentions/likes/connections)',
|
|
33
|
+
domain: 'www.xiaohongshu.com',
|
|
34
|
+
strategy: Strategy.INTERCEPT,
|
|
35
|
+
browser: true,
|
|
36
|
+
args: [
|
|
37
|
+
{
|
|
38
|
+
name: 'type',
|
|
39
|
+
default: 'mentions',
|
|
40
|
+
help: 'Notification type: mentions, likes, or connections',
|
|
41
|
+
},
|
|
42
|
+
{ name: 'limit', type: 'int', default: 20, help: 'Number of notifications to return' },
|
|
38
43
|
],
|
|
44
|
+
columns: ['rank', 'user', 'action', 'content', 'note', 'time'],
|
|
45
|
+
pipeline: buildNotificationsPipeline('www.xiaohongshu.com'),
|
|
39
46
|
});
|
|
@@ -52,37 +52,19 @@ export function stripXhsAuthorDateSuffix(value) {
|
|
|
52
52
|
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();
|
|
53
53
|
return stripped || text;
|
|
54
54
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
args: [
|
|
64
|
-
{ name: 'query', required: true, positional: true, help: 'Search keyword' },
|
|
65
|
-
{ name: 'limit', type: 'int', default: 20, help: 'Number of results' },
|
|
66
|
-
],
|
|
67
|
-
columns: ['rank', 'title', 'author', 'likes', 'published_at', 'url'],
|
|
68
|
-
func: async (page, kwargs) => {
|
|
69
|
-
const keyword = encodeURIComponent(kwargs.query);
|
|
70
|
-
await page.goto(`https://www.xiaohongshu.com/search_result?keyword=${keyword}&source=web_search_result_notes`);
|
|
71
|
-
// Wait for search results to render (or login wall to appear).
|
|
72
|
-
// Uses MutationObserver to resolve as soon as content appears,
|
|
73
|
-
// instead of a fixed delay + blind retry.
|
|
74
|
-
const waitResult = await page.evaluate(WAIT_FOR_CONTENT_JS);
|
|
75
|
-
if (waitResult === 'login_wall') {
|
|
76
|
-
throw new AuthRequiredError('www.xiaohongshu.com', 'Xiaohongshu search results are blocked behind a login wall');
|
|
77
|
-
}
|
|
78
|
-
// Scroll a couple of times to load more results
|
|
79
|
-
await page.autoScroll({ times: 2 });
|
|
80
|
-
const payload = await page.evaluate(`
|
|
55
|
+
/**
|
|
56
|
+
* Build the search-result extraction IIFE. The web host is baked into the
|
|
57
|
+
* `normalizeUrl` fallback so relative `/explore/...` hrefs resolve to a full
|
|
58
|
+
* URL on the calling site. Exported so the rednote adapter can call it with
|
|
59
|
+
* `www.rednote.com` without duplicating the selector logic.
|
|
60
|
+
*/
|
|
61
|
+
export function buildSearchExtractJs(webHost) {
|
|
62
|
+
return `
|
|
81
63
|
(() => {
|
|
82
64
|
const normalizeUrl = (href) => {
|
|
83
65
|
if (!href) return '';
|
|
84
66
|
if (href.startsWith('http://') || href.startsWith('https://')) return href;
|
|
85
|
-
if (href.startsWith('/')) return 'https
|
|
67
|
+
if (href.startsWith('/')) return 'https://${webHost}' + href;
|
|
86
68
|
return '';
|
|
87
69
|
};
|
|
88
70
|
|
|
@@ -131,7 +113,34 @@ cli({
|
|
|
131
113
|
|
|
132
114
|
return results;
|
|
133
115
|
})()
|
|
134
|
-
|
|
116
|
+
`;
|
|
117
|
+
}
|
|
118
|
+
export const command = cli({
|
|
119
|
+
site: 'xiaohongshu',
|
|
120
|
+
name: 'search',
|
|
121
|
+
access: 'read',
|
|
122
|
+
description: '搜索小红书笔记',
|
|
123
|
+
domain: 'www.xiaohongshu.com',
|
|
124
|
+
strategy: Strategy.COOKIE,
|
|
125
|
+
navigateBefore: false,
|
|
126
|
+
args: [
|
|
127
|
+
{ name: 'query', required: true, positional: true, help: 'Search keyword' },
|
|
128
|
+
{ name: 'limit', type: 'int', default: 20, help: 'Number of results' },
|
|
129
|
+
],
|
|
130
|
+
columns: ['rank', 'title', 'author', 'likes', 'published_at', 'url'],
|
|
131
|
+
func: async (page, kwargs) => {
|
|
132
|
+
const keyword = encodeURIComponent(kwargs.query);
|
|
133
|
+
await page.goto(`https://www.xiaohongshu.com/search_result?keyword=${keyword}&source=web_search_result_notes`);
|
|
134
|
+
// Wait for search results to render (or login wall to appear).
|
|
135
|
+
// Uses MutationObserver to resolve as soon as content appears,
|
|
136
|
+
// instead of a fixed delay + blind retry.
|
|
137
|
+
const waitResult = await page.evaluate(WAIT_FOR_CONTENT_JS);
|
|
138
|
+
if (waitResult === 'login_wall') {
|
|
139
|
+
throw new AuthRequiredError('www.xiaohongshu.com', 'Xiaohongshu search results are blocked behind a login wall');
|
|
140
|
+
}
|
|
141
|
+
// Scroll a couple of times to load more results
|
|
142
|
+
await page.autoScroll({ times: 2 });
|
|
143
|
+
const payload = await page.evaluate(buildSearchExtractJs('www.xiaohongshu.com'));
|
|
135
144
|
const data = Array.isArray(payload) ? payload : [];
|
|
136
145
|
return data
|
|
137
146
|
.filter((item) => item.title)
|
|
@@ -27,12 +27,17 @@ export function flattenXhsNoteGroups(noteGroups) {
|
|
|
27
27
|
}
|
|
28
28
|
return notes;
|
|
29
29
|
}
|
|
30
|
-
|
|
30
|
+
/**
|
|
31
|
+
* Build a signed user-profile note URL on the given web host (defaults to
|
|
32
|
+
* `www.xiaohongshu.com`). The rednote adapter passes `'www.rednote.com'` so
|
|
33
|
+
* the same builder works for both sites.
|
|
34
|
+
*/
|
|
35
|
+
export function buildXhsNoteUrl(userId, noteId, xsecToken, webHost = 'www.xiaohongshu.com') {
|
|
31
36
|
const cleanUserId = toCleanString(userId);
|
|
32
37
|
const cleanNoteId = toCleanString(noteId);
|
|
33
38
|
if (!cleanUserId || !cleanNoteId)
|
|
34
39
|
return '';
|
|
35
|
-
const url = new URL(`https
|
|
40
|
+
const url = new URL(`https://${webHost}/user/profile/${cleanUserId}/${cleanNoteId}`);
|
|
36
41
|
const cleanToken = toCleanString(xsecToken);
|
|
37
42
|
if (cleanToken) {
|
|
38
43
|
url.searchParams.set('xsec_token', cleanToken);
|
|
@@ -40,7 +45,11 @@ export function buildXhsNoteUrl(userId, noteId, xsecToken) {
|
|
|
40
45
|
}
|
|
41
46
|
return url.toString();
|
|
42
47
|
}
|
|
43
|
-
|
|
48
|
+
/**
|
|
49
|
+
* Normalise a Pinia user-store snapshot into CLI rows. `webHost` is forwarded
|
|
50
|
+
* to `buildXhsNoteUrl` so the resulting URLs point at the calling site.
|
|
51
|
+
*/
|
|
52
|
+
export function extractXhsUserNotes(snapshot, fallbackUserId, webHost = 'www.xiaohongshu.com') {
|
|
44
53
|
const notes = flattenXhsNoteGroups(snapshot.noteGroups);
|
|
45
54
|
const rows = [];
|
|
46
55
|
const seen = new Set();
|
|
@@ -62,7 +71,7 @@ export function extractXhsUserNotes(snapshot, fallbackUserId) {
|
|
|
62
71
|
type: toCleanString(noteCard.type),
|
|
63
72
|
likes,
|
|
64
73
|
cover,
|
|
65
|
-
url: buildXhsNoteUrl(userId || fallbackUserId, noteId, xsecToken),
|
|
74
|
+
url: buildXhsNoteUrl(userId || fallbackUserId, noteId, xsecToken, webHost),
|
|
66
75
|
});
|
|
67
76
|
}
|
|
68
77
|
return rows;
|
|
@@ -20,6 +20,9 @@ describe('buildXhsNoteUrl', () => {
|
|
|
20
20
|
it('includes xsec token when available', () => {
|
|
21
21
|
expect(buildXhsNoteUrl('user123', 'note456', 'token789')).toBe('https://www.xiaohongshu.com/user/profile/user123/note456?xsec_token=token789&xsec_source=pc_user');
|
|
22
22
|
});
|
|
23
|
+
it('emits a rednote URL when webHost is overridden', () => {
|
|
24
|
+
expect(buildXhsNoteUrl('user123', 'note456', 'token789', 'www.rednote.com')).toBe('https://www.rednote.com/user/profile/user123/note456?xsec_token=token789&xsec_source=pc_user');
|
|
25
|
+
});
|
|
23
26
|
});
|
|
24
27
|
describe('extractXhsUserNotes', () => {
|
|
25
28
|
it('normalizes grouped note cards into CLI rows', () => {
|
|
@@ -96,4 +99,21 @@ describe('extractXhsUserNotes', () => {
|
|
|
96
99
|
expect(rows).toHaveLength(1);
|
|
97
100
|
expect(rows[0]?.title).toBe('keep me');
|
|
98
101
|
});
|
|
102
|
+
it('emits rednote-hosted URLs when webHost is overridden', () => {
|
|
103
|
+
const rows = extractXhsUserNotes({
|
|
104
|
+
noteGroups: [
|
|
105
|
+
[
|
|
106
|
+
{
|
|
107
|
+
xsecToken: 'tok',
|
|
108
|
+
noteCard: {
|
|
109
|
+
noteId: 'note-red',
|
|
110
|
+
displayTitle: 'rednote note',
|
|
111
|
+
user: { userId: 'user-red' },
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
],
|
|
116
|
+
}, 'fallback-user', 'www.rednote.com');
|
|
117
|
+
expect(rows[0]?.url).toBe('https://www.rednote.com/user/profile/user-red/note-red?xsec_token=tok&xsec_source=pc_user');
|
|
118
|
+
});
|
|
99
119
|
});
|
package/clis/xiaohongshu/user.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
2
|
import { extractXhsUserNotes, normalizeXhsUserId } from './user-helpers.js';
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Host-agnostic IIFE that snapshots the user profile's Pinia store. Exported
|
|
5
|
+
* so the rednote adapter can reuse it without copying the safeClone block.
|
|
6
|
+
*/
|
|
7
|
+
export const USER_SNAPSHOT_JS = `
|
|
5
8
|
(() => {
|
|
6
9
|
const safeClone = (value) => {
|
|
7
10
|
try {
|
|
@@ -17,9 +20,11 @@ async function readUserSnapshot(page) {
|
|
|
17
20
|
pageData: safeClone(userStore.userPageData?._value || userStore.userPageData || {}),
|
|
18
21
|
};
|
|
19
22
|
})()
|
|
20
|
-
|
|
23
|
+
`;
|
|
24
|
+
async function readUserSnapshot(page) {
|
|
25
|
+
return await page.evaluate(USER_SNAPSHOT_JS);
|
|
21
26
|
}
|
|
22
|
-
cli({
|
|
27
|
+
export const command = cli({
|
|
23
28
|
site: 'xiaohongshu',
|
|
24
29
|
name: 'user',
|
|
25
30
|
access: 'read',
|
|
@@ -86,12 +86,37 @@ cli({
|
|
|
86
86
|
console.error(`Warning: --lang "${captionData.requestedLang}" not found. Using "${captionData.language}" instead. Available: ${captionData.available.join(', ')}`);
|
|
87
87
|
}
|
|
88
88
|
// Step 2: Fetch caption XML and parse segments
|
|
89
|
+
// Ensure caption URL requests srv3 XML format — YouTube may return empty
|
|
90
|
+
// responses when no explicit format is specified.
|
|
91
|
+
const originalCaptionUrl = captionData.captionUrl;
|
|
92
|
+
let captionUrl = originalCaptionUrl;
|
|
93
|
+
if (!/[&?]fmt=/.test(originalCaptionUrl)) {
|
|
94
|
+
captionUrl = originalCaptionUrl + (originalCaptionUrl.includes('?') ? '&' : '?') + 'fmt=srv3';
|
|
95
|
+
}
|
|
89
96
|
const segments = await page.evaluate(`
|
|
90
97
|
(async () => {
|
|
91
|
-
|
|
92
|
-
|
|
98
|
+
async function fetchCaptionXml(url) {
|
|
99
|
+
const resp = await fetch(url);
|
|
100
|
+
if (!resp.ok) return { error: 'Caption URL returned HTTP ' + resp.status };
|
|
101
|
+
return { xml: await resp.text() || '' };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const primaryUrl = ${JSON.stringify(captionUrl)};
|
|
105
|
+
const originalUrl = ${JSON.stringify(originalCaptionUrl)};
|
|
106
|
+
let result = await fetchCaptionXml(primaryUrl);
|
|
107
|
+
if (result.error) return result;
|
|
108
|
+
|
|
109
|
+
// If srv3 format returned an empty successful body, retry with the
|
|
110
|
+
// original URL. Do not hide HTTP/non-OK failures behind fallback.
|
|
111
|
+
if (!result.xml.length && originalUrl !== primaryUrl) {
|
|
112
|
+
result = await fetchCaptionXml(originalUrl);
|
|
113
|
+
if (result.error) {
|
|
114
|
+
return result;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const xml = result.xml;
|
|
93
118
|
|
|
94
|
-
if (!xml
|
|
119
|
+
if (!xml.length) {
|
|
95
120
|
return { error: 'Caption URL returned empty response' };
|
|
96
121
|
}
|
|
97
122
|
|
|
@@ -1,11 +1,37 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
3
|
import { dirname, resolve } from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
6
|
+
import './transcript.js';
|
|
5
7
|
|
|
6
8
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
9
|
const transcriptSource = readFileSync(resolve(__dirname, 'transcript.js'), 'utf8');
|
|
8
10
|
|
|
11
|
+
function createPageMock(captionUrl) {
|
|
12
|
+
const page = {
|
|
13
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
14
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
15
|
+
evaluate: vi.fn(),
|
|
16
|
+
};
|
|
17
|
+
page.evaluate
|
|
18
|
+
.mockResolvedValueOnce({
|
|
19
|
+
captionUrl,
|
|
20
|
+
language: 'en',
|
|
21
|
+
kind: 'manual',
|
|
22
|
+
available: ['en'],
|
|
23
|
+
requestedLang: null,
|
|
24
|
+
langMatched: false,
|
|
25
|
+
langPrefixMatched: false,
|
|
26
|
+
})
|
|
27
|
+
.mockResolvedValue([{ start: 1, end: 3, text: 'hello & world' }]);
|
|
28
|
+
return page;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
vi.unstubAllGlobals();
|
|
33
|
+
});
|
|
34
|
+
|
|
9
35
|
describe('youtube transcript source contract', () => {
|
|
10
36
|
it('gets caption tracks from watch page bootstrap data, not Android InnerTube', () => {
|
|
11
37
|
expect(transcriptSource).toContain("fetch('/watch?v='");
|
|
@@ -14,4 +40,67 @@ describe('youtube transcript source contract', () => {
|
|
|
14
40
|
expect(transcriptSource).not.toContain('/youtubei/v1/player');
|
|
15
41
|
expect(transcriptSource).not.toContain("clientName: 'ANDROID'");
|
|
16
42
|
});
|
|
43
|
+
|
|
44
|
+
it('normalizes caption URL to request srv3 XML format', () => {
|
|
45
|
+
expect(transcriptSource).toContain('fmt=srv3');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('checks HTTP status before reading caption response body', () => {
|
|
49
|
+
expect(transcriptSource).toContain('resp.ok');
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('youtube transcript caption fetch', () => {
|
|
54
|
+
const command = getRegistry().get('youtube/transcript');
|
|
55
|
+
|
|
56
|
+
it('requests srv3 when the caption track URL has no explicit format', async () => {
|
|
57
|
+
const page = createPageMock('https://www.youtube.com/api/timedtext?v=abc&lang=en');
|
|
58
|
+
|
|
59
|
+
const rows = await command.func(page, { url: 'abc', mode: 'raw' });
|
|
60
|
+
|
|
61
|
+
expect(page.evaluate.mock.calls[1][0]).toContain('const primaryUrl = "https://www.youtube.com/api/timedtext?v=abc&lang=en&fmt=srv3"');
|
|
62
|
+
expect(page.evaluate.mock.calls[1][0]).toContain('const originalUrl = "https://www.youtube.com/api/timedtext?v=abc&lang=en"');
|
|
63
|
+
expect(rows).toEqual([{ index: 1, start: '1.00s', end: '3.00s', text: 'hello & world' }]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('does not override an existing caption format', async () => {
|
|
67
|
+
const page = createPageMock('https://www.youtube.com/api/timedtext?v=abc&lang=en&fmt=vtt');
|
|
68
|
+
|
|
69
|
+
await command.func(page, { url: 'abc', mode: 'raw' });
|
|
70
|
+
|
|
71
|
+
expect(page.evaluate.mock.calls[1][0]).toContain('const primaryUrl = "https://www.youtube.com/api/timedtext?v=abc&lang=en&fmt=vtt"');
|
|
72
|
+
expect(page.evaluate.mock.calls[1][0]).toContain('const originalUrl = "https://www.youtube.com/api/timedtext?v=abc&lang=en&fmt=vtt"');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('falls back to the original URL only after an empty successful srv3 response', async () => {
|
|
76
|
+
const page = createPageMock('https://www.youtube.com/api/timedtext?v=abc&lang=en');
|
|
77
|
+
|
|
78
|
+
await command.func(page, { url: 'abc', mode: 'raw' });
|
|
79
|
+
|
|
80
|
+
const script = page.evaluate.mock.calls[1][0];
|
|
81
|
+
expect(script).toContain('if (!result.xml.length && originalUrl !== primaryUrl)');
|
|
82
|
+
expect(script).toContain('result = await fetchCaptionXml(originalUrl)');
|
|
83
|
+
expect(script).toContain('if (result.error) {');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('fails typed on caption HTTP errors instead of falling back silently', async () => {
|
|
87
|
+
const page = createPageMock('https://www.youtube.com/api/timedtext?v=abc&lang=en');
|
|
88
|
+
page.evaluate.mockReset();
|
|
89
|
+
page.evaluate
|
|
90
|
+
.mockResolvedValueOnce({
|
|
91
|
+
captionUrl: 'https://www.youtube.com/api/timedtext?v=abc&lang=en',
|
|
92
|
+
language: 'en',
|
|
93
|
+
kind: 'manual',
|
|
94
|
+
available: ['en'],
|
|
95
|
+
requestedLang: null,
|
|
96
|
+
langMatched: false,
|
|
97
|
+
langPrefixMatched: false,
|
|
98
|
+
})
|
|
99
|
+
.mockResolvedValueOnce({ error: 'Caption URL returned HTTP 503' });
|
|
100
|
+
|
|
101
|
+
await expect(command.func(page, { url: 'abc', mode: 'raw' })).rejects.toMatchObject({
|
|
102
|
+
code: 'COMMAND_EXEC',
|
|
103
|
+
message: expect.stringContaining('HTTP 503'),
|
|
104
|
+
});
|
|
105
|
+
});
|
|
17
106
|
});
|
package/dist/src/cli.js
CHANGED
|
@@ -600,7 +600,7 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
|
|
|
600
600
|
// All commands wrapped in browserAction() for consistent error handling.
|
|
601
601
|
const browser = program
|
|
602
602
|
.command('browser')
|
|
603
|
-
.
|
|
603
|
+
.requiredOption('--session <name>', 'Browser session to use (required)')
|
|
604
604
|
.option('--window <mode>', 'Browser window mode: foreground or background')
|
|
605
605
|
.description('Browser control — navigate, click, type, extract, wait (no LLM needed)');
|
|
606
606
|
const originalBrowserDescription = browser.description();
|
|
@@ -723,7 +723,7 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
|
|
|
723
723
|
};
|
|
724
724
|
}
|
|
725
725
|
browser.command('bind')
|
|
726
|
-
.option('--session <name>', 'Browser session name to bind')
|
|
726
|
+
.option('--session <name>', 'Browser session name to bind (required)')
|
|
727
727
|
.description('Bind the current Chrome tab/window to a browser session')
|
|
728
728
|
.action(async (optsOrCommand, maybeCommand) => {
|
|
729
729
|
const command = optsOrCommand instanceof Command ? optsOrCommand : maybeCommand;
|
|
@@ -754,7 +754,7 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
|
|
|
754
754
|
}
|
|
755
755
|
});
|
|
756
756
|
browser.command('unbind')
|
|
757
|
-
.option('--session <name>', 'Browser session name to detach')
|
|
757
|
+
.option('--session <name>', 'Browser session name to detach (required)')
|
|
758
758
|
.description('Detach a bound browser session without closing the user tab/window')
|
|
759
759
|
.action(async (optsOrCommand, maybeCommand) => {
|
|
760
760
|
const command = optsOrCommand instanceof Command ? optsOrCommand : maybeCommand;
|
package/dist/src/cli.test.js
CHANGED
|
@@ -354,6 +354,8 @@ describe('createProgram root help descriptions', () => {
|
|
|
354
354
|
name: 'session',
|
|
355
355
|
flags: '--session <name>',
|
|
356
356
|
takes_value: 'required',
|
|
357
|
+
required: true,
|
|
358
|
+
help: expect.stringContaining('required'),
|
|
357
359
|
}),
|
|
358
360
|
expect.objectContaining({
|
|
359
361
|
name: 'window',
|
|
@@ -896,10 +898,13 @@ describe('browser tab targeting commands', () => {
|
|
|
896
898
|
});
|
|
897
899
|
it('requires an explicit session for browser commands', async () => {
|
|
898
900
|
const program = createProgram('', '');
|
|
899
|
-
|
|
901
|
+
program.exitOverride((err) => { throw err; });
|
|
902
|
+
program.commands.find(cmd => cmd.name() === 'browser')?.exitOverride((err) => { throw err; });
|
|
903
|
+
await expect(program.parseAsync(['node', 'opencli', 'browser', 'state'])).rejects.toMatchObject({
|
|
904
|
+
code: 'commander.missingMandatoryOptionValue',
|
|
905
|
+
});
|
|
900
906
|
expect(mockBrowserConnect).not.toHaveBeenCalled();
|
|
901
|
-
expect(stderrSpy.mock.calls.flat().join('')).toContain('--session <name>
|
|
902
|
-
expect(process.exitCode).toBeDefined();
|
|
907
|
+
expect(stderrSpy.mock.calls.flat().join('')).toContain("required option '--session <name>' not specified");
|
|
903
908
|
});
|
|
904
909
|
it('runs browser commands against an explicit session', async () => {
|
|
905
910
|
const program = createProgram('', '');
|
package/dist/src/doctor.js
CHANGED
|
@@ -14,6 +14,7 @@ import { aliasForContextId, loadProfileConfig } from './browser/profile.js';
|
|
|
14
14
|
import { formatDaemonVersion, isDaemonStale, staleDaemonIssue } from './browser/daemon-version.js';
|
|
15
15
|
import { findShadowedUserAdapters, formatAdapterShadowIssue } from './adapter-shadow.js';
|
|
16
16
|
const DOCTOR_LIVE_TIMEOUT_SECONDS = 8;
|
|
17
|
+
const DOCTOR_SESSION = '__doctor__';
|
|
17
18
|
/** Parse a semver string into [major, minor, patch]. Returns null on invalid input. */
|
|
18
19
|
function parseSemver(v) {
|
|
19
20
|
const parts = v.replace(/^v/, '').split('-')[0].split('.').map(Number);
|
|
@@ -50,7 +51,11 @@ export async function checkConnectivity(opts) {
|
|
|
50
51
|
const start = Date.now();
|
|
51
52
|
try {
|
|
52
53
|
const bridge = new BrowserBridge();
|
|
53
|
-
const page = await bridge.connect({
|
|
54
|
+
const page = await bridge.connect({
|
|
55
|
+
timeout: opts?.timeout ?? DOCTOR_LIVE_TIMEOUT_SECONDS,
|
|
56
|
+
session: DOCTOR_SESSION,
|
|
57
|
+
surface: 'browser',
|
|
58
|
+
});
|
|
54
59
|
try {
|
|
55
60
|
// Try a simple eval to verify end-to-end connectivity.
|
|
56
61
|
await page.evaluate('1 + 1');
|
package/dist/src/doctor.test.js
CHANGED
|
@@ -173,6 +173,8 @@ describe('doctor report rendering', () => {
|
|
|
173
173
|
const closeWindow = vi.fn().mockResolvedValue(undefined);
|
|
174
174
|
mockConnect.mockImplementationOnce(async (opts) => {
|
|
175
175
|
timeoutSeen = opts?.timeout;
|
|
176
|
+
expect(opts?.session).toBe('__doctor__');
|
|
177
|
+
expect(opts?.surface).toBe('browser');
|
|
176
178
|
return {
|
|
177
179
|
evaluate: vi.fn().mockResolvedValue(2),
|
|
178
180
|
closeWindow,
|