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