@jackwener/opencli 1.4.0 → 1.4.1

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 (209) hide show
  1. package/.github/actions/setup-chrome/action.yml +5 -4
  2. package/.github/workflows/ci.yml +17 -3
  3. package/.github/workflows/e2e-headed.yml +16 -3
  4. package/CHANGELOG.md +23 -0
  5. package/PRIVACY.md +57 -0
  6. package/README.md +1 -1
  7. package/README.zh-CN.md +1 -1
  8. package/SKILL.md +101 -2
  9. package/dist/cli-manifest.json +720 -32
  10. package/dist/clis/apple-podcasts/search.js +2 -1
  11. package/dist/clis/arxiv/search.js +2 -2
  12. package/dist/clis/bbc/news.js +0 -1
  13. package/dist/clis/ctrip/search.js +0 -1
  14. package/dist/clis/douyin/_shared/browser-fetch.d.ts +10 -0
  15. package/dist/clis/douyin/_shared/browser-fetch.js +30 -0
  16. package/dist/clis/douyin/_shared/browser-fetch.test.d.ts +1 -0
  17. package/dist/clis/douyin/_shared/browser-fetch.test.js +31 -0
  18. package/dist/clis/douyin/_shared/creation-id.d.ts +1 -0
  19. package/dist/clis/douyin/_shared/creation-id.js +5 -0
  20. package/dist/clis/douyin/_shared/creation-id.test.d.ts +1 -0
  21. package/dist/clis/douyin/_shared/creation-id.test.js +22 -0
  22. package/dist/clis/douyin/_shared/imagex-upload.d.ts +20 -0
  23. package/dist/clis/douyin/_shared/imagex-upload.js +53 -0
  24. package/dist/clis/douyin/_shared/imagex-upload.test.d.ts +1 -0
  25. package/dist/clis/douyin/_shared/imagex-upload.test.js +87 -0
  26. package/dist/clis/douyin/_shared/sts2.d.ts +8 -0
  27. package/dist/clis/douyin/_shared/sts2.js +15 -0
  28. package/dist/clis/douyin/_shared/text-extra.d.ts +18 -0
  29. package/dist/clis/douyin/_shared/text-extra.js +15 -0
  30. package/dist/clis/douyin/_shared/text-extra.test.d.ts +1 -0
  31. package/dist/clis/douyin/_shared/text-extra.test.js +37 -0
  32. package/dist/clis/douyin/_shared/timing.d.ts +2 -0
  33. package/dist/clis/douyin/_shared/timing.js +22 -0
  34. package/dist/clis/douyin/_shared/timing.test.d.ts +1 -0
  35. package/dist/clis/douyin/_shared/timing.test.js +28 -0
  36. package/dist/clis/douyin/_shared/tos-upload-short-read.test.d.ts +11 -0
  37. package/dist/clis/douyin/_shared/tos-upload-short-read.test.js +83 -0
  38. package/dist/clis/douyin/_shared/tos-upload.d.ts +53 -0
  39. package/dist/clis/douyin/_shared/tos-upload.js +295 -0
  40. package/dist/clis/douyin/_shared/tos-upload.test.d.ts +1 -0
  41. package/dist/clis/douyin/_shared/tos-upload.test.js +229 -0
  42. package/dist/clis/douyin/_shared/transcode.d.ts +27 -0
  43. package/dist/clis/douyin/_shared/transcode.js +45 -0
  44. package/dist/clis/douyin/_shared/transcode.test.d.ts +1 -0
  45. package/dist/clis/douyin/_shared/transcode.test.js +93 -0
  46. package/dist/clis/douyin/_shared/types.d.ts +26 -0
  47. package/dist/clis/douyin/_shared/types.js +1 -0
  48. package/dist/clis/douyin/activities.d.ts +1 -0
  49. package/dist/clis/douyin/activities.js +20 -0
  50. package/dist/clis/douyin/activities.test.d.ts +1 -0
  51. package/dist/clis/douyin/activities.test.js +22 -0
  52. package/dist/clis/douyin/collections.d.ts +1 -0
  53. package/dist/clis/douyin/collections.js +22 -0
  54. package/dist/clis/douyin/collections.test.d.ts +1 -0
  55. package/dist/clis/douyin/collections.test.js +23 -0
  56. package/dist/clis/douyin/delete.d.ts +1 -0
  57. package/dist/clis/douyin/delete.js +18 -0
  58. package/dist/clis/douyin/delete.test.d.ts +1 -0
  59. package/dist/clis/douyin/delete.test.js +11 -0
  60. package/dist/clis/douyin/draft.d.ts +14 -0
  61. package/dist/clis/douyin/draft.js +237 -0
  62. package/dist/clis/douyin/draft.test.d.ts +1 -0
  63. package/dist/clis/douyin/draft.test.js +11 -0
  64. package/dist/clis/douyin/drafts.d.ts +1 -0
  65. package/dist/clis/douyin/drafts.js +23 -0
  66. package/dist/clis/douyin/drafts.test.d.ts +1 -0
  67. package/dist/clis/douyin/drafts.test.js +11 -0
  68. package/dist/clis/douyin/hashtag.d.ts +1 -0
  69. package/dist/clis/douyin/hashtag.js +45 -0
  70. package/dist/clis/douyin/hashtag.test.d.ts +1 -0
  71. package/dist/clis/douyin/hashtag.test.js +25 -0
  72. package/dist/clis/douyin/location.d.ts +1 -0
  73. package/dist/clis/douyin/location.js +24 -0
  74. package/dist/clis/douyin/location.test.d.ts +1 -0
  75. package/dist/clis/douyin/location.test.js +23 -0
  76. package/dist/clis/douyin/profile.d.ts +1 -0
  77. package/dist/clis/douyin/profile.js +28 -0
  78. package/dist/clis/douyin/profile.test.d.ts +1 -0
  79. package/dist/clis/douyin/profile.test.js +11 -0
  80. package/dist/clis/douyin/publish.d.ts +14 -0
  81. package/dist/clis/douyin/publish.js +288 -0
  82. package/dist/clis/douyin/publish.test.d.ts +1 -0
  83. package/dist/clis/douyin/publish.test.js +38 -0
  84. package/dist/clis/douyin/stats.d.ts +1 -0
  85. package/dist/clis/douyin/stats.js +27 -0
  86. package/dist/clis/douyin/stats.test.d.ts +1 -0
  87. package/dist/clis/douyin/stats.test.js +22 -0
  88. package/dist/clis/douyin/update.d.ts +1 -0
  89. package/dist/clis/douyin/update.js +31 -0
  90. package/dist/clis/douyin/update.test.d.ts +1 -0
  91. package/dist/clis/douyin/update.test.js +11 -0
  92. package/dist/clis/douyin/videos.d.ts +1 -0
  93. package/dist/clis/douyin/videos.js +34 -0
  94. package/dist/clis/douyin/videos.test.d.ts +1 -0
  95. package/dist/clis/douyin/videos.test.js +11 -0
  96. package/dist/clis/hackernews/search.yaml +1 -1
  97. package/dist/clis/instagram/search.yaml +2 -1
  98. package/dist/clis/linux-do/search.yaml +3 -1
  99. package/dist/clis/medium/search.js +1 -1
  100. package/dist/clis/reuters/search.js +0 -1
  101. package/dist/clis/twitter/search.js +5 -3
  102. package/dist/clis/twitter/search.test.js +54 -2
  103. package/dist/clis/weibo/comments.d.ts +1 -0
  104. package/dist/clis/weibo/comments.js +53 -0
  105. package/dist/clis/weibo/feed.d.ts +1 -0
  106. package/dist/clis/weibo/feed.js +56 -0
  107. package/dist/clis/weibo/hot.js +0 -1
  108. package/dist/clis/weibo/me.d.ts +1 -0
  109. package/dist/clis/weibo/me.js +76 -0
  110. package/dist/clis/weibo/post.d.ts +1 -0
  111. package/dist/clis/weibo/post.js +75 -0
  112. package/dist/clis/weibo/user.d.ts +1 -0
  113. package/dist/clis/weibo/user.js +63 -0
  114. package/dist/clis/weibo/utils.d.ts +6 -0
  115. package/dist/clis/weibo/utils.js +30 -0
  116. package/dist/clis/weread/search.js +3 -2
  117. package/dist/clis/xueqiu/search.yaml +2 -1
  118. package/dist/clis/yahoo-finance/quote.js +0 -1
  119. package/dist/clis/youtube/channel.d.ts +1 -0
  120. package/dist/clis/youtube/channel.js +150 -0
  121. package/dist/clis/youtube/comments.d.ts +1 -0
  122. package/dist/clis/youtube/comments.js +95 -0
  123. package/dist/clis/youtube/search.js +0 -1
  124. package/dist/clis/zhihu/search.yaml +2 -1
  125. package/dist/external-clis.yaml +0 -17
  126. package/dist/weread-search-regression.test.d.ts +1 -0
  127. package/dist/weread-search-regression.test.js +39 -0
  128. package/docs/.vitepress/config.mts +13 -0
  129. package/docs/adapters/browser/douyin.md +75 -0
  130. package/docs/adapters/browser/twitter.md +6 -0
  131. package/docs/adapters/index.md +6 -1
  132. package/extension/dist/background.js +508 -518
  133. package/extension/manifest.json +6 -2
  134. package/extension/package.json +1 -1
  135. package/extension/popup.html +84 -0
  136. package/extension/popup.js +25 -0
  137. package/extension/src/background.ts +20 -1
  138. package/package.json +1 -1
  139. package/src/clis/apple-podcasts/search.ts +2 -1
  140. package/src/clis/arxiv/search.ts +2 -2
  141. package/src/clis/bbc/news.ts +0 -1
  142. package/src/clis/ctrip/search.ts +0 -1
  143. package/src/clis/douyin/_shared/browser-fetch.test.ts +38 -0
  144. package/src/clis/douyin/_shared/browser-fetch.ts +45 -0
  145. package/src/clis/douyin/_shared/creation-id.test.ts +26 -0
  146. package/src/clis/douyin/_shared/creation-id.ts +8 -0
  147. package/src/clis/douyin/_shared/imagex-upload.test.ts +113 -0
  148. package/src/clis/douyin/_shared/imagex-upload.ts +76 -0
  149. package/src/clis/douyin/_shared/sts2.ts +20 -0
  150. package/src/clis/douyin/_shared/text-extra.test.ts +42 -0
  151. package/src/clis/douyin/_shared/text-extra.ts +33 -0
  152. package/src/clis/douyin/_shared/timing.test.ts +38 -0
  153. package/src/clis/douyin/_shared/timing.ts +22 -0
  154. package/src/clis/douyin/_shared/tos-upload-short-read.test.ts +102 -0
  155. package/src/clis/douyin/_shared/tos-upload.test.ts +281 -0
  156. package/src/clis/douyin/_shared/tos-upload.ts +444 -0
  157. package/src/clis/douyin/_shared/transcode.test.ts +117 -0
  158. package/src/clis/douyin/_shared/transcode.ts +78 -0
  159. package/src/clis/douyin/_shared/types.ts +29 -0
  160. package/src/clis/douyin/activities.test.ts +25 -0
  161. package/src/clis/douyin/activities.ts +23 -0
  162. package/src/clis/douyin/collections.test.ts +26 -0
  163. package/src/clis/douyin/collections.ts +25 -0
  164. package/src/clis/douyin/delete.test.ts +12 -0
  165. package/src/clis/douyin/delete.ts +20 -0
  166. package/src/clis/douyin/draft.test.ts +12 -0
  167. package/src/clis/douyin/draft.ts +282 -0
  168. package/src/clis/douyin/drafts.test.ts +12 -0
  169. package/src/clis/douyin/drafts.ts +27 -0
  170. package/src/clis/douyin/hashtag.test.ts +28 -0
  171. package/src/clis/douyin/hashtag.ts +56 -0
  172. package/src/clis/douyin/location.test.ts +26 -0
  173. package/src/clis/douyin/location.ts +27 -0
  174. package/src/clis/douyin/profile.test.ts +12 -0
  175. package/src/clis/douyin/profile.ts +37 -0
  176. package/src/clis/douyin/publish.test.ts +45 -0
  177. package/src/clis/douyin/publish.ts +340 -0
  178. package/src/clis/douyin/stats.test.ts +25 -0
  179. package/src/clis/douyin/stats.ts +30 -0
  180. package/src/clis/douyin/update.test.ts +12 -0
  181. package/src/clis/douyin/update.ts +43 -0
  182. package/src/clis/douyin/videos.test.ts +12 -0
  183. package/src/clis/douyin/videos.ts +49 -0
  184. package/src/clis/hackernews/search.yaml +1 -1
  185. package/src/clis/instagram/search.yaml +2 -1
  186. package/src/clis/linux-do/search.yaml +3 -1
  187. package/src/clis/medium/search.ts +1 -1
  188. package/src/clis/reuters/search.ts +0 -1
  189. package/src/clis/twitter/search.test.ts +69 -2
  190. package/src/clis/twitter/search.ts +5 -3
  191. package/src/clis/weibo/comments.ts +54 -0
  192. package/src/clis/weibo/feed.ts +57 -0
  193. package/src/clis/weibo/hot.ts +0 -1
  194. package/src/clis/weibo/me.ts +77 -0
  195. package/src/clis/weibo/post.ts +77 -0
  196. package/src/clis/weibo/user.ts +64 -0
  197. package/src/clis/weibo/utils.ts +32 -0
  198. package/src/clis/weread/search.ts +3 -2
  199. package/src/clis/xueqiu/search.yaml +2 -1
  200. package/src/clis/yahoo-finance/quote.ts +0 -1
  201. package/src/clis/youtube/channel.ts +155 -0
  202. package/src/clis/youtube/comments.ts +97 -0
  203. package/src/clis/youtube/search.ts +0 -1
  204. package/src/clis/zhihu/search.yaml +2 -1
  205. package/src/external-clis.yaml +0 -17
  206. package/src/weread-search-regression.test.ts +44 -0
  207. package/tests/e2e/browser-public-extended.test.ts +162 -0
  208. package/tests/e2e/browser-public.test.ts +7 -146
  209. package/vitest.config.ts +24 -17
