@jackwener/opencli 1.4.0 → 1.4.1

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 (209) hide show
  1. package/.github/actions/setup-chrome/action.yml +5 -4
  2. package/.github/workflows/ci.yml +17 -3
  3. package/.github/workflows/e2e-headed.yml +16 -3
  4. package/CHANGELOG.md +23 -0
  5. package/PRIVACY.md +57 -0
  6. package/README.md +1 -1
  7. package/README.zh-CN.md +1 -1
  8. package/SKILL.md +101 -2
  9. package/dist/cli-manifest.json +720 -32
  10. package/dist/clis/apple-podcasts/search.js +2 -1
  11. package/dist/clis/arxiv/search.js +2 -2
  12. package/dist/clis/bbc/news.js +0 -1
  13. package/dist/clis/ctrip/search.js +0 -1
  14. package/dist/clis/douyin/_shared/browser-fetch.d.ts +10 -0
  15. package/dist/clis/douyin/_shared/browser-fetch.js +30 -0
  16. package/dist/clis/douyin/_shared/browser-fetch.test.d.ts +1 -0
  17. package/dist/clis/douyin/_shared/browser-fetch.test.js +31 -0
  18. package/dist/clis/douyin/_shared/creation-id.d.ts +1 -0
  19. package/dist/clis/douyin/_shared/creation-id.js +5 -0
  20. package/dist/clis/douyin/_shared/creation-id.test.d.ts +1 -0
  21. package/dist/clis/douyin/_shared/creation-id.test.js +22 -0
  22. package/dist/clis/douyin/_shared/imagex-upload.d.ts +20 -0
  23. package/dist/clis/douyin/_shared/imagex-upload.js +53 -0
  24. package/dist/clis/douyin/_shared/imagex-upload.test.d.ts +1 -0
  25. package/dist/clis/douyin/_shared/imagex-upload.test.js +87 -0
  26. package/dist/clis/douyin/_shared/sts2.d.ts +8 -0
  27. package/dist/clis/douyin/_shared/sts2.js +15 -0
  28. package/dist/clis/douyin/_shared/text-extra.d.ts +18 -0
  29. package/dist/clis/douyin/_shared/text-extra.js +15 -0
  30. package/dist/clis/douyin/_shared/text-extra.test.d.ts +1 -0
  31. package/dist/clis/douyin/_shared/text-extra.test.js +37 -0
  32. package/dist/clis/douyin/_shared/timing.d.ts +2 -0
  33. package/dist/clis/douyin/_shared/timing.js +22 -0
  34. package/dist/clis/douyin/_shared/timing.test.d.ts +1 -0
  35. package/dist/clis/douyin/_shared/timing.test.js +28 -0
  36. package/dist/clis/douyin/_shared/tos-upload-short-read.test.d.ts +11 -0
  37. package/dist/clis/douyin/_shared/tos-upload-short-read.test.js +83 -0
  38. package/dist/clis/douyin/_shared/tos-upload.d.ts +53 -0
  39. package/dist/clis/douyin/_shared/tos-upload.js +295 -0
  40. package/dist/clis/douyin/_shared/tos-upload.test.d.ts +1 -0
  41. package/dist/clis/douyin/_shared/tos-upload.test.js +229 -0
  42. package/dist/clis/douyin/_shared/transcode.d.ts +27 -0
  43. package/dist/clis/douyin/_shared/transcode.js +45 -0
  44. package/dist/clis/douyin/_shared/transcode.test.d.ts +1 -0
  45. package/dist/clis/douyin/_shared/transcode.test.js +93 -0
  46. package/dist/clis/douyin/_shared/types.d.ts +26 -0
  47. package/dist/clis/douyin/_shared/types.js +1 -0
  48. package/dist/clis/douyin/activities.d.ts +1 -0
  49. package/dist/clis/douyin/activities.js +20 -0
  50. package/dist/clis/douyin/activities.test.d.ts +1 -0
  51. package/dist/clis/douyin/activities.test.js +22 -0
  52. package/dist/clis/douyin/collections.d.ts +1 -0
  53. package/dist/clis/douyin/collections.js +22 -0
  54. package/dist/clis/douyin/collections.test.d.ts +1 -0
  55. package/dist/clis/douyin/collections.test.js +23 -0
  56. package/dist/clis/douyin/delete.d.ts +1 -0
  57. package/dist/clis/douyin/delete.js +18 -0
  58. package/dist/clis/douyin/delete.test.d.ts +1 -0
  59. package/dist/clis/douyin/delete.test.js +11 -0
  60. package/dist/clis/douyin/draft.d.ts +14 -0
  61. package/dist/clis/douyin/draft.js +237 -0
  62. package/dist/clis/douyin/draft.test.d.ts +1 -0
  63. package/dist/clis/douyin/draft.test.js +11 -0
  64. package/dist/clis/douyin/drafts.d.ts +1 -0
  65. package/dist/clis/douyin/drafts.js +23 -0
  66. package/dist/clis/douyin/drafts.test.d.ts +1 -0
  67. package/dist/clis/douyin/drafts.test.js +11 -0
  68. package/dist/clis/douyin/hashtag.d.ts +1 -0
  69. package/dist/clis/douyin/hashtag.js +45 -0
  70. package/dist/clis/douyin/hashtag.test.d.ts +1 -0
  71. package/dist/clis/douyin/hashtag.test.js +25 -0
  72. package/dist/clis/douyin/location.d.ts +1 -0
  73. package/dist/clis/douyin/location.js +24 -0
  74. package/dist/clis/douyin/location.test.d.ts +1 -0
  75. package/dist/clis/douyin/location.test.js +23 -0
  76. package/dist/clis/douyin/profile.d.ts +1 -0
  77. package/dist/clis/douyin/profile.js +28 -0
  78. package/dist/clis/douyin/profile.test.d.ts +1 -0
  79. package/dist/clis/douyin/profile.test.js +11 -0
  80. package/dist/clis/douyin/publish.d.ts +14 -0
  81. package/dist/clis/douyin/publish.js +288 -0
  82. package/dist/clis/douyin/publish.test.d.ts +1 -0
  83. package/dist/clis/douyin/publish.test.js +38 -0
  84. package/dist/clis/douyin/stats.d.ts +1 -0
  85. package/dist/clis/douyin/stats.js +27 -0
  86. package/dist/clis/douyin/stats.test.d.ts +1 -0
  87. package/dist/clis/douyin/stats.test.js +22 -0
  88. package/dist/clis/douyin/update.d.ts +1 -0
  89. package/dist/clis/douyin/update.js +31 -0
  90. package/dist/clis/douyin/update.test.d.ts +1 -0
  91. package/dist/clis/douyin/update.test.js +11 -0
  92. package/dist/clis/douyin/videos.d.ts +1 -0
  93. package/dist/clis/douyin/videos.js +34 -0
  94. package/dist/clis/douyin/videos.test.d.ts +1 -0
  95. package/dist/clis/douyin/videos.test.js +11 -0
  96. package/dist/clis/hackernews/search.yaml +1 -1
  97. package/dist/clis/instagram/search.yaml +2 -1
  98. package/dist/clis/linux-do/search.yaml +3 -1
  99. package/dist/clis/medium/search.js +1 -1
  100. package/dist/clis/reuters/search.js +0 -1
  101. package/dist/clis/twitter/search.js +5 -3
  102. package/dist/clis/twitter/search.test.js +54 -2
  103. package/dist/clis/weibo/comments.d.ts +1 -0
  104. package/dist/clis/weibo/comments.js +53 -0
  105. package/dist/clis/weibo/feed.d.ts +1 -0
  106. package/dist/clis/weibo/feed.js +56 -0
  107. package/dist/clis/weibo/hot.js +0 -1
  108. package/dist/clis/weibo/me.d.ts +1 -0
  109. package/dist/clis/weibo/me.js +76 -0
  110. package/dist/clis/weibo/post.d.ts +1 -0
  111. package/dist/clis/weibo/post.js +75 -0
  112. package/dist/clis/weibo/user.d.ts +1 -0
  113. package/dist/clis/weibo/user.js +63 -0
  114. package/dist/clis/weibo/utils.d.ts +6 -0
  115. package/dist/clis/weibo/utils.js +30 -0
  116. package/dist/clis/weread/search.js +3 -2
  117. package/dist/clis/xueqiu/search.yaml +2 -1
  118. package/dist/clis/yahoo-finance/quote.js +0 -1
  119. package/dist/clis/youtube/channel.d.ts +1 -0
  120. package/dist/clis/youtube/channel.js +150 -0
  121. package/dist/clis/youtube/comments.d.ts +1 -0
  122. package/dist/clis/youtube/comments.js +95 -0
  123. package/dist/clis/youtube/search.js +0 -1
  124. package/dist/clis/zhihu/search.yaml +2 -1
  125. package/dist/external-clis.yaml +0 -17
  126. package/dist/weread-search-regression.test.d.ts +1 -0
  127. package/dist/weread-search-regression.test.js +39 -0
  128. package/docs/.vitepress/config.mts +13 -0
  129. package/docs/adapters/browser/douyin.md +75 -0
  130. package/docs/adapters/browser/twitter.md +6 -0
  131. package/docs/adapters/index.md +6 -1
  132. package/extension/dist/background.js +508 -518
  133. package/extension/manifest.json +6 -2
  134. package/extension/package.json +1 -1
  135. package/extension/popup.html +84 -0
  136. package/extension/popup.js +25 -0
  137. package/extension/src/background.ts +20 -1
  138. package/package.json +1 -1
  139. package/src/clis/apple-podcasts/search.ts +2 -1
  140. package/src/clis/arxiv/search.ts +2 -2
  141. package/src/clis/bbc/news.ts +0 -1
  142. package/src/clis/ctrip/search.ts +0 -1
  143. package/src/clis/douyin/_shared/browser-fetch.test.ts +38 -0
  144. package/src/clis/douyin/_shared/browser-fetch.ts +45 -0
  145. package/src/clis/douyin/_shared/creation-id.test.ts +26 -0
  146. package/src/clis/douyin/_shared/creation-id.ts +8 -0
  147. package/src/clis/douyin/_shared/imagex-upload.test.ts +113 -0
  148. package/src/clis/douyin/_shared/imagex-upload.ts +76 -0
  149. package/src/clis/douyin/_shared/sts2.ts +20 -0
  150. package/src/clis/douyin/_shared/text-extra.test.ts +42 -0
  151. package/src/clis/douyin/_shared/text-extra.ts +33 -0
  152. package/src/clis/douyin/_shared/timing.test.ts +38 -0
  153. package/src/clis/douyin/_shared/timing.ts +22 -0
  154. package/src/clis/douyin/_shared/tos-upload-short-read.test.ts +102 -0
  155. package/src/clis/douyin/_shared/tos-upload.test.ts +281 -0
  156. package/src/clis/douyin/_shared/tos-upload.ts +444 -0
  157. package/src/clis/douyin/_shared/transcode.test.ts +117 -0
  158. package/src/clis/douyin/_shared/transcode.ts +78 -0
  159. package/src/clis/douyin/_shared/types.ts +29 -0
  160. package/src/clis/douyin/activities.test.ts +25 -0
  161. package/src/clis/douyin/activities.ts +23 -0
  162. package/src/clis/douyin/collections.test.ts +26 -0
  163. package/src/clis/douyin/collections.ts +25 -0
  164. package/src/clis/douyin/delete.test.ts +12 -0
  165. package/src/clis/douyin/delete.ts +20 -0
  166. package/src/clis/douyin/draft.test.ts +12 -0
  167. package/src/clis/douyin/draft.ts +282 -0
  168. package/src/clis/douyin/drafts.test.ts +12 -0
  169. package/src/clis/douyin/drafts.ts +27 -0
  170. package/src/clis/douyin/hashtag.test.ts +28 -0
  171. package/src/clis/douyin/hashtag.ts +56 -0
  172. package/src/clis/douyin/location.test.ts +26 -0
  173. package/src/clis/douyin/location.ts +27 -0
  174. package/src/clis/douyin/profile.test.ts +12 -0
  175. package/src/clis/douyin/profile.ts +37 -0
  176. package/src/clis/douyin/publish.test.ts +45 -0
  177. package/src/clis/douyin/publish.ts +340 -0
  178. package/src/clis/douyin/stats.test.ts +25 -0
  179. package/src/clis/douyin/stats.ts +30 -0
  180. package/src/clis/douyin/update.test.ts +12 -0
  181. package/src/clis/douyin/update.ts +43 -0
  182. package/src/clis/douyin/videos.test.ts +12 -0
  183. package/src/clis/douyin/videos.ts +49 -0
  184. package/src/clis/hackernews/search.yaml +1 -1
  185. package/src/clis/instagram/search.yaml +2 -1
  186. package/src/clis/linux-do/search.yaml +3 -1
  187. package/src/clis/medium/search.ts +1 -1
  188. package/src/clis/reuters/search.ts +0 -1
  189. package/src/clis/twitter/search.test.ts +69 -2
  190. package/src/clis/twitter/search.ts +5 -3
  191. package/src/clis/weibo/comments.ts +54 -0
  192. package/src/clis/weibo/feed.ts +57 -0
  193. package/src/clis/weibo/hot.ts +0 -1
  194. package/src/clis/weibo/me.ts +77 -0
  195. package/src/clis/weibo/post.ts +77 -0
  196. package/src/clis/weibo/user.ts +64 -0
  197. package/src/clis/weibo/utils.ts +32 -0
  198. package/src/clis/weread/search.ts +3 -2
  199. package/src/clis/xueqiu/search.yaml +2 -1
  200. package/src/clis/yahoo-finance/quote.ts +0 -1
  201. package/src/clis/youtube/channel.ts +155 -0
  202. package/src/clis/youtube/comments.ts +97 -0
  203. package/src/clis/youtube/search.ts +0 -1
  204. package/src/clis/zhihu/search.yaml +2 -1
  205. package/src/external-clis.yaml +0 -17
  206. package/src/weread-search-regression.test.ts +44 -0
  207. package/tests/e2e/browser-public-extended.test.ts +162 -0
  208. package/tests/e2e/browser-public.test.ts +7 -146
  209. package/vitest.config.ts +24 -17
