@jackwener/opencli 1.7.22 → 1.8.0
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 +30 -148
- package/README.zh-CN.md +37 -211
- package/cli-manifest.json +6423 -4260
- package/clis/12306/me.js +73 -0
- package/clis/12306/orders.js +96 -0
- package/clis/12306/passengers.js +90 -0
- package/clis/12306/price.js +166 -0
- package/clis/12306/stations.js +66 -0
- package/clis/12306/train.js +91 -0
- package/clis/12306/trains.js +119 -0
- package/clis/12306/utils.js +272 -0
- package/clis/12306/utils.test.js +331 -0
- package/clis/36kr/article.js +6 -3
- package/clis/36kr/article.test.js +46 -0
- package/clis/apple-podcasts/commands.test.js +20 -0
- package/clis/apple-podcasts/search.js +2 -2
- package/clis/barchart/greeks.js +144 -56
- package/clis/barchart/greeks.test.js +138 -0
- package/clis/bilibili/summary.js +167 -0
- package/clis/bilibili/summary.test.js +210 -0
- package/clis/booking/booking.test.js +356 -0
- package/clis/booking/search.js +351 -0
- package/clis/chatgpt/envelope.test.js +108 -0
- package/clis/chatgpt/image.js +2 -2
- package/clis/chatgpt/image.test.js +6 -0
- package/clis/chatgpt/utils.js +148 -41
- package/clis/chatgpt/utils.test.js +92 -2
- package/clis/douyin/_shared/browser-fetch.js +44 -20
- package/clis/douyin/_shared/browser-fetch.test.js +22 -1
- package/clis/douyin/_shared/evaluate-result.js +16 -0
- package/clis/douyin/_shared/tos-upload.js +105 -69
- package/clis/douyin/_shared/vod-upload.js +212 -0
- package/clis/douyin/_shared/vod-upload.test.js +38 -0
- package/clis/douyin/delete.js +137 -4
- package/clis/douyin/delete.test.js +90 -1
- package/clis/douyin/publish-upload-id.test.js +170 -0
- package/clis/douyin/publish.js +88 -42
- package/clis/douyin/user-videos.js +9 -2
- package/clis/douyin/user-videos.test.js +43 -0
- package/clis/flomo/memos.js +228 -0
- package/clis/flomo/memos.test.js +144 -0
- package/clis/gitee/search.js +2 -2
- package/clis/gitee/search.test.js +65 -0
- package/clis/jike/post.js +27 -17
- package/clis/jike/read.test.js +86 -0
- package/clis/jike/topic.js +32 -19
- package/clis/jike/user.js +33 -20
- package/clis/lesswrong/comments.js +1 -1
- package/clis/lesswrong/curated.js +1 -1
- package/clis/lesswrong/frontpage.js +1 -1
- package/clis/lesswrong/frontpage.test.js +37 -0
- package/clis/lesswrong/new.js +1 -1
- package/clis/lesswrong/read.js +1 -1
- package/clis/lesswrong/sequences.js +1 -1
- package/clis/lesswrong/shortform.js +1 -1
- package/clis/lesswrong/tag.js +1 -1
- package/clis/lesswrong/top-month.js +1 -1
- package/clis/lesswrong/top-week.js +1 -1
- package/clis/lesswrong/top-year.js +1 -1
- package/clis/lesswrong/top.js +1 -1
- package/clis/linkedin/connect.js +401 -0
- package/clis/linkedin/connect.test.js +213 -0
- package/clis/linkedin/inbox.js +234 -0
- package/clis/linkedin/inbox.test.js +152 -0
- package/clis/linkedin/people-search.js +262 -0
- package/clis/linkedin/people-search.test.js +216 -0
- package/clis/linkedin/safe-send.js +357 -0
- package/clis/linkedin/safe-send.test.js +204 -0
- package/clis/linkedin/salesnav-inbox.js +210 -0
- package/clis/linkedin/salesnav-inbox.test.js +113 -0
- package/clis/linkedin/salesnav-message.js +360 -0
- package/clis/linkedin/salesnav-message.test.js +172 -0
- package/clis/linkedin/salesnav-search.js +186 -0
- package/clis/linkedin/salesnav-search.test.js +76 -0
- package/clis/linkedin/salesnav-thread.js +212 -0
- package/clis/linkedin/salesnav-thread.test.js +79 -0
- package/clis/linkedin/sent-invitations.js +92 -0
- package/clis/linkedin/sent-invitations.test.js +62 -0
- package/clis/linkedin/thread-snapshot.js +214 -0
- package/clis/linkedin/thread-snapshot.test.js +89 -0
- package/clis/linkedin-learning/course.js +138 -0
- package/clis/linkedin-learning/course.test.js +114 -0
- package/clis/linkedin-learning/search.js +155 -0
- package/clis/linkedin-learning/search.test.js +144 -0
- package/clis/linkedin-learning/trending.js +133 -0
- package/clis/linkedin-learning/trending.test.js +123 -0
- package/clis/powerchina/search.js +3 -3
- package/clis/powerchina/search.test.js +27 -1
- package/clis/reddit/extract-media.test.js +149 -0
- package/clis/reddit/frontpage.js +47 -9
- package/clis/reddit/frontpage.test.js +34 -0
- package/clis/reddit/home.js +31 -1
- package/clis/reddit/home.test.js +46 -3
- package/clis/reddit/hot.js +32 -1
- package/clis/reddit/hot.test.js +15 -1
- package/clis/reddit/popular.js +39 -1
- package/clis/reddit/popular.test.js +26 -0
- package/clis/reddit/saved.js +1 -1
- package/clis/reddit/search.js +38 -1
- package/clis/reddit/search.test.js +26 -0
- package/clis/reddit/subreddit.js +52 -7
- package/clis/reddit/subreddit.test.js +31 -0
- package/clis/reddit/subscribed.js +165 -0
- package/clis/reddit/subscribed.test.js +168 -0
- package/clis/reddit/upvoted.js +1 -1
- package/clis/suno/commands.test.js +188 -0
- package/clis/suno/download.js +140 -0
- package/clis/suno/download.test.js +151 -0
- package/clis/suno/generate.js +226 -0
- package/clis/suno/generate.test.js +243 -0
- package/clis/suno/list.js +79 -0
- package/clis/suno/status.js +62 -0
- package/clis/suno/utils.js +540 -0
- package/clis/suno/utils.test.js +223 -0
- package/clis/twitter/device-follow.js +193 -0
- package/clis/twitter/device-follow.test.js +287 -0
- package/clis/twitter/download.js +443 -73
- package/clis/twitter/download.test.js +457 -0
- package/clis/twitter/list-create.js +155 -0
- package/clis/twitter/list-create.test.js +169 -0
- package/clis/twitter/list-remove.js +12 -5
- package/clis/twitter/list-remove.test.js +74 -0
- package/clis/twitter/list-tweets.js +6 -2
- package/clis/twitter/list-tweets.test.js +41 -1
- package/clis/twitter/lists.js +31 -4
- package/clis/twitter/lists.test.js +152 -16
- package/clis/twitter/search.js +6 -2
- package/clis/twitter/search.test.js +6 -0
- package/clis/twitter/shared.js +144 -0
- package/clis/twitter/shared.test.js +429 -1
- package/clis/twitter/thread.js +10 -2
- package/clis/twitter/thread.test.js +58 -0
- package/clis/twitter/timeline.js +6 -2
- package/clis/twitter/timeline.test.js +2 -0
- package/clis/twitter/tweets.js +3 -2
- package/clis/twitter/tweets.test.js +1 -1
- package/clis/weibo/delete.js +172 -0
- package/clis/weibo/delete.test.js +94 -0
- package/clis/weibo/publish.js +37 -14
- package/clis/weibo/publish.test.js +14 -5
- package/clis/weibo/user-posts.js +234 -0
- package/clis/weibo/user-posts.test.js +92 -0
- package/clis/weread/search-regression.test.js +18 -11
- package/clis/weread/search.js +15 -7
- package/clis/weread-official/book.js +135 -0
- package/clis/weread-official/commands.test.js +385 -0
- package/clis/weread-official/discover.js +107 -0
- package/clis/weread-official/list-apis.js +95 -0
- package/clis/weread-official/notes.js +171 -0
- package/clis/weread-official/readdata.js +158 -0
- package/clis/weread-official/review.js +93 -0
- package/clis/weread-official/search.js +106 -0
- package/clis/weread-official/shelf.js +97 -0
- package/clis/weread-official/utils.js +293 -0
- package/clis/weread-official/utils.test.js +242 -0
- package/clis/wikipedia/trending.js +7 -3
- package/clis/wikipedia/trending.test.js +57 -0
- package/clis/xianyu/chat.js +24 -109
- package/clis/xianyu/chat.test.js +5 -0
- package/clis/xianyu/im.js +322 -0
- package/clis/xianyu/im.test.js +253 -0
- package/clis/xianyu/inbox.js +96 -0
- package/clis/xianyu/messages.js +91 -0
- package/clis/xianyu/reply.js +82 -0
- package/clis/xiaohongshu/creator-note-detail.js +2 -1
- package/clis/xiaohongshu/creator-note-detail.test.js +11 -0
- package/clis/xiaohongshu/creator-notes-summary.js +2 -1
- package/clis/xiaohongshu/creator-notes-summary.test.js +7 -0
- package/clis/xiaohongshu/creator-notes.js +2 -1
- package/clis/xiaohongshu/creator-notes.test.js +12 -0
- package/clis/xiaohongshu/creator-stats.js +2 -1
- package/clis/xiaohongshu/creator-stats.test.js +24 -0
- package/clis/xiaohongshu/delete-note.js +260 -0
- package/clis/xiaohongshu/delete-note.test.js +172 -0
- package/clis/xiaohongshu/publish.js +48 -8
- package/clis/xiaohongshu/publish.test.js +65 -10
- package/clis/xiaohongshu/user-helpers.test.js +41 -0
- package/clis/xiaohongshu/user.js +27 -4
- package/clis/xiaoyuzhou/download.js +1 -1
- package/clis/xiaoyuzhou/transcript.js +1 -1
- package/clis/youdao/note.js +258 -0
- package/clis/youdao/note.test.js +99 -0
- package/clis/youtube/transcript.js +397 -24
- package/clis/youtube/transcript.test.js +196 -6
- package/clis/zhihu/answer-comments.js +299 -0
- package/clis/zhihu/answer-comments.test.js +287 -0
- package/clis/zhihu/answer-detail.js +12 -0
- package/clis/zhihu/answer-detail.test.js +8 -0
- package/clis/zhihu/collection.js +15 -2
- package/clis/zhihu/collection.test.js +46 -0
- package/clis/zhihu/download.js +1 -1
- package/clis/zhihu/question.js +42 -9
- package/clis/zhihu/question.test.js +111 -9
- package/clis/zhihu/search.js +206 -43
- package/clis/zhihu/search.test.js +198 -0
- package/dist/src/browser/errors.js +4 -2
- package/dist/src/browser/errors.test.js +6 -0
- package/dist/src/browser/page.js +30 -4
- package/dist/src/browser/page.test.js +42 -0
- package/dist/src/browser/utils.d.ts +1 -1
- package/dist/src/cli-argv-preprocess.d.ts +26 -0
- package/dist/src/cli-argv-preprocess.js +138 -0
- package/dist/src/cli-argv-preprocess.test.js +79 -0
- package/dist/src/convention-audit.js +15 -8
- package/dist/src/convention-audit.test.js +21 -0
- package/dist/src/download/media-download.js +15 -2
- package/dist/src/download/media-download.test.d.ts +1 -0
- package/dist/src/download/media-download.test.js +110 -0
- package/dist/src/electron-apps.js +1 -1
- package/dist/src/electron-apps.test.js +7 -2
- package/dist/src/errors.d.ts +17 -0
- package/dist/src/errors.js +22 -0
- package/dist/src/external-clis.yaml +8 -0
- package/dist/src/main.js +14 -2
- package/dist/src/utils.d.ts +43 -0
- package/dist/src/utils.js +97 -0
- package/dist/src/utils.test.d.ts +1 -0
- package/dist/src/utils.test.js +155 -0
- package/package.json +8 -2
- package/scripts/silent-column-drop-baseline.json +0 -52
- package/scripts/typed-error-lint-baseline.json +28 -380
- package/clis/slock/_utils.js +0 -12
|
@@ -1,11 +1,100 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
2
4
|
import { getRegistry } from '@jackwener/opencli/registry';
|
|
5
|
+
|
|
6
|
+
const mocks = vi.hoisted(() => ({
|
|
7
|
+
browserFetch: vi.fn(),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
vi.mock('./_shared/browser-fetch.js', () => ({ browserFetch: mocks.browserFetch }));
|
|
11
|
+
|
|
3
12
|
import './delete.js';
|
|
13
|
+
|
|
14
|
+
function makePage({ evaluateResult, listBefore = [], listAfter = [] } = {}) {
|
|
15
|
+
let listCalls = 0;
|
|
16
|
+
mocks.browserFetch.mockImplementation(async (_page, method, url) => {
|
|
17
|
+
if (method === 'GET' && String(url).includes('/work_list?')) {
|
|
18
|
+
listCalls += 1;
|
|
19
|
+
return { aweme_list: listCalls === 1 ? listBefore : listAfter };
|
|
20
|
+
}
|
|
21
|
+
return { status_code: 0 };
|
|
22
|
+
});
|
|
23
|
+
return {
|
|
24
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
25
|
+
evaluate: vi.fn().mockResolvedValue(evaluateResult ?? { ok: false, reason: 'not_found' }),
|
|
26
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
4
30
|
describe('douyin delete registration', () => {
|
|
31
|
+
const command = getRegistry().get('douyin/delete');
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
vi.clearAllMocks();
|
|
35
|
+
vi.useFakeTimers();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
vi.useRealTimers();
|
|
40
|
+
});
|
|
41
|
+
|
|
5
42
|
it('registers the delete command', () => {
|
|
6
43
|
const registry = getRegistry();
|
|
7
44
|
const values = [...registry.values()];
|
|
8
45
|
const cmd = values.find(c => c.site === 'douyin' && c.name === 'delete');
|
|
9
46
|
expect(cmd).toBeDefined();
|
|
10
47
|
});
|
|
48
|
+
|
|
49
|
+
it('uses work_list id/index matching instead of title matching for fallback deletion', () => {
|
|
50
|
+
const source = readFileSync(new URL('./delete.js', import.meta.url), 'utf8');
|
|
51
|
+
expect(source).toContain('target_not_unique');
|
|
52
|
+
expect(source).toContain("String(entry.aweme_id || '') === targetId");
|
|
53
|
+
expect(source).toContain('cards[target.index]');
|
|
54
|
+
expect(source).not.toContain('text.includes(target.title)');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('validates aweme_id before navigation', async () => {
|
|
58
|
+
const page = makePage();
|
|
59
|
+
await expect(command.func(page, { aweme_id: '' })).rejects.toBeInstanceOf(ArgumentError);
|
|
60
|
+
await expect(command.func(page, { aweme_id: 'abc' })).rejects.toBeInstanceOf(ArgumentError);
|
|
61
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('does not treat a missing work as successful delete', async () => {
|
|
65
|
+
const page = makePage({ listBefore: [], listAfter: [] });
|
|
66
|
+
const promise = command.func(page, { aweme_id: '123' });
|
|
67
|
+
const assertion = expect(promise).rejects.toBeInstanceOf(CommandExecutionError);
|
|
68
|
+
await vi.advanceTimersByTimeAsync(7000);
|
|
69
|
+
await assertion;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('unwraps Browser Bridge envelopes around creator manage delete results', async () => {
|
|
73
|
+
const page = makePage({ evaluateResult: { session: 'site:douyin:test', data: { ok: true, aweme_id: '123' } } });
|
|
74
|
+
const promise = command.func(page, { aweme_id: '123' });
|
|
75
|
+
const assertion = expect(promise).resolves.toEqual([{ status: '✅ 已通过后台管理删除 123' }]);
|
|
76
|
+
await vi.advanceTimersByTimeAsync(7000);
|
|
77
|
+
await assertion;
|
|
78
|
+
expect(mocks.browserFetch).not.toHaveBeenCalled();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('throws typed on malformed creator manage delete result', async () => {
|
|
82
|
+
const page = makePage({ evaluateResult: 'bad-shape' });
|
|
83
|
+
const promise = command.func(page, { aweme_id: '123' });
|
|
84
|
+
const assertion = expect(promise).rejects.toBeInstanceOf(CommandExecutionError);
|
|
85
|
+
await vi.advanceTimersByTimeAsync(7000);
|
|
86
|
+
await assertion;
|
|
87
|
+
expect(mocks.browserFetch).not.toHaveBeenCalled();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('returns success only after fallback delete postcondition removes the target', async () => {
|
|
91
|
+
const page = makePage({
|
|
92
|
+
listBefore: [{ aweme_id: '123' }],
|
|
93
|
+
listAfter: [],
|
|
94
|
+
});
|
|
95
|
+
const promise = command.func(page, { aweme_id: '123' });
|
|
96
|
+
const assertion = expect(promise).resolves.toEqual([{ status: '✅ 已删除 123' }]);
|
|
97
|
+
await vi.advanceTimersByTimeAsync(8000);
|
|
98
|
+
await assertion;
|
|
99
|
+
});
|
|
11
100
|
});
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as os from 'node:os';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
|
|
6
|
+
const mocks = vi.hoisted(() => ({
|
|
7
|
+
browserFetch: vi.fn(),
|
|
8
|
+
getUploadAuthV5Credentials: vi.fn(),
|
|
9
|
+
applyVideoUploadInner: vi.fn(),
|
|
10
|
+
commitVideoUploadInner: vi.fn(),
|
|
11
|
+
tosUpload: vi.fn(),
|
|
12
|
+
pollTranscode: vi.fn(),
|
|
13
|
+
imagexUpload: vi.fn(),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
vi.mock('./_shared/browser-fetch.js', () => ({ browserFetch: mocks.browserFetch }));
|
|
17
|
+
vi.mock('./_shared/vod-upload.js', () => ({
|
|
18
|
+
getUploadAuthV5Credentials: mocks.getUploadAuthV5Credentials,
|
|
19
|
+
applyVideoUploadInner: mocks.applyVideoUploadInner,
|
|
20
|
+
commitVideoUploadInner: mocks.commitVideoUploadInner,
|
|
21
|
+
}));
|
|
22
|
+
vi.mock('./_shared/tos-upload.js', () => ({ tosUpload: mocks.tosUpload }));
|
|
23
|
+
vi.mock('./_shared/transcode.js', () => ({ pollTranscode: mocks.pollTranscode }));
|
|
24
|
+
vi.mock('./_shared/imagex-upload.js', () => ({ imagexUpload: mocks.imagexUpload }));
|
|
25
|
+
|
|
26
|
+
describe('douyin publish upload identifier handling', () => {
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
vi.resetModules();
|
|
29
|
+
vi.clearAllMocks();
|
|
30
|
+
mocks.getUploadAuthV5Credentials.mockResolvedValue({ access_key_id: 'ak', secret_access_key: 'sk', session_token: 'token' });
|
|
31
|
+
mocks.applyVideoUploadInner.mockResolvedValue({ video_id: 'apply-video-id', tos_upload_url: 'https://tos.example.com/bucket/key', auth: 'auth', session_key: 'session-key' });
|
|
32
|
+
mocks.commitVideoUploadInner.mockResolvedValue({ video_id: 'canonical-video-id', poster_uri: 'poster-uri' });
|
|
33
|
+
mocks.tosUpload.mockResolvedValue('object-key-returned-by-complete');
|
|
34
|
+
mocks.pollTranscode.mockResolvedValue({ width: 720, height: 1280, poster_uri: 'poster-uri' });
|
|
35
|
+
mocks.browserFetch.mockImplementation(async (_page, method, url) => {
|
|
36
|
+
if (method === 'POST' && String(url).includes('/aweme/create_v2/')) return { aweme_id: 'aweme-1' };
|
|
37
|
+
return { status_code: 0 };
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('uses CommitUploadInner Vid for create_v2, not the completed TOS object key', async () => {
|
|
42
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'douyin-publish-id-'));
|
|
43
|
+
const video = path.join(tmpDir, 'video.mp4');
|
|
44
|
+
fs.writeFileSync(video, Buffer.from('fake-video'));
|
|
45
|
+
|
|
46
|
+
const { getRegistry } = await import('@jackwener/opencli/registry');
|
|
47
|
+
getRegistry().delete('douyin/publish');
|
|
48
|
+
await import('./publish.js');
|
|
49
|
+
const cmd = getRegistry().get('douyin/publish');
|
|
50
|
+
if (!cmd) throw new Error('douyin publish command not registered');
|
|
51
|
+
|
|
52
|
+
await cmd.func({}, {
|
|
53
|
+
video,
|
|
54
|
+
title: 'OpenCLI自测',
|
|
55
|
+
schedule: new Date(Date.now() + 3 * 60 * 60 * 1000).toISOString(),
|
|
56
|
+
caption: '',
|
|
57
|
+
visibility: 'private',
|
|
58
|
+
no_safety_check: true,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
expect(mocks.commitVideoUploadInner).toHaveBeenCalledWith(
|
|
62
|
+
{ video_id: 'apply-video-id', tos_upload_url: 'https://tos.example.com/bucket/key', auth: 'auth', session_key: 'session-key' },
|
|
63
|
+
{ access_key_id: 'ak', secret_access_key: 'sk', session_token: 'token' },
|
|
64
|
+
);
|
|
65
|
+
expect(mocks.pollTranscode).not.toHaveBeenCalled();
|
|
66
|
+
const createCall = mocks.browserFetch.mock.calls.find((call) => String(call[2]).includes('/aweme/create_v2/'));
|
|
67
|
+
expect(createCall?.[3]?.body.item.common.video_id).toBe('canonical-video-id');
|
|
68
|
+
expect(createCall?.[3]?.body.item.common.video_id).not.toBe('object-key-returned-by-complete');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('continues to create_v2 when the legacy fast detect API returns an empty response', async () => {
|
|
72
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'douyin-publish-safety-'));
|
|
73
|
+
const video = path.join(tmpDir, 'video.mp4');
|
|
74
|
+
fs.writeFileSync(video, Buffer.from('fake-video'));
|
|
75
|
+
mocks.browserFetch.mockImplementation(async (_page, method, url) => {
|
|
76
|
+
if (method === 'POST' && String(url).includes('/post_assistant/fast_detect/pre_check')) {
|
|
77
|
+
throw new Error('Empty response from Douyin API (POST https://creator.douyin.com/aweme/v1/post_assistant/fast_detect/pre_check)');
|
|
78
|
+
}
|
|
79
|
+
if (method === 'POST' && String(url).includes('/post_assistant/fast_detect/poll')) return { status: -1, has_done: true, detect_result: { reason_code: 0 }, detect_list: [] };
|
|
80
|
+
if (method === 'POST' && String(url).includes('/aweme/create_v2/')) return { item_id: 'item-1' };
|
|
81
|
+
return { status_code: 0 };
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const { getRegistry } = await import('@jackwener/opencli/registry');
|
|
85
|
+
getRegistry().delete('douyin/publish');
|
|
86
|
+
await import('./publish.js');
|
|
87
|
+
const cmd = getRegistry().get('douyin/publish');
|
|
88
|
+
if (!cmd) throw new Error('douyin publish command not registered');
|
|
89
|
+
|
|
90
|
+
await cmd.func({}, {
|
|
91
|
+
video,
|
|
92
|
+
title: 'OpenCLI自测',
|
|
93
|
+
schedule: new Date(Date.now() + 3 * 60 * 60 * 1000).toISOString(),
|
|
94
|
+
visibility: 'public',
|
|
95
|
+
caption: 'caption',
|
|
96
|
+
no_safety_check: false,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
expect(mocks.browserFetch.mock.calls.some((call) => String(call[2]).includes('/post_assistant/fast_detect/pre_check'))).toBe(true);
|
|
100
|
+
expect(mocks.browserFetch.mock.calls.some((call) => String(call[2]).includes('/aweme/create_v2/'))).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('unwraps Browser Bridge envelopes around cover ImageX evaluate results', async () => {
|
|
104
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'douyin-publish-cover-'));
|
|
105
|
+
const video = path.join(tmpDir, 'video.mp4');
|
|
106
|
+
const cover = path.join(tmpDir, 'cover.jpg');
|
|
107
|
+
fs.writeFileSync(video, Buffer.from('fake-video'));
|
|
108
|
+
fs.writeFileSync(cover, Buffer.from('fake-cover'));
|
|
109
|
+
mocks.imagexUpload.mockResolvedValue('cover-store-uri');
|
|
110
|
+
|
|
111
|
+
const page = {
|
|
112
|
+
evaluate: vi.fn()
|
|
113
|
+
.mockResolvedValueOnce({
|
|
114
|
+
session: 'site:douyin:test',
|
|
115
|
+
data: { Result: { UploadAddress: { StoreInfos: [{ UploadHost: 'imagex.example.com', StoreUri: 'cover/key.jpg' }] } } },
|
|
116
|
+
})
|
|
117
|
+
.mockResolvedValueOnce({ session: 'site:douyin:test', data: { Result: {} } }),
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const { getRegistry } = await import('@jackwener/opencli/registry');
|
|
121
|
+
getRegistry().delete('douyin/publish');
|
|
122
|
+
await import('./publish.js');
|
|
123
|
+
const cmd = getRegistry().get('douyin/publish');
|
|
124
|
+
if (!cmd) throw new Error('douyin publish command not registered');
|
|
125
|
+
|
|
126
|
+
await cmd.func(page, {
|
|
127
|
+
video,
|
|
128
|
+
cover,
|
|
129
|
+
title: 'OpenCLI自测',
|
|
130
|
+
schedule: new Date(Date.now() + 3 * 60 * 60 * 1000).toISOString(),
|
|
131
|
+
caption: '',
|
|
132
|
+
visibility: 'private',
|
|
133
|
+
no_safety_check: true,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
expect(mocks.imagexUpload).toHaveBeenCalledWith(cover, {
|
|
137
|
+
upload_url: 'https://imagex.example.com/cover/key.jpg',
|
|
138
|
+
store_uri: 'cover/key.jpg',
|
|
139
|
+
});
|
|
140
|
+
const createCall = mocks.browserFetch.mock.calls.find((call) => String(call[2]).includes('/aweme/create_v2/'));
|
|
141
|
+
expect(createCall?.[3]?.body.item.cover.poster).toBe('cover-store-uri');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('throws typed when cover ImageX apply returns the wrong shape', async () => {
|
|
145
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'douyin-publish-cover-bad-'));
|
|
146
|
+
const video = path.join(tmpDir, 'video.mp4');
|
|
147
|
+
const cover = path.join(tmpDir, 'cover.jpg');
|
|
148
|
+
fs.writeFileSync(video, Buffer.from('fake-video'));
|
|
149
|
+
fs.writeFileSync(cover, Buffer.from('fake-cover'));
|
|
150
|
+
|
|
151
|
+
const page = { evaluate: vi.fn().mockResolvedValueOnce({ session: 'site:douyin:test', data: { Result: { UploadAddress: { StoreInfos: [] } } } }) };
|
|
152
|
+
|
|
153
|
+
const { getRegistry } = await import('@jackwener/opencli/registry');
|
|
154
|
+
getRegistry().delete('douyin/publish');
|
|
155
|
+
await import('./publish.js');
|
|
156
|
+
const cmd = getRegistry().get('douyin/publish');
|
|
157
|
+
if (!cmd) throw new Error('douyin publish command not registered');
|
|
158
|
+
|
|
159
|
+
await expect(cmd.func(page, {
|
|
160
|
+
video,
|
|
161
|
+
cover,
|
|
162
|
+
title: 'OpenCLI自测',
|
|
163
|
+
schedule: new Date(Date.now() + 3 * 60 * 60 * 1000).toISOString(),
|
|
164
|
+
caption: '',
|
|
165
|
+
visibility: 'private',
|
|
166
|
+
no_safety_check: true,
|
|
167
|
+
})).rejects.toThrow('UploadHost/StoreUri');
|
|
168
|
+
expect(mocks.imagexUpload).not.toHaveBeenCalled();
|
|
169
|
+
});
|
|
170
|
+
});
|
package/clis/douyin/publish.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Douyin publish — 8-phase pipeline for scheduling video posts.
|
|
3
3
|
*
|
|
4
4
|
* Phases:
|
|
5
|
-
* 1.
|
|
5
|
+
* 1. upload auth v5 credentials
|
|
6
6
|
* 2. Apply TOS upload URL
|
|
7
7
|
* 3. TOS multipart upload
|
|
8
8
|
* 4. Cover upload (optional, via ImageX)
|
|
@@ -15,11 +15,11 @@ import * as fs from 'node:fs';
|
|
|
15
15
|
import * as path from 'node:path';
|
|
16
16
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
17
17
|
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
18
|
-
import {
|
|
18
|
+
import { getUploadAuthV5Credentials, applyVideoUploadInner, commitVideoUploadInner } from './_shared/vod-upload.js';
|
|
19
19
|
import { tosUpload } from './_shared/tos-upload.js';
|
|
20
20
|
import { imagexUpload } from './_shared/imagex-upload.js';
|
|
21
|
-
import { pollTranscode } from './_shared/transcode.js';
|
|
22
21
|
import { browserFetch } from './_shared/browser-fetch.js';
|
|
22
|
+
import { requireObjectEvaluateResult } from './_shared/evaluate-result.js';
|
|
23
23
|
import { generateCreationId } from './_shared/creation-id.js';
|
|
24
24
|
import { validateTiming, toUnixSeconds } from './_shared/timing.js';
|
|
25
25
|
import { parseTextExtra, extractHashtagNames } from './_shared/text-extra.js';
|
|
@@ -54,6 +54,36 @@ const DEFAULT_COVER_TOOLS_INFO = JSON.stringify({
|
|
|
54
54
|
initial_cover_uri: '',
|
|
55
55
|
cut_coordinate: '',
|
|
56
56
|
});
|
|
57
|
+
function isFastDetectRetryable(error) {
|
|
58
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
59
|
+
return message.includes('post_assistant/fast_detect') && (message.includes('Empty response') || message.includes('404') || message.includes('Not Found') || message.includes('Timeout') || message.includes('timed out') || message.includes('Failed to fetch'));
|
|
60
|
+
}
|
|
61
|
+
function sleep(ms) {
|
|
62
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
63
|
+
}
|
|
64
|
+
function throwIfImagexError(action, payload) {
|
|
65
|
+
const error = payload?.ResponseMetadata?.Error ?? payload?.Error;
|
|
66
|
+
if (error) {
|
|
67
|
+
throw new CommandExecutionError(`${action}失败: ${JSON.stringify(error)}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
async function tryFastDetectFetch(page, method, url, options) {
|
|
71
|
+
let lastError;
|
|
72
|
+
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
73
|
+
try {
|
|
74
|
+
return { ok: true, value: await browserFetch(page, method, url, options) };
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if (!isFastDetectRetryable(error)) {
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
lastError = error;
|
|
80
|
+
if (attempt < 3) {
|
|
81
|
+
await sleep(500 * attempt);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return { ok: false, error: lastError };
|
|
86
|
+
}
|
|
57
87
|
cli({
|
|
58
88
|
site: 'douyin',
|
|
59
89
|
name: 'publish',
|
|
@@ -106,19 +136,13 @@ cli({
|
|
|
106
136
|
throw new ArgumentError(`封面文件不存在: ${path.resolve(coverPath)}`);
|
|
107
137
|
}
|
|
108
138
|
}
|
|
109
|
-
// ── Phase 1:
|
|
110
|
-
const credentials = await
|
|
139
|
+
// ── Phase 1: upload credentials ────────────────────────────────────
|
|
140
|
+
const credentials = await getUploadAuthV5Credentials(page);
|
|
111
141
|
// ── Phase 2: Apply TOS upload URL ───────────────────────────────────
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const tosUrl = `https://${UploadHosts[0]}/${StoreInfos[0].StoreUri}`;
|
|
117
|
-
const tosUploadInfo = {
|
|
118
|
-
tos_upload_url: tosUrl,
|
|
119
|
-
auth: StoreInfos[0].Auth,
|
|
120
|
-
video_id: videoId,
|
|
121
|
-
};
|
|
142
|
+
const tosUploadInfo = await applyVideoUploadInner(fileSize, credentials);
|
|
143
|
+
let coverUri = '';
|
|
144
|
+
let coverWidth = 720;
|
|
145
|
+
let coverHeight = 1280;
|
|
122
146
|
// ── Phase 3: TOS upload ─────────────────────────────────────────────
|
|
123
147
|
await tosUpload({
|
|
124
148
|
filePath: videoPath,
|
|
@@ -130,22 +154,32 @@ cli({
|
|
|
130
154
|
},
|
|
131
155
|
});
|
|
132
156
|
process.stderr.write('\n');
|
|
157
|
+
process.stderr.write(' 提交上传...\n');
|
|
158
|
+
const committedVideo = await commitVideoUploadInner(tosUploadInfo, credentials);
|
|
159
|
+
const videoId = committedVideo.video_id;
|
|
160
|
+
process.stderr.write(` 上传已提交: ${videoId}\n`);
|
|
161
|
+
coverWidth = committedVideo.width || coverWidth;
|
|
162
|
+
coverHeight = committedVideo.height || coverHeight;
|
|
163
|
+
if (!coverUri && committedVideo.poster_uri) {
|
|
164
|
+
coverUri = committedVideo.poster_uri;
|
|
165
|
+
}
|
|
133
166
|
// ── Phase 4: Cover upload (optional) ────────────────────────────────
|
|
134
|
-
let coverUri = '';
|
|
135
|
-
let coverWidth = 720;
|
|
136
|
-
let coverHeight = 1280;
|
|
137
167
|
if (kwargs.cover) {
|
|
138
168
|
const resolvedCoverPath = path.resolve(kwargs.cover);
|
|
139
169
|
// 4A: Apply ImageX upload
|
|
140
170
|
const applyUrl = `${IMAGEX_BASE}/?Action=ApplyImageUpload&ServiceId=${IMAGEX_SERVICE_ID}&Version=2018-08-01&UploadNum=1`;
|
|
141
171
|
const applyJs = `fetch(${JSON.stringify(applyUrl)}, { credentials: 'include' }).then(r => r.json())`;
|
|
142
|
-
const applyRes = (await page.evaluate(applyJs));
|
|
143
|
-
|
|
144
|
-
const
|
|
172
|
+
const applyRes = requireObjectEvaluateResult(await page.evaluate(applyJs), '抖音封面申请上传地址响应异常');
|
|
173
|
+
throwIfImagexError('抖音封面申请上传地址', applyRes);
|
|
174
|
+
const imgStoreInfo = applyRes.Result?.UploadAddress?.StoreInfos?.[0];
|
|
175
|
+
if (!imgStoreInfo?.UploadHost || !imgStoreInfo?.StoreUri) {
|
|
176
|
+
throw new CommandExecutionError(`抖音封面申请上传地址响应缺少 UploadHost/StoreUri: ${JSON.stringify(applyRes).slice(0, 500)}`);
|
|
177
|
+
}
|
|
178
|
+
const imgUploadUrl = `https://${imgStoreInfo.UploadHost}/${imgStoreInfo.StoreUri}`;
|
|
145
179
|
// 4B: Upload image
|
|
146
180
|
const coverStoreUri = await imagexUpload(resolvedCoverPath, {
|
|
147
181
|
upload_url: imgUploadUrl,
|
|
148
|
-
store_uri:
|
|
182
|
+
store_uri: imgStoreInfo.StoreUri,
|
|
149
183
|
});
|
|
150
184
|
// 4C: Commit ImageX upload
|
|
151
185
|
const commitUrl = `${IMAGEX_BASE}/?Action=CommitImageUpload&ServiceId=${IMAGEX_SERVICE_ID}&Version=2018-08-01`;
|
|
@@ -158,19 +192,13 @@ cli({
|
|
|
158
192
|
body: ${JSON.stringify(commitBody)}
|
|
159
193
|
}).then(r => r.json())
|
|
160
194
|
`;
|
|
161
|
-
await page.evaluate(commitJs);
|
|
195
|
+
const commitRes = requireObjectEvaluateResult(await page.evaluate(commitJs), '抖音封面提交上传响应异常');
|
|
196
|
+
throwIfImagexError('抖音封面提交上传', commitRes);
|
|
162
197
|
coverUri = coverStoreUri;
|
|
163
198
|
}
|
|
164
|
-
//
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
// ── Phase 6: Poll transcode ─────────────────────────────────────────
|
|
168
|
-
const transResult = await pollTranscode(page, videoId);
|
|
169
|
-
coverWidth = transResult.width;
|
|
170
|
-
coverHeight = transResult.height;
|
|
171
|
-
if (!coverUri) {
|
|
172
|
-
coverUri = transResult.poster_uri;
|
|
173
|
-
}
|
|
199
|
+
// The gateway upload flow returns a committed VOD upload result; the legacy
|
|
200
|
+
// enable/transend endpoints can hang for that flow, so create_v2 consumes
|
|
201
|
+
// the committed video_id and poster metadata directly.
|
|
174
202
|
// ── Phase 7: Content safety check ───────────────────────────────────
|
|
175
203
|
if (!kwargs.no_safety_check) {
|
|
176
204
|
const safetyUrl = 'https://creator.douyin.com/aweme/v1/post_assistant/fast_detect/pre_check';
|
|
@@ -179,25 +207,42 @@ cli({
|
|
|
179
207
|
title,
|
|
180
208
|
desc: caption,
|
|
181
209
|
};
|
|
182
|
-
await
|
|
210
|
+
const preCheck = await tryFastDetectFetch(page, 'POST', safetyUrl, { body: safetyBody });
|
|
211
|
+
if (!preCheck.ok) {
|
|
212
|
+
process.stderr.write(' 内容安全预检接口无响应,继续轮询检测结果。\n');
|
|
213
|
+
}
|
|
183
214
|
const pollUrl = 'https://creator.douyin.com/aweme/v1/post_assistant/fast_detect/poll';
|
|
184
215
|
const deadline = Date.now() + 30_000;
|
|
185
216
|
let safetyPassed = false;
|
|
217
|
+
let pollUnavailableCount = 0;
|
|
186
218
|
while (Date.now() < deadline) {
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
219
|
+
const poll = await tryFastDetectFetch(page, 'POST', pollUrl, { body: safetyBody });
|
|
220
|
+
if (!poll.ok) {
|
|
221
|
+
pollUnavailableCount += 1;
|
|
222
|
+
if (!preCheck.ok && pollUnavailableCount >= 3) {
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
await sleep(2000);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
pollUnavailableCount = 0;
|
|
229
|
+
const pollRes = poll.value;
|
|
230
|
+
if (pollRes.status === 0 || (pollRes.has_done === true && pollRes.detect_result?.reason_code === 0 && (pollRes.detect_list?.length ?? 0) === 0)) {
|
|
191
231
|
safetyPassed = true;
|
|
192
232
|
break;
|
|
193
233
|
}
|
|
194
234
|
if (pollRes.status === 1) {
|
|
195
235
|
throw new CommandExecutionError('内容安全检测不通过,请修改后重试', '使用 --no_safety_check 跳过');
|
|
196
236
|
}
|
|
197
|
-
await
|
|
237
|
+
await sleep(2000);
|
|
198
238
|
}
|
|
199
239
|
if (!safetyPassed) {
|
|
200
|
-
|
|
240
|
+
if (!preCheck.ok && pollUnavailableCount >= 3) {
|
|
241
|
+
process.stderr.write(' 内容安全预检持续无响应,跳过本地预检,交由 create_v2 后的平台审核。\n');
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
throw new CommandExecutionError('内容安全检测超时(30s),请稍后重试', '如确认要跳过本地预检,可使用 --no_safety_check;提交后仍会走抖音平台审核');
|
|
245
|
+
}
|
|
201
246
|
}
|
|
202
247
|
}
|
|
203
248
|
// ── Phase 8: create_v2 publish ──────────────────────────────────────
|
|
@@ -266,12 +311,13 @@ cli({
|
|
|
266
311
|
},
|
|
267
312
|
};
|
|
268
313
|
const publishUrl = `https://creator.douyin.com/web/api/media/aweme/create_v2/?read_aid=2906&${DEVICE_PARAMS}`;
|
|
314
|
+
process.stderr.write(' 创建定时发布...\n');
|
|
269
315
|
const publishRes = (await browserFetch(page, 'POST', publishUrl, {
|
|
270
316
|
body: publishBody,
|
|
271
317
|
}));
|
|
272
|
-
const awemeId = publishRes.aweme_id;
|
|
318
|
+
const awemeId = publishRes.aweme_id ?? publishRes.item_id;
|
|
273
319
|
if (!awemeId) {
|
|
274
|
-
throw new CommandExecutionError(`发布成功但未返回 aweme_id: ${JSON.stringify(publishRes)}`);
|
|
320
|
+
throw new CommandExecutionError(`发布成功但未返回 aweme_id/item_id: ${JSON.stringify(publishRes)}`);
|
|
275
321
|
}
|
|
276
322
|
const url = `https://www.douyin.com/video/${awemeId}`;
|
|
277
323
|
const publishTimeStr = new Date(timingTs * 1000).toLocaleString('zh-CN', {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
import { CliError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
2
3
|
import { fetchDouyinComments, fetchDouyinUserVideos } from './_shared/public-api.js';
|
|
3
4
|
export const MAX_USER_VIDEOS_LIMIT = 20;
|
|
4
5
|
export const USER_VIDEO_COMMENT_CONCURRENCY = 4;
|
|
@@ -27,8 +28,11 @@ async function fetchTopComments(page, awemeId, count) {
|
|
|
27
28
|
try {
|
|
28
29
|
return await fetchDouyinComments(page, awemeId, count);
|
|
29
30
|
}
|
|
30
|
-
catch {
|
|
31
|
-
|
|
31
|
+
catch (error) {
|
|
32
|
+
if (error instanceof CliError) {
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
throw new CommandExecutionError(`Failed to fetch Douyin comments for video ${awemeId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
32
36
|
}
|
|
33
37
|
}
|
|
34
38
|
cli({
|
|
@@ -53,6 +57,9 @@ cli({
|
|
|
53
57
|
await page.goto(`https://www.douyin.com/user/${secUid}`);
|
|
54
58
|
await page.wait(3);
|
|
55
59
|
const awemeList = (await fetchDouyinUserVideos(page, secUid, limit)).slice(0, limit);
|
|
60
|
+
if (awemeList.length === 0) {
|
|
61
|
+
throw new EmptyResultError('douyin user-videos', `No videos were returned for sec_uid ${secUid}. Confirm the user exists and the Douyin session is valid.`);
|
|
62
|
+
}
|
|
56
63
|
const videos = withComments
|
|
57
64
|
? await mapInBatches(awemeList, USER_VIDEO_COMMENT_CONCURRENCY, async (video) => ({
|
|
58
65
|
...video,
|
|
@@ -8,6 +8,7 @@ vi.mock('./_shared/public-api.js', () => ({
|
|
|
8
8
|
fetchDouyinComments: fetchDouyinCommentsMock,
|
|
9
9
|
}));
|
|
10
10
|
import { getRegistry } from '@jackwener/opencli/registry';
|
|
11
|
+
import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
11
12
|
import { DEFAULT_COMMENT_LIMIT, MAX_USER_VIDEOS_LIMIT, normalizeCommentLimit, normalizeUserVideosLimit } from './user-videos.js';
|
|
12
13
|
describe('douyin user-videos', () => {
|
|
13
14
|
beforeEach(() => {
|
|
@@ -105,4 +106,46 @@ describe('douyin user-videos', () => {
|
|
|
105
106
|
},
|
|
106
107
|
]);
|
|
107
108
|
});
|
|
109
|
+
it('throws EmptyResultError when the user videos API returns no rows', async () => {
|
|
110
|
+
const command = [...getRegistry().values()].find((cmd) => cmd.site === 'douyin' && cmd.name === 'user-videos');
|
|
111
|
+
expect(command?.func).toBeDefined();
|
|
112
|
+
if (!command?.func)
|
|
113
|
+
throw new Error('douyin user-videos command not registered');
|
|
114
|
+
fetchDouyinUserVideosMock.mockResolvedValueOnce([]);
|
|
115
|
+
const page = {
|
|
116
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
117
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
118
|
+
};
|
|
119
|
+
await expect(command.func(page, {
|
|
120
|
+
sec_uid: 'MS4w-empty',
|
|
121
|
+
limit: 3,
|
|
122
|
+
with_comments: true,
|
|
123
|
+
comment_limit: 5,
|
|
124
|
+
})).rejects.toBeInstanceOf(EmptyResultError);
|
|
125
|
+
});
|
|
126
|
+
it('surfaces comment enrichment failures instead of returning empty comments', async () => {
|
|
127
|
+
const command = [...getRegistry().values()].find((cmd) => cmd.site === 'douyin' && cmd.name === 'user-videos');
|
|
128
|
+
expect(command?.func).toBeDefined();
|
|
129
|
+
if (!command?.func)
|
|
130
|
+
throw new Error('douyin user-videos command not registered');
|
|
131
|
+
fetchDouyinUserVideosMock.mockResolvedValueOnce([
|
|
132
|
+
{
|
|
133
|
+
aweme_id: '3',
|
|
134
|
+
desc: 'comment failure',
|
|
135
|
+
video: { duration: 2000, play_addr: { url_list: ['https://example.com/fail.mp4'] } },
|
|
136
|
+
statistics: { digg_count: 1 },
|
|
137
|
+
},
|
|
138
|
+
]);
|
|
139
|
+
fetchDouyinCommentsMock.mockRejectedValueOnce(new Error('comment API down'));
|
|
140
|
+
const page = {
|
|
141
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
142
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
143
|
+
};
|
|
144
|
+
await expect(command.func(page, {
|
|
145
|
+
sec_uid: 'MS4w-test',
|
|
146
|
+
limit: 3,
|
|
147
|
+
with_comments: true,
|
|
148
|
+
comment_limit: 5,
|
|
149
|
+
})).rejects.toBeInstanceOf(CommandExecutionError);
|
|
150
|
+
});
|
|
108
151
|
});
|