@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.
Files changed (174) hide show
  1. package/README.md +11 -9
  2. package/README.zh-CN.md +10 -8
  3. package/cli-manifest.json +377 -271
  4. package/clis/chatgpt/ask.js +1 -1
  5. package/clis/chatgpt/commands.test.js +2 -2
  6. package/clis/chatgpt/detail.js +1 -1
  7. package/clis/chatgpt/history.js +1 -1
  8. package/clis/chatgpt/image.js +38 -4
  9. package/clis/chatgpt/image.test.js +68 -1
  10. package/clis/chatgpt/new.js +1 -1
  11. package/clis/chatgpt/read.js +1 -1
  12. package/clis/chatgpt/send.js +1 -1
  13. package/clis/chatgpt/status.js +1 -1
  14. package/clis/chatgpt/utils.js +208 -16
  15. package/clis/chatgpt/utils.test.js +131 -2
  16. package/clis/claude/ask.js +1 -1
  17. package/clis/claude/detail.js +1 -1
  18. package/clis/claude/history.js +1 -1
  19. package/clis/claude/new.js +1 -1
  20. package/clis/claude/read.js +1 -1
  21. package/clis/claude/send.js +1 -1
  22. package/clis/claude/status.js +1 -1
  23. package/clis/deepseek/ask.js +1 -1
  24. package/clis/deepseek/detail.js +1 -1
  25. package/clis/deepseek/history.js +1 -1
  26. package/clis/deepseek/new.js +1 -1
  27. package/clis/deepseek/read.js +1 -1
  28. package/clis/deepseek/send.js +1 -1
  29. package/clis/deepseek/status.js +1 -1
  30. package/clis/doubao/ask.js +1 -1
  31. package/clis/doubao/detail.js +1 -1
  32. package/clis/doubao/history.js +1 -1
  33. package/clis/doubao/meeting-summary.js +1 -1
  34. package/clis/doubao/meeting-transcript.js +1 -1
  35. package/clis/doubao/new.js +1 -1
  36. package/clis/doubao/read.js +1 -1
  37. package/clis/doubao/send.js +1 -1
  38. package/clis/doubao/status.js +1 -1
  39. package/clis/doubao/utils.js +17 -0
  40. package/clis/doubao/utils.test.js +61 -0
  41. package/clis/gemini/ask.js +1 -1
  42. package/clis/gemini/deep-research-result.js +1 -1
  43. package/clis/gemini/deep-research.js +1 -1
  44. package/clis/gemini/image.js +1 -1
  45. package/clis/gemini/new.js +1 -1
  46. package/clis/grok/ask.js +1 -1
  47. package/clis/grok/detail.js +1 -1
  48. package/clis/grok/history.js +1 -1
  49. package/clis/grok/image.js +1 -1
  50. package/clis/grok/new.js +1 -1
  51. package/clis/grok/read.js +1 -1
  52. package/clis/grok/send.js +1 -1
  53. package/clis/grok/status.js +1 -1
  54. package/clis/notebooklm/current.js +1 -1
  55. package/clis/notebooklm/get.js +1 -1
  56. package/clis/notebooklm/history.js +1 -1
  57. package/clis/notebooklm/note-list.js +1 -1
  58. package/clis/notebooklm/notes-get.js +1 -1
  59. package/clis/notebooklm/open.js +2 -2
  60. package/clis/notebooklm/open.test.js +1 -1
  61. package/clis/notebooklm/source-fulltext.js +1 -1
  62. package/clis/notebooklm/source-get.js +1 -1
  63. package/clis/notebooklm/source-guide.js +1 -1
  64. package/clis/notebooklm/source-list.js +1 -1
  65. package/clis/notebooklm/summary.js +1 -1
  66. package/clis/qwen/ask.js +1 -1
  67. package/clis/qwen/detail.js +1 -1
  68. package/clis/qwen/history.js +1 -1
  69. package/clis/qwen/image.js +1 -1
  70. package/clis/qwen/new.js +1 -1
  71. package/clis/qwen/read.js +1 -1
  72. package/clis/qwen/send.js +1 -1
  73. package/clis/qwen/status.js +1 -1
  74. package/clis/reddit/comment.js +1 -1
  75. package/clis/reddit/frontpage.js +1 -1
  76. package/clis/reddit/popular.js +1 -1
  77. package/clis/reddit/read.js +1 -1
  78. package/clis/reddit/read.test.js +2 -2
  79. package/clis/reddit/reply.js +182 -0
  80. package/clis/reddit/reply.test.js +89 -0
  81. package/clis/reddit/save.js +1 -1
  82. package/clis/reddit/saved.js +1 -1
  83. package/clis/reddit/search.js +1 -1
  84. package/clis/reddit/subreddit.js +1 -1
  85. package/clis/reddit/subscribe.js +1 -1
  86. package/clis/reddit/upvote.js +1 -1
  87. package/clis/reddit/upvoted.js +1 -1
  88. package/clis/reddit/user-comments.js +1 -1
  89. package/clis/reddit/user-posts.js +1 -1
  90. package/clis/reddit/user.js +1 -1
  91. package/clis/rednote/comments.js +76 -0
  92. package/clis/rednote/download.js +59 -0
  93. package/clis/rednote/feed.js +95 -0
  94. package/clis/rednote/navigation.test.js +26 -0
  95. package/clis/rednote/note.js +68 -0
  96. package/clis/rednote/notifications.js +139 -0
  97. package/clis/rednote/rednote.test.js +157 -0
  98. package/clis/rednote/search.js +97 -0
  99. package/clis/rednote/user.js +55 -0
  100. package/clis/twitter/article.js +1 -1
  101. package/clis/twitter/bookmark-folder.js +1 -1
  102. package/clis/twitter/bookmark-folders.js +1 -1
  103. package/clis/twitter/bookmarks.js +1 -1
  104. package/clis/twitter/download.js +1 -1
  105. package/clis/twitter/followers.js +1 -1
  106. package/clis/twitter/following.js +1 -1
  107. package/clis/twitter/likes.js +1 -1
  108. package/clis/twitter/list-tweets.js +1 -1
  109. package/clis/twitter/lists.js +1 -1
  110. package/clis/twitter/notifications.js +1 -1
  111. package/clis/twitter/profile.js +1 -1
  112. package/clis/twitter/search.js +1 -1
  113. package/clis/twitter/thread.js +1 -1
  114. package/clis/twitter/timeline.js +1 -1
  115. package/clis/twitter/trending.js +1 -1
  116. package/clis/twitter/tweets.js +1 -1
  117. package/clis/xiaohongshu/comments.js +34 -24
  118. package/clis/xiaohongshu/download.js +32 -23
  119. package/clis/xiaohongshu/feed.js +23 -15
  120. package/clis/xiaohongshu/note-helpers.js +16 -6
  121. package/clis/xiaohongshu/note.js +26 -20
  122. package/clis/xiaohongshu/notifications.js +26 -19
  123. package/clis/xiaohongshu/search.js +37 -28
  124. package/clis/xiaohongshu/user-helpers.js +13 -4
  125. package/clis/xiaohongshu/user-helpers.test.js +20 -0
  126. package/clis/xiaohongshu/user.js +9 -4
  127. package/clis/youtube/transcript.js +28 -3
  128. package/clis/youtube/transcript.test.js +90 -1
  129. package/clis/yuanbao/ask.js +1 -1
  130. package/clis/yuanbao/detail.js +1 -1
  131. package/clis/yuanbao/history.js +1 -1
  132. package/clis/yuanbao/new.js +1 -1
  133. package/clis/yuanbao/read.js +1 -1
  134. package/clis/yuanbao/send.js +1 -1
  135. package/clis/yuanbao/status.js +1 -1
  136. package/dist/src/browser/bridge.d.ts +3 -1
  137. package/dist/src/browser/bridge.js +3 -1
  138. package/dist/src/browser/cdp.d.ts +3 -1
  139. package/dist/src/browser/daemon-client.d.ts +7 -14
  140. package/dist/src/browser/daemon-client.js +2 -6
  141. package/dist/src/browser/network-cache.d.ts +5 -5
  142. package/dist/src/browser/network-cache.js +8 -8
  143. package/dist/src/browser/network-cache.test.js +4 -4
  144. package/dist/src/browser/page.d.ts +8 -7
  145. package/dist/src/browser/page.js +23 -16
  146. package/dist/src/browser/page.test.js +60 -30
  147. package/dist/src/build-manifest.js +1 -1
  148. package/dist/src/cli.js +60 -162
  149. package/dist/src/cli.test.js +184 -198
  150. package/dist/src/commanderAdapter.js +2 -0
  151. package/dist/src/discovery.js +1 -1
  152. package/dist/src/doctor.d.ts +0 -4
  153. package/dist/src/doctor.js +14 -73
  154. package/dist/src/doctor.test.js +28 -97
  155. package/dist/src/execution.d.ts +1 -0
  156. package/dist/src/execution.js +20 -21
  157. package/dist/src/execution.test.js +27 -31
  158. package/dist/src/help.js +7 -1
  159. package/dist/src/main.js +0 -19
  160. package/dist/src/manifest-types.d.ts +2 -4
  161. package/dist/src/observation/artifact.js +1 -1
  162. package/dist/src/observation/artifact.test.js +3 -3
  163. package/dist/src/observation/events.d.ts +1 -1
  164. package/dist/src/observation/manager.js +1 -1
  165. package/dist/src/observation/manager.test.js +3 -3
  166. package/dist/src/registry-api.d.ts +1 -1
  167. package/dist/src/registry.d.ts +3 -12
  168. package/dist/src/registry.js +6 -10
  169. package/dist/src/runtime.d.ts +7 -2
  170. package/dist/src/runtime.js +3 -1
  171. package/dist/src/serialization.d.ts +1 -1
  172. package/dist/src/serialization.js +1 -1
  173. package/dist/src/types.d.ts +0 -15
  174. package/package.json +1 -1