@@ -0,0 +1,155 @@
1
+ /**
2
+ * YouTube channel — get channel info and recent videos via InnerTube API.
3
+ */
4
+ import { cli, Strategy } from '../../registry.js';
5
+ import { CommandExecutionError } from '../../errors.js';
6
+
7
+ cli({
8
+ site: 'youtube',
9
+ name: 'channel',
10
+ description: 'Get YouTube channel info and recent videos',
11
+ domain: 'www.youtube.com',
12
+ strategy: Strategy.COOKIE,
13
+ args: [
14
+ { name: 'id', required: true, positional: true, help: 'Channel ID (UCxxxx) or handle (@name)' },
15
+ { name: 'limit', type: 'int', default: 10, help: 'Max recent videos (max 30)' },
16
+ ],
17
+ columns: ['field', 'value'],
18
+ func: async (page, kwargs) => {
19
+ const channelId = String(kwargs.id);
20
+ const limit = Math.min(kwargs.limit || 10, 30);
21
+ await page.goto('https://www.youtube.com');
22
+ await page.wait(2);
23
+
24
+ const data = await page.evaluate(`
25
+ (async () => {
26
+ const channelId = ${JSON.stringify(channelId)};
27
+ const limit = ${limit};
28
+ const cfg = window.ytcfg?.data_ || {};
29
+ const apiKey = cfg.INNERTUBE_API_KEY;
30
+ const context = cfg.INNERTUBE_CONTEXT;
31
+ if (!apiKey || !context) return {error: 'YouTube config not found'};
32
+
33
+ // Resolve handle to browseId if needed
34
+ let browseId = channelId;
35
+ if (channelId.startsWith('@')) {
36
+ const resolveResp = await fetch('/youtubei/v1/navigation/resolve_url?key=' + apiKey + '&prettyPrint=false', {
37
+ method: 'POST', credentials: 'include',
38
+ headers: {'Content-Type': 'application/json'},
39
+ body: JSON.stringify({context, url: 'https://www.youtube.com/' + channelId})
40
+ });
41
+ if (resolveResp.ok) {
42
+ const resolveData = await resolveResp.json();
43
+ browseId = resolveData.endpoint?.browseEndpoint?.browseId || channelId;
44
+ }
45
+ }
46
+
47
+ // Fetch channel data
48
+ const resp = await fetch('/youtubei/v1/browse?key=' + apiKey + '&prettyPrint=false', {
49
+ method: 'POST', credentials: 'include',
50
+ headers: {'Content-Type': 'application/json'},
51
+ body: JSON.stringify({context, browseId})
52
+ });
53
+ if (!resp.ok) return {error: 'Channel API returned HTTP ' + resp.status};
54
+ const data = await resp.json();
55
+
56
+ // Channel metadata
57
+ const metadata = data.metadata?.channelMetadataRenderer || {};
58
+ const header = data.header?.pageHeaderRenderer || data.header?.c4TabbedHeaderRenderer || {};
59
+
60
+ // Subscriber count from header
61
+ let subscriberCount = '';
62
+ try {
63
+ const rows = header.content?.pageHeaderViewModel?.metadata?.contentMetadataViewModel?.metadataRows || [];
64
+ for (const row of rows) {
65
+ for (const part of (row.metadataParts || [])) {
66
+ const text = part.text?.content || '';
67
+ if (text.includes('subscriber')) subscriberCount = text;
68
+ }
69
+ }
70
+ } catch {}
71
+ // Fallback for old c4TabbedHeaderRenderer format
72
+ if (!subscriberCount && header.subscriberCountText?.simpleText) {
73
+ subscriberCount = header.subscriberCountText.simpleText;
74
+ }
75
+
76
+ // Extract recent videos from Home tab
77
+ const tabs = data.contents?.twoColumnBrowseResultsRenderer?.tabs || [];
78
+ const homeTab = tabs.find(t => t.tabRenderer?.selected);
79
+ const recentVideos = [];
80
+
81
+ if (homeTab) {
82
+ const sections = homeTab.tabRenderer?.content?.sectionListRenderer?.contents || [];
83
+ for (const section of sections) {
84
+ for (const shelf of (section.itemSectionRenderer?.contents || [])) {
85
+ for (const item of (shelf.shelfRenderer?.content?.horizontalListRenderer?.items || [])) {
86
+ // New lockupViewModel format
87
+ const lvm = item.lockupViewModel;
88
+ if (lvm && lvm.contentType === 'LOCKUP_CONTENT_TYPE_VIDEO' && recentVideos.length < limit) {
89
+ const meta = lvm.metadata?.lockupMetadataViewModel;
90
+ const rows = meta?.metadata?.contentMetadataViewModel?.metadataRows || [];
91
+ const viewsAndTime = (rows[0]?.metadataParts || []).map(p => p.text?.content).filter(Boolean).join(' | ');
92
+ let duration = '';
93
+ for (const ov of (lvm.contentImage?.thumbnailViewModel?.overlays || [])) {
94
+ for (const b of (ov.thumbnailBottomOverlayViewModel?.badges || [])) {
95
+ if (b.thumbnailBadgeViewModel?.text) duration = b.thumbnailBadgeViewModel.text;
96
+ }
97
+ }
98
+ recentVideos.push({
99
+ title: meta?.title?.content || '',
100
+ duration,
101
+ views: viewsAndTime,
102
+ url: 'https://www.youtube.com/watch?v=' + lvm.contentId,
103
+ });
104
+ }
105
+ // Legacy gridVideoRenderer format
106
+ if (item.gridVideoRenderer && recentVideos.length < limit) {
107
+ const v = item.gridVideoRenderer;
108
+ recentVideos.push({
109
+ title: v.title?.runs?.[0]?.text || v.title?.simpleText || '',
110
+ duration: v.thumbnailOverlays?.[0]?.thumbnailOverlayTimeStatusRenderer?.text?.simpleText || '',
111
+ views: (v.shortViewCountText?.simpleText || '') + (v.publishedTimeText?.simpleText ? ' | ' + v.publishedTimeText.simpleText : ''),
112
+ url: 'https://www.youtube.com/watch?v=' + v.videoId,
113
+ });
114
+ }
115
+ }
116
+ }
117
+ }
118
+ }
119
+
120
+ return {
121
+ name: metadata.title || '',
122
+ channelId: metadata.externalId || browseId,
123
+ handle: metadata.vanityChannelUrl?.split('/').pop() || '',
124
+ description: (metadata.description || '').substring(0, 500),
125
+ subscribers: subscriberCount,
126
+ url: metadata.channelUrl || 'https://www.youtube.com/channel/' + browseId,
127
+ keywords: metadata.keywords || '',
128
+ recentVideos,
129
+ };
130
+ })()
131
+ `);
132
+
133
+ if (!data || typeof data !== 'object') throw new CommandExecutionError('Failed to fetch channel data');
134
+ if ((data as Record<string, unknown>).error) throw new CommandExecutionError(String((data as Record<string, unknown>).error));
135
+
136
+ const result = data as Record<string, unknown>;
137
+ const videos = result.recentVideos as Array<Record<string, string>> | undefined;
138
+ delete result.recentVideos;
139
+
140
+ // Channel info as field/value pairs + recent videos as table
141
+ const rows = Object.entries(result).map(([field, value]) => ({
142
+ field,
143
+ value: String(value),
144
+ }));
145
+
146
+ if (videos && videos.length > 0) {
147
+ rows.push({ field: '---', value: '--- Recent Videos ---' });
148
+ for (const v of videos) {
149
+ rows.push({ field: v.title, value: `${v.duration} | ${v.views} | ${v.url}` });
150
+ }
151
+ }
152
+
153
+ return rows;
154
+ },
155
+ });
@@ -0,0 +1,97 @@
1
+ /**
2
+ * YouTube comments — get video comments via InnerTube API.
3
+ */
4
+ import { cli, Strategy } from '../../registry.js';
5
+ import { CommandExecutionError } from '../../errors.js';
6
+ import { parseVideoId } from './utils.js';
7
+
8
+ cli({
9
+ site: 'youtube',
10
+ name: 'comments',
11
+ description: 'Get YouTube video comments',
12
+ domain: 'www.youtube.com',
13
+ strategy: Strategy.COOKIE,
14
+ args: [
15
+ { name: 'url', required: true, positional: true, help: 'YouTube video URL or video ID' },
16
+ { name: 'limit', type: 'int', default: 20, help: 'Max comments (max 100)' },
17
+ ],
18
+ columns: ['rank', 'author', 'text', 'likes', 'replies', 'time'],
19
+ func: async (page, kwargs) => {
20
+ const videoId = parseVideoId(kwargs.url);
21
+ const limit = Math.min(kwargs.limit || 20, 100);
22
+ await page.goto(`https://www.youtube.com/watch?v=${videoId}`);
23
+ await page.wait(3);
24
+
25
+ const data = await page.evaluate(`
26
+ (async () => {
27
+ const videoId = ${JSON.stringify(videoId)};
28
+ const limit = ${limit};
29
+ const cfg = window.ytcfg?.data_ || {};
30
+ const apiKey = cfg.INNERTUBE_API_KEY;
31
+ const context = cfg.INNERTUBE_CONTEXT;
32
+ if (!apiKey || !context) return {error: 'YouTube config not found'};
33
+
34
+ // Step 1: Get comment continuation token
35
+ let continuationToken = null;
36
+
37
+ // Try from current page ytInitialData
38
+ if (window.ytInitialData) {
39
+ const results = window.ytInitialData.contents?.twoColumnWatchNextResults?.results?.results?.contents || [];
40
+ const commentSection = results.find(i => i.itemSectionRenderer?.targetId === 'comments-section');
41
+ continuationToken = commentSection?.itemSectionRenderer?.contents?.[0]?.continuationItemRenderer?.continuationEndpoint?.continuationCommand?.token;
42
+ }
43
+
44
+ // Fallback: fetch via next API
45
+ if (!continuationToken) {
46
+ const nextResp = await fetch('/youtubei/v1/next?key=' + apiKey + '&prettyPrint=false', {
47
+ method: 'POST', credentials: 'include',
48
+ headers: {'Content-Type': 'application/json'},
49
+ body: JSON.stringify({context, videoId})
50
+ });
51
+ if (!nextResp.ok) return {error: 'Failed to get video data: HTTP ' + nextResp.status};
52
+ const nextData = await nextResp.json();
53
+ const results = nextData.contents?.twoColumnWatchNextResults?.results?.results?.contents || [];
54
+ const commentSection = results.find(i => i.itemSectionRenderer?.targetId === 'comments-section');
55
+ continuationToken = commentSection?.itemSectionRenderer?.contents?.[0]?.continuationItemRenderer?.continuationEndpoint?.continuationCommand?.token;
56
+ }
57
+
58
+ if (!continuationToken) return {error: 'No comment section found — comments may be disabled'};
59
+
60
+ // Step 2: Fetch comments
61
+ const commentResp = await fetch('/youtubei/v1/next?key=' + apiKey + '&prettyPrint=false', {
62
+ method: 'POST', credentials: 'include',
63
+ headers: {'Content-Type': 'application/json'},
64
+ body: JSON.stringify({context, continuation: continuationToken})
65
+ });
66
+ if (!commentResp.ok) return {error: 'Failed to fetch comments: HTTP ' + commentResp.status};
67
+ const commentData = await commentResp.json();
68
+
69
+ // Parse from frameworkUpdates (new ViewModel format)
70
+ const mutations = commentData.frameworkUpdates?.entityBatchUpdate?.mutations || [];
71
+ const commentEntities = mutations.filter(m => m.payload?.commentEntityPayload);
72
+
73
+ return commentEntities.slice(0, limit).map((m, i) => {
74
+ const p = m.payload.commentEntityPayload;
75
+ const props = p.properties || {};
76
+ const author = p.author || {};
77
+ const toolbar = p.toolbar || {};
78
+ return {
79
+ rank: i + 1,
80
+ author: author.displayName || '',
81
+ text: (props.content?.content || '').substring(0, 300),
82
+ likes: toolbar.likeCountNotliked || '0',
83
+ replies: toolbar.replyCount || '0',
84
+ time: props.publishedTime || '',
85
+ };
86
+ });
87
+ })()
88
+ `);
89
+
90
+ if (!Array.isArray(data)) {
91
+ const errMsg = data && typeof data === 'object' ? String((data as Record<string, unknown>).error || '') : '';
92
+ if (errMsg) throw new CommandExecutionError(errMsg);
93
+ return [];
94
+ }
95
+ return data;
96
+ },
97
+ });
@@ -1,6 +1,5 @@
1
1
  /**
2
2
  * YouTube search — innertube API via browser session.
3
- * Source: bb-sites/youtube/search.js
4
3
  */
