@jackwener/opencli 1.7.21 → 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.
Files changed (238) hide show
  1. package/README.md +31 -148
  2. package/README.zh-CN.md +38 -211
  3. package/cli-manifest.json +6423 -4260
  4. package/clis/12306/me.js +73 -0
  5. package/clis/12306/orders.js +96 -0
  6. package/clis/12306/passengers.js +90 -0
  7. package/clis/12306/price.js +166 -0
  8. package/clis/12306/stations.js +66 -0
  9. package/clis/12306/train.js +91 -0
  10. package/clis/12306/trains.js +119 -0
  11. package/clis/12306/utils.js +272 -0
  12. package/clis/12306/utils.test.js +331 -0
  13. package/clis/36kr/article.js +6 -3
  14. package/clis/36kr/article.test.js +46 -0
  15. package/clis/apple-podcasts/commands.test.js +20 -0
  16. package/clis/apple-podcasts/search.js +2 -2
  17. package/clis/barchart/greeks.js +144 -56
  18. package/clis/barchart/greeks.test.js +138 -0
  19. package/clis/bilibili/summary.js +167 -0
  20. package/clis/bilibili/summary.test.js +210 -0
  21. package/clis/booking/booking.test.js +356 -0
  22. package/clis/booking/search.js +351 -0
  23. package/clis/boss/utils.js +17 -1
  24. package/clis/boss/utils.test.js +34 -0
  25. package/clis/chatgpt/envelope.test.js +108 -0
  26. package/clis/chatgpt/image.js +2 -2
  27. package/clis/chatgpt/image.test.js +6 -0
  28. package/clis/chatgpt/utils.js +148 -41
  29. package/clis/chatgpt/utils.test.js +92 -2
  30. package/clis/douyin/_shared/browser-fetch.js +44 -20
  31. package/clis/douyin/_shared/browser-fetch.test.js +22 -1
  32. package/clis/douyin/_shared/evaluate-result.js +16 -0
  33. package/clis/douyin/_shared/tos-upload.js +105 -69
  34. package/clis/douyin/_shared/vod-upload.js +212 -0
  35. package/clis/douyin/_shared/vod-upload.test.js +38 -0
  36. package/clis/douyin/delete.js +137 -4
  37. package/clis/douyin/delete.test.js +90 -1
  38. package/clis/douyin/publish-upload-id.test.js +170 -0
  39. package/clis/douyin/publish.js +88 -42
  40. package/clis/douyin/user-videos.js +9 -2
  41. package/clis/douyin/user-videos.test.js +43 -0
  42. package/clis/flomo/memos.js +228 -0
  43. package/clis/flomo/memos.test.js +144 -0
  44. package/clis/gitee/search.js +2 -2
  45. package/clis/gitee/search.test.js +65 -0
  46. package/clis/jike/post.js +27 -17
  47. package/clis/jike/read.test.js +86 -0
  48. package/clis/jike/topic.js +32 -19
  49. package/clis/jike/user.js +33 -20
  50. package/clis/lesswrong/comments.js +1 -1
  51. package/clis/lesswrong/curated.js +1 -1
  52. package/clis/lesswrong/frontpage.js +1 -1
  53. package/clis/lesswrong/frontpage.test.js +37 -0
  54. package/clis/lesswrong/new.js +1 -1
  55. package/clis/lesswrong/read.js +1 -1
  56. package/clis/lesswrong/sequences.js +1 -1
  57. package/clis/lesswrong/shortform.js +1 -1
  58. package/clis/lesswrong/tag.js +1 -1
  59. package/clis/lesswrong/top-month.js +1 -1
  60. package/clis/lesswrong/top-week.js +1 -1
  61. package/clis/lesswrong/top-year.js +1 -1
  62. package/clis/lesswrong/top.js +1 -1
  63. package/clis/linkedin/connect.js +401 -0
  64. package/clis/linkedin/connect.test.js +213 -0
  65. package/clis/linkedin/inbox.js +234 -0
  66. package/clis/linkedin/inbox.test.js +152 -0
  67. package/clis/linkedin/people-search.js +262 -0
  68. package/clis/linkedin/people-search.test.js +216 -0
  69. package/clis/linkedin/safe-send.js +357 -0
  70. package/clis/linkedin/safe-send.test.js +204 -0
  71. package/clis/linkedin/salesnav-inbox.js +210 -0
  72. package/clis/linkedin/salesnav-inbox.test.js +113 -0
  73. package/clis/linkedin/salesnav-message.js +360 -0
  74. package/clis/linkedin/salesnav-message.test.js +172 -0
  75. package/clis/linkedin/salesnav-search.js +186 -0
  76. package/clis/linkedin/salesnav-search.test.js +76 -0
  77. package/clis/linkedin/salesnav-thread.js +212 -0
  78. package/clis/linkedin/salesnav-thread.test.js +79 -0
  79. package/clis/linkedin/sent-invitations.js +92 -0
  80. package/clis/linkedin/sent-invitations.test.js +62 -0
  81. package/clis/linkedin/thread-snapshot.js +214 -0
  82. package/clis/linkedin/thread-snapshot.test.js +89 -0
  83. package/clis/linkedin-learning/course.js +138 -0
  84. package/clis/linkedin-learning/course.test.js +114 -0
  85. package/clis/linkedin-learning/search.js +155 -0
  86. package/clis/linkedin-learning/search.test.js +144 -0
  87. package/clis/linkedin-learning/trending.js +133 -0
  88. package/clis/linkedin-learning/trending.test.js +123 -0
  89. package/clis/powerchina/search.js +3 -3
  90. package/clis/powerchina/search.test.js +27 -1
  91. package/clis/reddit/extract-media.test.js +149 -0
  92. package/clis/reddit/frontpage.js +47 -9
  93. package/clis/reddit/frontpage.test.js +34 -0
  94. package/clis/reddit/home.js +31 -1
  95. package/clis/reddit/home.test.js +46 -3
  96. package/clis/reddit/hot.js +32 -1
  97. package/clis/reddit/hot.test.js +15 -1
  98. package/clis/reddit/popular.js +39 -1
  99. package/clis/reddit/popular.test.js +26 -0
  100. package/clis/reddit/saved.js +1 -1
  101. package/clis/reddit/search.js +38 -1
  102. package/clis/reddit/search.test.js +26 -0
  103. package/clis/reddit/subreddit.js +52 -7
  104. package/clis/reddit/subreddit.test.js +31 -0
  105. package/clis/reddit/subscribed.js +165 -0
  106. package/clis/reddit/subscribed.test.js +168 -0
  107. package/clis/reddit/upvoted.js +1 -1
  108. package/clis/suno/commands.test.js +188 -0
  109. package/clis/suno/download.js +140 -0
  110. package/clis/suno/download.test.js +151 -0
  111. package/clis/suno/generate.js +226 -0
  112. package/clis/suno/generate.test.js +243 -0
  113. package/clis/suno/list.js +79 -0
  114. package/clis/suno/status.js +62 -0
  115. package/clis/suno/utils.js +540 -0
  116. package/clis/suno/utils.test.js +223 -0
  117. package/clis/twitter/device-follow.js +193 -0
  118. package/clis/twitter/device-follow.test.js +287 -0
  119. package/clis/twitter/download.js +443 -73
  120. package/clis/twitter/download.test.js +457 -0
  121. package/clis/twitter/list-create.js +155 -0
  122. package/clis/twitter/list-create.test.js +169 -0
  123. package/clis/twitter/list-remove.js +12 -5
  124. package/clis/twitter/list-remove.test.js +74 -0
  125. package/clis/twitter/list-tweets.js +6 -2
  126. package/clis/twitter/list-tweets.test.js +41 -1
  127. package/clis/twitter/lists.js +31 -4
  128. package/clis/twitter/lists.test.js +152 -16
  129. package/clis/twitter/search.js +6 -2
  130. package/clis/twitter/search.test.js +6 -0
  131. package/clis/twitter/shared.js +144 -0
  132. package/clis/twitter/shared.test.js +429 -1
  133. package/clis/twitter/thread.js +10 -2
  134. package/clis/twitter/thread.test.js +58 -0
  135. package/clis/twitter/timeline.js +6 -2
  136. package/clis/twitter/timeline.test.js +2 -0
  137. package/clis/twitter/tweets.js +3 -2
  138. package/clis/twitter/tweets.test.js +1 -1
  139. package/clis/weibo/comments.js +3 -4
  140. package/clis/weibo/delete.js +172 -0
  141. package/clis/weibo/delete.test.js +94 -0
  142. package/clis/weibo/envelope.test.js +85 -0
  143. package/clis/weibo/favorites.js +4 -4
  144. package/clis/weibo/feed.js +3 -5
  145. package/clis/weibo/hot.js +3 -4
  146. package/clis/weibo/me.js +3 -5
  147. package/clis/weibo/post.js +3 -4
  148. package/clis/weibo/publish.js +37 -14
  149. package/clis/weibo/publish.test.js +14 -5
  150. package/clis/weibo/search.js +4 -3
  151. package/clis/weibo/user-posts.js +234 -0
  152. package/clis/weibo/user-posts.test.js +92 -0
  153. package/clis/weibo/user.js +3 -4
  154. package/clis/weibo/utils.js +34 -5
  155. package/clis/weibo/utils.test.js +36 -0
  156. package/clis/weread/search-regression.test.js +18 -11
  157. package/clis/weread/search.js +15 -7
  158. package/clis/weread-official/book.js +135 -0
  159. package/clis/weread-official/commands.test.js +385 -0
  160. package/clis/weread-official/discover.js +107 -0
  161. package/clis/weread-official/list-apis.js +95 -0
  162. package/clis/weread-official/notes.js +171 -0
  163. package/clis/weread-official/readdata.js +158 -0
  164. package/clis/weread-official/review.js +93 -0
  165. package/clis/weread-official/search.js +106 -0
  166. package/clis/weread-official/shelf.js +97 -0
  167. package/clis/weread-official/utils.js +293 -0
  168. package/clis/weread-official/utils.test.js +242 -0
  169. package/clis/wikipedia/trending.js +7 -3
  170. package/clis/wikipedia/trending.test.js +57 -0
  171. package/clis/xianyu/chat.js +24 -109
  172. package/clis/xianyu/chat.test.js +5 -0
  173. package/clis/xianyu/im.js +322 -0
  174. package/clis/xianyu/im.test.js +253 -0
  175. package/clis/xianyu/inbox.js +96 -0
  176. package/clis/xianyu/messages.js +91 -0
  177. package/clis/xianyu/reply.js +82 -0
  178. package/clis/xiaohongshu/creator-note-detail.js +2 -1
  179. package/clis/xiaohongshu/creator-note-detail.test.js +11 -0
  180. package/clis/xiaohongshu/creator-notes-summary.js +2 -1
  181. package/clis/xiaohongshu/creator-notes-summary.test.js +7 -0
  182. package/clis/xiaohongshu/creator-notes.js +2 -1
  183. package/clis/xiaohongshu/creator-notes.test.js +12 -0
  184. package/clis/xiaohongshu/creator-stats.js +2 -1
  185. package/clis/xiaohongshu/creator-stats.test.js +24 -0
  186. package/clis/xiaohongshu/delete-note.js +260 -0
  187. package/clis/xiaohongshu/delete-note.test.js +172 -0
  188. package/clis/xiaohongshu/publish.js +48 -8
  189. package/clis/xiaohongshu/publish.test.js +65 -10
  190. package/clis/xiaohongshu/user-helpers.test.js +41 -0
  191. package/clis/xiaohongshu/user.js +27 -4
  192. package/clis/xiaoyuzhou/download.js +1 -1
  193. package/clis/xiaoyuzhou/transcript.js +1 -1
  194. package/clis/youdao/note.js +258 -0
  195. package/clis/youdao/note.test.js +99 -0
  196. package/clis/youtube/transcript.js +397 -24
  197. package/clis/youtube/transcript.test.js +196 -6
  198. package/clis/zhihu/answer-comments.js +299 -0
  199. package/clis/zhihu/answer-comments.test.js +287 -0
  200. package/clis/zhihu/answer-detail.js +12 -0
  201. package/clis/zhihu/answer-detail.test.js +8 -0
  202. package/clis/zhihu/collection.js +15 -2
  203. package/clis/zhihu/collection.test.js +46 -0
  204. package/clis/zhihu/download.js +1 -1
  205. package/clis/zhihu/question.js +42 -9
  206. package/clis/zhihu/question.test.js +111 -9
  207. package/clis/zhihu/search.js +206 -43
  208. package/clis/zhihu/search.test.js +198 -0
  209. package/dist/src/browser/errors.js +4 -2
  210. package/dist/src/browser/errors.test.js +6 -0
  211. package/dist/src/browser/page.js +30 -4
  212. package/dist/src/browser/page.test.js +42 -0
  213. package/dist/src/browser/utils.d.ts +1 -1
  214. package/dist/src/cli-argv-preprocess.d.ts +26 -0
  215. package/dist/src/cli-argv-preprocess.js +138 -0
  216. package/dist/src/cli-argv-preprocess.test.js +79 -0
  217. package/dist/src/cli.js +1 -1
  218. package/dist/src/convention-audit.js +15 -8
  219. package/dist/src/convention-audit.test.js +21 -0
  220. package/dist/src/download/media-download.js +15 -2
  221. package/dist/src/download/media-download.test.d.ts +1 -0
  222. package/dist/src/download/media-download.test.js +110 -0
  223. package/dist/src/electron-apps.js +1 -1
  224. package/dist/src/electron-apps.test.js +7 -2
  225. package/dist/src/errors.d.ts +17 -0
  226. package/dist/src/errors.js +22 -0
  227. package/dist/src/external-clis.yaml +20 -0
  228. package/dist/src/external.d.ts +6 -1
  229. package/dist/src/external.test.js +19 -0
  230. package/dist/src/main.js +14 -2
  231. package/dist/src/utils.d.ts +43 -0
  232. package/dist/src/utils.js +97 -0
  233. package/dist/src/utils.test.d.ts +1 -0
  234. package/dist/src/utils.test.js +155 -0
  235. package/package.json +8 -2
  236. package/scripts/silent-column-drop-baseline.json +0 -52
  237. package/scripts/typed-error-lint-baseline.json +28 -380
  238. package/clis/slock/_utils.js +0 -12
