@jackwener/opencli 1.7.3 → 1.7.5
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/README.md +81 -59
- package/README.zh-CN.md +93 -67
- package/cli-manifest.json +5015 -2975
- package/clis/antigravity/serve.js +71 -25
- package/clis/baidu-scholar/search.js +87 -0
- package/clis/baidu-scholar/search.test.js +23 -0
- package/clis/bilibili/favorite.js +18 -13
- package/clis/binance/depth.js +3 -4
- package/clis/boss/utils.js +2 -3
- package/clis/chatgpt-app/ax.js +6 -3
- package/clis/deepseek/ask.js +74 -0
- package/clis/deepseek/history.js +25 -0
- package/clis/deepseek/new.js +20 -0
- package/clis/deepseek/read.js +22 -0
- package/clis/deepseek/status.js +24 -0
- package/clis/deepseek/utils.js +208 -0
- package/clis/douban/search.js +1 -0
- package/clis/douban/search.test.js +11 -0
- package/clis/douban/subject.js +20 -93
- package/clis/douban/subject.test.js +11 -0
- package/clis/douban/utils.js +250 -8
- package/clis/douban/utils.test.js +179 -4
- package/clis/doubao/utils.js +319 -130
- package/clis/doubao/utils.test.js +241 -2
- package/clis/eastmoney/_secid.js +78 -0
- package/clis/eastmoney/announcement.js +52 -0
- package/clis/eastmoney/convertible.js +73 -0
- package/clis/eastmoney/etf.js +65 -0
- package/clis/eastmoney/holders.js +78 -0
- package/clis/eastmoney/hot-rank.js +50 -0
- package/clis/eastmoney/hot-rank.test.js +59 -0
- package/clis/eastmoney/index-board.js +96 -0
- package/clis/eastmoney/kline.js +87 -0
- package/clis/eastmoney/kuaixun.js +54 -0
- package/clis/eastmoney/longhu.js +67 -0
- package/clis/eastmoney/money-flow.js +78 -0
- package/clis/eastmoney/northbound.js +57 -0
- package/clis/eastmoney/quote.js +107 -0
- package/clis/eastmoney/rank.js +94 -0
- package/clis/eastmoney/sectors.js +76 -0
- package/clis/google-scholar/search.js +58 -0
- package/clis/google-scholar/search.test.js +23 -0
- package/clis/gov-law/commands.test.js +39 -0
- package/clis/gov-law/recent.js +22 -0
- package/clis/gov-law/search.js +41 -0
- package/clis/gov-law/shared.js +51 -0
- package/clis/gov-policy/commands.test.js +27 -0
- package/clis/gov-policy/recent.js +47 -0
- package/clis/gov-policy/search.js +48 -0
- package/clis/grok/image.test.ts +107 -0
- package/clis/grok/image.ts +356 -0
- package/clis/nowcoder/companies.js +23 -0
- package/clis/nowcoder/creators.js +27 -0
- package/clis/nowcoder/detail.js +61 -0
- package/clis/nowcoder/experience.js +36 -0
- package/clis/nowcoder/hot.js +24 -0
- package/clis/nowcoder/jobs.js +21 -0
- package/clis/nowcoder/notifications.js +29 -0
- package/clis/nowcoder/papers.js +40 -0
- package/clis/nowcoder/practice.js +37 -0
- package/clis/nowcoder/recommend.js +30 -0
- package/clis/nowcoder/referral.js +39 -0
- package/clis/nowcoder/salary.js +40 -0
- package/clis/nowcoder/search.js +49 -0
- package/clis/nowcoder/suggest.js +33 -0
- package/clis/nowcoder/topics.js +27 -0
- package/clis/nowcoder/trending.js +25 -0
- package/clis/tdx/hot-rank.js +47 -0
- package/clis/tdx/hot-rank.test.js +59 -0
- package/clis/ths/hot-rank.js +49 -0
- package/clis/ths/hot-rank.test.js +64 -0
- package/clis/twitter/bookmarks.js +2 -1
- package/clis/twitter/list-add.js +337 -0
- package/clis/twitter/list-add.test.js +15 -0
- package/clis/twitter/list-remove.js +297 -0
- package/clis/twitter/list-remove.test.js +14 -0
- package/clis/twitter/list-tweets.js +185 -0
- package/clis/twitter/list-tweets.test.js +108 -0
- package/clis/twitter/lists.js +134 -47
- package/clis/twitter/lists.test.js +105 -38
- package/clis/uiverse/_shared.js +368 -0
- package/clis/uiverse/_shared.test.js +55 -0
- package/clis/uiverse/code.js +47 -0
- package/clis/uiverse/preview.js +71 -0
- package/clis/wanfang/search.js +66 -0
- package/clis/wanfang/search.test.js +23 -0
- package/clis/web/read.js +1 -1
- package/clis/weixin/download.js +3 -2
- package/clis/xiaohongshu/comments.js +2 -2
- package/clis/xiaohongshu/comments.test.js +46 -25
- package/clis/xiaohongshu/download.js +6 -7
- package/clis/xiaohongshu/download.test.js +17 -5
- package/clis/xiaohongshu/note-helpers.js +46 -12
- package/clis/xiaohongshu/note.js +3 -5
- package/clis/xiaohongshu/note.test.js +52 -25
- package/clis/xiaohongshu/publish.js +149 -28
- package/clis/xiaohongshu/publish.test.js +319 -6
- package/clis/xiaoyuzhou/auth.js +303 -0
- package/clis/xiaoyuzhou/auth.test.js +124 -0
- package/clis/xiaoyuzhou/download.js +53 -0
- package/clis/xiaoyuzhou/download.test.js +135 -0
- package/clis/xiaoyuzhou/episode.js +9 -4
- package/clis/xiaoyuzhou/podcast-episodes.js +15 -11
- package/clis/xiaoyuzhou/podcast.js +9 -4
- package/clis/xiaoyuzhou/transcript.js +76 -0
- package/clis/xiaoyuzhou/transcript.test.js +195 -0
- package/clis/xiaoyuzhou/utils.js +0 -40
- package/clis/xiaoyuzhou/utils.test.js +15 -75
- package/clis/youtube/feed.js +120 -0
- package/clis/youtube/history.js +118 -0
- package/clis/youtube/like.js +62 -0
- package/clis/youtube/playlist.js +97 -0
- package/clis/youtube/subscribe.js +71 -0
- package/clis/youtube/subscriptions.js +57 -0
- package/clis/youtube/unlike.js +62 -0
- package/clis/youtube/unsubscribe.js +71 -0
- package/clis/youtube/utils.js +122 -0
- package/clis/youtube/utils.test.js +32 -1
- package/clis/youtube/watch-later.js +76 -0
- package/clis/zsxq/dynamics.js +1 -1
- package/clis/zsxq/utils.js +6 -3
- package/clis/zsxq/utils.test.js +31 -0
- package/dist/src/browser/base-page.d.ts +1 -1
- package/dist/src/browser/base-page.js +25 -5
- package/dist/src/browser/bridge.d.ts +3 -0
- package/dist/src/browser/bridge.js +52 -15
- package/dist/src/browser/cdp.js +2 -1
- package/dist/src/browser/daemon-client.d.ts +7 -4
- package/dist/src/browser/daemon-client.js +6 -1
- package/dist/src/browser/daemon-client.test.js +40 -1
- package/dist/src/browser/dom-snapshot.js +20 -3
- package/dist/src/browser/page.d.ts +18 -5
- package/dist/src/browser/page.js +96 -15
- package/dist/src/browser/page.test.js +158 -1
- package/dist/src/browser/target-errors.d.ts +23 -0
- package/dist/src/browser/target-errors.js +29 -0
- package/dist/src/browser/target-errors.test.js +61 -0
- package/dist/src/browser/target-resolver.d.ts +57 -0
- package/dist/src/browser/target-resolver.js +298 -0
- package/dist/src/browser/target-resolver.test.js +43 -0
- package/dist/src/browser.test.js +38 -1
- package/dist/src/cli.js +272 -187
- package/dist/src/cli.test.js +167 -90
- package/dist/src/commanderAdapter.d.ts +0 -1
- package/dist/src/commanderAdapter.js +2 -16
- package/dist/src/commanderAdapter.test.js +1 -1
- package/dist/src/commands/daemon.d.ts +4 -2
- package/dist/src/commands/daemon.js +22 -2
- package/dist/src/commands/daemon.test.js +65 -2
- package/dist/src/completion-shared.js +2 -5
- package/dist/src/daemon.js +10 -0
- package/dist/src/doctor.d.ts +1 -0
- package/dist/src/doctor.js +32 -9
- package/dist/src/doctor.test.js +28 -12
- package/dist/src/download/article-download.d.ts +1 -0
- package/dist/src/download/article-download.js +3 -0
- package/dist/src/download/article-download.test.js +39 -0
- package/dist/src/external-clis.yaml +2 -2
- package/dist/src/logger.d.ts +2 -2
- package/dist/src/logger.js +3 -3
- package/dist/src/output.js +1 -5
- package/dist/src/output.test.js +0 -21
- package/dist/src/pipeline/steps/transform.js +1 -1
- package/dist/src/pipeline/template.d.ts +1 -0
- package/dist/src/pipeline/template.js +11 -3
- package/dist/src/pipeline/template.test.js +3 -0
- package/dist/src/pipeline/transform.test.js +14 -0
- package/dist/src/plugin.d.ts +8 -9
- package/dist/src/plugin.js +24 -28
- package/dist/src/plugin.test.js +16 -60
- package/dist/src/registry.d.ts +1 -0
- package/dist/src/registry.js +3 -2
- package/dist/src/registry.test.js +22 -0
- package/dist/src/types.d.ts +15 -6
- package/package.json +1 -1
- package/clis/twitter/lists-parser.js +0 -77
- package/clis/twitter/lists.d.ts +0 -5
- package/dist/src/cascade.d.ts +0 -46
- package/dist/src/cascade.js +0 -135
- package/dist/src/explore.d.ts +0 -99
- package/dist/src/explore.js +0 -402
- package/dist/src/generate-verified.d.ts +0 -105
- package/dist/src/generate-verified.js +0 -696
- package/dist/src/generate-verified.test.js +0 -925
- package/dist/src/generate.d.ts +0 -46
- package/dist/src/generate.js +0 -117
- package/dist/src/record.d.ts +0 -96
- package/dist/src/record.js +0 -657
- package/dist/src/record.test.js +0 -293
- package/dist/src/skill-generate.d.ts +0 -30
- package/dist/src/skill-generate.js +0 -75
- package/dist/src/skill-generate.test.js +0 -173
- package/dist/src/synthesize.d.ts +0 -97
- package/dist/src/synthesize.js +0 -208
- /package/dist/src/{generate-verified.test.d.ts → browser/target-errors.test.d.ts} +0 -0
- /package/dist/src/{record.test.d.ts → browser/target-resolver.test.d.ts} +0 -0
- /package/dist/src/{skill-generate.test.d.ts → download/article-download.test.d.ts} +0 -0
|
@@ -1,30 +1,34 @@
|
|
|
1
1
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
2
|
import { CliError } from '@jackwener/opencli/errors';
|
|
3
|
-
import {
|
|
3
|
+
import { loadXiaoyuzhouCredentials, requestXiaoyuzhouJson } from './auth.js';
|
|
4
|
+
import { formatDuration, formatDate } from './utils.js';
|
|
4
5
|
cli({
|
|
5
6
|
site: 'xiaoyuzhou',
|
|
6
7
|
name: 'podcast-episodes',
|
|
7
|
-
description: 'List
|
|
8
|
+
description: 'List episodes of a Xiaoyuzhou podcast',
|
|
8
9
|
domain: 'www.xiaoyuzhoufm.com',
|
|
9
|
-
strategy: Strategy.
|
|
10
|
+
strategy: Strategy.LOCAL,
|
|
10
11
|
browser: false,
|
|
11
12
|
args: [
|
|
12
13
|
{ name: 'id', positional: true, required: true, help: 'Podcast ID (from xiaoyuzhoufm.com URL)' },
|
|
13
|
-
{ name: 'limit', type: 'int', default:
|
|
14
|
+
{ name: 'limit', type: 'int', default: 20, help: 'Max episodes to show' },
|
|
14
15
|
],
|
|
15
16
|
columns: ['eid', 'title', 'duration', 'plays', 'date'],
|
|
16
17
|
func: async (_page, args) => {
|
|
17
|
-
const pageProps = await fetchPageProps(`/podcast/${args.id}`);
|
|
18
|
-
const podcast = pageProps.podcast;
|
|
19
|
-
if (!podcast)
|
|
20
|
-
throw new CliError('NOT_FOUND', 'Podcast not found', 'Please check the ID');
|
|
21
|
-
const allEpisodes = podcast.episodes ?? [];
|
|
22
18
|
const requestedLimit = Number(args.limit);
|
|
23
19
|
if (!Number.isInteger(requestedLimit) || requestedLimit < 1) {
|
|
24
20
|
throw new CliError('INVALID_ARGUMENT', 'limit must be a positive integer', 'Example: --limit 5');
|
|
25
21
|
}
|
|
26
|
-
const
|
|
27
|
-
const
|
|
22
|
+
const credentials = loadXiaoyuzhouCredentials();
|
|
23
|
+
const response = await requestXiaoyuzhouJson('/v1/podcast/listEpisode', {
|
|
24
|
+
method: 'POST',
|
|
25
|
+
body: { pid: args.id, limit: requestedLimit },
|
|
26
|
+
credentials,
|
|
27
|
+
});
|
|
28
|
+
const episodes = response.data ?? [];
|
|
29
|
+
if (!Array.isArray(episodes)) {
|
|
30
|
+
throw new CliError('PARSE_ERROR', 'Unexpected API response format', 'Expected an array of episodes');
|
|
31
|
+
}
|
|
28
32
|
return episodes.map((ep) => ({
|
|
29
33
|
eid: ep.eid,
|
|
30
34
|
title: ep.title,
|
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
2
|
import { CliError } from '@jackwener/opencli/errors';
|
|
3
|
-
import {
|
|
3
|
+
import { loadXiaoyuzhouCredentials, requestXiaoyuzhouJson } from './auth.js';
|
|
4
|
+
import { formatDate } from './utils.js';
|
|
4
5
|
cli({
|
|
5
6
|
site: 'xiaoyuzhou',
|
|
6
7
|
name: 'podcast',
|
|
7
8
|
description: 'View a Xiaoyuzhou podcast profile',
|
|
8
9
|
domain: 'www.xiaoyuzhoufm.com',
|
|
9
|
-
strategy: Strategy.
|
|
10
|
+
strategy: Strategy.LOCAL,
|
|
10
11
|
browser: false,
|
|
11
12
|
args: [{ name: 'id', positional: true, required: true, help: 'Podcast ID (from xiaoyuzhoufm.com URL)' }],
|
|
12
13
|
columns: ['title', 'author', 'description', 'subscribers', 'episodes', 'updated'],
|
|
13
14
|
func: async (_page, args) => {
|
|
14
|
-
const
|
|
15
|
-
const
|
|
15
|
+
const credentials = loadXiaoyuzhouCredentials();
|
|
16
|
+
const response = await requestXiaoyuzhouJson('/v1/podcast/get', {
|
|
17
|
+
query: { pid: args.id },
|
|
18
|
+
credentials,
|
|
19
|
+
});
|
|
20
|
+
const p = response.data;
|
|
16
21
|
if (!p)
|
|
17
22
|
throw new CliError('NOT_FOUND', 'Podcast not found', 'Please check the ID');
|
|
18
23
|
return [{
|
|
@@ -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
|
+
});
|
package/clis/xiaoyuzhou/utils.js
CHANGED
|
@@ -1,43 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shared Xiaoyuzhou utilities — page data extraction and formatting.
|
|
3
|
-
*
|
|
4
|
-
* Xiaoyuzhou (小宇宙) is a Next.js app that embeds full page data in
|
|
5
|
-
* <script id="__NEXT_DATA__">. We fetch the HTML and extract that JSON
|
|
6
|
-
* instead of using their authenticated API.
|
|
7
|
-
*/
|
|
8
|
-
import { CliError } from '@jackwener/opencli/errors';
|
|
9
|
-
/**
|
|
10
|
-
* Fetch a Xiaoyuzhou page and extract __NEXT_DATA__.props.pageProps.
|
|
11
|
-
* @param path - URL path, e.g. '/podcast/xxx' or '/episode/xxx'
|
|
12
|
-
*/
|
|
13
|
-
export async function fetchPageProps(path) {
|
|
14
|
-
const url = `https://www.xiaoyuzhoufm.com${path}`;
|
|
15
|
-
// Node.js fetch sends UA "node" which gets blocked; use a browser-like UA
|
|
16
|
-
const resp = await fetch(url, {
|
|
17
|
-
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; opencli)' },
|
|
18
|
-
});
|
|
19
|
-
if (!resp.ok) {
|
|
20
|
-
throw new CliError('FETCH_ERROR', `HTTP ${resp.status} for ${path}`, 'Please check the ID — you can find it in xiaoyuzhoufm.com URLs');
|
|
21
|
-
}
|
|
22
|
-
const html = await resp.text();
|
|
23
|
-
// [\s\S]*? for multiline safety (JSON may span lines)
|
|
24
|
-
const match = html.match(/<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/);
|
|
25
|
-
if (!match) {
|
|
26
|
-
throw new CliError('PARSE_ERROR', 'Failed to extract __NEXT_DATA__', 'Page structure may have changed');
|
|
27
|
-
}
|
|
28
|
-
let parsed;
|
|
29
|
-
try {
|
|
30
|
-
parsed = JSON.parse(match[1]);
|
|
31
|
-
}
|
|
32
|
-
catch {
|
|
33
|
-
throw new CliError('PARSE_ERROR', 'Malformed __NEXT_DATA__ JSON', 'Page structure may have changed');
|
|
34
|
-
}
|
|
35
|
-
const pageProps = parsed.props?.pageProps;
|
|
36
|
-
if (!pageProps || Object.keys(pageProps).length === 0) {
|
|
37
|
-
throw new CliError('NOT_FOUND', 'Resource not found', 'Please check the ID — you can find it in xiaoyuzhoufm.com URLs');
|
|
38
|
-
}
|
|
39
|
-
return pageProps;
|
|
40
|
-
}
|
|
41
1
|
/** Format seconds to mm:ss (e.g. 3890 → "64:50"). Returns '-' for invalid input. */
|
|
42
2
|
export function formatDuration(seconds) {
|
|
43
3
|
if (!Number.isFinite(seconds) || seconds < 0)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { describe, it, expect
|
|
2
|
-
import { formatDuration, formatDate
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { formatDuration, formatDate } from './utils.js';
|
|
3
3
|
describe('formatDuration', () => {
|
|
4
4
|
it('formats typical duration', () => {
|
|
5
5
|
expect(formatDuration(3890)).toBe('64:50');
|
|
@@ -8,13 +8,13 @@ describe('formatDuration', () => {
|
|
|
8
8
|
expect(formatDuration(0)).toBe('0:00');
|
|
9
9
|
});
|
|
10
10
|
it('pads single-digit seconds', () => {
|
|
11
|
-
expect(formatDuration(
|
|
11
|
+
expect(formatDuration(62)).toBe('1:02');
|
|
12
12
|
});
|
|
13
|
-
it('
|
|
14
|
-
expect(formatDuration(
|
|
13
|
+
it('handles exact minutes', () => {
|
|
14
|
+
expect(formatDuration(120)).toBe('2:00');
|
|
15
15
|
});
|
|
16
|
-
it('rounds
|
|
17
|
-
expect(formatDuration(
|
|
16
|
+
it('rounds fractional seconds', () => {
|
|
17
|
+
expect(formatDuration(65.7)).toBe('1:06');
|
|
18
18
|
});
|
|
19
19
|
it('returns dash for NaN', () => {
|
|
20
20
|
expect(formatDuration(NaN)).toBe('-');
|
|
@@ -22,78 +22,18 @@ describe('formatDuration', () => {
|
|
|
22
22
|
it('returns dash for negative', () => {
|
|
23
23
|
expect(formatDuration(-1)).toBe('-');
|
|
24
24
|
});
|
|
25
|
+
it('returns dash for Infinity', () => {
|
|
26
|
+
expect(formatDuration(Infinity)).toBe('-');
|
|
27
|
+
});
|
|
25
28
|
});
|
|
26
29
|
describe('formatDate', () => {
|
|
27
|
-
it('
|
|
28
|
-
expect(formatDate('2026-03-
|
|
29
|
-
});
|
|
30
|
-
it('handles date-only string', () => {
|
|
31
|
-
expect(formatDate('2025-01-01')).toBe('2025-01-01');
|
|
30
|
+
it('slices ISO string to date', () => {
|
|
31
|
+
expect(formatDate('2026-03-15T12:00:00Z')).toBe('2026-03-15');
|
|
32
32
|
});
|
|
33
|
-
it('returns dash for
|
|
33
|
+
it('returns dash for empty string', () => {
|
|
34
34
|
expect(formatDate('')).toBe('-');
|
|
35
|
-
expect(formatDate(undefined)).toBe('-');
|
|
36
|
-
});
|
|
37
|
-
});
|
|
38
|
-
describe('fetchPageProps', () => {
|
|
39
|
-
beforeEach(() => {
|
|
40
|
-
vi.restoreAllMocks();
|
|
41
|
-
});
|
|
42
|
-
it('extracts pageProps from valid HTML', async () => {
|
|
43
|
-
const mockHtml = `<html><script id="__NEXT_DATA__" type="application/json">{"props":{"pageProps":{"podcast":{"title":"Test"}}}}</script></html>`;
|
|
44
|
-
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
45
|
-
ok: true,
|
|
46
|
-
text: () => Promise.resolve(mockHtml),
|
|
47
|
-
}));
|
|
48
|
-
const result = await fetchPageProps('/podcast/abc123');
|
|
49
|
-
expect(result).toEqual({ podcast: { title: 'Test' } });
|
|
50
35
|
});
|
|
51
|
-
it('
|
|
52
|
-
|
|
53
|
-
ok: false,
|
|
54
|
-
status: 404,
|
|
55
|
-
text: () => Promise.resolve('Not Found'),
|
|
56
|
-
}));
|
|
57
|
-
await expect(fetchPageProps('/podcast/invalid')).rejects.toThrow('HTTP 404');
|
|
58
|
-
});
|
|
59
|
-
it('throws when __NEXT_DATA__ is missing', async () => {
|
|
60
|
-
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
61
|
-
ok: true,
|
|
62
|
-
text: () => Promise.resolve('<html><body>No data here</body></html>'),
|
|
63
|
-
}));
|
|
64
|
-
await expect(fetchPageProps('/podcast/abc')).rejects.toThrow('Failed to extract');
|
|
65
|
-
});
|
|
66
|
-
it('throws when pageProps is empty', async () => {
|
|
67
|
-
const mockHtml = `<script id="__NEXT_DATA__" type="application/json">{"props":{"pageProps":{}}}</script>`;
|
|
68
|
-
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
69
|
-
ok: true,
|
|
70
|
-
text: () => Promise.resolve(mockHtml),
|
|
71
|
-
}));
|
|
72
|
-
await expect(fetchPageProps('/podcast/abc')).rejects.toThrow('Resource not found');
|
|
73
|
-
});
|
|
74
|
-
it('throws on malformed JSON in __NEXT_DATA__', async () => {
|
|
75
|
-
const mockHtml = `<script id="__NEXT_DATA__" type="application/json">{broken json</script>`;
|
|
76
|
-
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
77
|
-
ok: true,
|
|
78
|
-
text: () => Promise.resolve(mockHtml),
|
|
79
|
-
}));
|
|
80
|
-
await expect(fetchPageProps('/podcast/abc')).rejects.toThrow('Malformed __NEXT_DATA__');
|
|
81
|
-
});
|
|
82
|
-
it('handles multiline JSON in __NEXT_DATA__', async () => {
|
|
83
|
-
const mockHtml = `<script id="__NEXT_DATA__" type="application/json">
|
|
84
|
-
{
|
|
85
|
-
"props": {
|
|
86
|
-
"pageProps": {
|
|
87
|
-
"episode": {"title": "Multiline Test"}
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
</script>`;
|
|
92
|
-
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
93
|
-
ok: true,
|
|
94
|
-
text: () => Promise.resolve(mockHtml),
|
|
95
|
-
}));
|
|
96
|
-
const result = await fetchPageProps('/episode/abc');
|
|
97
|
-
expect(result).toEqual({ episode: { title: 'Multiline Test' } });
|
|
36
|
+
it('returns dash for undefined', () => {
|
|
37
|
+
expect(formatDate(undefined)).toBe('-');
|
|
98
38
|
});
|
|
99
39
|
});
|
|
@@ -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
|
+
});
|