@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.
- package/.github/actions/setup-chrome/action.yml +5 -4
- package/.github/workflows/ci.yml +17 -3
- package/.github/workflows/e2e-headed.yml +16 -3
- package/CHANGELOG.md +23 -0
- package/PRIVACY.md +57 -0
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/SKILL.md +101 -2
- package/dist/cli-manifest.json +720 -32
- package/dist/clis/apple-podcasts/search.js +2 -1
- package/dist/clis/arxiv/search.js +2 -2
- package/dist/clis/bbc/news.js +0 -1
- package/dist/clis/ctrip/search.js +0 -1
- package/dist/clis/douyin/_shared/browser-fetch.d.ts +10 -0
- package/dist/clis/douyin/_shared/browser-fetch.js +30 -0
- package/dist/clis/douyin/_shared/browser-fetch.test.d.ts +1 -0
- package/dist/clis/douyin/_shared/browser-fetch.test.js +31 -0
- package/dist/clis/douyin/_shared/creation-id.d.ts +1 -0
- package/dist/clis/douyin/_shared/creation-id.js +5 -0
- package/dist/clis/douyin/_shared/creation-id.test.d.ts +1 -0
- package/dist/clis/douyin/_shared/creation-id.test.js +22 -0
- package/dist/clis/douyin/_shared/imagex-upload.d.ts +20 -0
- package/dist/clis/douyin/_shared/imagex-upload.js +53 -0
- package/dist/clis/douyin/_shared/imagex-upload.test.d.ts +1 -0
- package/dist/clis/douyin/_shared/imagex-upload.test.js +87 -0
- package/dist/clis/douyin/_shared/sts2.d.ts +8 -0
- package/dist/clis/douyin/_shared/sts2.js +15 -0
- package/dist/clis/douyin/_shared/text-extra.d.ts +18 -0
- package/dist/clis/douyin/_shared/text-extra.js +15 -0
- package/dist/clis/douyin/_shared/text-extra.test.d.ts +1 -0
- package/dist/clis/douyin/_shared/text-extra.test.js +37 -0
- package/dist/clis/douyin/_shared/timing.d.ts +2 -0
- package/dist/clis/douyin/_shared/timing.js +22 -0
- package/dist/clis/douyin/_shared/timing.test.d.ts +1 -0
- package/dist/clis/douyin/_shared/timing.test.js +28 -0
- package/dist/clis/douyin/_shared/tos-upload-short-read.test.d.ts +11 -0
- package/dist/clis/douyin/_shared/tos-upload-short-read.test.js +83 -0
- package/dist/clis/douyin/_shared/tos-upload.d.ts +53 -0
- package/dist/clis/douyin/_shared/tos-upload.js +295 -0
- package/dist/clis/douyin/_shared/tos-upload.test.d.ts +1 -0
- package/dist/clis/douyin/_shared/tos-upload.test.js +229 -0
- package/dist/clis/douyin/_shared/transcode.d.ts +27 -0
- package/dist/clis/douyin/_shared/transcode.js +45 -0
- package/dist/clis/douyin/_shared/transcode.test.d.ts +1 -0
- package/dist/clis/douyin/_shared/transcode.test.js +93 -0
- package/dist/clis/douyin/_shared/types.d.ts +26 -0
- package/dist/clis/douyin/_shared/types.js +1 -0
- package/dist/clis/douyin/activities.d.ts +1 -0
- package/dist/clis/douyin/activities.js +20 -0
- package/dist/clis/douyin/activities.test.d.ts +1 -0
- package/dist/clis/douyin/activities.test.js +22 -0
- package/dist/clis/douyin/collections.d.ts +1 -0
- package/dist/clis/douyin/collections.js +22 -0
- package/dist/clis/douyin/collections.test.d.ts +1 -0
- package/dist/clis/douyin/collections.test.js +23 -0
- package/dist/clis/douyin/delete.d.ts +1 -0
- package/dist/clis/douyin/delete.js +18 -0
- package/dist/clis/douyin/delete.test.d.ts +1 -0
- package/dist/clis/douyin/delete.test.js +11 -0
- package/dist/clis/douyin/draft.d.ts +14 -0
- package/dist/clis/douyin/draft.js +237 -0
- package/dist/clis/douyin/draft.test.d.ts +1 -0
- package/dist/clis/douyin/draft.test.js +11 -0
- package/dist/clis/douyin/drafts.d.ts +1 -0
- package/dist/clis/douyin/drafts.js +23 -0
- package/dist/clis/douyin/drafts.test.d.ts +1 -0
- package/dist/clis/douyin/drafts.test.js +11 -0
- package/dist/clis/douyin/hashtag.d.ts +1 -0
- package/dist/clis/douyin/hashtag.js +45 -0
- package/dist/clis/douyin/hashtag.test.d.ts +1 -0
- package/dist/clis/douyin/hashtag.test.js +25 -0
- package/dist/clis/douyin/location.d.ts +1 -0
- package/dist/clis/douyin/location.js +24 -0
- package/dist/clis/douyin/location.test.d.ts +1 -0
- package/dist/clis/douyin/location.test.js +23 -0
- package/dist/clis/douyin/profile.d.ts +1 -0
- package/dist/clis/douyin/profile.js +28 -0
- package/dist/clis/douyin/profile.test.d.ts +1 -0
- package/dist/clis/douyin/profile.test.js +11 -0
- package/dist/clis/douyin/publish.d.ts +14 -0
- package/dist/clis/douyin/publish.js +288 -0
- package/dist/clis/douyin/publish.test.d.ts +1 -0
- package/dist/clis/douyin/publish.test.js +38 -0
- package/dist/clis/douyin/stats.d.ts +1 -0
- package/dist/clis/douyin/stats.js +27 -0
- package/dist/clis/douyin/stats.test.d.ts +1 -0
- package/dist/clis/douyin/stats.test.js +22 -0
- package/dist/clis/douyin/update.d.ts +1 -0
- package/dist/clis/douyin/update.js +31 -0
- package/dist/clis/douyin/update.test.d.ts +1 -0
- package/dist/clis/douyin/update.test.js +11 -0
- package/dist/clis/douyin/videos.d.ts +1 -0
- package/dist/clis/douyin/videos.js +34 -0
- package/dist/clis/douyin/videos.test.d.ts +1 -0
- package/dist/clis/douyin/videos.test.js +11 -0
- package/dist/clis/hackernews/search.yaml +1 -1
- package/dist/clis/instagram/search.yaml +2 -1
- package/dist/clis/linux-do/search.yaml +3 -1
- package/dist/clis/medium/search.js +1 -1
- package/dist/clis/reuters/search.js +0 -1
- package/dist/clis/twitter/search.js +5 -3
- package/dist/clis/twitter/search.test.js +54 -2
- package/dist/clis/weibo/comments.d.ts +1 -0
- package/dist/clis/weibo/comments.js +53 -0
- package/dist/clis/weibo/feed.d.ts +1 -0
- package/dist/clis/weibo/feed.js +56 -0
- package/dist/clis/weibo/hot.js +0 -1
- package/dist/clis/weibo/me.d.ts +1 -0
- package/dist/clis/weibo/me.js +76 -0
- package/dist/clis/weibo/post.d.ts +1 -0
- package/dist/clis/weibo/post.js +75 -0
- package/dist/clis/weibo/user.d.ts +1 -0
- package/dist/clis/weibo/user.js +63 -0
- package/dist/clis/weibo/utils.d.ts +6 -0
- package/dist/clis/weibo/utils.js +30 -0
- package/dist/clis/weread/search.js +3 -2
- package/dist/clis/xueqiu/search.yaml +2 -1
- package/dist/clis/yahoo-finance/quote.js +0 -1
- package/dist/clis/youtube/channel.d.ts +1 -0
- package/dist/clis/youtube/channel.js +150 -0
- package/dist/clis/youtube/comments.d.ts +1 -0
- package/dist/clis/youtube/comments.js +95 -0
- package/dist/clis/youtube/search.js +0 -1
- package/dist/clis/zhihu/search.yaml +2 -1
- package/dist/external-clis.yaml +0 -17
- package/dist/weread-search-regression.test.d.ts +1 -0
- package/dist/weread-search-regression.test.js +39 -0
- package/docs/.vitepress/config.mts +13 -0
- package/docs/adapters/browser/douyin.md +75 -0
- package/docs/adapters/browser/twitter.md +6 -0
- package/docs/adapters/index.md +6 -1
- package/extension/dist/background.js +508 -518
- package/extension/manifest.json +6 -2
- package/extension/package.json +1 -1
- package/extension/popup.html +84 -0
- package/extension/popup.js +25 -0
- package/extension/src/background.ts +20 -1
- package/package.json +1 -1
- package/src/clis/apple-podcasts/search.ts +2 -1
- package/src/clis/arxiv/search.ts +2 -2
- package/src/clis/bbc/news.ts +0 -1
- package/src/clis/ctrip/search.ts +0 -1
- package/src/clis/douyin/_shared/browser-fetch.test.ts +38 -0
- package/src/clis/douyin/_shared/browser-fetch.ts +45 -0
- package/src/clis/douyin/_shared/creation-id.test.ts +26 -0
- package/src/clis/douyin/_shared/creation-id.ts +8 -0
- package/src/clis/douyin/_shared/imagex-upload.test.ts +113 -0
- package/src/clis/douyin/_shared/imagex-upload.ts +76 -0
- package/src/clis/douyin/_shared/sts2.ts +20 -0
- package/src/clis/douyin/_shared/text-extra.test.ts +42 -0
- package/src/clis/douyin/_shared/text-extra.ts +33 -0
- package/src/clis/douyin/_shared/timing.test.ts +38 -0
- package/src/clis/douyin/_shared/timing.ts +22 -0
- package/src/clis/douyin/_shared/tos-upload-short-read.test.ts +102 -0
- package/src/clis/douyin/_shared/tos-upload.test.ts +281 -0
- package/src/clis/douyin/_shared/tos-upload.ts +444 -0
- package/src/clis/douyin/_shared/transcode.test.ts +117 -0
- package/src/clis/douyin/_shared/transcode.ts +78 -0
- package/src/clis/douyin/_shared/types.ts +29 -0
- package/src/clis/douyin/activities.test.ts +25 -0
- package/src/clis/douyin/activities.ts +23 -0
- package/src/clis/douyin/collections.test.ts +26 -0
- package/src/clis/douyin/collections.ts +25 -0
- package/src/clis/douyin/delete.test.ts +12 -0
- package/src/clis/douyin/delete.ts +20 -0
- package/src/clis/douyin/draft.test.ts +12 -0
- package/src/clis/douyin/draft.ts +282 -0
- package/src/clis/douyin/drafts.test.ts +12 -0
- package/src/clis/douyin/drafts.ts +27 -0
- package/src/clis/douyin/hashtag.test.ts +28 -0
- package/src/clis/douyin/hashtag.ts +56 -0
- package/src/clis/douyin/location.test.ts +26 -0
- package/src/clis/douyin/location.ts +27 -0
- package/src/clis/douyin/profile.test.ts +12 -0
- package/src/clis/douyin/profile.ts +37 -0
- package/src/clis/douyin/publish.test.ts +45 -0
- package/src/clis/douyin/publish.ts +340 -0
- package/src/clis/douyin/stats.test.ts +25 -0
- package/src/clis/douyin/stats.ts +30 -0
- package/src/clis/douyin/update.test.ts +12 -0
- package/src/clis/douyin/update.ts +43 -0
- package/src/clis/douyin/videos.test.ts +12 -0
- package/src/clis/douyin/videos.ts +49 -0
- package/src/clis/hackernews/search.yaml +1 -1
- package/src/clis/instagram/search.yaml +2 -1
- package/src/clis/linux-do/search.yaml +3 -1
- package/src/clis/medium/search.ts +1 -1
- package/src/clis/reuters/search.ts +0 -1
- package/src/clis/twitter/search.test.ts +69 -2
- package/src/clis/twitter/search.ts +5 -3
- package/src/clis/weibo/comments.ts +54 -0
- package/src/clis/weibo/feed.ts +57 -0
- package/src/clis/weibo/hot.ts +0 -1
- package/src/clis/weibo/me.ts +77 -0
- package/src/clis/weibo/post.ts +77 -0
- package/src/clis/weibo/user.ts +64 -0
- package/src/clis/weibo/utils.ts +32 -0
- package/src/clis/weread/search.ts +3 -2
- package/src/clis/xueqiu/search.yaml +2 -1
- package/src/clis/yahoo-finance/quote.ts +0 -1
- package/src/clis/youtube/channel.ts +155 -0
- package/src/clis/youtube/comments.ts +97 -0
- package/src/clis/youtube/search.ts +0 -1
- package/src/clis/zhihu/search.yaml +2 -1
- package/src/external-clis.yaml +0 -17
- package/src/weread-search-regression.test.ts +44 -0
- package/tests/e2e/browser-public-extended.test.ts +162 -0
- package/tests/e2e/browser-public.test.ts +7 -146
- 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
|
+
});
|
package/src/external-clis.yaml
CHANGED
|
@@ -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
|
+
});
|