@@ -1,11 +1,100 @@
1
- import { describe, expect, it } from 'vitest';
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
+ });
@@ -2,7 +2,7 @@
2
2
  * Douyin publish — 8-phase pipeline for scheduling video posts.
3
3
  *
4
4
  * Phases:
5
- * 1. STS2 credentials
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 { getSts2Credentials } from './_shared/sts2.js';
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: STS2 credentials ───────────────────────────────────────
110
- const credentials = await getSts2Credentials(page);
139
+ // ── Phase 1: upload credentials ────────────────────────────────────
140
+ const credentials = await getUploadAuthV5Credentials(page);
111
141
  // ── Phase 2: Apply TOS upload URL ───────────────────────────────────
112
- const vodUrl = `https://vod.bytedanceapi.com/?Action=ApplyVideoUpload&ServiceId=1128&Version=2021-01-01&FileType=video&FileSize=${fileSize}`;
113
- const vodJs = `fetch(${JSON.stringify(vodUrl)}, { credentials: 'include' }).then(r => r.json())`;
114
- const vodRes = (await page.evaluate(vodJs));
115
- const { VideoId: videoId, UploadHosts, StoreInfos } = vodRes.Result.UploadAddress;
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
- const { StoreInfos: imgStoreInfos } = applyRes.Result.UploadAddress;
144
- const imgUploadUrl = `https://${imgStoreInfos[0].UploadHost}/${imgStoreInfos[0].StoreUri}`;
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: imgStoreInfos[0].StoreUri,
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
- // ── Phase 5: Enable video ───────────────────────────────────────────
165
- const enableUrl = `https://creator.douyin.com/web/api/media/video/enable/?video_id=${videoId}&aid=1128`;
166
- await browserFetch(page, 'GET', enableUrl);
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 browserFetch(page, 'POST', safetyUrl, { body: safetyBody });
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 pollRes = (await browserFetch(page, 'POST', pollUrl, {
188
- body: safetyBody,
189
- }));
190
- if (pollRes.status === 0) {
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 new Promise((r) => setTimeout(r, 2000));
237
+ await sleep(2000);
198
238
  }
199
239
  if (!safetyPassed) {
200
- throw new CommandExecutionError('内容安全检测超时(30s),请稍后重试', '使用 --no_safety_check 跳过');
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
- return [];
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
  });