5
4
  import { cli, Strategy } from '../../registry.js';
6
5
 
@@ -52,7 +52,8 @@ pipeline:
52
52
  type: ${{ item.type }}
53
53
  author: ${{ item.author }}
54
54
  votes: ${{ item.votes }}
55
+ url: ${{ item.url }}
55
56
 
56
57
  - limit: ${{ args.limit }}
57
58
 
58
- columns: [rank, title, type, author, votes]
59
+ columns: [rank, title, type, author, votes, url]
@@ -14,14 +14,6 @@
14
14
  install:
15
15
  mac: "brew install --cask obsidian"
16
16
 
17
- - name: readwise
18
- binary: readwise
19
- description: "Readwise & Reader CLI — highlights, annotations, reading list"
20
- homepage: "https://github.com/readwiseio/readwise-cli"
21
- tags: [reading, highlights]
22
- install:
23
- default: "npm install -g @readwiseio/readwise-cli"
24
-
25
17
  - name: docker
26
18
  binary: docker
27
19
  description: "Docker command-line interface"
@@ -29,12 +21,3 @@
29
21
  tags: [docker, containers, devops]
30
22
  install:
31
23
  mac: "brew install --cask docker"
32
-
33
- - name: gws
34
- binary: gws
35
- description: "Google Workspace CLI — Docs, Sheets, Drive, Gmail, Calendar"
36
- homepage: "https://github.com/nicholasgasior/gws"
37
- tags: [google, docs, sheets, drive, workspace]
38
- install:
39
- mac: "brew install gws"
40
- default: "npm install -g @nicholasgasior/gws"
@@ -0,0 +1,44 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { getRegistry } from './registry.js';
3
+ import './clis/weread/search.js';
4
+
5
+ describe('weread/search regression', () => {
6
+ beforeEach(() => {
7
+ vi.restoreAllMocks();
8
+ });
9
+
10
+ it('uses the query argument for the search API and returns urls', async () => {
11
+ const command = getRegistry().get('weread/search');
12
+ expect(command?.func).toBeTypeOf('function');
13
+
14
+ const fetchMock = vi.fn().mockResolvedValue({
15
+ ok: true,
16
+ json: () => Promise.resolve({
17
+ books: [
18
+ {
19
+ bookInfo: {
20
+ title: 'Deep Work',
21
+ author: 'Cal Newport',
22
+ bookId: 'abc123',
23
+ },
24
+ },
25
+ ],
26
+ }),
27
+ });
28
+ vi.stubGlobal('fetch', fetchMock);
29
+
30
+ const result = await command!.func!(null as any, { query: 'deep work', limit: 5 });
31
+
32
+ expect(fetchMock).toHaveBeenCalledTimes(1);
33
+ expect(String(fetchMock.mock.calls[0][0])).toContain('keyword=deep+work');
34
+ expect(result).toEqual([
35
+ {
36
+ rank: 1,
37
+ title: 'Deep Work',
38
+ author: 'Cal Newport',
39
+ bookId: 'abc123',
40
+ url: 'https://weread.qq.com/web/bookDetail/abc123',
41
+ },
42
+ ]);
43
+ });
44
+ });
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Extended E2E tests for all other browser commands.
3
+ * Opt-in only: OPENCLI_E2E=1 npx vitest run
4
+ */
5
+
6
+ import { describe, it, expect } from 'vitest';
7
+ import { runCli, parseJsonOutput } from './helpers.js';
8
+
9
+ async function tryBrowserCommand(args: string[]): Promise<any[] | null> {
10
+ const { stdout, code } = await runCli(args, { timeout: 60_000 });
11
+ if (code !== 0) return null;
12
+ try {
13
+ const data = parseJsonOutput(stdout);
14
+ return Array.isArray(data) ? data : null;
15
+ } catch {
16
+ return null;
17
+ }
18
+ }
19
+
20
+ function expectDataOrSkip(data: any[] | null, label: string) {
21
+ if (data === null || data.length === 0) {
22
+ console.warn(`${label}: skipped — no data returned (likely bot detection or geo-blocking)`);
23
+ return;
24
+ }
25
+ expect(data.length).toBeGreaterThanOrEqual(1);
26
+ }
27
+
28
+ describe('browser extended public-data commands E2E', () => {
29
+
30
+ // ── bbc ──
31
+ it('bbc news returns headlines', async () => {
32
+ const data = await tryBrowserCommand(['bbc', 'news', '--limit', '3', '-f', 'json']);
33
+ expectDataOrSkip(data, 'bbc news');
34
+ if (data) {
35
+ expect(data[0]).toHaveProperty('title');
36
+ }
37
+ }, 60_000);
38
+
39
+ // ── bloomberg ──
40
+ it('bloomberg news returns article detail when the article page is accessible', async () => {
41
+ const feedResult = await runCli(['bloomberg', 'tech', '--limit', '1', '-f', 'json']);
42
+ if (feedResult.code !== 0) {
43
+ console.warn('bloomberg news: skipped — could not load Bloomberg tech feed');
44
+ return;
45
+ }
46
+
47
+ const feedItems = parseJsonOutput(feedResult.stdout);
48
+ const link = Array.isArray(feedItems) ? feedItems[0]?.link : null;
49
+ if (!link) {
50
+ console.warn('bloomberg news: skipped — tech feed returned no link');
51
+ return;
52
+ }
53
+
54
+ const data = await tryBrowserCommand(['bloomberg', 'news', link, '-f', 'json']);
55
+ expectDataOrSkip(data, 'bloomberg news');
56
+ if (data) {
57
+ expect(data[0]).toHaveProperty('title');
58
+ expect(data[0]).toHaveProperty('summary');
59
+ expect(data[0]).toHaveProperty('link');
60
+ expect(data[0]).toHaveProperty('mediaLinks');
61
+ expect(data[0]).toHaveProperty('content');
62
+ }
63
+ }, 60_000);
64
+
65
+ // ── weibo ──
66
+ it('weibo hot returns trending topics', async () => {
67
+ const data = await tryBrowserCommand(['weibo', 'hot', '--limit', '5', '-f', 'json']);
68
+ expectDataOrSkip(data, 'weibo hot');
69
+ }, 60_000);
70
+
71
+ it('weibo search returns results', async () => {
72
+ const data = await tryBrowserCommand(['weibo', 'search', 'openai', '--limit', '3', '-f', 'json']);
73
+ expectDataOrSkip(data, 'weibo search');
74
+ }, 60_000);
75
+
76
+ // ── reddit ──
77
+ it('reddit hot returns posts', async () => {
78
+ const data = await tryBrowserCommand(['reddit', 'hot', '--limit', '5', '-f', 'json']);
79
+ expectDataOrSkip(data, 'reddit hot');
80
+ }, 60_000);
81
+
82
+ it('reddit frontpage returns posts', async () => {
83
+ const data = await tryBrowserCommand(['reddit', 'frontpage', '--limit', '5', '-f', 'json']);
84
+ expectDataOrSkip(data, 'reddit frontpage');
85
+ }, 60_000);
86
+
87
+ // ── twitter ──
88
+ it('twitter trending returns trends', async () => {
89
+ const data = await tryBrowserCommand(['twitter', 'trending', '--limit', '5', '-f', 'json']);
90
+ expectDataOrSkip(data, 'twitter trending');
91
+ }, 60_000);
92
+
93
+ // ── xueqiu ──
94
+ it('xueqiu hot returns hot posts', async () => {
95
+ const data = await tryBrowserCommand(['xueqiu', 'hot', '--limit', '5', '-f', 'json']);
96
+ expectDataOrSkip(data, 'xueqiu hot');
97
+ }, 60_000);
98
+
99
+ it('xueqiu hot-stock returns stocks', async () => {
100
+ const data = await tryBrowserCommand(['xueqiu', 'hot-stock', '--limit', '5', '-f', 'json']);
101
+ expectDataOrSkip(data, 'xueqiu hot-stock');
102
+ }, 60_000);
103
+
104
+ // ── reuters ──
105
+ it('reuters search returns articles', async () => {
106
+ const data = await tryBrowserCommand(['reuters', 'search', 'technology', '--limit', '3', '-f', 'json']);
107
+ expectDataOrSkip(data, 'reuters search');
108
+ }, 60_000);
109
+
110
+ // ── youtube ──
111
+ it('youtube search returns videos', async () => {
112
+ const data = await tryBrowserCommand(['youtube', 'search', 'typescript tutorial', '--limit', '3', '-f', 'json']);
113
+ expectDataOrSkip(data, 'youtube search');
114
+ }, 60_000);
115
+
116
+ // ── smzdm ──
117
+ it('smzdm search returns deals', async () => {
118
+ const data = await tryBrowserCommand(['smzdm', 'search', '键盘', '--limit', '3', '-f', 'json']);
119
+ expectDataOrSkip(data, 'smzdm search');
120
+ }, 60_000);
121
+
122
+ // ── boss ──
123
+ it('boss search returns jobs', async () => {
124
+ const data = await tryBrowserCommand(['boss', 'search', 'golang', '--limit', '3', '-f', 'json']);
125
+ expectDataOrSkip(data, 'boss search');
126
+ }, 60_000);
127
+
128
+ // ── ctrip ──
129
+ it('ctrip search returns flights', async () => {
130
+ const data = await tryBrowserCommand(['ctrip', 'search', '-f', 'json']);
131
+ expectDataOrSkip(data, 'ctrip search');
132
+ }, 60_000);
133
+
134
+ // ── coupang ──
135
+ it('coupang search returns products', async () => {
136
+ const data = await tryBrowserCommand(['coupang', 'search', 'laptop', '--limit', '3', '-f', 'json']);
137
+ expectDataOrSkip(data, 'coupang search');
138
+ }, 60_000);
139
+
140
+ // ── xiaohongshu ──
141
+ it('xiaohongshu search returns notes', async () => {
142
+ const data = await tryBrowserCommand(['xiaohongshu', 'search', '美食', '--limit', '3', '-f', 'json']);
143
+ expectDataOrSkip(data, 'xiaohongshu search');
144
+ }, 60_000);
145
+
146
+ // ── google ──
147
+ it('google search returns results', async () => {
148
+ const data = await tryBrowserCommand(['google', 'search', 'typescript', '--limit', '5', '-f', 'json']);
149
+ expectDataOrSkip(data, 'google search');
150
+ if (data) {
151
+ expect(data[0]).toHaveProperty('type');
152
+ expect(data[0]).toHaveProperty('title');
153
+ expect(data[0]).toHaveProperty('url');
154
+ }
155
+ }, 60_000);
156
+
157
+ // ── yahoo-finance ──
158
+ it('yahoo-finance quote returns stock data', async () => {
159
+ const data = await tryBrowserCommand(['yahoo-finance', 'quote', '--symbol', 'AAPL', '-f', 'json']);
160
+ expectDataOrSkip(data, 'yahoo-finance quote');
161
+ }, 60_000);
162
+ });