@jackwener/opencli 1.7.2 → 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 (144) hide show
  1. package/README.md +18 -15
  2. package/README.zh-CN.md +31 -15
  3. package/cli-manifest.json +1265 -101
  4. package/clis/barchart/flow.js +1 -1
  5. package/clis/barchart/greeks.js +2 -2
  6. package/clis/barchart/options.js +2 -2
  7. package/clis/barchart/quote.js +1 -1
  8. package/clis/bilibili/favorite.js +18 -13
  9. package/clis/bilibili/feed.js +202 -48
  10. package/clis/binance/depth.js +3 -4
  11. package/clis/boss/utils.js +2 -2
  12. package/clis/chatgpt/image.js +97 -0
  13. package/clis/chatgpt/utils.js +297 -0
  14. package/clis/{chatgpt → chatgpt-app}/ask.js +1 -1
  15. package/clis/{chatgpt → chatgpt-app}/ax.js +6 -3
  16. package/clis/{chatgpt → chatgpt-app}/model.js +1 -1
  17. package/clis/{chatgpt → chatgpt-app}/new.js +1 -1
  18. package/clis/{chatgpt → chatgpt-app}/read.js +1 -1
  19. package/clis/{chatgpt → chatgpt-app}/send.js +1 -1
  20. package/clis/{chatgpt → chatgpt-app}/status.js +1 -1
  21. package/clis/discord-app/delete.js +114 -0
  22. package/clis/douban/search.js +1 -0
  23. package/clis/douban/search.test.js +11 -0
  24. package/clis/douban/subject.js +20 -93
  25. package/clis/douban/subject.test.js +11 -0
  26. package/clis/douban/utils.js +279 -10
  27. package/clis/douban/utils.test.js +296 -1
  28. package/clis/doubao/utils.js +319 -130
  29. package/clis/doubao/utils.test.js +241 -2
  30. package/clis/eastmoney/hot-rank.js +50 -0
  31. package/clis/eastmoney/hot-rank.test.js +59 -0
  32. package/clis/grok/image.test.ts +107 -0
  33. package/clis/grok/image.ts +356 -0
  34. package/clis/ke/chengjiao.js +77 -0
  35. package/clis/ke/ershoufang.js +100 -0
  36. package/clis/ke/utils.js +104 -0
  37. package/clis/ke/xiaoqu.js +77 -0
  38. package/clis/ke/zufang.js +94 -0
  39. package/clis/maimai/search-talents.js +172 -0
  40. package/clis/mubu/doc.js +40 -0
  41. package/clis/mubu/docs.js +43 -0
  42. package/clis/mubu/notes.js +244 -0
  43. package/clis/mubu/recent.js +27 -0
  44. package/clis/mubu/search.js +62 -0
  45. package/clis/mubu/utils.js +304 -0
  46. package/clis/reuters/search.js +1 -1
  47. package/clis/tdx/hot-rank.js +47 -0
  48. package/clis/tdx/hot-rank.test.js +59 -0
  49. package/clis/ths/hot-rank.js +49 -0
  50. package/clis/ths/hot-rank.test.js +64 -0
  51. package/clis/twitter/bookmarks.js +2 -1
  52. package/clis/uiverse/_shared.js +368 -0
  53. package/clis/uiverse/_shared.test.js +55 -0
  54. package/clis/uiverse/code.js +47 -0
  55. package/clis/uiverse/preview.js +71 -0
  56. package/clis/xiaohongshu/comments.js +20 -8
  57. package/clis/xiaohongshu/comments.test.js +69 -12
  58. package/clis/xiaohongshu/creator-note-detail.js +2 -0
  59. package/clis/xiaohongshu/creator-note-detail.test.js +32 -0
  60. package/clis/xiaohongshu/creator-notes-summary.js +4 -0
  61. package/clis/xiaohongshu/creator-notes-summary.test.js +39 -1
  62. package/clis/xiaohongshu/creator-notes.js +1 -0
  63. package/clis/xiaohongshu/creator-profile.js +1 -0
  64. package/clis/xiaohongshu/creator-stats.js +1 -0
  65. package/clis/xiaohongshu/download.js +18 -7
  66. package/clis/xiaohongshu/download.test.js +42 -0
  67. package/clis/xiaohongshu/navigation.test.js +34 -0
  68. package/clis/xiaohongshu/note-helpers.js +46 -12
  69. package/clis/xiaohongshu/note.js +17 -10
  70. package/clis/xiaohongshu/note.test.js +66 -11
  71. package/clis/xiaohongshu/publish.js +1 -0
  72. package/clis/xiaohongshu/search.js +1 -0
  73. package/clis/xiaohongshu/user.js +1 -0
  74. package/clis/xiaoyuzhou/auth.js +303 -0
  75. package/clis/xiaoyuzhou/auth.test.js +124 -0
  76. package/clis/xiaoyuzhou/download.js +49 -0
  77. package/clis/xiaoyuzhou/download.test.js +125 -0
  78. package/clis/xiaoyuzhou/transcript.js +76 -0
  79. package/clis/xiaoyuzhou/transcript.test.js +195 -0
  80. package/clis/yahoo-finance/quote.js +1 -1
  81. package/clis/youtube/feed.js +120 -0
  82. package/clis/youtube/history.js +118 -0
  83. package/clis/youtube/like.js +62 -0
  84. package/clis/youtube/playlist.js +97 -0
  85. package/clis/youtube/subscribe.js +71 -0
  86. package/clis/youtube/subscriptions.js +57 -0
  87. package/clis/youtube/unlike.js +62 -0
  88. package/clis/youtube/unsubscribe.js +71 -0
  89. package/clis/youtube/utils.js +122 -0
  90. package/clis/youtube/utils.test.js +32 -1
  91. package/clis/youtube/watch-later.js +76 -0
  92. package/dist/src/browser/base-page.d.ts +9 -0
  93. package/dist/src/browser/base-page.js +44 -5
  94. package/dist/src/browser/bridge.d.ts +2 -0
  95. package/dist/src/browser/bridge.js +51 -14
  96. package/dist/src/browser/cdp.js +11 -2
  97. package/dist/src/browser/daemon-client.d.ts +2 -0
  98. package/dist/src/browser/dom-snapshot.js +13 -1
  99. package/dist/src/browser/page.d.ts +4 -1
  100. package/dist/src/browser/page.js +48 -8
  101. package/dist/src/browser/page.test.js +61 -1
  102. package/dist/src/browser/target-errors.d.ts +23 -0
  103. package/dist/src/browser/target-errors.js +29 -0
  104. package/dist/src/browser/target-errors.test.d.ts +1 -0
  105. package/dist/src/browser/target-errors.test.js +61 -0
  106. package/dist/src/browser/target-resolver.d.ts +57 -0
  107. package/dist/src/browser/target-resolver.js +298 -0
  108. package/dist/src/browser/target-resolver.test.d.ts +1 -0
  109. package/dist/src/browser/target-resolver.test.js +43 -0
  110. package/dist/src/browser.test.js +38 -1
  111. package/dist/src/cli.js +45 -35
  112. package/dist/src/commands/daemon.d.ts +4 -2
  113. package/dist/src/commands/daemon.js +22 -2
  114. package/dist/src/commands/daemon.test.js +65 -2
  115. package/dist/src/daemon.js +7 -0
  116. package/dist/src/doctor.d.ts +2 -0
  117. package/dist/src/doctor.js +82 -10
  118. package/dist/src/doctor.test.js +28 -12
  119. package/dist/src/electron-apps.js +1 -1
  120. package/dist/src/errors.d.ts +1 -0
  121. package/dist/src/errors.js +13 -0
  122. package/dist/src/execution.js +36 -9
  123. package/dist/src/execution.test.js +23 -0
  124. package/dist/src/external-clis.yaml +2 -2
  125. package/dist/src/logger.d.ts +2 -2
  126. package/dist/src/logger.js +3 -8
  127. package/dist/src/output.js +1 -5
  128. package/dist/src/output.test.js +0 -21
  129. package/dist/src/pipeline/steps/transform.js +1 -1
  130. package/dist/src/pipeline/template.d.ts +1 -0
  131. package/dist/src/pipeline/template.js +11 -3
  132. package/dist/src/pipeline/template.test.js +3 -0
  133. package/dist/src/pipeline/transform.test.js +14 -0
  134. package/dist/src/plugin.d.ts +7 -1
  135. package/dist/src/plugin.js +23 -1
  136. package/dist/src/plugin.test.js +15 -1
  137. package/dist/src/registry.js +3 -4
  138. package/dist/src/types.d.ts +3 -1
  139. package/dist/src/update-check.d.ts +14 -0
  140. package/dist/src/update-check.js +48 -3
  141. package/dist/src/update-check.test.d.ts +1 -0
  142. package/dist/src/update-check.test.js +31 -0
  143. package/package.json +1 -1
  144. package/scripts/fetch-adapters.js +35 -8