@@ -0,0 +1,23 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import { browserFetch } from './_shared/browser-fetch.js';
3
+
4
+ cli({
5
+ site: 'douyin',
6
+ name: 'activities',
7
+ description: '官方活动列表',
8
+ domain: 'creator.douyin.com',
9
+ strategy: Strategy.COOKIE,
10
+ args: [],
11
+ columns: ['activity_id', 'title', 'end_time'],
12
+ func: async (page, _kwargs) => {
13
+ const url = 'https://creator.douyin.com/web/api/media/activity/get/?aid=1128';
14
+ const res = await browserFetch(page, 'GET', url) as {
15
+ activity_list: Array<{ activity_id: string; title: string; end_time: number }>
16
+ };
17
+ return (res.activity_list ?? []).map(a => ({
18
+ activity_id: a.activity_id,
19
+ title: a.title,
20
+ end_time: new Date(a.end_time * 1000).toLocaleString('zh-CN', { timeZone: 'Asia/Tokyo' }),
21
+ }));
22
+ },
23
+ });
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { getRegistry } from '../../registry.js';
3
+ import './collections.js';
4
+
5
+ describe('douyin collections registration', () => {
6
+ it('registers the collections command', () => {
7
+ const registry = getRegistry();
8
+ const cmd = [...registry.values()].find(c => c.site === 'douyin' && c.name === 'collections');
9
+ expect(cmd).toBeDefined();
10
+ expect(cmd?.args.some(a => a.name === 'limit')).toBe(true);
11
+ });
12
+
13
+ it('has expected columns', () => {
14
+ const registry = getRegistry();
15
+ const cmd = [...registry.values()].find(c => c.site === 'douyin' && c.name === 'collections');
16
+ expect(cmd?.columns).toContain('mix_id');
17
+ expect(cmd?.columns).toContain('name');
18
+ expect(cmd?.columns).toContain('item_count');
19
+ });
20
+
21
+ it('uses COOKIE strategy', () => {
22
+ const registry = getRegistry();
23
+ const cmd = [...registry.values()].find(c => c.site === 'douyin' && c.name === 'collections');
24
+ expect(cmd?.strategy).toBe('cookie');
25
+ });
26
+ });
@@ -0,0 +1,25 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import { browserFetch } from './_shared/browser-fetch.js';
3
+
4
+ cli({
5
+ site: 'douyin',
6
+ name: 'collections',
7
+ description: '合集列表',
8
+ domain: 'creator.douyin.com',
9
+ strategy: Strategy.COOKIE,
10
+ args: [
11
+ { name: 'limit', type: 'int', default: 20 },
12
+ ],
13
+ columns: ['mix_id', 'name', 'item_count'],
14
+ func: async (page, kwargs) => {
15
+ const url = `https://creator.douyin.com/web/api/mix/list/?aid=1128&count=${kwargs.limit}`;
16
+ const res = await browserFetch(page, 'GET', url) as {
17
+ mix_list: Array<{ mix_id: string; mix_name: string; item_count: number }>
18
+ };
19
+ return (res.mix_list ?? []).map(m => ({
20
+ mix_id: m.mix_id,
21
+ name: m.mix_name,
22
+ item_count: m.item_count,
23
+ }));
24
+ },
25
+ });
@@ -0,0 +1,12 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { getRegistry } from '../../registry.js';
3
+ import './delete.js';
4
+
5
+ describe('douyin delete registration', () => {
6
+ it('registers the delete command', () => {
7
+ const registry = getRegistry();
8
+ const values = [...registry.values()];
9
+ const cmd = values.find(c => c.site === 'douyin' && c.name === 'delete');
10
+ expect(cmd).toBeDefined();
11
+ });
12
+ });
@@ -0,0 +1,20 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import { browserFetch } from './_shared/browser-fetch.js';
3
+ import type { IPage } from '../../types.js';
4
+
5
+ cli({
6
+ site: 'douyin',
7
+ name: 'delete',
8
+ description: '删除作品',
9
+ domain: 'creator.douyin.com',
10
+ strategy: Strategy.COOKIE,
11
+ args: [
12
+ { name: 'aweme_id', required: true, positional: true, help: '作品 ID' },
13
+ ],
14
+ columns: ['status'],
15
+ func: async (page: IPage, kwargs) => {
16
+ const url = 'https://creator.douyin.com/web/api/media/aweme/delete/?aid=1128';
17
+ await browserFetch(page, 'POST', url, { body: { aweme_id: kwargs.aweme_id } });
18
+ return [{ status: `✅ 已删除 ${kwargs.aweme_id}` }];
19
+ },
20
+ });
@@ -0,0 +1,12 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { getRegistry } from '../../registry.js';
3
+ import './draft.js';
4
+
5
+ describe('douyin draft registration', () => {
6
+ it('registers the draft command', () => {
7
+ const registry = getRegistry();
8
+ const values = [...registry.values()];
9
+ const cmd = values.find(c => c.site === 'douyin' && c.name === 'draft');
10
+ expect(cmd).toBeDefined();
11
+ });
12
+ });
@@ -0,0 +1,282 @@
1
+ /**
2
+ * Douyin draft — 6-phase pipeline for saving video as draft.
3
+ *
4
+ * Phases:
5
+ * 1. STS2 credentials
6
+ * 2. Apply TOS upload URL
7
+ * 3. TOS multipart upload
8
+ * 4. Cover upload (optional, via ImageX)
9
+ * 5. Enable video
10
+ * 6. Poll transcode
11
+ * 7. (skipped — no safety check for drafts)
12
+ * 8. create_v2 with is_draft: 1
13
+ */
14
+
15
+ import * as fs from 'node:fs';
16
+ import * as path from 'node:path';
17
+ import { cli, Strategy } from '../../registry.js';
18
+ import { ArgumentError, CommandExecutionError } from '../../errors.js';
19
+ import type { IPage } from '../../types.js';
20
+ import type { TosUploadInfo } from './_shared/types.js';
21
+ import { getSts2Credentials } from './_shared/sts2.js';
22
+ import { tosUpload } from './_shared/tos-upload.js';
23
+ import { imagexUpload } from './_shared/imagex-upload.js';
24
+ import { pollTranscode } from './_shared/transcode.js';
25
+ import { browserFetch } from './_shared/browser-fetch.js';
26
+ import { generateCreationId } from './_shared/creation-id.js';
27
+ import { parseTextExtra, extractHashtagNames } from './_shared/text-extra.js';
28
+ import type { HashtagInfo } from './_shared/text-extra.js';
29
+
30
+ const VISIBILITY_MAP: Record<string, number> = {
31
+ public: 0,
32
+ friends: 1,
33
+ private: 2,
34
+ };
35
+
36
+ const IMAGEX_BASE = 'https://imagex.bytedanceapi.com';
37
+ const IMAGEX_SERVICE_ID = '1147';
38
+
39
+ const DEVICE_PARAMS =
40
+ 'aid=1128&cookie_enabled=true&screen_width=1512&screen_height=982&browser_language=zh-CN&browser_platform=MacIntel&browser_name=Mozilla&browser_online=true&timezone_name=Asia%2FTokyo&support_h265=1';
41
+
42
+ const DEFAULT_COVER_TOOLS_INFO = JSON.stringify({
43
+ video_cover_source: 2,
44
+ cover_timestamp: 0,
45
+ recommend_timestamp: 0,
46
+ is_cover_edit: 0,
47
+ is_cover_template: 0,
48
+ cover_template_id: '',
49
+ is_text_template: 0,
50
+ text_template_id: '',
51
+ text_template_content: '',
52
+ is_text: 0,
53
+ text_num: 0,
54
+ text_content: '',
55
+ is_use_sticker: 0,
56
+ sticker_id: '',
57
+ is_use_filter: 0,
58
+ filter_id: '',
59
+ is_cover_modify: 0,
60
+ to_status: 0,
61
+ cover_type: 0,
62
+ initial_cover_uri: '',
63
+ cut_coordinate: '',
64
+ });
65
+
66
+ cli({
67
+ site: 'douyin',
68
+ name: 'draft',
69
+ description: '上传视频并保存为草稿',
70
+ domain: 'creator.douyin.com',
71
+ strategy: Strategy.COOKIE,
72
+ args: [
73
+ { name: 'video', required: true, positional: true, help: '视频文件路径' },
74
+ { name: 'title', required: true, help: '视频标题(≤30字)' },
75
+ { name: 'caption', default: '', help: '正文内容(≤1000字,支持 #话题)' },
76
+ { name: 'cover', default: '', help: '封面图片路径' },
77
+ { name: 'visibility', default: 'public', choices: ['public', 'friends', 'private'] },
78
+ ],
79
+ columns: ['status', 'aweme_id'],
80
+ func: async (page: IPage, kwargs) => {
81
+ // ── Fail-fast validation ────────────────────────────────────────────
82
+ const videoPath = path.resolve(kwargs.video as string);
83
+ if (!fs.existsSync(videoPath)) {
84
+ throw new ArgumentError(`视频文件不存在: ${videoPath}`);
85
+ }
86
+ const ext = path.extname(videoPath).toLowerCase();
87
+ if (!['.mp4', '.mov', '.avi', '.webm'].includes(ext)) {
88
+ throw new ArgumentError(`不支持的视频格式: ${ext}(支持 mp4/mov/avi/webm)`);
89
+ }
90
+ const fileSize = fs.statSync(videoPath).size;
91
+
92
+ const title = kwargs.title as string;
93
+ if (title.length > 30) {
94
+ throw new ArgumentError('标题不能超过 30 字');
95
+ }
96
+
97
+ const caption = (kwargs.caption as string) || '';
98
+ if (caption.length > 1000) {
99
+ throw new ArgumentError('正文不能超过 1000 字');
100
+ }
101
+
102
+ const visibilityType = VISIBILITY_MAP[kwargs.visibility as string] ?? 0;
103
+
104
+ const coverPath = kwargs.cover as string;
105
+ if (coverPath) {
106
+ if (!fs.existsSync(path.resolve(coverPath))) {
107
+ throw new ArgumentError(`封面文件不存在: ${path.resolve(coverPath)}`);
108
+ }
109
+ }
110
+
111
+ // ── Phase 1: STS2 credentials ───────────────────────────────────────
112
+ const credentials = await getSts2Credentials(page);
113
+
114
+ // ── Phase 2: Apply TOS upload URL ───────────────────────────────────
115
+ const vodUrl = `https://vod.bytedanceapi.com/?Action=ApplyVideoUpload&ServiceId=1128&Version=2021-01-01&FileType=video&FileSize=${fileSize}`;
116
+ const vodJs = `fetch(${JSON.stringify(vodUrl)}, { credentials: 'include' }).then(r => r.json())`;
117
+ const vodRes = (await page.evaluate(vodJs)) as {
118
+ Result: {
119
+ UploadAddress: {
120
+ VideoId: string;
121
+ UploadHosts: string[];
122
+ StoreInfos: Array<{ Auth: string; StoreUri: string }>;
123
+ };
124
+ };
125
+ };
126
+ const { VideoId: videoId, UploadHosts, StoreInfos } = vodRes.Result.UploadAddress;
127
+ const tosUrl = `https://${UploadHosts[0]}/${StoreInfos[0].StoreUri}`;
128
+ const tosUploadInfo: TosUploadInfo = {
129
+ tos_upload_url: tosUrl,
130
+ auth: StoreInfos[0].Auth,
131
+ video_id: videoId,
132
+ };
133
+
134
+ // ── Phase 3: TOS upload ─────────────────────────────────────────────
135
+ await tosUpload({
136
+ filePath: videoPath,
137
+ uploadInfo: tosUploadInfo,
138
+ credentials,
139
+ onProgress: (uploaded, total) => {
140
+ const pct = Math.round((uploaded / total) * 100);
141
+ process.stderr.write(`\r 上传进度: ${pct}%`);
142
+ },
143
+ });
144
+ process.stderr.write('\n');
145
+
146
+ // ── Phase 4: Cover upload (optional) ────────────────────────────────
147
+ let coverUri = '';
148
+ let coverWidth = 720;
149
+ let coverHeight = 1280;
150
+
151
+ if (kwargs.cover) {
152
+ const resolvedCoverPath = path.resolve(kwargs.cover as string);
153
+
154
+ // 4A: Apply ImageX upload
155
+ const applyUrl = `${IMAGEX_BASE}/?Action=ApplyImageUpload&ServiceId=${IMAGEX_SERVICE_ID}&Version=2018-08-01&UploadNum=1`;
156
+ const applyJs = `fetch(${JSON.stringify(applyUrl)}, { credentials: 'include' }).then(r => r.json())`;
157
+ const applyRes = (await page.evaluate(applyJs)) as {
158
+ Result: {
159
+ UploadAddress: {
160
+ UploadHosts: string[];
161
+ StoreInfos: Array<{ Auth: string; StoreUri: string; UploadHost: string }>;
162
+ };
163
+ };
164
+ };
165
+ const { StoreInfos: imgStoreInfos } = applyRes.Result.UploadAddress;
166
+ const imgUploadUrl = `https://${imgStoreInfos[0].UploadHost}/${imgStoreInfos[0].StoreUri}`;
167
+
168
+ // 4B: Upload image
169
+ const coverStoreUri = await imagexUpload(resolvedCoverPath, {
170
+ upload_url: imgUploadUrl,
171
+ store_uri: imgStoreInfos[0].StoreUri,
172
+ });
173
+
174
+ // 4C: Commit ImageX upload
175
+ const commitUrl = `${IMAGEX_BASE}/?Action=CommitImageUpload&ServiceId=${IMAGEX_SERVICE_ID}&Version=2018-08-01`;
176
+ const commitBody = JSON.stringify({ SuccessObjKeys: [coverStoreUri] });
177
+ const commitJs = `
178
+ fetch(${JSON.stringify(commitUrl)}, {
179
+ method: 'POST',
180
+ credentials: 'include',
181
+ headers: { 'Content-Type': 'application/json' },
182
+ body: ${JSON.stringify(commitBody)}
183
+ }).then(r => r.json())
184
+ `;
185
+ await page.evaluate(commitJs);
186
+
187
+ coverUri = coverStoreUri;
188
+ }
189
+
190
+ // ── Phase 5: Enable video ───────────────────────────────────────────
191
+ const enableUrl = `https://creator.douyin.com/web/api/media/video/enable/?video_id=${videoId}&aid=1128`;
192
+ await browserFetch(page, 'GET', enableUrl);
193
+
194
+ // ── Phase 6: Poll transcode ─────────────────────────────────────────
195
+ const transResult = await pollTranscode(page, videoId);
196
+ coverWidth = transResult.width;
197
+ coverHeight = transResult.height;
198
+ if (!coverUri) {
199
+ coverUri = transResult.poster_uri;
200
+ }
201
+
202
+ // ── Phase 7: SKIP (no safety check for drafts) ──────────────────────
203
+
204
+ // ── Phase 8: create_v2 with is_draft: 1 ────────────────────────────
205
+ const hashtagNames = extractHashtagNames(caption);
206
+ const hashtags: HashtagInfo[] = [];
207
+ let searchFrom = 0;
208
+ for (const name of hashtagNames) {
209
+ const idx = caption.indexOf(`#${name}`, searchFrom);
210
+ if (idx === -1) continue;
211
+ hashtags.push({ name, id: 0, start: idx, end: idx + name.length + 1 });
212
+ searchFrom = idx + name.length + 1;
213
+ }
214
+ const textExtraArr = parseTextExtra(caption, hashtags);
215
+
216
+ const publishBody = {
217
+ item: {
218
+ common: {
219
+ text: caption,
220
+ caption: '',
221
+ item_title: title,
222
+ activity: '[]',
223
+ text_extra: JSON.stringify(textExtraArr),
224
+ challenges: '[]',
225
+ mentions: '[]',
226
+ hashtag_source: '',
227
+ hot_sentence: '',
228
+ interaction_stickers: '[]',
229
+ visibility_type: visibilityType,
230
+ download: 0,
231
+ is_draft: 1,
232
+ creation_id: generateCreationId(),
233
+ media_type: 4,
234
+ video_id: videoId,
235
+ music_source: 0,
236
+ music_id: null,
237
+ },
238
+ cover: {
239
+ poster: coverUri,
240
+ custom_cover_image_height: coverHeight,
241
+ custom_cover_image_width: coverWidth,
242
+ poster_delay: 0,
243
+ cover_tools_info: DEFAULT_COVER_TOOLS_INFO,
244
+ cover_tools_extend_info: '{}',
245
+ },
246
+ mix: {},
247
+ chapter: {
248
+ chapter: JSON.stringify({
249
+ chapter_abstract: '',
250
+ chapter_details: [],
251
+ chapter_type: 0,
252
+ }),
253
+ },
254
+ anchor: {},
255
+ sync: {
256
+ should_sync: false,
257
+ sync_to_toutiao: 0,
258
+ },
259
+ open_platform: {},
260
+ assistant: { is_preview: 0, is_post_assistant: 1 },
261
+ declare: { user_declare_info: '{}' },
262
+ },
263
+ };
264
+
265
+ const publishUrl = `https://creator.douyin.com/web/api/media/aweme/create_v2/?read_aid=2906&${DEVICE_PARAMS}`;
266
+ const publishRes = (await browserFetch(page, 'POST', publishUrl, {
267
+ body: publishBody,
268
+ })) as { status_code: number; aweme_id: string };
269
+
270
+ const awemeId = publishRes.aweme_id;
271
+ if (!awemeId) {
272
+ throw new CommandExecutionError(`草稿保存成功但未返回 aweme_id: ${JSON.stringify(publishRes)}`);
273
+ }
274
+
275
+ return [
276
+ {
277
+ status: '✅ 草稿保存成功!',
278
+ aweme_id: awemeId,
279
+ },
280
+ ];
281
+ },
282
+ });
@@ -0,0 +1,12 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { getRegistry } from '../../registry.js';
3
+ import './drafts.js';
4
+
5
+ describe('douyin drafts registration', () => {
6
+ it('registers the drafts command', () => {
7
+ const registry = getRegistry();
8
+ const values = [...registry.values()];
9
+ const cmd = values.find(c => c.site === 'douyin' && c.name === 'drafts');
10
+ expect(cmd).toBeDefined();
11
+ });
12
+ });
@@ -0,0 +1,27 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import { browserFetch } from './_shared/browser-fetch.js';
3
+ import type { IPage } from '../../types.js';
4
+
5
+ cli({
6
+ site: 'douyin',
7
+ name: 'drafts',
8
+ description: '获取草稿列表',
9
+ domain: 'creator.douyin.com',
10
+ strategy: Strategy.COOKIE,
11
+ args: [
12
+ { name: 'limit', type: 'int', default: 20 },
13
+ ],
14
+ columns: ['aweme_id', 'title', 'create_time'],
15
+ func: async (page: IPage, kwargs) => {
16
+ const url = 'https://creator.douyin.com/web/api/media/aweme/draft/?aid=1128';
17
+ const res = (await browserFetch(page, 'GET', url)) as {
18
+ aweme_list: Array<{ aweme_id: string; desc: string; create_time: number }>;
19
+ };
20
+ const items = (res.aweme_list ?? []).slice(0, kwargs.limit as number);
21
+ return items.map((v) => ({
22
+ aweme_id: v.aweme_id,
23
+ title: v.desc,
24
+ create_time: new Date(v.create_time * 1000).toLocaleString('zh-CN', { timeZone: 'Asia/Tokyo' }),
25
+ }));
26
+ },
27
+ });
@@ -0,0 +1,28 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { getRegistry } from '../../registry.js';
3
+ import './hashtag.js';
4
+
5
+ describe('douyin hashtag registration', () => {
6
+ it('registers the hashtag command', () => {
7
+ const registry = getRegistry();
8
+ const cmd = [...registry.values()].find(c => c.site === 'douyin' && c.name === 'hashtag');
9
+ expect(cmd).toBeDefined();
10
+ expect(cmd?.args.some(a => a.name === 'action')).toBe(true);
11
+ });
12
+
13
+ it('has all expected args', () => {
14
+ const registry = getRegistry();
15
+ const cmd = [...registry.values()].find(c => c.site === 'douyin' && c.name === 'hashtag');
16
+ const argNames = cmd?.args.map(a => a.name) ?? [];
17
+ expect(argNames).toContain('action');
18
+ expect(argNames).toContain('keyword');
19
+ expect(argNames).toContain('cover');
20
+ expect(argNames).toContain('limit');
21
+ });
22
+
23
+ it('uses COOKIE strategy', () => {
24
+ const registry = getRegistry();
25
+ const cmd = [...registry.values()].find(c => c.site === 'douyin' && c.name === 'hashtag');
26
+ expect(cmd?.strategy).toBe('cookie');
27
+ });
28
+ });
@@ -0,0 +1,56 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import { browserFetch } from './_shared/browser-fetch.js';
3
+ import { ArgumentError } from '../../errors.js';
4
+
5
+ cli({
6
+ site: 'douyin',
7
+ name: 'hashtag',
8
+ description: '话题搜索 / AI推荐 / 热点词',
9
+ domain: 'creator.douyin.com',
10
+ strategy: Strategy.COOKIE,
11
+ args: [
12
+ { name: 'action', required: true, positional: true, choices: ['search', 'suggest', 'hot'], help: 'search=关键词搜索 suggest=AI推荐 hot=热点词' },
13
+ { name: 'keyword', default: '', help: '搜索关键词(search/hot 使用)' },
14
+ { name: 'cover', default: '', help: '封面 URI(suggest 使用)' },
15
+ { name: 'limit', type: 'int', default: 10 },
16
+ ],
17
+ columns: ['name', 'id', 'view_count'],
18
+ func: async (page, kwargs) => {
19
+ const action = kwargs.action as string;
20
+
21
+ if (action === 'search') {
22
+ const url = `https://creator.douyin.com/aweme/v1/challenge/search/?keyword=${encodeURIComponent(kwargs.keyword as string)}&count=${kwargs.limit}&aid=1128`;
23
+ const res = await browserFetch(page, 'GET', url) as {
24
+ challenge_list: Array<{ challenge_info: { cid: string; cha_name: string; view_count: number } }>
25
+ };
26
+ return (res.challenge_list ?? []).map(c => ({
27
+ name: c.challenge_info.cha_name,
28
+ id: c.challenge_info.cid,
29
+ view_count: c.challenge_info.view_count,
30
+ }));
31
+ }
32
+
33
+ if (action === 'suggest') {
34
+ const url = `https://creator.douyin.com/web/api/media/hashtag/rec/?cover_uri=${encodeURIComponent(kwargs.cover as string)}&aid=1128`;
35
+ const res = await browserFetch(page, 'GET', url) as {
36
+ hashtag_list: Array<{ name: string; id: string; view_count: number }>
37
+ };
38
+ return (res.hashtag_list ?? []).map(h => ({ name: h.name, id: h.id, view_count: h.view_count }));
39
+ }
40
+
41
+ if (action === 'hot') {
42
+ const kw = kwargs.keyword as string;
43
+ const url = `https://creator.douyin.com/aweme/v1/hotspot/recommend/?${kw ? `keyword=${encodeURIComponent(kw)}&` : ''}aid=1128`;
44
+ const res = await browserFetch(page, 'GET', url) as {
45
+ hotspot_list: Array<{ sentence: string; hot_value: number }>
46
+ };
47
+ return (res.hotspot_list ?? []).slice(0, kwargs.limit as number).map(h => ({
48
+ name: h.sentence,
49
+ id: '',
50
+ view_count: h.hot_value,
51
+ }));
52
+ }
53
+
54
+ throw new ArgumentError(`未知的 action: ${action}`);
55
+ },
56
+ });
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { getRegistry } from '../../registry.js';
3
+ import './location.js';
4
+
5
+ describe('douyin location registration', () => {
6
+ it('registers the location command', () => {
7
+ const registry = getRegistry();
8
+ const cmd = [...registry.values()].find(c => c.site === 'douyin' && c.name === 'location');
9
+ expect(cmd).toBeDefined();
10
+ expect(cmd?.args.some(a => a.name === 'query')).toBe(true);
11
+ });
12
+
13
+ it('has all expected args', () => {
14
+ const registry = getRegistry();
15
+ const cmd = [...registry.values()].find(c => c.site === 'douyin' && c.name === 'location');
16
+ const argNames = cmd?.args.map(a => a.name) ?? [];
17
+ expect(argNames).toContain('query');
18
+ expect(argNames).toContain('limit');
19
+ });
20
+
21
+ it('uses COOKIE strategy', () => {
22
+ const registry = getRegistry();
23
+ const cmd = [...registry.values()].find(c => c.site === 'douyin' && c.name === 'location');
24
+ expect(cmd?.strategy).toBe('cookie');
25
+ });
26
+ });
@@ -0,0 +1,27 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import { browserFetch } from './_shared/browser-fetch.js';
3
+
4
+ cli({
5
+ site: 'douyin',
6
+ name: 'location',
7
+ description: '地理位置 POI 搜索',
8
+ domain: 'creator.douyin.com',
9
+ strategy: Strategy.COOKIE,
10
+ args: [
11
+ { name: 'query', required: true, positional: true, help: '地名关键词' },
12
+ { name: 'limit', type: 'int', default: 20 },
13
+ ],
14
+ columns: ['poi_id', 'name', 'address', 'city'],
15
+ func: async (page, kwargs) => {
16
+ const url = `https://creator.douyin.com/aweme/v1/life/video_api/search/poi/?keyword=${encodeURIComponent(kwargs.query as string)}&count=${kwargs.limit}&aid=1128`;
17
+ const res = await browserFetch(page, 'GET', url) as {
18
+ poi_list: Array<{ poi_id: string; poi_name: string; address: string; city_name: string }>
19
+ };
20
+ return (res.poi_list ?? []).map(p => ({
21
+ poi_id: p.poi_id,
22
+ name: p.poi_name,
23
+ address: p.address,
24
+ city: p.city_name,
25
+ }));
26
+ },
27
+ });
@@ -0,0 +1,12 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { getRegistry } from '../../registry.js';
3
+ import './profile.js';
4
+
5
+ describe('douyin profile registration', () => {
6
+ it('registers the profile command', () => {
7
+ const registry = getRegistry();
8
+ const values = [...registry.values()];
9
+ const cmd = values.find(c => c.site === 'douyin' && c.name === 'profile');
10
+ expect(cmd).toBeDefined();
11
+ });
12
+ });
@@ -0,0 +1,37 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import { browserFetch } from './_shared/browser-fetch.js';
3
+ import { CommandExecutionError } from '../../errors.js';
4
+ import type { IPage } from '../../types.js';
5
+
6
+ cli({
7
+ site: 'douyin',
8
+ name: 'profile',
9
+ description: '获取账号信息',
10
+ domain: 'creator.douyin.com',
11
+ strategy: Strategy.COOKIE,
12
+ args: [],
13
+ columns: ['uid', 'nickname', 'follower_count', 'following_count', 'aweme_count'],
14
+ func: async (page: IPage, _kwargs) => {
15
+ const url = 'https://creator.douyin.com/web/api/media/user/info/?aid=1128';
16
+ const res = (await browserFetch(page, 'GET', url)) as {
17
+ user_info: {
18
+ uid: string;
19
+ nickname: string;
20
+ follower_count: number;
21
+ following_count: number;
22
+ aweme_count: number;
23
+ };
24
+ };
25
+ const u = res.user_info;
26
+ if (!u) throw new CommandExecutionError('用户信息获取失败,请确认已登录 creator.douyin.com');
27
+ return [
28
+ {
29
+ uid: u.uid,
30
+ nickname: u.nickname,
31
+ follower_count: u.follower_count,
32
+ following_count: u.following_count,
33
+ aweme_count: u.aweme_count,
34
+ },
35
+ ];
36
+ },
37
+ });