@jackwener/opencli 1.7.16 → 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 +11 -9
- package/README.zh-CN.md +10 -8
- package/cli-manifest.json +377 -271
- package/clis/chatgpt/ask.js +1 -1
- package/clis/chatgpt/commands.test.js +2 -2
- package/clis/chatgpt/detail.js +1 -1
- package/clis/chatgpt/history.js +1 -1
- package/clis/chatgpt/image.js +38 -4
- package/clis/chatgpt/image.test.js +68 -1
- package/clis/chatgpt/new.js +1 -1
- package/clis/chatgpt/read.js +1 -1
- package/clis/chatgpt/send.js +1 -1
- package/clis/chatgpt/status.js +1 -1
- package/clis/chatgpt/utils.js +208 -16
- package/clis/chatgpt/utils.test.js +131 -2
- package/clis/claude/ask.js +1 -1
- package/clis/claude/detail.js +1 -1
- package/clis/claude/history.js +1 -1
- package/clis/claude/new.js +1 -1
- package/clis/claude/read.js +1 -1
- package/clis/claude/send.js +1 -1
- package/clis/claude/status.js +1 -1
- package/clis/deepseek/ask.js +1 -1
- package/clis/deepseek/detail.js +1 -1
- package/clis/deepseek/history.js +1 -1
- package/clis/deepseek/new.js +1 -1
- package/clis/deepseek/read.js +1 -1
- package/clis/deepseek/send.js +1 -1
- package/clis/deepseek/status.js +1 -1
- package/clis/doubao/ask.js +1 -1
- package/clis/doubao/detail.js +1 -1
- package/clis/doubao/history.js +1 -1
- package/clis/doubao/meeting-summary.js +1 -1
- package/clis/doubao/meeting-transcript.js +1 -1
- package/clis/doubao/new.js +1 -1
- package/clis/doubao/read.js +1 -1
- package/clis/doubao/send.js +1 -1
- package/clis/doubao/status.js +1 -1
- package/clis/doubao/utils.js +17 -0
- package/clis/doubao/utils.test.js +61 -0
- package/clis/gemini/ask.js +1 -1
- package/clis/gemini/deep-research-result.js +1 -1
- package/clis/gemini/deep-research.js +1 -1
- package/clis/gemini/image.js +1 -1
- package/clis/gemini/new.js +1 -1
- package/clis/grok/ask.js +1 -1
- package/clis/grok/detail.js +1 -1
- package/clis/grok/history.js +1 -1
- package/clis/grok/image.js +1 -1
- package/clis/grok/new.js +1 -1
- package/clis/grok/read.js +1 -1
- package/clis/grok/send.js +1 -1
- package/clis/grok/status.js +1 -1
- package/clis/notebooklm/current.js +1 -1
- package/clis/notebooklm/get.js +1 -1
- package/clis/notebooklm/history.js +1 -1
- package/clis/notebooklm/note-list.js +1 -1
- package/clis/notebooklm/notes-get.js +1 -1
- package/clis/notebooklm/open.js +2 -2
- package/clis/notebooklm/open.test.js +1 -1
- package/clis/notebooklm/source-fulltext.js +1 -1
- package/clis/notebooklm/source-get.js +1 -1
- package/clis/notebooklm/source-guide.js +1 -1
- package/clis/notebooklm/source-list.js +1 -1
- package/clis/notebooklm/summary.js +1 -1
- package/clis/qwen/ask.js +1 -1
- package/clis/qwen/detail.js +1 -1
- package/clis/qwen/history.js +1 -1
- package/clis/qwen/image.js +1 -1
- package/clis/qwen/new.js +1 -1
- package/clis/qwen/read.js +1 -1
- package/clis/qwen/send.js +1 -1
- package/clis/qwen/status.js +1 -1
- package/clis/reddit/comment.js +1 -1
- package/clis/reddit/frontpage.js +1 -1
- package/clis/reddit/popular.js +1 -1
- package/clis/reddit/read.js +1 -1
- package/clis/reddit/read.test.js +2 -2
- package/clis/reddit/reply.js +182 -0
- package/clis/reddit/reply.test.js +89 -0
- package/clis/reddit/save.js +1 -1
- package/clis/reddit/saved.js +1 -1
- package/clis/reddit/search.js +1 -1
- package/clis/reddit/subreddit.js +1 -1
- package/clis/reddit/subscribe.js +1 -1
- package/clis/reddit/upvote.js +1 -1
- package/clis/reddit/upvoted.js +1 -1
- package/clis/reddit/user-comments.js +1 -1
- package/clis/reddit/user-posts.js +1 -1
- package/clis/reddit/user.js +1 -1
- 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/twitter/article.js +1 -1
- package/clis/twitter/bookmark-folder.js +1 -1
- package/clis/twitter/bookmark-folders.js +1 -1
- package/clis/twitter/bookmarks.js +1 -1
- package/clis/twitter/download.js +1 -1
- package/clis/twitter/followers.js +1 -1
- package/clis/twitter/following.js +1 -1
- package/clis/twitter/likes.js +1 -1
- package/clis/twitter/list-tweets.js +1 -1
- package/clis/twitter/lists.js +1 -1
- package/clis/twitter/notifications.js +1 -1
- package/clis/twitter/profile.js +1 -1
- package/clis/twitter/search.js +1 -1
- package/clis/twitter/thread.js +1 -1
- package/clis/twitter/timeline.js +1 -1
- package/clis/twitter/trending.js +1 -1
- package/clis/twitter/tweets.js +1 -1
- 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/clis/yuanbao/ask.js +1 -1
- package/clis/yuanbao/detail.js +1 -1
- package/clis/yuanbao/history.js +1 -1
- package/clis/yuanbao/new.js +1 -1
- package/clis/yuanbao/read.js +1 -1
- package/clis/yuanbao/send.js +1 -1
- package/clis/yuanbao/status.js +1 -1
- package/dist/src/browser/bridge.d.ts +3 -1
- package/dist/src/browser/bridge.js +3 -1
- package/dist/src/browser/cdp.d.ts +3 -1
- package/dist/src/browser/daemon-client.d.ts +7 -14
- package/dist/src/browser/daemon-client.js +2 -6
- package/dist/src/browser/network-cache.d.ts +5 -5
- package/dist/src/browser/network-cache.js +8 -8
- package/dist/src/browser/network-cache.test.js +4 -4
- package/dist/src/browser/page.d.ts +8 -7
- package/dist/src/browser/page.js +23 -16
- package/dist/src/browser/page.test.js +60 -30
- package/dist/src/build-manifest.js +1 -1
- package/dist/src/cli.js +60 -162
- package/dist/src/cli.test.js +184 -198
- package/dist/src/commanderAdapter.js +2 -0
- package/dist/src/discovery.js +1 -1
- package/dist/src/doctor.d.ts +0 -4
- package/dist/src/doctor.js +14 -73
- package/dist/src/doctor.test.js +28 -97
- package/dist/src/execution.d.ts +1 -0
- package/dist/src/execution.js +20 -21
- package/dist/src/execution.test.js +27 -31
- package/dist/src/help.js +7 -1
- package/dist/src/main.js +0 -19
- package/dist/src/manifest-types.d.ts +2 -4
- package/dist/src/observation/artifact.js +1 -1
- package/dist/src/observation/artifact.test.js +3 -3
- package/dist/src/observation/events.d.ts +1 -1
- package/dist/src/observation/manager.js +1 -1
- package/dist/src/observation/manager.test.js +3 -3
- package/dist/src/registry-api.d.ts +1 -1
- package/dist/src/registry.d.ts +3 -12
- package/dist/src/registry.js +6 -10
- package/dist/src/runtime.d.ts +7 -2
- package/dist/src/runtime.js +3 -1
- package/dist/src/serialization.d.ts +1 -1
- package/dist/src/serialization.js +1 -1
- package/dist/src/types.d.ts +0 -15
- 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/clis/yuanbao/ask.js
CHANGED
package/clis/yuanbao/detail.js
CHANGED
package/clis/yuanbao/history.js
CHANGED
|
@@ -17,7 +17,7 @@ cli({
|
|
|
17
17
|
domain: YUANBAO_DOMAIN,
|
|
18
18
|
strategy: Strategy.COOKIE,
|
|
19
19
|
browser: true,
|
|
20
|
-
|
|
20
|
+
siteSession: 'persistent',
|
|
21
21
|
navigateBefore: false,
|
|
22
22
|
args: [
|
|
23
23
|
{ name: 'limit', type: 'int', default: 20, help: 'Max conversations to list (sidebar virtual scroll caps actual count)' },
|
package/clis/yuanbao/new.js
CHANGED
package/clis/yuanbao/read.js
CHANGED
package/clis/yuanbao/send.js
CHANGED
|
@@ -18,7 +18,7 @@ cli({
|
|
|
18
18
|
domain: YUANBAO_DOMAIN,
|
|
19
19
|
strategy: Strategy.COOKIE,
|
|
20
20
|
browser: true,
|
|
21
|
-
|
|
21
|
+
siteSession: 'persistent',
|
|
22
22
|
navigateBefore: false,
|
|
23
23
|
args: [
|
|
24
24
|
{ name: 'prompt', positional: true, required: true, help: 'Prompt to send to Yuanbao' },
|
package/clis/yuanbao/status.js
CHANGED
|
@@ -15,7 +15,7 @@ cli({
|
|
|
15
15
|
domain: YUANBAO_DOMAIN,
|
|
16
16
|
strategy: Strategy.COOKIE,
|
|
17
17
|
browser: true,
|
|
18
|
-
|
|
18
|
+
siteSession: 'persistent',
|
|
19
19
|
navigateBefore: false,
|
|
20
20
|
args: [],
|
|
21
21
|
columns: ['Status', 'Login', 'Model', 'ModelId', 'AgentId', 'SessionId', 'Url'],
|
|
@@ -14,10 +14,12 @@ export declare class BrowserBridge implements IBrowserFactory {
|
|
|
14
14
|
get state(): BrowserBridgeState;
|
|
15
15
|
connect(opts?: {
|
|
16
16
|
timeout?: number;
|
|
17
|
-
|
|
17
|
+
session?: string;
|
|
18
18
|
idleTimeout?: number;
|
|
19
19
|
contextId?: string;
|
|
20
20
|
windowMode?: 'foreground' | 'background';
|
|
21
|
+
surface?: 'browser' | 'adapter';
|
|
22
|
+
siteSession?: 'ephemeral' | 'persistent';
|
|
21
23
|
}): Promise<IPage>;
|
|
22
24
|
close(): Promise<void>;
|
|
23
25
|
private _ensureDaemon;
|
|
@@ -32,7 +32,9 @@ export class BrowserBridge {
|
|
|
32
32
|
try {
|
|
33
33
|
const contextId = opts.contextId ?? resolveProfileContextId();
|
|
34
34
|
await this._ensureDaemon(opts.timeout, contextId);
|
|
35
|
-
|
|
35
|
+
if (!opts.session?.trim())
|
|
36
|
+
throw new Error('Browser session is required');
|
|
37
|
+
this._page = new Page(opts.session.trim(), opts.idleTimeout, contextId, opts.windowMode, opts.surface, opts.siteSession);
|
|
36
38
|
this._state = 'connected';
|
|
37
39
|
return this._page;
|
|
38
40
|
}
|
|
@@ -23,11 +23,13 @@ export declare class CDPBridge implements IBrowserFactory {
|
|
|
23
23
|
private _eventListeners;
|
|
24
24
|
connect(opts?: {
|
|
25
25
|
timeout?: number;
|
|
26
|
-
|
|
26
|
+
session?: string;
|
|
27
27
|
cdpEndpoint?: string;
|
|
28
28
|
contextId?: string;
|
|
29
29
|
idleTimeout?: number;
|
|
30
30
|
windowMode?: 'foreground' | 'background';
|
|
31
|
+
surface?: 'browser' | 'adapter';
|
|
32
|
+
siteSession?: 'ephemeral' | 'persistent';
|
|
31
33
|
}): Promise<IPage>;
|
|
32
34
|
close(): Promise<void>;
|
|
33
35
|
send(method: string, params?: Record<string, unknown>, timeoutMs?: number): Promise<unknown>;
|
|
@@ -3,20 +3,20 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Provides a typed send() function that posts a Command and returns a Result.
|
|
5
5
|
*/
|
|
6
|
-
import type { BrowserSessionInfo } from '../types.js';
|
|
7
6
|
export interface DaemonCommand {
|
|
8
7
|
id: string;
|
|
9
|
-
action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | '
|
|
8
|
+
action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'set-file-input' | 'insert-text' | 'bind' | 'network-capture-start' | 'network-capture-read' | 'wait-download' | 'cdp' | 'frames';
|
|
10
9
|
/** Target page identity (targetId). Cross-layer contract with the extension. */
|
|
11
10
|
page?: string;
|
|
12
11
|
code?: string;
|
|
13
|
-
|
|
12
|
+
session?: string;
|
|
13
|
+
surface?: 'browser' | 'adapter';
|
|
14
|
+
/** Adapter site session lifecycle. Persistent site sessions do not idle-expire. */
|
|
15
|
+
siteSession?: 'ephemeral' | 'persistent';
|
|
14
16
|
url?: string;
|
|
15
17
|
op?: string;
|
|
16
18
|
index?: number;
|
|
17
19
|
domain?: string;
|
|
18
|
-
matchDomain?: string;
|
|
19
|
-
matchPathPrefix?: string;
|
|
20
20
|
format?: 'png' | 'jpeg';
|
|
21
21
|
quality?: number;
|
|
22
22
|
fullPage?: boolean;
|
|
@@ -38,10 +38,8 @@ export interface DaemonCommand {
|
|
|
38
38
|
cdpParams?: Record<string, unknown>;
|
|
39
39
|
/** Window foreground/background policy for owned Browser Bridge containers. */
|
|
40
40
|
windowMode?: 'foreground' | 'background';
|
|
41
|
-
/** Custom idle timeout in seconds for this
|
|
41
|
+
/** Custom idle timeout in seconds for this session. Overrides the default. */
|
|
42
42
|
idleTimeout?: number;
|
|
43
|
-
/** Explicitly allow navigation inside a borrowed bound tab. */
|
|
44
|
-
allowBoundNavigation?: boolean;
|
|
45
43
|
/** Frame index for cross-frame operations (0-based, from 'frames' action) */
|
|
46
44
|
frameIndex?: number;
|
|
47
45
|
/** Browser profile/context to route the command to. */
|
|
@@ -129,11 +127,6 @@ export declare function sendCommandFull(action: DaemonCommand['action'], params?
|
|
|
129
127
|
data: unknown;
|
|
130
128
|
page?: string;
|
|
131
129
|
}>;
|
|
132
|
-
export declare function
|
|
133
|
-
contextId?: string;
|
|
134
|
-
}): Promise<BrowserSessionInfo[]>;
|
|
135
|
-
export declare function bindTab(workspace: string, opts?: {
|
|
136
|
-
matchDomain?: string;
|
|
137
|
-
matchPathPrefix?: string;
|
|
130
|
+
export declare function bindTab(session: string, opts?: {
|
|
138
131
|
contextId?: string;
|
|
139
132
|
}): Promise<unknown>;
|
|
@@ -146,10 +146,6 @@ export async function sendCommandFull(action, params = {}) {
|
|
|
146
146
|
const result = await sendCommandRaw(action, params);
|
|
147
147
|
return { data: result.data, page: result.page };
|
|
148
148
|
}
|
|
149
|
-
export async function
|
|
150
|
-
|
|
151
|
-
return Array.isArray(result) ? result : [];
|
|
152
|
-
}
|
|
153
|
-
export async function bindTab(workspace, opts = {}) {
|
|
154
|
-
return sendCommand('bind', { workspace, ...opts });
|
|
149
|
+
export async function bindTab(session, opts = {}) {
|
|
150
|
+
return sendCommand('bind', { session, surface: 'browser', ...opts });
|
|
155
151
|
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* stable references to request bodies after running other commands,
|
|
7
7
|
* so every `browser network` call snapshots its results to disk.
|
|
8
8
|
*
|
|
9
|
-
* Layout: <cacheDir>/browser-network/<
|
|
9
|
+
* Layout: <cacheDir>/browser-network/<session>.json
|
|
10
10
|
* Entries expire after DEFAULT_TTL_MS (24h).
|
|
11
11
|
*/
|
|
12
12
|
export declare const DEFAULT_TTL_MS: number;
|
|
@@ -29,12 +29,12 @@ export interface CachedNetworkEntry {
|
|
|
29
29
|
}
|
|
30
30
|
export interface NetworkCacheFile {
|
|
31
31
|
version: 1;
|
|
32
|
-
|
|
32
|
+
session: string;
|
|
33
33
|
savedAt: string;
|
|
34
34
|
entries: CachedNetworkEntry[];
|
|
35
35
|
}
|
|
36
|
-
export declare function getCachePath(
|
|
37
|
-
export declare function saveNetworkCache(
|
|
36
|
+
export declare function getCachePath(session: string, baseDir?: string): string;
|
|
37
|
+
export declare function saveNetworkCache(session: string, entries: CachedNetworkEntry[], baseDir?: string): void;
|
|
38
38
|
export interface LoadOptions {
|
|
39
39
|
baseDir?: string;
|
|
40
40
|
ttlMs?: number;
|
|
@@ -45,5 +45,5 @@ export interface LoadResult {
|
|
|
45
45
|
file?: NetworkCacheFile;
|
|
46
46
|
ageMs?: number;
|
|
47
47
|
}
|
|
48
|
-
export declare function loadNetworkCache(
|
|
48
|
+
export declare function loadNetworkCache(session: string, opts?: LoadOptions): LoadResult;
|
|
49
49
|
export declare function findEntry(file: NetworkCacheFile, key: string): CachedNetworkEntry | null;
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* stable references to request bodies after running other commands,
|
|
7
7
|
* so every `browser network` call snapshots its results to disk.
|
|
8
8
|
*
|
|
9
|
-
* Layout: <cacheDir>/browser-network/<
|
|
9
|
+
* Layout: <cacheDir>/browser-network/<session>.json
|
|
10
10
|
* Entries expire after DEFAULT_TTL_MS (24h).
|
|
11
11
|
*/
|
|
12
12
|
import * as fs from 'node:fs';
|
|
@@ -16,23 +16,23 @@ export const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
|
|
|
16
16
|
function getDefaultCacheDir() {
|
|
17
17
|
return process.env.OPENCLI_CACHE_DIR || path.join(os.homedir(), '.opencli', 'cache');
|
|
18
18
|
}
|
|
19
|
-
export function getCachePath(
|
|
20
|
-
const safe =
|
|
19
|
+
export function getCachePath(session, baseDir = getDefaultCacheDir()) {
|
|
20
|
+
const safe = session.replace(/[^a-zA-Z0-9_-]+/g, '_');
|
|
21
21
|
return path.join(baseDir, 'browser-network', `${safe}.json`);
|
|
22
22
|
}
|
|
23
|
-
export function saveNetworkCache(
|
|
24
|
-
const target = getCachePath(
|
|
23
|
+
export function saveNetworkCache(session, entries, baseDir) {
|
|
24
|
+
const target = getCachePath(session, baseDir);
|
|
25
25
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
26
26
|
const payload = {
|
|
27
27
|
version: 1,
|
|
28
|
-
|
|
28
|
+
session,
|
|
29
29
|
savedAt: new Date().toISOString(),
|
|
30
30
|
entries,
|
|
31
31
|
};
|
|
32
32
|
fs.writeFileSync(target, JSON.stringify(payload), 'utf-8');
|
|
33
33
|
}
|
|
34
|
-
export function loadNetworkCache(
|
|
35
|
-
const target = getCachePath(
|
|
34
|
+
export function loadNetworkCache(session, opts = {}) {
|
|
35
|
+
const target = getCachePath(session, opts.baseDir);
|
|
36
36
|
let raw;
|
|
37
37
|
try {
|
|
38
38
|
raw = fs.readFileSync(target, 'utf-8');
|