@@ -0,0 +1,125 @@
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 { mockFetchPageProps, mockHttpDownload, mockMkdirSync } = vi.hoisted(() => ({
6
+ mockFetchPageProps: vi.fn(),
7
+ mockHttpDownload: vi.fn(),
8
+ mockMkdirSync: vi.fn(),
9
+ }));
10
+
11
+ vi.mock('./utils.js', async () => {
12
+ const actual = await vi.importActual('./utils.js');
13
+ return {
14
+ ...actual,
15
+ fetchPageProps: mockFetchPageProps,
16
+ };
17
+ });
18
+
19
+ vi.mock('@jackwener/opencli/download', () => ({
20
+ httpDownload: mockHttpDownload,
21
+ sanitizeFilename: vi.fn((value) => value.replace(/\s+/g, '_')),
22
+ }));
23
+
24
+ vi.mock('@jackwener/opencli/download/progress', () => ({
25
+ formatBytes: vi.fn((size) => `${size} B`),
26
+ }));
27
+
28
+ vi.mock('node:fs', () => ({
29
+ mkdirSync: mockMkdirSync,
30
+ }));
31
+
32
+ await import('./download.js');
33
+
34
+ let cmd;
35
+
36
+ function toPosixPath(value) {
37
+ return value.replaceAll(path.sep, '/');
38
+ }
39
+
40
+ beforeAll(() => {
41
+ cmd = getRegistry().get('xiaoyuzhou/download');
42
+ expect(cmd?.func).toBeTypeOf('function');
43
+ });
44
+
45
+ describe('xiaoyuzhou download', () => {
46
+ beforeEach(() => {
47
+ mockFetchPageProps.mockReset();
48
+ mockHttpDownload.mockReset();
49
+ mockMkdirSync.mockReset();
50
+ });
51
+
52
+ it('downloads audio from media.source.url into an episode subdirectory', async () => {
53
+ mockFetchPageProps.mockResolvedValue({
54
+ episode: {
55
+ title: 'Hello World',
56
+ podcast: { title: 'OpenCLI FM' },
57
+ media: {
58
+ source: {
59
+ url: 'https://media.xyzcdn.net/audio/hello-world.mp3?sign=abc',
60
+ },
61
+ },
62
+ },
63
+ });
64
+ mockHttpDownload.mockResolvedValue({ success: true, size: 1234 });
65
+
66
+ const result = await cmd.func(null, {
67
+ id: 'ep123',
68
+ output: '/tmp/xiaoyuzhou-test',
69
+ });
70
+
71
+ expect(mockFetchPageProps).toHaveBeenCalledWith('/episode/ep123');
72
+ expect(toPosixPath(mockMkdirSync.mock.calls[0][0])).toBe('/tmp/xiaoyuzhou-test/ep123');
73
+ expect(mockMkdirSync.mock.calls[0][1]).toEqual({ recursive: true });
74
+ expect(mockHttpDownload).toHaveBeenCalledWith('https://media.xyzcdn.net/audio/hello-world.mp3?sign=abc', expect.stringContaining('/tmp/xiaoyuzhou-test/ep123/ep123_Hello_World.mp3'), {
75
+ timeout: 60000,
76
+ });
77
+ expect(result).toEqual([{
78
+ title: 'Hello World',
79
+ podcast: 'OpenCLI FM',
80
+ status: 'success',
81
+ size: '1234 B',
82
+ file: '/tmp/xiaoyuzhou-test/ep123/ep123_Hello_World.mp3',
83
+ }]);
84
+ });
85
+
86
+ it('preserves non-mp3 extensions from media.source.url', async () => {
87
+ mockFetchPageProps.mockResolvedValue({
88
+ episode: {
89
+ title: 'Lossless Episode',
90
+ podcast: { title: 'OpenCLI FM' },
91
+ media: {
92
+ source: {
93
+ url: 'https://media.xyzcdn.net/audio/lossless.m4a',
94
+ },
95
+ },
96
+ },
97
+ });
98
+ mockHttpDownload.mockResolvedValue({ success: true, size: 2048 });
99
+
100
+ const result = await cmd.func(null, {
101
+ id: 'ep456',
102
+ output: '/tmp/xiaoyuzhou-test',
103
+ });
104
+
105
+ expect(mockHttpDownload.mock.calls[0][1]).toContain('ep456_Lossless_Episode.m4a');
106
+ expect(result[0].file).toBe('/tmp/xiaoyuzhou-test/ep456/ep456_Lossless_Episode.m4a');
107
+ });
108
+
109
+ it('throws when media.source.url is missing', async () => {
110
+ mockFetchPageProps.mockResolvedValue({
111
+ episode: {
112
+ title: 'No Audio',
113
+ podcast: { title: 'OpenCLI FM' },
114
+ media: {},
115
+ },
116
+ });
117
+
118
+ await expect(cmd.func(null, { id: 'ep789', output: '/tmp/xiaoyuzhou-test' })).rejects.toMatchObject({
119
+ code: 'PARSE_ERROR',
120
+ message: 'Audio URL not found in episode payload',
121
+ hint: 'Episode payload does not expose media.source.url',
122
+ });
123
+ expect(mockHttpDownload).not.toHaveBeenCalled();
124
+ });
125
+ });
@@ -0,0 +1,76 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { cli, Strategy } from '@jackwener/opencli/registry';
4
+ import { ArgumentError, CliError } from '@jackwener/opencli/errors';
5
+ import { loadXiaoyuzhouCredentials, requestXiaoyuzhouJson, fetchXiaoyuzhouTranscriptBody, extractTranscriptText } from './auth.js';
6
+
7
+ cli({
8
+ site: 'xiaoyuzhou',
9
+ name: 'transcript',
10
+ description: 'Download Xiaoyuzhou transcript as JSON and text (requires local credentials)',
11
+ domain: 'www.xiaoyuzhoufm.com',
12
+ strategy: Strategy.PUBLIC,
13
+ browser: false,
14
+ args: [
15
+ { name: 'id', positional: true, required: true, help: 'Episode ID (eid from podcast-episodes output)' },
16
+ { name: 'output', default: './xiaoyuzhou-transcripts', help: 'Output directory' },
17
+ { name: 'json', type: 'boolean', default: true, help: 'Save transcript JSON file' },
18
+ { name: 'text', type: 'boolean', default: true, help: 'Save extracted transcript text file' },
19
+ ],
20
+ columns: ['title', 'podcast', 'status', 'segments', 'json_file', 'text_file'],
21
+ func: async (_page, kwargs) => {
22
+ if (kwargs.json === false && kwargs.text === false) {
23
+ throw new ArgumentError('At least one of --json or --text must be enabled', 'Example: opencli xiaoyuzhou transcript 69dd0c98e2c8be31551f6a33 --text true');
24
+ }
25
+ let credentials = loadXiaoyuzhouCredentials();
26
+ const episodeResponse = await requestXiaoyuzhouJson('/v1/episode/get', {
27
+ query: { eid: kwargs.id },
28
+ credentials,
29
+ });
30
+ credentials = episodeResponse.credentials;
31
+ const episode = episodeResponse.data;
32
+ if (!episode) {
33
+ throw new CliError('NOT_FOUND', 'Episode not found', 'Please check the episode ID');
34
+ }
35
+ const mediaId = String(episode.transcript?.mediaId || episode.media?.id || episode.transcriptMediaId || '').trim();
36
+ if (!mediaId) {
37
+ throw new CliError('PARSE_ERROR', 'mediaId not found in episode payload', 'Transcript metadata requires episode.transcript.mediaId, episode.media.id, or episode.transcriptMediaId');
38
+ }
39
+ const transcriptResponse = await requestXiaoyuzhouJson('/v1/episode-transcript/get', {
40
+ method: 'POST',
41
+ body: {
42
+ eid: kwargs.id,
43
+ mediaId,
44
+ },
45
+ credentials,
46
+ });
47
+ const transcriptMeta = transcriptResponse.data;
48
+ const transcriptUrl = String(transcriptMeta?.transcriptUrl || transcriptMeta?.url || '').trim();
49
+ if (!transcriptUrl) {
50
+ throw new CliError('EMPTY_RESULT', 'Transcript URL not found', 'This episode may not have transcript data available');
51
+ }
52
+ const transcriptBody = await fetchXiaoyuzhouTranscriptBody(transcriptUrl);
53
+ const { text, segmentCount } = extractTranscriptText(transcriptBody);
54
+ if (kwargs.text !== false && transcriptBody.trim() && !text.trim()) {
55
+ throw new CliError('PARSE_ERROR', 'Failed to extract transcript text', 'Transcript payload format is unsupported. Re-run with --json true to inspect the raw payload.');
56
+ }
57
+ const outputDir = path.join(String(kwargs.output || './xiaoyuzhou-transcripts'), String(kwargs.id));
58
+ fs.mkdirSync(outputDir, { recursive: true });
59
+ const jsonPath = path.join(outputDir, 'transcript.json');
60
+ const textPath = path.join(outputDir, 'transcript.txt');
61
+ if (kwargs.json !== false) {
62
+ fs.writeFileSync(jsonPath, transcriptBody, 'utf-8');
63
+ }
64
+ if (kwargs.text !== false) {
65
+ fs.writeFileSync(textPath, text, 'utf-8');
66
+ }
67
+ return [{
68
+ title: episode.title || 'episode',
69
+ podcast: episode.podcast?.title || '-',
70
+ status: 'success',
71
+ segments: kwargs.text === false ? '-' : String(segmentCount),
72
+ json_file: kwargs.json === false ? '-' : jsonPath,
73
+ text_file: kwargs.text === false ? '-' : textPath,
74
+ }];
75
+ },
76
+ });
@@ -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
+ });
@@ -18,7 +18,7 @@ cli({
18
18
  await page.goto(`https://finance.yahoo.com/quote/${encodeURIComponent(symbol)}/`);
19
19
  const data = await page.evaluate(`
20
20
  (async () => {
21
- const sym = '${symbol}';
21
+ const sym = ${JSON.stringify(symbol)};
22
22
 
23
23
  // Strategy 1: v8 chart API
24
24
  try {
@@ -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
+ });