@@ -1,23 +1,11 @@
1
1
  import { cli, Strategy } from '@jackwener/opencli/registry';
2
- cli({
3
- site: 'xiaohongshu',
4
- name: 'notifications',
5
- access: 'read',
6
- description: '小红书通知 (mentions/likes/connections)',
7
- domain: 'www.xiaohongshu.com',
8
- strategy: Strategy.INTERCEPT,
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
- cli({
56
- site: 'xiaohongshu',
57
- name: 'search',
58
- access: 'read',
59
- description: '搜索小红书笔记',
60
- domain: 'www.xiaohongshu.com',
61
- strategy: Strategy.COOKIE,
62
- navigateBefore: false,
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://www.xiaohongshu.com' + href;
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
- export function buildXhsNoteUrl(userId, noteId, xsecToken) {
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://www.xiaohongshu.com/user/profile/${cleanUserId}/${cleanNoteId}`);
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
- export function extractXhsUserNotes(snapshot, fallbackUserId) {
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
  });
@@ -1,7 +1,10 @@
1
1
  import { cli, Strategy } from '@jackwener/opencli/registry';
2
2
  import { extractXhsUserNotes, normalizeXhsUserId } from './user-helpers.js';
3
- async function readUserSnapshot(page) {
4
- return await page.evaluate(`
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
- const resp = await fetch(${JSON.stringify(captionData.captionUrl)});
92
- const xml = await resp.text();
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?.length) {
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
  });
@@ -305,7 +305,7 @@ export const askCommand = cli({
305
305
  domain: YUANBAO_DOMAIN,
306
306
  strategy: Strategy.COOKIE,
307
307
  browser: true,
308
- browserSession: { reuse: 'site' },
308
+ siteSession: 'persistent',
309
309
  navigateBefore: false,
310
310
  defaultFormat: 'plain',
311
311
  args: [
@@ -18,7 +18,7 @@ cli({
18
18
  domain: YUANBAO_DOMAIN,
19
19
  strategy: Strategy.COOKIE,
20
20
  browser: true,
21
- browserSession: { reuse: 'site' },
21
+ siteSession: 'persistent',
22
22
  navigateBefore: false,
23
23
  args: [
24
24
  {
@@ -17,7 +17,7 @@ cli({
17
17
  domain: YUANBAO_DOMAIN,
18
18
  strategy: Strategy.COOKIE,
19
19
  browser: true,
20
- browserSession: { reuse: 'site' },
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)' },
@@ -55,7 +55,7 @@ export const newCommand = cli({
55
55
  domain: YUANBAO_DOMAIN,
56
56
  strategy: Strategy.COOKIE,
57
57
  browser: true,
58
- browserSession: { reuse: 'site' },
58
+ siteSession: 'persistent',
59
59
  navigateBefore: false,
60
60
  args: [],
61
61
  columns: ['Status', 'Action'],
@@ -14,7 +14,7 @@ cli({
14
14
  domain: YUANBAO_DOMAIN,
15
15
  strategy: Strategy.COOKIE,
16
16
  browser: true,
17
- browserSession: { reuse: 'site' },
17
+ siteSession: 'persistent',
18
18
  navigateBefore: false,
19
19
  args: [],
20
20
  columns: ['Role', 'Text'],
@@ -18,7 +18,7 @@ cli({
18
18
  domain: YUANBAO_DOMAIN,
19
19
  strategy: Strategy.COOKIE,
20
20
  browser: true,
21
- browserSession: { reuse: 'site' },
21
+ siteSession: 'persistent',
22
22
  navigateBefore: false,
23
23
  args: [
24
24
  { name: 'prompt', positional: true, required: true, help: 'Prompt to send to Yuanbao' },
@@ -15,7 +15,7 @@ cli({
15
15
  domain: YUANBAO_DOMAIN,
16
16
  strategy: Strategy.COOKIE,
17
17
  browser: true,
18
- browserSession: { reuse: 'site' },
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
- workspace?: string;
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
- this._page = new Page(opts.workspace, opts.idleTimeout, contextId, opts.windowMode);
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
- workspace?: string;
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' | 'sessions' | 'set-file-input' | 'insert-text' | 'bind' | 'network-capture-start' | 'network-capture-read' | 'wait-download' | 'cdp' | 'frames';
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
- workspace?: string;
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 workspace session. Overrides the default. */
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 listSessions(opts?: {
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 listSessions(opts) {
150
- const result = await sendCommand('sessions', { ...(opts?.contextId && { contextId: opts.contextId }) });
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/<workspace>.json
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
- workspace: string;
32
+ session: string;
33
33
  savedAt: string;
34
34
  entries: CachedNetworkEntry[];
35
35
  }
36
- export declare function getCachePath(workspace: string, baseDir?: string): string;
37
- export declare function saveNetworkCache(workspace: string, entries: CachedNetworkEntry[], baseDir?: string): void;
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(workspace: string, opts?: LoadOptions): LoadResult;
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/<workspace>.json
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(workspace, baseDir = getDefaultCacheDir()) {
20
- const safe = workspace.replace(/[^a-zA-Z0-9_-]+/g, '_');
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(workspace, entries, baseDir) {
24
- const target = getCachePath(workspace, baseDir);
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
- workspace,
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(workspace, opts = {}) {
35
- const target = getCachePath(workspace, opts.baseDir);
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');