@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 @@
1
+ export {};
@@ -0,0 +1,24 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import { browserFetch } from './_shared/browser-fetch.js';
3
+ cli({
4
+ site: 'douyin',
5
+ name: 'location',
6
+ description: '地理位置 POI 搜索',
7
+ domain: 'creator.douyin.com',
8
+ strategy: Strategy.COOKIE,
9
+ args: [
10
+ { name: 'query', required: true, positional: true, help: '地名关键词' },
11
+ { name: 'limit', type: 'int', default: 20 },
12
+ ],
13
+ columns: ['poi_id', 'name', 'address', 'city'],
14
+ func: async (page, kwargs) => {
15
+ const url = `https://creator.douyin.com/aweme/v1/life/video_api/search/poi/?keyword=${encodeURIComponent(kwargs.query)}&count=${kwargs.limit}&aid=1128`;
16
+ const res = await browserFetch(page, 'GET', url);
17
+ return (res.poi_list ?? []).map(p => ({
18
+ poi_id: p.poi_id,
19
+ name: p.poi_name,
20
+ address: p.address,
21
+ city: p.city_name,
22
+ }));
23
+ },
24
+ });
@@ -0,0 +1 @@
1
+ import './location.js';
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { getRegistry } from '../../registry.js';
3
+ import './location.js';
4
+ describe('douyin location registration', () => {
5
+ it('registers the location command', () => {
6
+ const registry = getRegistry();
7
+ const cmd = [...registry.values()].find(c => c.site === 'douyin' && c.name === 'location');
8
+ expect(cmd).toBeDefined();
9
+ expect(cmd?.args.some(a => a.name === 'query')).toBe(true);
10
+ });
11
+ it('has all expected args', () => {
12
+ const registry = getRegistry();
13
+ const cmd = [...registry.values()].find(c => c.site === 'douyin' && c.name === 'location');
14
+ const argNames = cmd?.args.map(a => a.name) ?? [];
15
+ expect(argNames).toContain('query');
16
+ expect(argNames).toContain('limit');
17
+ });
18
+ it('uses COOKIE strategy', () => {
19
+ const registry = getRegistry();
20
+ const cmd = [...registry.values()].find(c => c.site === 'douyin' && c.name === 'location');
21
+ expect(cmd?.strategy).toBe('cookie');
22
+ });
23
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,28 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import { browserFetch } from './_shared/browser-fetch.js';
3
+ import { CommandExecutionError } from '../../errors.js';
4
+ cli({
5
+ site: 'douyin',
6
+ name: 'profile',
7
+ description: '获取账号信息',
8
+ domain: 'creator.douyin.com',
9
+ strategy: Strategy.COOKIE,
10
+ args: [],
11
+ columns: ['uid', 'nickname', 'follower_count', 'following_count', 'aweme_count'],
12
+ func: async (page, _kwargs) => {
13
+ const url = 'https://creator.douyin.com/web/api/media/user/info/?aid=1128';
14
+ const res = (await browserFetch(page, 'GET', url));
15
+ const u = res.user_info;
16
+ if (!u)
17
+ throw new CommandExecutionError('用户信息获取失败,请确认已登录 creator.douyin.com');
18
+ return [
19
+ {
20
+ uid: u.uid,
21
+ nickname: u.nickname,
22
+ follower_count: u.follower_count,
23
+ following_count: u.following_count,
24
+ aweme_count: u.aweme_count,
25
+ },
26
+ ];
27
+ },
28
+ });
@@ -0,0 +1 @@
1
+ import './profile.js';
@@ -0,0 +1,11 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { getRegistry } from '../../registry.js';
3
+ import './profile.js';
4
+ describe('douyin profile registration', () => {
5
+ it('registers the profile command', () => {
6
+ const registry = getRegistry();
7
+ const values = [...registry.values()];
8
+ const cmd = values.find(c => c.site === 'douyin' && c.name === 'profile');
9
+ expect(cmd).toBeDefined();
10
+ });
11
+ });
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Douyin publish — 8-phase pipeline for scheduling video posts.
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. Content safety check
12
+ * 8. create_v2 publish
13
+ */
14
+ export {};
@@ -0,0 +1,288 @@
1
+ /**
2
+ * Douyin publish — 8-phase pipeline for scheduling video posts.
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. Content safety check
12
+ * 8. create_v2 publish
13
+ */
14
+ import * as fs from 'node:fs';
15
+ import * as path from 'node:path';
16
+ import { cli, Strategy } from '../../registry.js';
17
+ import { ArgumentError, CommandExecutionError } from '../../errors.js';
18
+ import { getSts2Credentials } from './_shared/sts2.js';
19
+ import { tosUpload } from './_shared/tos-upload.js';
20
+ import { imagexUpload } from './_shared/imagex-upload.js';
21
+ import { pollTranscode } from './_shared/transcode.js';
22
+ import { browserFetch } from './_shared/browser-fetch.js';
23
+ import { generateCreationId } from './_shared/creation-id.js';
24
+ import { validateTiming, toUnixSeconds } from './_shared/timing.js';
25
+ import { parseTextExtra, extractHashtagNames } from './_shared/text-extra.js';
26
+ const VISIBILITY_MAP = {
27
+ public: 0,
28
+ friends: 1,
29
+ private: 2,
30
+ };
31
+ const IMAGEX_BASE = 'https://imagex.bytedanceapi.com';
32
+ const IMAGEX_SERVICE_ID = '1147';
33
+ const DEVICE_PARAMS = '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';
34
+ const DEFAULT_COVER_TOOLS_INFO = JSON.stringify({
35
+ video_cover_source: 2,
36
+ cover_timestamp: 0,
37
+ recommend_timestamp: 0,
38
+ is_cover_edit: 0,
39
+ is_cover_template: 0,
40
+ cover_template_id: '',
41
+ is_text_template: 0,
42
+ text_template_id: '',
43
+ text_template_content: '',
44
+ is_text: 0,
45
+ text_num: 0,
46
+ text_content: '',
47
+ is_use_sticker: 0,
48
+ sticker_id: '',
49
+ is_use_filter: 0,
50
+ filter_id: '',
51
+ is_cover_modify: 0,
52
+ to_status: 0,
53
+ cover_type: 0,
54
+ initial_cover_uri: '',
55
+ cut_coordinate: '',
56
+ });
57
+ cli({
58
+ site: 'douyin',
59
+ name: 'publish',
60
+ description: '定时发布视频到抖音(必须设置 2h ~ 14天后的发布时间)',
61
+ domain: 'creator.douyin.com',
62
+ strategy: Strategy.COOKIE,
63
+ args: [
64
+ { name: 'video', required: true, positional: true, help: '视频文件路径' },
65
+ { name: 'title', required: true, help: '视频标题(≤30字)' },
66
+ { name: 'schedule', required: true, help: '定时发布时间(ISO8601 或 Unix 秒,2h ~ 14天后)' },
67
+ { name: 'caption', default: '', help: '正文内容(≤1000字,支持 #话题)' },
68
+ { name: 'cover', default: '', help: '封面图片路径(不提供时使用视频截帧)' },
69
+ { name: 'visibility', default: 'public', choices: ['public', 'friends', 'private'] },
70
+ { name: 'allow_download', type: 'bool', default: false, help: '允许下载' },
71
+ { name: 'collection', default: '', help: '合集 ID' },
72
+ { name: 'activity', default: '', help: '活动 ID' },
73
+ { name: 'poi_id', default: '', help: '地理位置 ID' },
74
+ { name: 'poi_name', default: '', help: '地理位置名称' },
75
+ { name: 'hotspot', default: '', help: '关联热点词' },
76
+ { name: 'no_safety_check', type: 'bool', default: false, help: '跳过内容安全检测' },
77
+ { name: 'sync_toutiao', type: 'bool', default: false, help: '同步发布到头条' },
78
+ ],
79
+ columns: ['status', 'aweme_id', 'url', 'publish_time'],
80
+ func: async (page, kwargs) => {
81
+ // ── Fail-fast validation ────────────────────────────────────────────
82
+ const videoPath = path.resolve(kwargs.video);
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
+ const title = kwargs.title;
92
+ if (title.length > 30) {
93
+ throw new ArgumentError('标题不能超过 30 字');
94
+ }
95
+ const caption = kwargs.caption || '';
96
+ if (caption.length > 1000) {
97
+ throw new ArgumentError('正文不能超过 1000 字');
98
+ }
99
+ const timingTs = toUnixSeconds(kwargs.schedule);
100
+ validateTiming(timingTs);
101
+ const visibilityType = VISIBILITY_MAP[kwargs.visibility] ?? 0;
102
+ const coverPath = kwargs.cover;
103
+ if (coverPath) {
104
+ if (!fs.existsSync(path.resolve(coverPath))) {
105
+ throw new ArgumentError(`封面文件不存在: ${path.resolve(coverPath)}`);
106
+ }
107
+ }
108
+ // ── Phase 1: STS2 credentials ───────────────────────────────────────
109
+ const credentials = await getSts2Credentials(page);
110
+ // ── Phase 2: Apply TOS upload URL ───────────────────────────────────
111
+ const vodUrl = `https://vod.bytedanceapi.com/?Action=ApplyVideoUpload&ServiceId=1128&Version=2021-01-01&FileType=video&FileSize=${fileSize}`;
112
+ const vodJs = `fetch(${JSON.stringify(vodUrl)}, { credentials: 'include' }).then(r => r.json())`;
113
+ const vodRes = (await page.evaluate(vodJs));
114
+ const { VideoId: videoId, UploadHosts, StoreInfos } = vodRes.Result.UploadAddress;
115
+ const tosUrl = `https://${UploadHosts[0]}/${StoreInfos[0].StoreUri}`;
116
+ const tosUploadInfo = {
117
+ tos_upload_url: tosUrl,
118
+ auth: StoreInfos[0].Auth,
119
+ video_id: videoId,
120
+ };
121
+ // ── Phase 3: TOS upload ─────────────────────────────────────────────
122
+ await tosUpload({
123
+ filePath: videoPath,
124
+ uploadInfo: tosUploadInfo,
125
+ credentials,
126
+ onProgress: (uploaded, total) => {
127
+ const pct = Math.round((uploaded / total) * 100);
128
+ process.stderr.write(`\r 上传进度: ${pct}%`);
129
+ },
130
+ });
131
+ process.stderr.write('\n');
132
+ // ── Phase 4: Cover upload (optional) ────────────────────────────────
133
+ let coverUri = '';
134
+ let coverWidth = 720;
135
+ let coverHeight = 1280;
136
+ if (kwargs.cover) {
137
+ const resolvedCoverPath = path.resolve(kwargs.cover);
138
+ // 4A: Apply ImageX upload
139
+ const applyUrl = `${IMAGEX_BASE}/?Action=ApplyImageUpload&ServiceId=${IMAGEX_SERVICE_ID}&Version=2018-08-01&UploadNum=1`;
140
+ const applyJs = `fetch(${JSON.stringify(applyUrl)}, { credentials: 'include' }).then(r => r.json())`;
141
+ const applyRes = (await page.evaluate(applyJs));
142
+ const { StoreInfos: imgStoreInfos } = applyRes.Result.UploadAddress;
143
+ const imgUploadUrl = `https://${imgStoreInfos[0].UploadHost}/${imgStoreInfos[0].StoreUri}`;
144
+ // 4B: Upload image
145
+ const coverStoreUri = await imagexUpload(resolvedCoverPath, {
146
+ upload_url: imgUploadUrl,
147
+ store_uri: imgStoreInfos[0].StoreUri,
148
+ });
149
+ // 4C: Commit ImageX upload
150
+ const commitUrl = `${IMAGEX_BASE}/?Action=CommitImageUpload&ServiceId=${IMAGEX_SERVICE_ID}&Version=2018-08-01`;
151
+ const commitBody = JSON.stringify({ SuccessObjKeys: [coverStoreUri] });
152
+ const commitJs = `
153
+ fetch(${JSON.stringify(commitUrl)}, {
154
+ method: 'POST',
155
+ credentials: 'include',
156
+ headers: { 'Content-Type': 'application/json' },
157
+ body: ${JSON.stringify(commitBody)}
158
+ }).then(r => r.json())
159
+ `;
160
+ await page.evaluate(commitJs);
161
+ coverUri = coverStoreUri;
162
+ }
163
+ // ── Phase 5: Enable video ───────────────────────────────────────────
164
+ const enableUrl = `https://creator.douyin.com/web/api/media/video/enable/?video_id=${videoId}&aid=1128`;
165
+ await browserFetch(page, 'GET', enableUrl);
166
+ // ── Phase 6: Poll transcode ─────────────────────────────────────────
167
+ const transResult = await pollTranscode(page, videoId);
168
+ coverWidth = transResult.width;
169
+ coverHeight = transResult.height;
170
+ if (!coverUri) {
171
+ coverUri = transResult.poster_uri;
172
+ }
173
+ // ── Phase 7: Content safety check ───────────────────────────────────
174
+ if (!kwargs.no_safety_check) {
175
+ const safetyUrl = 'https://creator.douyin.com/aweme/v1/post_assistant/fast_detect/pre_check';
176
+ const safetyBody = {
177
+ video_id: videoId,
178
+ title,
179
+ desc: caption,
180
+ };
181
+ await browserFetch(page, 'POST', safetyUrl, { body: safetyBody });
182
+ const pollUrl = 'https://creator.douyin.com/aweme/v1/post_assistant/fast_detect/poll';
183
+ const deadline = Date.now() + 30_000;
184
+ let safetyPassed = false;
185
+ while (Date.now() < deadline) {
186
+ const pollRes = (await browserFetch(page, 'POST', pollUrl, {
187
+ body: safetyBody,
188
+ }));
189
+ if (pollRes.status === 0) {
190
+ safetyPassed = true;
191
+ break;
192
+ }
193
+ if (pollRes.status === 1) {
194
+ throw new CommandExecutionError('内容安全检测不通过,请修改后重试', '使用 --no_safety_check 跳过');
195
+ }
196
+ await new Promise((r) => setTimeout(r, 2000));
197
+ }
198
+ if (!safetyPassed) {
199
+ throw new CommandExecutionError('内容安全检测超时(30s),请稍后重试', '使用 --no_safety_check 跳过');
200
+ }
201
+ }
202
+ // ── Phase 8: create_v2 publish ──────────────────────────────────────
203
+ const hashtagNames = extractHashtagNames(caption);
204
+ const hashtags = [];
205
+ let searchFrom = 0;
206
+ for (const name of hashtagNames) {
207
+ const idx = caption.indexOf(`#${name}`, searchFrom);
208
+ if (idx === -1)
209
+ continue;
210
+ hashtags.push({ name, id: 0, start: idx, end: idx + name.length + 1 });
211
+ searchFrom = idx + name.length + 1;
212
+ }
213
+ const textExtraArr = parseTextExtra(caption, hashtags);
214
+ const publishBody = {
215
+ item: {
216
+ common: {
217
+ text: caption,
218
+ caption: '',
219
+ item_title: title,
220
+ activity: JSON.stringify(kwargs.activity ? [kwargs.activity] : []),
221
+ text_extra: JSON.stringify(textExtraArr),
222
+ challenges: '[]',
223
+ mentions: '[]',
224
+ hashtag_source: '',
225
+ hot_sentence: kwargs.hotspot || '',
226
+ interaction_stickers: '[]',
227
+ visibility_type: visibilityType,
228
+ download: kwargs.allow_download ? 1 : 0,
229
+ timing: timingTs,
230
+ creation_id: generateCreationId(),
231
+ media_type: 4,
232
+ video_id: videoId,
233
+ music_source: 0,
234
+ music_id: null,
235
+ ...(kwargs.poi_id
236
+ ? { poi_id: kwargs.poi_id, poi_name: kwargs.poi_name }
237
+ : {}),
238
+ },
239
+ cover: {
240
+ poster: coverUri,
241
+ custom_cover_image_height: coverHeight,
242
+ custom_cover_image_width: coverWidth,
243
+ poster_delay: 0,
244
+ cover_tools_info: DEFAULT_COVER_TOOLS_INFO,
245
+ cover_tools_extend_info: '{}',
246
+ },
247
+ mix: kwargs.collection
248
+ ? { mix_id: kwargs.collection, mix_order: 0 }
249
+ : {},
250
+ chapter: {
251
+ chapter: JSON.stringify({
252
+ chapter_abstract: '',
253
+ chapter_details: [],
254
+ chapter_type: 0,
255
+ }),
256
+ },
257
+ anchor: {},
258
+ sync: {
259
+ should_sync: false,
260
+ sync_to_toutiao: kwargs.sync_toutiao ? 1 : 0,
261
+ },
262
+ open_platform: {},
263
+ assistant: { is_preview: 0, is_post_assistant: 1 },
264
+ declare: { user_declare_info: '{}' },
265
+ },
266
+ };
267
+ const publishUrl = `https://creator.douyin.com/web/api/media/aweme/create_v2/?read_aid=2906&${DEVICE_PARAMS}`;
268
+ const publishRes = (await browserFetch(page, 'POST', publishUrl, {
269
+ body: publishBody,
270
+ }));
271
+ const awemeId = publishRes.aweme_id;
272
+ if (!awemeId) {
273
+ throw new CommandExecutionError(`发布成功但未返回 aweme_id: ${JSON.stringify(publishRes)}`);
274
+ }
275
+ const url = `https://www.douyin.com/video/${awemeId}`;
276
+ const publishTimeStr = new Date(timingTs * 1000).toLocaleString('zh-CN', {
277
+ timeZone: 'Asia/Tokyo',
278
+ });
279
+ return [
280
+ {
281
+ status: '✅ 定时发布成功!',
282
+ aweme_id: awemeId,
283
+ url,
284
+ publish_time: publishTimeStr,
285
+ },
286
+ ];
287
+ },
288
+ });
@@ -0,0 +1 @@
1
+ import './publish.js';
@@ -0,0 +1,38 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { getRegistry } from '../../registry.js';
3
+ import './publish.js';
4
+ describe('douyin publish registration', () => {
5
+ it('registers the publish command', () => {
6
+ const registry = getRegistry();
7
+ const cmds = [...registry.values()];
8
+ const cmd = cmds.find((c) => c.site === 'douyin' && c.name === 'publish');
9
+ expect(cmd).toBeDefined();
10
+ expect(cmd?.args.some((a) => a.name === 'video')).toBe(true);
11
+ expect(cmd?.args.some((a) => a.name === 'title')).toBe(true);
12
+ expect(cmd?.args.some((a) => a.name === 'schedule')).toBe(true);
13
+ });
14
+ it('has all expected args', () => {
15
+ const registry = getRegistry();
16
+ const cmd = [...registry.values()].find((c) => c.site === 'douyin' && c.name === 'publish');
17
+ const argNames = cmd?.args.map((a) => a.name) ?? [];
18
+ expect(argNames).toContain('video');
19
+ expect(argNames).toContain('title');
20
+ expect(argNames).toContain('schedule');
21
+ expect(argNames).toContain('caption');
22
+ expect(argNames).toContain('cover');
23
+ expect(argNames).toContain('visibility');
24
+ expect(argNames).toContain('allow_download');
25
+ expect(argNames).toContain('collection');
26
+ expect(argNames).toContain('activity');
27
+ expect(argNames).toContain('poi_id');
28
+ expect(argNames).toContain('poi_name');
29
+ expect(argNames).toContain('hotspot');
30
+ expect(argNames).toContain('no_safety_check');
31
+ expect(argNames).toContain('sync_toutiao');
32
+ });
33
+ it('uses COOKIE strategy', () => {
34
+ const registry = getRegistry();
35
+ const cmd = [...registry.values()].find((c) => c.site === 'douyin' && c.name === 'publish');
36
+ expect(cmd?.strategy).toBe('cookie');
37
+ });
38
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,27 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import { browserFetch } from './_shared/browser-fetch.js';
3
+ cli({
4
+ site: 'douyin',
5
+ name: 'stats',
6
+ description: '作品数据分析',
7
+ domain: 'creator.douyin.com',
8
+ strategy: Strategy.COOKIE,
9
+ args: [
10
+ { name: 'aweme_id', required: true, positional: true },
11
+ ],
12
+ columns: ['metric', 'value'],
13
+ func: async (page, kwargs) => {
14
+ const now = Math.floor(Date.now() / 1000);
15
+ const sevenDaysAgo = now - 7 * 86400;
16
+ const url = 'https://creator.douyin.com/janus/douyin/creator/data/item_analysis/metrics_trend';
17
+ const body = {
18
+ aweme_id: kwargs.aweme_id,
19
+ start_time: sevenDaysAgo,
20
+ end_time: now,
21
+ metrics: ['play_count', 'like_count', 'comment_count', 'share_count'],
22
+ };
23
+ const res = await browserFetch(page, 'POST', url, { body });
24
+ const data = res.data ?? {};
25
+ return Object.entries(data).map(([metric, value]) => ({ metric, value }));
26
+ },
27
+ });
@@ -0,0 +1 @@
1
+ import './stats.js';
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { getRegistry } from '../../registry.js';
3
+ import './stats.js';
4
+ describe('douyin stats registration', () => {
5
+ it('registers the stats command', () => {
6
+ const registry = getRegistry();
7
+ const cmd = [...registry.values()].find(c => c.site === 'douyin' && c.name === 'stats');
8
+ expect(cmd).toBeDefined();
9
+ expect(cmd?.args.some(a => a.name === 'aweme_id')).toBe(true);
10
+ });
11
+ it('has expected columns', () => {
12
+ const registry = getRegistry();
13
+ const cmd = [...registry.values()].find(c => c.site === 'douyin' && c.name === 'stats');
14
+ expect(cmd?.columns).toContain('metric');
15
+ expect(cmd?.columns).toContain('value');
16
+ });
17
+ it('uses COOKIE strategy', () => {
18
+ const registry = getRegistry();
19
+ const cmd = [...registry.values()].find(c => c.site === 'douyin' && c.name === 'stats');
20
+ expect(cmd?.strategy).toBe('cookie');
21
+ });
22
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,31 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import { ArgumentError } from '../../errors.js';
3
+ import { browserFetch } from './_shared/browser-fetch.js';
4
+ import { toUnixSeconds, validateTiming } from './_shared/timing.js';
5
+ cli({
6
+ site: 'douyin',
7
+ name: 'update',
8
+ description: '更新视频信息',
9
+ domain: 'creator.douyin.com',
10
+ strategy: Strategy.COOKIE,
11
+ args: [
12
+ { name: 'aweme_id', required: true, positional: true },
13
+ { name: 'reschedule', default: '', help: '新的发布时间(ISO8601 或 Unix 秒)' },
14
+ { name: 'caption', default: '', help: '新的正文内容' },
15
+ ],
16
+ columns: ['status'],
17
+ func: async (page, kwargs) => {
18
+ if (!kwargs.reschedule && !kwargs.caption) {
19
+ throw new ArgumentError('必须提供 --reschedule 或 --caption');
20
+ }
21
+ if (kwargs.reschedule) {
22
+ const newTime = toUnixSeconds(kwargs.reschedule);
23
+ validateTiming(newTime);
24
+ await browserFetch(page, 'POST', 'https://creator.douyin.com/web/api/media/update/timer/?aid=1128', { body: { aweme_id: kwargs.aweme_id, publish_time: newTime } });
25
+ }
26
+ if (kwargs.caption) {
27
+ await browserFetch(page, 'POST', 'https://creator.douyin.com/web/api/media/update/desc/?aid=1128', { body: { aweme_id: kwargs.aweme_id, desc: kwargs.caption } });
28
+ }
29
+ return [{ status: '✅ 更新成功' }];
30
+ },
31
+ });
@@ -0,0 +1 @@
1
+ import './update.js';
@@ -0,0 +1,11 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { getRegistry } from '../../registry.js';
3
+ import './update.js';
4
+ describe('douyin update registration', () => {
5
+ it('registers the update command', () => {
6
+ const registry = getRegistry();
7
+ const values = [...registry.values()];
8
+ const cmd = values.find(c => c.site === 'douyin' && c.name === 'update');
9
+ expect(cmd).toBeDefined();
10
+ });
11
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,34 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import { browserFetch } from './_shared/browser-fetch.js';
3
+ cli({
4
+ site: 'douyin',
5
+ name: 'videos',
6
+ description: '获取作品列表',
7
+ domain: 'creator.douyin.com',
8
+ strategy: Strategy.COOKIE,
9
+ args: [
10
+ { name: 'limit', type: 'int', default: 20, help: '每页数量' },
11
+ { name: 'page', type: 'int', default: 1, help: '页码' },
12
+ { name: 'status', default: 'all', choices: ['all', 'published', 'reviewing', 'scheduled'] },
13
+ ],
14
+ columns: ['aweme_id', 'title', 'status', 'play_count', 'digg_count', 'create_time'],
15
+ func: async (page, kwargs) => {
16
+ const statusMap = { all: 0, published: 1, reviewing: 3, scheduled: 0 };
17
+ const statusNum = statusMap[kwargs.status] ?? 0;
18
+ const url = `https://creator.douyin.com/janus/douyin/creator/pc/work_list?page_size=${kwargs.limit}&page_num=${kwargs.page}&status=${statusNum}`;
19
+ const res = (await browserFetch(page, 'GET', url));
20
+ let items = res.data?.work_list ?? [];
21
+ // The API has a bug with status=16 for scheduled, so filter client-side
22
+ if (kwargs.status === 'scheduled') {
23
+ items = items.filter((v) => v.public_time > Date.now() / 1000);
24
+ }
25
+ return items.map((v) => ({
26
+ aweme_id: v.aweme_id,
27
+ title: v.desc,
28
+ status: v.status,
29
+ play_count: v.statistics?.play_count ?? 0,
30
+ digg_count: v.statistics?.digg_count ?? 0,
31
+ create_time: new Date(v.create_time * 1000).toLocaleString('zh-CN', { timeZone: 'Asia/Tokyo' }),
32
+ }));
33
+ },
34
+ });
@@ -0,0 +1 @@
1
+ import './videos.js';
@@ -0,0 +1,11 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { getRegistry } from '../../registry.js';
3
+ import './videos.js';
4
+ describe('douyin videos registration', () => {
5
+ it('registers the videos command', () => {
6
+ const registry = getRegistry();
7
+ const values = [...registry.values()];
8
+ const cmd = values.find(c => c.site === 'douyin' && c.name === 'videos');
9
+ expect(cmd).toBeDefined();
10
+ });
11
+ });
@@ -41,4 +41,4 @@ pipeline:
41
41
 
42
42
  - limit: ${{ args.limit }}
43
43
 
44
- columns: [rank, title, score, author, comments]
44
+ columns: [rank, title, score, author, comments, url]
@@ -37,7 +37,8 @@ pipeline:
37
37
  name: item.user?.full_name || '',
38
38
  verified: item.user?.is_verified ? 'Yes' : 'No',
39
39
  private: item.user?.is_private ? 'Yes' : 'No',
40
+ url: 'https://www.instagram.com/' + (item.user?.username || ''),
40
41
  }));
41
42
  })()
42
43
 
43
- columns: [rank, username, name, verified, private]
44
+ columns: [rank, username, name, verified, private, url]
@@ -31,6 +31,7 @@ pipeline:
31
31
  views: t.views,
32
32
  likes: t.like_count,
33
33
  replies: (t.posts_count || 1) - 1,
34
+ url: 'https://linux.do/t/topic/' + t.id,
34
35
  }));
35
36
  })()
36
37
 
@@ -40,7 +41,8 @@ pipeline:
40
41
  views: ${{ item.views }}
41
42
  likes: ${{ item.likes }}
42
43
  replies: ${{ item.replies }}
44
+ url: ${{ item.url }}
43
45
 
44
46
  - limit: ${{ args.limit }}
45
47
 
46
- columns: [rank, title, views, likes, replies]
48
+ columns: [rank, title, views, likes, replies, url]