@jackwener/opencli 1.7.3 → 1.7.4

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 (93) hide show
  1. package/README.md +16 -16
  2. package/README.zh-CN.md +28 -15
  3. package/cli-manifest.json +547 -10
  4. package/clis/bilibili/favorite.js +18 -13
  5. package/clis/binance/depth.js +3 -4
  6. package/clis/boss/utils.js +2 -3
  7. package/clis/chatgpt-app/ax.js +6 -3
  8. package/clis/douban/search.js +1 -0
  9. package/clis/douban/search.test.js +11 -0
  10. package/clis/douban/subject.js +20 -93
  11. package/clis/douban/subject.test.js +11 -0
  12. package/clis/douban/utils.js +250 -8
  13. package/clis/douban/utils.test.js +179 -4
  14. package/clis/doubao/utils.js +319 -130
  15. package/clis/doubao/utils.test.js +241 -2
  16. package/clis/eastmoney/hot-rank.js +50 -0
  17. package/clis/eastmoney/hot-rank.test.js +59 -0
  18. package/clis/grok/image.test.ts +107 -0
  19. package/clis/grok/image.ts +356 -0
  20. package/clis/tdx/hot-rank.js +47 -0
  21. package/clis/tdx/hot-rank.test.js +59 -0
  22. package/clis/ths/hot-rank.js +49 -0
  23. package/clis/ths/hot-rank.test.js +64 -0
  24. package/clis/twitter/bookmarks.js +2 -1
  25. package/clis/uiverse/_shared.js +368 -0
  26. package/clis/uiverse/_shared.test.js +55 -0
  27. package/clis/uiverse/code.js +47 -0
  28. package/clis/uiverse/preview.js +71 -0
  29. package/clis/xiaohongshu/comments.js +2 -2
  30. package/clis/xiaohongshu/comments.test.js +46 -25
  31. package/clis/xiaohongshu/download.js +6 -7
  32. package/clis/xiaohongshu/download.test.js +17 -5
  33. package/clis/xiaohongshu/note-helpers.js +46 -12
  34. package/clis/xiaohongshu/note.js +3 -5
  35. package/clis/xiaohongshu/note.test.js +52 -25
  36. package/clis/xiaoyuzhou/auth.js +303 -0
  37. package/clis/xiaoyuzhou/auth.test.js +124 -0
  38. package/clis/xiaoyuzhou/download.js +49 -0
  39. package/clis/xiaoyuzhou/download.test.js +125 -0
  40. package/clis/xiaoyuzhou/transcript.js +76 -0
  41. package/clis/xiaoyuzhou/transcript.test.js +195 -0
  42. package/clis/youtube/feed.js +120 -0
  43. package/clis/youtube/history.js +118 -0
  44. package/clis/youtube/like.js +62 -0
  45. package/clis/youtube/playlist.js +97 -0
  46. package/clis/youtube/subscribe.js +71 -0
  47. package/clis/youtube/subscriptions.js +57 -0
  48. package/clis/youtube/unlike.js +62 -0
  49. package/clis/youtube/unsubscribe.js +71 -0
  50. package/clis/youtube/utils.js +122 -0
  51. package/clis/youtube/utils.test.js +32 -1
  52. package/clis/youtube/watch-later.js +76 -0
  53. package/dist/src/browser/base-page.js +25 -5
  54. package/dist/src/browser/bridge.d.ts +2 -0
  55. package/dist/src/browser/bridge.js +51 -14
  56. package/dist/src/browser/cdp.js +1 -0
  57. package/dist/src/browser/daemon-client.d.ts +1 -0
  58. package/dist/src/browser/dom-snapshot.js +13 -1
  59. package/dist/src/browser/page.d.ts +4 -1
  60. package/dist/src/browser/page.js +48 -8
  61. package/dist/src/browser/page.test.js +61 -1
  62. package/dist/src/browser/target-errors.d.ts +23 -0
  63. package/dist/src/browser/target-errors.js +29 -0
  64. package/dist/src/browser/target-errors.test.d.ts +1 -0
  65. package/dist/src/browser/target-errors.test.js +61 -0
  66. package/dist/src/browser/target-resolver.d.ts +57 -0
  67. package/dist/src/browser/target-resolver.js +298 -0
  68. package/dist/src/browser/target-resolver.test.d.ts +1 -0
  69. package/dist/src/browser/target-resolver.test.js +43 -0
  70. package/dist/src/browser.test.js +38 -1
  71. package/dist/src/cli.js +45 -37
  72. package/dist/src/commands/daemon.d.ts +4 -2
  73. package/dist/src/commands/daemon.js +22 -2
  74. package/dist/src/commands/daemon.test.js +65 -2
  75. package/dist/src/daemon.js +2 -0
  76. package/dist/src/doctor.d.ts +1 -0
  77. package/dist/src/doctor.js +32 -9
  78. package/dist/src/doctor.test.js +28 -12
  79. package/dist/src/external-clis.yaml +2 -2
  80. package/dist/src/logger.d.ts +2 -2
  81. package/dist/src/logger.js +3 -3
  82. package/dist/src/output.js +1 -5
  83. package/dist/src/output.test.js +0 -21
  84. package/dist/src/pipeline/steps/transform.js +1 -1
  85. package/dist/src/pipeline/template.d.ts +1 -0
  86. package/dist/src/pipeline/template.js +11 -3
  87. package/dist/src/pipeline/template.test.js +3 -0
  88. package/dist/src/pipeline/transform.test.js +14 -0
  89. package/dist/src/plugin.d.ts +7 -1
  90. package/dist/src/plugin.js +23 -1
  91. package/dist/src/plugin.test.js +15 -1
  92. package/dist/src/types.d.ts +1 -1
  93. package/package.json +1 -1
@@ -0,0 +1,195 @@
1
+ import path from 'node:path';
2
+ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { getRegistry } from '@jackwener/opencli/registry';
4
+
5
+ const { mockLoadCredentials, mockRequestJson, mockFetchTranscriptBody, mockMkdirSync, mockWriteFileSync } = vi.hoisted(() => ({
6
+ mockLoadCredentials: vi.fn(),
7
+ mockRequestJson: vi.fn(),
8
+ mockFetchTranscriptBody: vi.fn(),
9
+ mockMkdirSync: vi.fn(),
10
+ mockWriteFileSync: vi.fn(),
11
+ }));
12
+
13
+ vi.mock('./auth.js', async () => {
14
+ const actual = await vi.importActual('./auth.js');
15
+ return {
16
+ ...actual,
17
+ loadXiaoyuzhouCredentials: mockLoadCredentials,
18
+ requestXiaoyuzhouJson: mockRequestJson,
19
+ fetchXiaoyuzhouTranscriptBody: mockFetchTranscriptBody,
20
+ };
21
+ });
22
+
23
+ vi.mock('node:fs', () => ({
24
+ mkdirSync: mockMkdirSync,
25
+ writeFileSync: mockWriteFileSync,
26
+ }));
27
+
28
+ await import('./transcript.js');
29
+
30
+ let cmd;
31
+
32
+ function toPosixPath(value) {
33
+ return value.replaceAll(path.sep, '/');
34
+ }
35
+
36
+ beforeAll(() => {
37
+ cmd = getRegistry().get('xiaoyuzhou/transcript');
38
+ expect(cmd?.func).toBeTypeOf('function');
39
+ });
40
+
41
+ describe('xiaoyuzhou transcript', () => {
42
+ beforeEach(() => {
43
+ mockLoadCredentials.mockReset();
44
+ mockRequestJson.mockReset();
45
+ mockFetchTranscriptBody.mockReset();
46
+ mockMkdirSync.mockReset();
47
+ mockWriteFileSync.mockReset();
48
+ mockLoadCredentials.mockReturnValue({ access_token: 'access', refresh_token: 'refresh' });
49
+ });
50
+
51
+ it('downloads transcript json and extracted text files', async () => {
52
+ mockRequestJson
53
+ .mockResolvedValueOnce({
54
+ credentials: { access_token: 'access-1', refresh_token: 'refresh-1' },
55
+ data: {
56
+ title: 'Transcript Episode',
57
+ podcast: { title: 'OpenCLI FM' },
58
+ transcript: { mediaId: 'media-123' },
59
+ },
60
+ })
61
+ .mockResolvedValueOnce({
62
+ credentials: { access_token: 'access-1', refresh_token: 'refresh-1' },
63
+ data: {
64
+ transcriptUrl: 'https://cdn.example.com/transcript.json',
65
+ },
66
+ });
67
+ mockFetchTranscriptBody.mockResolvedValue(JSON.stringify({
68
+ segments: [{ text: 'hello' }, { text: 'world' }],
69
+ }));
70
+ const result = await cmd.func(null, {
71
+ id: 'ep123',
72
+ output: '/tmp/xiaoyuzhou-transcripts',
73
+ json: true,
74
+ text: true,
75
+ });
76
+ expect(mockRequestJson).toHaveBeenNthCalledWith(1, '/v1/episode/get', {
77
+ query: { eid: 'ep123' },
78
+ credentials: { access_token: 'access', refresh_token: 'refresh' },
79
+ });
80
+ expect(mockRequestJson).toHaveBeenNthCalledWith(2, '/v1/episode-transcript/get', {
81
+ method: 'POST',
82
+ body: { eid: 'ep123', mediaId: 'media-123' },
83
+ credentials: { access_token: 'access-1', refresh_token: 'refresh-1' },
84
+ });
85
+ expect(mockMkdirSync).toHaveBeenCalledWith('/tmp/xiaoyuzhou-transcripts/ep123', { recursive: true });
86
+ expect(mockWriteFileSync).toHaveBeenNthCalledWith(1, '/tmp/xiaoyuzhou-transcripts/ep123/transcript.json', expect.any(String), 'utf-8');
87
+ expect(mockWriteFileSync).toHaveBeenNthCalledWith(2, '/tmp/xiaoyuzhou-transcripts/ep123/transcript.txt', 'hello\nworld', 'utf-8');
88
+ expect(result).toEqual([{
89
+ title: 'Transcript Episode',
90
+ podcast: 'OpenCLI FM',
91
+ status: 'success',
92
+ segments: '2',
93
+ json_file: '/tmp/xiaoyuzhou-transcripts/ep123/transcript.json',
94
+ text_file: '/tmp/xiaoyuzhou-transcripts/ep123/transcript.txt',
95
+ }]);
96
+ });
97
+
98
+ it('derives mediaId from episode.media.id when transcript.mediaId is absent', async () => {
99
+ mockRequestJson
100
+ .mockResolvedValueOnce({
101
+ credentials: { access_token: 'access-1', refresh_token: 'refresh-1' },
102
+ data: {
103
+ title: 'Transcript Episode',
104
+ podcast: { title: 'OpenCLI FM' },
105
+ media: { id: 'media-456' },
106
+ },
107
+ })
108
+ .mockResolvedValueOnce({
109
+ credentials: { access_token: 'access-1', refresh_token: 'refresh-1' },
110
+ data: {
111
+ transcriptUrl: 'https://cdn.example.com/transcript.json',
112
+ },
113
+ });
114
+ mockFetchTranscriptBody.mockResolvedValue(JSON.stringify({ text: 'hello' }));
115
+ await cmd.func(null, {
116
+ id: 'ep456',
117
+ output: '/tmp/xiaoyuzhou-transcripts',
118
+ json: false,
119
+ text: true,
120
+ });
121
+ expect(mockRequestJson.mock.calls[1][1].body.mediaId).toBe('media-456');
122
+ expect(mockWriteFileSync).toHaveBeenCalledTimes(1);
123
+ expect(mockWriteFileSync).toHaveBeenCalledWith('/tmp/xiaoyuzhou-transcripts/ep456/transcript.txt', 'hello', 'utf-8');
124
+ });
125
+
126
+ it('throws when transcript url is missing', async () => {
127
+ mockRequestJson
128
+ .mockResolvedValueOnce({
129
+ credentials: { access_token: 'access-1', refresh_token: 'refresh-1' },
130
+ data: {
131
+ title: 'Transcript Episode',
132
+ podcast: { title: 'OpenCLI FM' },
133
+ transcript: { mediaId: 'media-123' },
134
+ },
135
+ })
136
+ .mockResolvedValueOnce({
137
+ credentials: { access_token: 'access-1', refresh_token: 'refresh-1' },
138
+ data: {},
139
+ });
140
+ await expect(cmd.func(null, {
141
+ id: 'ep123',
142
+ output: '/tmp/xiaoyuzhou-transcripts',
143
+ json: true,
144
+ text: true,
145
+ })).rejects.toMatchObject({
146
+ code: 'EMPTY_RESULT',
147
+ message: 'Transcript URL not found',
148
+ });
149
+ expect(mockWriteFileSync).not.toHaveBeenCalled();
150
+ });
151
+
152
+ it('throws parse_error when transcript text extraction fails', async () => {
153
+ mockRequestJson
154
+ .mockResolvedValueOnce({
155
+ credentials: { access_token: 'access-1', refresh_token: 'refresh-1' },
156
+ data: {
157
+ title: 'Transcript Episode',
158
+ podcast: { title: 'OpenCLI FM' },
159
+ transcript: { mediaId: 'media-123' },
160
+ },
161
+ })
162
+ .mockResolvedValueOnce({
163
+ credentials: { access_token: 'access-1', refresh_token: 'refresh-1' },
164
+ data: {
165
+ transcriptUrl: 'https://cdn.example.com/transcript.json',
166
+ },
167
+ });
168
+ mockFetchTranscriptBody.mockResolvedValue(JSON.stringify({
169
+ segments: [{ startAt: 0, endAt: 1 }],
170
+ }));
171
+ await expect(cmd.func(null, {
172
+ id: 'ep123',
173
+ output: '/tmp/xiaoyuzhou-transcripts',
174
+ json: true,
175
+ text: true,
176
+ })).rejects.toMatchObject({
177
+ code: 'PARSE_ERROR',
178
+ message: 'Failed to extract transcript text',
179
+ });
180
+ expect(mockWriteFileSync).not.toHaveBeenCalled();
181
+ });
182
+
183
+ it('rejects disabling both json and text outputs', async () => {
184
+ await expect(cmd.func(null, {
185
+ id: 'ep123',
186
+ output: '/tmp/xiaoyuzhou-transcripts',
187
+ json: false,
188
+ text: false,
189
+ })).rejects.toMatchObject({
190
+ code: 'ARGUMENT',
191
+ message: 'At least one of --json or --text must be enabled',
192
+ });
193
+ expect(mockRequestJson).not.toHaveBeenCalled();
194
+ });
195
+ });
@@ -0,0 +1,120 @@
1
+ /**
2
+ * YouTube feed — homepage recommended videos.
3
+ * Reads ytInitialData from the homepage directly (personalized, no separate API call needed).
4
+ */
5
+ import { cli, Strategy } from '@jackwener/opencli/registry';
6
+ import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
7
+
8
+ cli({
9
+ site: 'youtube',
10
+ name: 'feed',
11
+ description: 'Get YouTube homepage recommended videos',
12
+ domain: 'www.youtube.com',
13
+ strategy: Strategy.COOKIE,
14
+ args: [
15
+ { name: 'limit', type: 'int', default: 20, help: 'Max videos to return (default 20, max 100)' },
16
+ ],
17
+ columns: ['rank', 'title', 'channel', 'views', 'duration', 'published', 'url'],
18
+ func: async (page, kwargs) => {
19
+ const limit = Math.min(kwargs.limit || 20, 100);
20
+ await page.goto('https://www.youtube.com');
21
+ await page.wait(3);
22
+ const data = await page.evaluate(`
23
+ (async () => {
24
+ const d = window.ytInitialData;
25
+ if (!d) return { error: 'YouTube data not found — are you logged in?' };
26
+
27
+ const limit = ${limit};
28
+ const cfg = window.ytcfg?.data_ || {};
29
+ const apiKey = cfg.INNERTUBE_API_KEY;
30
+ const context = cfg.INNERTUBE_CONTEXT;
31
+
32
+ function extractFromItem(item) {
33
+ // Modern lockupViewModel format
34
+ const lvm = item.richItemRenderer?.content?.lockupViewModel;
35
+ if (lvm && lvm.contentType === 'LOCKUP_CONTENT_TYPE_VIDEO') {
36
+ const meta = lvm.metadata?.lockupMetadataViewModel;
37
+ const rows = meta?.metadata?.contentMetadataViewModel?.metadataRows || [];
38
+ const parts = rows.flatMap(r => (r.metadataParts || []).map(p => p.text?.content || '').filter(Boolean));
39
+ let duration = '';
40
+ for (const ov of (lvm.contentImage?.thumbnailViewModel?.overlays || [])) {
41
+ for (const b of (ov.thumbnailBottomOverlayViewModel?.badges || [])) {
42
+ if (b.thumbnailBadgeViewModel?.text) duration = b.thumbnailBadgeViewModel.text;
43
+ }
44
+ }
45
+ return {
46
+ title: meta?.title?.content || '',
47
+ channel: parts[0] || '',
48
+ views: parts[1] || '',
49
+ duration,
50
+ published: parts[2] || '',
51
+ videoId: lvm.contentId,
52
+ };
53
+ }
54
+
55
+ // Legacy videoRenderer format
56
+ const v = item.richItemRenderer?.content?.videoRenderer || item.videoRenderer;
57
+ if (v?.videoId) {
58
+ return {
59
+ title: v.title?.runs?.[0]?.text || '',
60
+ channel: v.ownerText?.runs?.[0]?.text || v.shortBylineText?.runs?.[0]?.text || '',
61
+ views: v.viewCountText?.simpleText || v.shortViewCountText?.simpleText || '',
62
+ duration: v.lengthText?.simpleText || '',
63
+ published: v.publishedTimeText?.simpleText || '',
64
+ videoId: v.videoId,
65
+ };
66
+ }
67
+ return null;
68
+ }
69
+
70
+ const tabs = d.contents?.twoColumnBrowseResultsRenderer?.tabs || [];
71
+ const richContents = tabs[0]?.tabRenderer?.content?.richGridRenderer?.contents || [];
72
+
73
+ const videos = [];
74
+ for (const item of richContents) {
75
+ if (videos.length >= limit) break;
76
+ const v = extractFromItem(item);
77
+ if (v?.videoId) {
78
+ videos.push({ rank: videos.length + 1, ...v, url: 'https://www.youtube.com/watch?v=' + v.videoId });
79
+ }
80
+ }
81
+
82
+ // Pagination
83
+ if (videos.length < limit && apiKey && context) {
84
+ let contItem = richContents[richContents.length - 1];
85
+ while (videos.length < limit && contItem?.continuationItemRenderer) {
86
+ const token = contItem.continuationItemRenderer?.continuationEndpoint?.continuationCommand?.token;
87
+ if (!token) break;
88
+ const resp = await fetch('/youtubei/v1/browse?key=' + apiKey + '&prettyPrint=false', {
89
+ method: 'POST', credentials: 'include',
90
+ headers: { 'Content-Type': 'application/json' },
91
+ body: JSON.stringify({ context, continuation: token }),
92
+ });
93
+ if (!resp.ok) break;
94
+ const contData = await resp.json();
95
+ const newItems = contData.onResponseReceivedActions?.[0]?.appendContinuationItemsAction?.continuationItems || [];
96
+ if (!newItems.length) break;
97
+ for (const item of newItems) {
98
+ if (videos.length >= limit) break;
99
+ const v = extractFromItem(item);
100
+ if (v?.videoId) {
101
+ videos.push({ rank: videos.length + 1, ...v, url: 'https://www.youtube.com/watch?v=' + v.videoId });
102
+ }
103
+ }
104
+ contItem = newItems[newItems.length - 1];
105
+ }
106
+ }
107
+
108
+ return videos;
109
+ })()
110
+ `);
111
+ if (!Array.isArray(data)) {
112
+ const errMsg = data && typeof data === 'object' ? String(data.error || '') : '';
113
+ throw new CommandExecutionError(errMsg || 'Failed to fetch YouTube feed');
114
+ }
115
+ if (data.length === 0) {
116
+ throw new EmptyResultError('youtube feed');
117
+ }
118
+ return data;
119
+ },
120
+ });
@@ -0,0 +1,118 @@
1
+ /**
2
+ * YouTube history — watch history via InnerTube browse API (FEhistory).
3
+ */
4
+ import { cli, Strategy } from '@jackwener/opencli/registry';
5
+ import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
6
+
7
+ cli({
8
+ site: 'youtube',
9
+ name: 'history',
10
+ description: 'Get YouTube watch history',
11
+ domain: 'www.youtube.com',
12
+ strategy: Strategy.COOKIE,
13
+ args: [
14
+ { name: 'limit', type: 'int', default: 30, help: 'Max videos to return (default 30, max 200)' },
15
+ ],
16
+ columns: ['rank', 'title', 'channel', 'views', 'duration', 'url'],
17
+ func: async (page, kwargs) => {
18
+ const limit = Math.min(kwargs.limit || 30, 200);
19
+ await page.goto('https://www.youtube.com/feed/history');
20
+ await page.wait(3);
21
+ await page.autoScroll({ times: Math.min(Math.max(Math.ceil(limit / 20), 1), 8), delayMs: 1200 });
22
+ const data = await page.evaluate(`
23
+ (async () => {
24
+ const limit = ${limit};
25
+
26
+ const videos = [];
27
+ const seen = new Set();
28
+ const root = document.querySelector('ytd-two-column-browse-results-renderer #primary ytd-section-list-renderer');
29
+ if (!root) return { error: 'YouTube history list not found' };
30
+
31
+ function text(el) {
32
+ return (el?.textContent || '').replace(/\\s+/g, ' ').trim();
33
+ }
34
+
35
+ function push(entry) {
36
+ if (!entry?.url || seen.has(entry.url) || videos.length >= limit) return;
37
+ seen.add(entry.url);
38
+ videos.push({ rank: videos.length + 1, ...entry });
39
+ }
40
+
41
+ for (const section of root.querySelectorAll('ytd-item-section-renderer')) {
42
+ if (videos.length >= limit) break;
43
+
44
+ for (const renderer of section.querySelectorAll('yt-lockup-view-model, ytd-video-renderer, ytd-rich-item-renderer, ytd-grid-video-renderer, ytd-compact-video-renderer')) {
45
+ if (videos.length >= limit) break;
46
+ const link = renderer.querySelector('a[href^="/watch?v="]');
47
+ const href = link?.getAttribute('href') || '';
48
+ if (!href) continue;
49
+ const title =
50
+ link?.getAttribute('title')
51
+ || text(renderer.querySelector('#video-title'))
52
+ || text(renderer.querySelector('h3 a'))
53
+ || text(renderer.querySelector('h3'))
54
+ || text(link);
55
+ const channel =
56
+ text(renderer.querySelector('#channel-name a'))
57
+ || text(renderer.querySelector('[aria-label^="前往频道:"]'))
58
+ || text(renderer.querySelector('[aria-label^="Go to channel:"]'))
59
+ || text(renderer.querySelector('ytd-channel-name'))
60
+ || text(renderer.querySelector('#metadata #byline-container'))
61
+ || '';
62
+ const metadata = Array.from(renderer.querySelectorAll('#metadata-line span, #metadata span, .metadata span'))
63
+ .map(node => text(node))
64
+ .filter(Boolean);
65
+ const lockupMetadata = Array.from(renderer.querySelectorAll('yt-content-metadata-view-model span, yt-lockup-metadata-view-model span'))
66
+ .map(node => text(node))
67
+ .filter(Boolean);
68
+ const combinedMetadata = (metadata.length ? metadata : lockupMetadata)
69
+ .filter(value => value && value !== title && value !== '•');
70
+ const inferredChannel = channel || combinedMetadata.find(value => !/观看|views|前|前に|ago|次观看|次查看|stream/i.test(value)) || '';
71
+ const inferredViews = combinedMetadata.find(value => /观看|views/i.test(value)) || '';
72
+ const inferredPublished = combinedMetadata.find(value => value !== inferredChannel && value !== inferredViews) || '';
73
+ const duration =
74
+ text(renderer.querySelector('ytd-thumbnail-overlay-time-status-renderer'))
75
+ || text(renderer.querySelector('yt-thumbnail-badge-view-model'))
76
+ || text(renderer.querySelector('badge-shape'))
77
+ || '';
78
+ push({
79
+ title,
80
+ channel: inferredChannel,
81
+ views: inferredViews,
82
+ duration,
83
+ published: inferredPublished,
84
+ url: href.startsWith('http') ? href : 'https://www.youtube.com' + href,
85
+ });
86
+ }
87
+
88
+ for (const shortLink of section.querySelectorAll('a[href^="/shorts/"]')) {
89
+ if (videos.length >= limit) break;
90
+ const card = shortLink.closest('ytm-shorts-lockup-view-model-v2, ytm-shorts-lockup-view-model, ytd-reel-item-renderer') || shortLink.parentElement;
91
+ const href = shortLink.getAttribute('href') || '';
92
+ if (!href) continue;
93
+ const title = shortLink.getAttribute('title') || text(card?.querySelector('h3')) || text(shortLink);
94
+ const stats = Array.from(card?.querySelectorAll('span') || []).map(node => text(node)).filter(Boolean);
95
+ push({
96
+ title,
97
+ channel: 'Shorts',
98
+ views: stats.find(value => /观看|views/i.test(value)) || '',
99
+ duration: 'SHORT',
100
+ published: '',
101
+ url: href.startsWith('http') ? href : 'https://www.youtube.com' + href,
102
+ });
103
+ }
104
+ }
105
+
106
+ return videos.length ? videos : { error: 'No watch history items found on youtube.com/feed/history' };
107
+ })()
108
+ `);
109
+ if (!Array.isArray(data)) {
110
+ const errMsg = data && typeof data === 'object' ? String(data.error || '') : '';
111
+ throw new CommandExecutionError(errMsg || 'Failed to fetch watch history — make sure you are logged into YouTube');
112
+ }
113
+ if (data.length === 0) {
114
+ throw new EmptyResultError('youtube history');
115
+ }
116
+ return data;
117
+ },
118
+ });
@@ -0,0 +1,62 @@
1
+ /**
2
+ * YouTube like — like a video via InnerTube like API (requires SAPISIDHASH auth).
3
+ */
4
+ import { cli, Strategy } from '@jackwener/opencli/registry';
5
+ import { parseVideoId, prepareYoutubeApiPage, SAPISID_HASH_FN } from './utils.js';
6
+ import { CommandExecutionError, AuthRequiredError } from '@jackwener/opencli/errors';
7
+
8
+ cli({
9
+ site: 'youtube',
10
+ name: 'like',
11
+ description: 'Like a YouTube video',
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
+ ],
17
+ columns: ['status', 'message'],
18
+ func: async (page, kwargs) => {
19
+ const videoId = parseVideoId(String(kwargs.url));
20
+ await prepareYoutubeApiPage(page);
21
+ const result = await page.evaluate(`
22
+ (async () => {
23
+ ${SAPISID_HASH_FN}
24
+
25
+ const cfg = window.ytcfg?.data_ || {};
26
+ const apiKey = cfg.INNERTUBE_API_KEY;
27
+ const context = cfg.INNERTUBE_CONTEXT;
28
+ if (!apiKey || !context) return { error: 'config', message: 'YouTube config not found' };
29
+
30
+ const authHash = await getSapisidHash('https://www.youtube.com');
31
+ if (!authHash) return { error: 'auth', message: 'Not logged in (SAPISID cookie missing)' };
32
+
33
+ const resp = await fetch('/youtubei/v1/like/like?key=' + apiKey + '&prettyPrint=false', {
34
+ method: 'POST',
35
+ credentials: 'include',
36
+ headers: {
37
+ 'Content-Type': 'application/json',
38
+ 'Authorization': authHash,
39
+ 'X-Origin': 'https://www.youtube.com',
40
+ },
41
+ body: JSON.stringify({ context, target: { videoId: ${JSON.stringify(videoId)} } }),
42
+ });
43
+
44
+ if (resp.status === 401 || resp.status === 403) return { error: 'auth', message: 'Not logged in' };
45
+ if (!resp.ok) {
46
+ const body = await resp.json().catch(() => ({}));
47
+ const errStatus = body?.error?.status || '';
48
+ if (errStatus === 'UNAUTHENTICATED') return { error: 'auth', message: 'Not logged in' };
49
+ return { error: 'http', message: 'HTTP ' + resp.status + (errStatus ? ' ' + errStatus : '') };
50
+ }
51
+ return { ok: true };
52
+ })()
53
+ `);
54
+ if (result?.error === 'auth') {
55
+ throw new AuthRequiredError('www.youtube.com');
56
+ }
57
+ if (result?.error) {
58
+ throw new CommandExecutionError(result.message || 'Failed to like video');
59
+ }
60
+ return [{ status: 'success', message: 'Liked: ' + videoId }];
61
+ },
62
+ });
@@ -0,0 +1,97 @@
1
+ /**
2
+ * YouTube playlist — get playlist info and video list via InnerTube browse API.
3
+ */
4
+ import { cli, Strategy } from '@jackwener/opencli/registry';
5
+ import { prepareYoutubeApiPage, FETCH_BROWSE_FN, extractPlaylistVideos } from './utils.js';
6
+ import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
7
+
8
+ /**
9
+ * Parse a playlist ID from a URL or bare ID string.
10
+ */
11
+ function parsePlaylistId(input) {
12
+ if (!input.startsWith('http'))
13
+ return input;
14
+ try {
15
+ const url = new URL(input);
16
+ return url.searchParams.get('list') || input;
17
+ }
18
+ catch {
19
+ return input;
20
+ }
21
+ }
22
+
23
+ cli({
24
+ site: 'youtube',
25
+ name: 'playlist',
26
+ description: 'Get YouTube playlist info and video list',
27
+ domain: 'www.youtube.com',
28
+ strategy: Strategy.COOKIE,
29
+ args: [
30
+ { name: 'id', required: true, positional: true, help: 'Playlist URL or playlist ID (PLxxxxxx)' },
31
+ { name: 'limit', type: 'int', default: 50, help: 'Max videos to return (default 50, max 200)' },
32
+ ],
33
+ columns: ['rank', 'title', 'channel', 'duration', 'views', 'published', 'url'],
34
+ func: async (page, kwargs) => {
35
+ const playlistId = parsePlaylistId(String(kwargs.id));
36
+ const limit = Math.min(kwargs.limit || 50, 200);
37
+ await prepareYoutubeApiPage(page);
38
+ const data = await page.evaluate(`
39
+ (async () => {
40
+ const cfg = window.ytcfg?.data_ || {};
41
+ const apiKey = cfg.INNERTUBE_API_KEY;
42
+ const context = cfg.INNERTUBE_CONTEXT;
43
+ if (!apiKey || !context) return { error: 'YouTube config not found' };
44
+
45
+ const browseId = 'VL' + ${JSON.stringify(playlistId)};
46
+ const limit = ${limit};
47
+
48
+ ${FETCH_BROWSE_FN}
49
+
50
+ const data = await fetchBrowse(apiKey, { context, browseId });
51
+ if (data.error) return data;
52
+
53
+ const header = data.header?.pageHeaderRenderer;
54
+ const title = header?.pageTitle || '';
55
+ const metaRows = header?.content?.pageHeaderViewModel?.metadata?.contentMetadataViewModel?.metadataRows || [];
56
+ const stats = metaRows.flatMap(r => (r.metadataParts || []).map(p => p.text?.content || '').filter(Boolean));
57
+
58
+ const sidebarItems = data.sidebar?.playlistSidebarRenderer?.items || [];
59
+ const secondaryInfo = sidebarItems.find(i => i.playlistSidebarSecondaryInfoRenderer)?.playlistSidebarSecondaryInfoRenderer;
60
+ const channelName = secondaryInfo?.videoOwner?.videoOwnerRenderer?.title?.runs?.[0]?.text || '';
61
+
62
+ const tabs = data.contents?.twoColumnBrowseResultsRenderer?.tabs || [];
63
+ let listContents = tabs[0]?.tabRenderer?.content?.sectionListRenderer?.contents?.[0]?.itemSectionRenderer?.contents?.[0]?.playlistVideoListRenderer?.contents || [];
64
+
65
+ const extractVideos = ${extractPlaylistVideos.toString()};
66
+
67
+ let videos = extractVideos(listContents);
68
+
69
+ let contItem = listContents[listContents.length - 1];
70
+ while (videos.length < limit && contItem?.continuationItemRenderer) {
71
+ const token = contItem.continuationItemRenderer?.continuationEndpoint?.continuationCommand?.token;
72
+ if (!token) break;
73
+ const contData = await fetchBrowse(apiKey, { context, continuation: token });
74
+ if (contData.error) break;
75
+ const newItems = contData.onResponseReceivedActions?.[0]?.appendContinuationItemsAction?.continuationItems || [];
76
+ if (!newItems.length) break;
77
+ videos = videos.concat(extractVideos(newItems));
78
+ contItem = newItems[newItems.length - 1];
79
+ }
80
+
81
+ return { title, channelName, stats, videos: videos.slice(0, limit) };
82
+ })()
83
+ `);
84
+ if (!data || typeof data !== 'object') {
85
+ throw new CommandExecutionError('Failed to fetch playlist data');
86
+ }
87
+ if (data.error) {
88
+ throw new CommandExecutionError(String(data.error));
89
+ }
90
+ if (!data.videos?.length) {
91
+ throw new EmptyResultError('youtube playlist');
92
+ }
93
+ const statsStr = (data.stats || []).join(' | ');
94
+ process.stderr.write(`${data.title} [${data.channelName}] ${statsStr}\n`);
95
+ return data.videos;
96
+ },
97
+ });
@@ -0,0 +1,71 @@
1
+ /**
2
+ * YouTube subscribe — subscribe to a channel via InnerTube subscription API.
3
+ */
4
+ import { cli, Strategy } from '@jackwener/opencli/registry';
5
+ import { prepareYoutubeApiPage, SAPISID_HASH_FN, RESOLVE_CHANNEL_HANDLE_FN } from './utils.js';
6
+ import { CommandExecutionError, AuthRequiredError } from '@jackwener/opencli/errors';
7
+
8
+ cli({
9
+ site: 'youtube',
10
+ name: 'subscribe',
11
+ description: 'Subscribe to a YouTube channel',
12
+ domain: 'www.youtube.com',
13
+ strategy: Strategy.COOKIE,
14
+ args: [
15
+ { name: 'channel', required: true, positional: true, help: 'Channel ID (UCxxxx) or handle (@name)' },
16
+ ],
17
+ columns: ['status', 'message'],
18
+ func: async (page, kwargs) => {
19
+ const channelInput = String(kwargs.channel);
20
+ await prepareYoutubeApiPage(page);
21
+ const result = await page.evaluate(`
22
+ (async () => {
23
+ ${SAPISID_HASH_FN}
24
+
25
+ const cfg = window.ytcfg?.data_ || {};
26
+ const apiKey = cfg.INNERTUBE_API_KEY;
27
+ const context = cfg.INNERTUBE_CONTEXT;
28
+ if (!apiKey || !context) return { error: 'config', message: 'YouTube config not found' };
29
+
30
+ const authHash = await getSapisidHash('https://www.youtube.com');
31
+ if (!authHash) return { error: 'auth', message: 'Not logged in (SAPISID cookie missing)' };
32
+
33
+ ${RESOLVE_CHANNEL_HANDLE_FN}
34
+
35
+ let channelId = ${JSON.stringify(channelInput)};
36
+ channelId = await resolveChannelHandle(channelId, apiKey, context);
37
+
38
+ if (!channelId.startsWith('UC')) {
39
+ return { error: 'arg', message: 'Could not resolve channel ID from: ' + ${JSON.stringify(channelInput)} };
40
+ }
41
+
42
+ const resp = await fetch('/youtubei/v1/subscription/subscribe?key=' + apiKey + '&prettyPrint=false', {
43
+ method: 'POST',
44
+ credentials: 'include',
45
+ headers: {
46
+ 'Content-Type': 'application/json',
47
+ 'Authorization': authHash,
48
+ 'X-Origin': 'https://www.youtube.com',
49
+ },
50
+ body: JSON.stringify({ context, channelIds: [channelId] }),
51
+ });
52
+
53
+ if (resp.status === 401 || resp.status === 403) return { error: 'auth', message: 'Not logged in' };
54
+ if (!resp.ok) {
55
+ const body = await resp.json().catch(() => ({}));
56
+ const errStatus = body?.error?.status || '';
57
+ if (errStatus === 'UNAUTHENTICATED') return { error: 'auth', message: 'Not logged in' };
58
+ return { error: 'http', message: 'HTTP ' + resp.status + (errStatus ? ' ' + errStatus : '') };
59
+ }
60
+ return { ok: true, channelId };
61
+ })()
62
+ `);
63
+ if (result?.error === 'auth') {
64
+ throw new AuthRequiredError('www.youtube.com');
65
+ }
66
+ if (result?.error) {
67
+ throw new CommandExecutionError(result.message || 'Failed to subscribe');
68
+ }
69
+ return [{ status: 'success', message: 'Subscribed to: ' + (result.channelId || channelInput) }];
70
+ },
71
+ });