@jackwener/opencli 1.7.22 → 1.8.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 (346) hide show
  1. package/README.md +35 -194
  2. package/README.zh-CN.md +42 -260
  3. package/cli-manifest.json +8160 -4392
  4. package/clis/12306/me.js +73 -0
  5. package/clis/12306/orders.js +96 -0
  6. package/clis/12306/passengers.js +90 -0
  7. package/clis/12306/price.js +166 -0
  8. package/clis/12306/stations.js +66 -0
  9. package/clis/12306/train.js +91 -0
  10. package/clis/12306/trains.js +119 -0
  11. package/clis/12306/utils.js +272 -0
  12. package/clis/12306/utils.test.js +331 -0
  13. package/clis/36kr/article.js +6 -3
  14. package/clis/36kr/article.test.js +46 -0
  15. package/clis/_atlassian/shared.js +577 -0
  16. package/clis/_atlassian/shared.test.js +170 -0
  17. package/clis/apple-podcasts/commands.test.js +20 -0
  18. package/clis/apple-podcasts/search.js +2 -2
  19. package/clis/barchart/greeks.js +144 -56
  20. package/clis/barchart/greeks.test.js +138 -0
  21. package/clis/bilibili/comment.js +125 -0
  22. package/clis/bilibili/comment.test.js +153 -0
  23. package/clis/bilibili/comments.js +116 -21
  24. package/clis/bilibili/comments.test.js +77 -18
  25. package/clis/bilibili/subtitle.js +76 -31
  26. package/clis/bilibili/subtitle.test.js +156 -9
  27. package/clis/bilibili/summary.js +167 -0
  28. package/clis/bilibili/summary.test.js +210 -0
  29. package/clis/bilibili/utils.js +63 -5
  30. package/clis/bilibili/utils.test.js +45 -1
  31. package/clis/booking/booking.test.js +356 -0
  32. package/clis/booking/search.js +351 -0
  33. package/clis/chatgpt/envelope.test.js +108 -0
  34. package/clis/chatgpt/image.js +2 -2
  35. package/clis/chatgpt/image.test.js +6 -0
  36. package/clis/chatgpt/utils.js +148 -41
  37. package/clis/chatgpt/utils.test.js +92 -2
  38. package/clis/chess/analyze.js +35 -0
  39. package/clis/chess/analyze.test.js +79 -0
  40. package/clis/chess/game.js +114 -0
  41. package/clis/chess/game.test.js +178 -0
  42. package/clis/chess/games.js +67 -0
  43. package/clis/chess/games.test.js +164 -0
  44. package/clis/chess/stats.js +32 -0
  45. package/clis/chess/stats.test.js +79 -0
  46. package/clis/chess/utils.js +170 -0
  47. package/clis/chess/utils.test.js +230 -0
  48. package/clis/confluence/commands.test.js +195 -0
  49. package/clis/confluence/create.js +39 -0
  50. package/clis/confluence/page.js +23 -0
  51. package/clis/confluence/search.js +34 -0
  52. package/clis/confluence/shared.js +173 -0
  53. package/clis/confluence/update.js +38 -0
  54. package/clis/douyin/_shared/browser-fetch.js +44 -20
  55. package/clis/douyin/_shared/browser-fetch.test.js +22 -1
  56. package/clis/douyin/_shared/evaluate-result.js +16 -0
  57. package/clis/douyin/_shared/tos-upload.js +105 -69
  58. package/clis/douyin/_shared/vod-upload.js +212 -0
  59. package/clis/douyin/_shared/vod-upload.test.js +38 -0
  60. package/clis/douyin/delete.js +137 -4
  61. package/clis/douyin/delete.test.js +90 -1
  62. package/clis/douyin/hashtag.js +84 -23
  63. package/clis/douyin/hashtag.test.js +113 -0
  64. package/clis/douyin/publish-upload-id.test.js +170 -0
  65. package/clis/douyin/publish.js +88 -42
  66. package/clis/douyin/user-videos.js +9 -2
  67. package/clis/douyin/user-videos.test.js +43 -0
  68. package/clis/flomo/memos.js +228 -0
  69. package/clis/flomo/memos.test.js +144 -0
  70. package/clis/geogebra/add-circle.js +46 -0
  71. package/clis/geogebra/add-line.js +35 -0
  72. package/clis/geogebra/add-point.js +27 -0
  73. package/clis/geogebra/add-polygon.js +25 -0
  74. package/clis/geogebra/eval.js +35 -0
  75. package/clis/geogebra/geogebra.test.js +175 -0
  76. package/clis/geogebra/hexagon.js +62 -0
  77. package/clis/geogebra/info.js +72 -0
  78. package/clis/geogebra/list.js +35 -0
  79. package/clis/geogebra/triangle.js +60 -0
  80. package/clis/geogebra/utils.js +271 -0
  81. package/clis/gitee/search.js +2 -2
  82. package/clis/gitee/search.test.js +65 -0
  83. package/clis/jike/post.js +27 -17
  84. package/clis/jike/read.test.js +86 -0
  85. package/clis/jike/topic.js +32 -19
  86. package/clis/jike/user.js +33 -20
  87. package/clis/jira/attachments.js +28 -0
  88. package/clis/jira/commands.test.js +287 -0
  89. package/clis/jira/comments.js +28 -0
  90. package/clis/jira/issue.js +28 -0
  91. package/clis/jira/links.js +28 -0
  92. package/clis/jira/search.js +47 -0
  93. package/clis/jira/shared.js +256 -0
  94. package/clis/lesswrong/comments.js +1 -1
  95. package/clis/lesswrong/curated.js +1 -1
  96. package/clis/lesswrong/frontpage.js +1 -1
  97. package/clis/lesswrong/frontpage.test.js +37 -0
  98. package/clis/lesswrong/new.js +1 -1
  99. package/clis/lesswrong/read.js +1 -1
  100. package/clis/lesswrong/sequences.js +1 -1
  101. package/clis/lesswrong/shortform.js +1 -1
  102. package/clis/lesswrong/tag.js +1 -1
  103. package/clis/lesswrong/top-month.js +1 -1
  104. package/clis/lesswrong/top-week.js +1 -1
  105. package/clis/lesswrong/top-year.js +1 -1
  106. package/clis/lesswrong/top.js +1 -1
  107. package/clis/linkedin/connect.js +401 -0
  108. package/clis/linkedin/connect.test.js +213 -0
  109. package/clis/linkedin/inbox.js +234 -0
  110. package/clis/linkedin/inbox.test.js +152 -0
  111. package/clis/linkedin/job-detail.js +167 -0
  112. package/clis/linkedin/job-detail.test.js +38 -0
  113. package/clis/linkedin/jobs-preferences.js +113 -0
  114. package/clis/linkedin/jobs-preferences.test.js +43 -0
  115. package/clis/linkedin/people-search.js +262 -0
  116. package/clis/linkedin/people-search.test.js +216 -0
  117. package/clis/linkedin/post-analytics.js +74 -0
  118. package/clis/linkedin/post-analytics.test.js +40 -0
  119. package/clis/linkedin/posts-core.js +241 -0
  120. package/clis/linkedin/posts.js +22 -0
  121. package/clis/linkedin/posts.test.js +40 -0
  122. package/clis/linkedin/profile-analytics.js +104 -0
  123. package/clis/linkedin/profile-analytics.test.js +67 -0
  124. package/clis/linkedin/profile-experience.js +671 -0
  125. package/clis/linkedin/profile-experience.test.js +152 -0
  126. package/clis/linkedin/profile-projects.js +311 -0
  127. package/clis/linkedin/profile-projects.test.js +111 -0
  128. package/clis/linkedin/profile-read.js +148 -0
  129. package/clis/linkedin/profile-read.test.js +77 -0
  130. package/clis/linkedin/safe-send.js +357 -0
  131. package/clis/linkedin/safe-send.test.js +204 -0
  132. package/clis/linkedin/salesnav-inbox.js +210 -0
  133. package/clis/linkedin/salesnav-inbox.test.js +113 -0
  134. package/clis/linkedin/salesnav-message.js +360 -0
  135. package/clis/linkedin/salesnav-message.test.js +172 -0
  136. package/clis/linkedin/salesnav-search.js +186 -0
  137. package/clis/linkedin/salesnav-search.test.js +76 -0
  138. package/clis/linkedin/salesnav-thread.js +212 -0
  139. package/clis/linkedin/salesnav-thread.test.js +79 -0
  140. package/clis/linkedin/sent-invitations.js +92 -0
  141. package/clis/linkedin/sent-invitations.test.js +62 -0
  142. package/clis/linkedin/services-read.js +213 -0
  143. package/clis/linkedin/services-read.test.js +105 -0
  144. package/clis/linkedin/shared.js +124 -0
  145. package/clis/linkedin/thread-snapshot.js +214 -0
  146. package/clis/linkedin/thread-snapshot.test.js +89 -0
  147. package/clis/linkedin/timeline.js +14 -7
  148. package/clis/linkedin-learning/course.js +138 -0
  149. package/clis/linkedin-learning/course.test.js +114 -0
  150. package/clis/linkedin-learning/search.js +155 -0
  151. package/clis/linkedin-learning/search.test.js +144 -0
  152. package/clis/linkedin-learning/trending.js +133 -0
  153. package/clis/linkedin-learning/trending.test.js +123 -0
  154. package/clis/notebooklm/add-source.js +269 -0
  155. package/clis/notebooklm/add-source.test.js +97 -0
  156. package/clis/notebooklm/create.js +76 -0
  157. package/clis/notebooklm/create.test.js +58 -0
  158. package/clis/notebooklm/generate-audio.js +91 -0
  159. package/clis/notebooklm/generate-audio.test.js +63 -0
  160. package/clis/notebooklm/generate-slides.js +106 -0
  161. package/clis/notebooklm/generate-slides.test.js +75 -0
  162. package/clis/notebooklm/open.test.js +10 -10
  163. package/clis/notebooklm/rpc.js +20 -6
  164. package/clis/notebooklm/rpc.test.js +27 -1
  165. package/clis/notebooklm/utils.js +100 -24
  166. package/clis/notebooklm/utils.test.js +60 -1
  167. package/clis/notebooklm/write-note.js +103 -0
  168. package/clis/notebooklm/write-note.test.js +70 -0
  169. package/clis/pixiv/detail.js +41 -34
  170. package/clis/pixiv/detail.test.js +93 -0
  171. package/clis/pixiv/user.js +36 -31
  172. package/clis/pixiv/user.test.js +100 -0
  173. package/clis/pixiv/utils.js +56 -7
  174. package/clis/powerchina/search.js +3 -3
  175. package/clis/powerchina/search.test.js +27 -1
  176. package/clis/reddit/extract-media.test.js +149 -0
  177. package/clis/reddit/frontpage.js +47 -9
  178. package/clis/reddit/frontpage.test.js +34 -0
  179. package/clis/reddit/home.js +31 -1
  180. package/clis/reddit/home.test.js +46 -3
  181. package/clis/reddit/hot.js +32 -1
  182. package/clis/reddit/hot.test.js +15 -1
  183. package/clis/reddit/popular.js +39 -1
  184. package/clis/reddit/popular.test.js +26 -0
  185. package/clis/reddit/saved.js +1 -1
  186. package/clis/reddit/search.js +38 -1
  187. package/clis/reddit/search.test.js +26 -0
  188. package/clis/reddit/subreddit.js +52 -7
  189. package/clis/reddit/subreddit.test.js +31 -0
  190. package/clis/reddit/subscribed.js +165 -0
  191. package/clis/reddit/subscribed.test.js +168 -0
  192. package/clis/reddit/upvoted.js +1 -1
  193. package/clis/suno/commands.test.js +188 -0
  194. package/clis/suno/download.js +140 -0
  195. package/clis/suno/download.test.js +151 -0
  196. package/clis/suno/generate.js +231 -0
  197. package/clis/suno/generate.test.js +252 -0
  198. package/clis/suno/list.js +79 -0
  199. package/clis/suno/status.js +63 -0
  200. package/clis/suno/utils.js +549 -0
  201. package/clis/suno/utils.test.js +329 -0
  202. package/clis/twitter/device-follow.js +193 -0
  203. package/clis/twitter/device-follow.test.js +287 -0
  204. package/clis/twitter/download.js +443 -73
  205. package/clis/twitter/download.test.js +457 -0
  206. package/clis/twitter/followers.js +6 -2
  207. package/clis/twitter/followers.test.js +19 -1
  208. package/clis/twitter/following.js +14 -5
  209. package/clis/twitter/following.test.js +29 -0
  210. package/clis/twitter/likes.js +12 -4
  211. package/clis/twitter/likes.test.js +26 -1
  212. package/clis/twitter/list-add.js +1 -1
  213. package/clis/twitter/list-create.js +155 -0
  214. package/clis/twitter/list-create.test.js +169 -0
  215. package/clis/twitter/list-remove.js +13 -6
  216. package/clis/twitter/list-remove.test.js +74 -0
  217. package/clis/twitter/list-tweets.js +6 -2
  218. package/clis/twitter/list-tweets.test.js +41 -1
  219. package/clis/twitter/lists.js +31 -4
  220. package/clis/twitter/lists.test.js +152 -16
  221. package/clis/twitter/notifications.js +4 -4
  222. package/clis/twitter/post.js +62 -4
  223. package/clis/twitter/post.test.js +35 -3
  224. package/clis/twitter/profile.js +81 -28
  225. package/clis/twitter/profile.test.js +113 -2
  226. package/clis/twitter/quote.js +9 -4
  227. package/clis/twitter/reply.js +13 -10
  228. package/clis/twitter/reply.test.js +41 -0
  229. package/clis/twitter/search.js +7 -3
  230. package/clis/twitter/search.test.js +41 -0
  231. package/clis/twitter/shared.js +155 -0
  232. package/clis/twitter/shared.test.js +465 -1
  233. package/clis/twitter/thread.js +10 -2
  234. package/clis/twitter/thread.test.js +58 -0
  235. package/clis/twitter/timeline.js +6 -2
  236. package/clis/twitter/timeline.test.js +2 -0
  237. package/clis/twitter/tweets.js +3 -2
  238. package/clis/twitter/tweets.test.js +1 -1
  239. package/clis/twitter/utils.js +53 -16
  240. package/clis/upwork/detail.js +132 -0
  241. package/clis/upwork/feed.js +109 -0
  242. package/clis/upwork/search.js +115 -0
  243. package/clis/upwork/upwork.test.js +566 -0
  244. package/clis/upwork/utils.js +323 -0
  245. package/clis/weibo/delete.js +172 -0
  246. package/clis/weibo/delete.test.js +94 -0
  247. package/clis/weibo/publish.js +37 -14
  248. package/clis/weibo/publish.test.js +14 -5
  249. package/clis/weibo/user-posts.js +234 -0
  250. package/clis/weibo/user-posts.test.js +92 -0
  251. package/clis/weread/book-search.js +438 -0
  252. package/clis/weread/book-search.test.js +242 -0
  253. package/clis/weread/search-regression.test.js +98 -11
  254. package/clis/weread/search.js +32 -9
  255. package/clis/weread-official/book.js +135 -0
  256. package/clis/weread-official/commands.test.js +385 -0
  257. package/clis/weread-official/discover.js +107 -0
  258. package/clis/weread-official/list-apis.js +95 -0
  259. package/clis/weread-official/notes.js +171 -0
  260. package/clis/weread-official/readdata.js +158 -0
  261. package/clis/weread-official/review.js +93 -0
  262. package/clis/weread-official/search.js +106 -0
  263. package/clis/weread-official/shelf.js +97 -0
  264. package/clis/weread-official/utils.js +293 -0
  265. package/clis/weread-official/utils.test.js +242 -0
  266. package/clis/wikipedia/trending.js +7 -3
  267. package/clis/wikipedia/trending.test.js +57 -0
  268. package/clis/xianyu/chat.js +24 -109
  269. package/clis/xianyu/chat.test.js +5 -0
  270. package/clis/xianyu/im.js +322 -0
  271. package/clis/xianyu/im.test.js +253 -0
  272. package/clis/xianyu/inbox.js +96 -0
  273. package/clis/xianyu/messages.js +91 -0
  274. package/clis/xianyu/reply.js +82 -0
  275. package/clis/xiaohongshu/creator-note-detail.js +166 -28
  276. package/clis/xiaohongshu/creator-note-detail.test.js +196 -36
  277. package/clis/xiaohongshu/creator-notes-summary.js +2 -1
  278. package/clis/xiaohongshu/creator-notes-summary.test.js +7 -0
  279. package/clis/xiaohongshu/creator-notes.js +252 -2
  280. package/clis/xiaohongshu/creator-notes.test.js +90 -1
  281. package/clis/xiaohongshu/creator-stats.js +2 -1
  282. package/clis/xiaohongshu/creator-stats.test.js +24 -0
  283. package/clis/xiaohongshu/delete-note.js +260 -0
  284. package/clis/xiaohongshu/delete-note.test.js +172 -0
  285. package/clis/xiaohongshu/download.js +97 -39
  286. package/clis/xiaohongshu/download.test.js +201 -0
  287. package/clis/xiaohongshu/publish.js +48 -8
  288. package/clis/xiaohongshu/publish.test.js +65 -10
  289. package/clis/xiaohongshu/user-helpers.test.js +41 -0
  290. package/clis/xiaohongshu/user.js +27 -4
  291. package/clis/xiaoyuzhou/download.js +1 -1
  292. package/clis/xiaoyuzhou/transcript.js +1 -1
  293. package/clis/youdao/note.js +258 -0
  294. package/clis/youdao/note.test.js +99 -0
  295. package/clis/youtube/transcript.js +397 -24
  296. package/clis/youtube/transcript.test.js +196 -6
  297. package/clis/zhihu/answer-comments.js +280 -0
  298. package/clis/zhihu/answer-comments.test.js +287 -0
  299. package/clis/zhihu/answer-detail.js +2 -19
  300. package/clis/zhihu/answer-detail.test.js +8 -0
  301. package/clis/zhihu/collection.js +17 -16
  302. package/clis/zhihu/collection.test.js +50 -3
  303. package/clis/zhihu/download.js +1 -1
  304. package/clis/zhihu/question.js +42 -17
  305. package/clis/zhihu/question.test.js +113 -11
  306. package/clis/zhihu/search.js +195 -43
  307. package/clis/zhihu/search.test.js +198 -0
  308. package/clis/zhihu/text.js +29 -0
  309. package/clis/zhihu/text.test.js +24 -0
  310. package/dist/src/browser/errors.js +4 -2
  311. package/dist/src/browser/errors.test.js +6 -0
  312. package/dist/src/browser/network-cache.js +13 -1
  313. package/dist/src/browser/network-cache.test.js +17 -0
  314. package/dist/src/browser/page.js +30 -4
  315. package/dist/src/browser/page.test.js +42 -0
  316. package/dist/src/browser/utils.d.ts +1 -1
  317. package/dist/src/cli-argv-preprocess.d.ts +26 -0
  318. package/dist/src/cli-argv-preprocess.js +138 -0
  319. package/dist/src/cli-argv-preprocess.test.js +79 -0
  320. package/dist/src/convention-audit.js +15 -8
  321. package/dist/src/convention-audit.test.js +21 -0
  322. package/dist/src/download/index.js +13 -1
  323. package/dist/src/download/index.test.js +23 -1
  324. package/dist/src/download/media-download.js +15 -2
  325. package/dist/src/download/media-download.test.d.ts +1 -0
  326. package/dist/src/download/media-download.test.js +112 -0
  327. package/dist/src/download/progress.js +2 -2
  328. package/dist/src/download/progress.test.js +12 -1
  329. package/dist/src/electron-apps.js +1 -1
  330. package/dist/src/electron-apps.test.js +7 -2
  331. package/dist/src/errors.d.ts +17 -0
  332. package/dist/src/errors.js +22 -0
  333. package/dist/src/external-clis.yaml +8 -0
  334. package/dist/src/main.js +14 -2
  335. package/dist/src/output.js +11 -1
  336. package/dist/src/output.test.js +6 -0
  337. package/dist/src/registry.js +1 -0
  338. package/dist/src/registry.test.js +11 -0
  339. package/dist/src/utils.d.ts +43 -0
  340. package/dist/src/utils.js +97 -0
  341. package/dist/src/utils.test.d.ts +1 -0
  342. package/dist/src/utils.test.js +155 -0
  343. package/package.json +8 -2
  344. package/scripts/silent-column-drop-baseline.json +0 -52
  345. package/scripts/typed-error-lint-baseline.json +28 -380
  346. package/clis/slock/_utils.js +0 -12
@@ -0,0 +1,212 @@
1
+ import * as crypto from 'node:crypto';
2
+ import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
3
+ import { unwrapEvaluateResult } from './evaluate-result.js';
4
+
5
+ const AUTH_V5_URL = 'https://creator.douyin.com/web/api/media/upload/auth/v5/';
6
+ const VOD_UPLOAD_HOST = 'https://vod.bytedanceapi.com/';
7
+ const VOD_SPACE_NAME = 'aweme';
8
+
9
+ function hmacSha256(key, data) {
10
+ return crypto.createHmac('sha256', key).update(data, 'utf8').digest();
11
+ }
12
+
13
+ function sha256Hex(data) {
14
+ const hash = crypto.createHash('sha256');
15
+ if (Buffer.isBuffer(data) || data instanceof Uint8Array) {
16
+ hash.update(data);
17
+ } else {
18
+ hash.update(data ?? '', 'utf8');
19
+ }
20
+ return hash.digest('hex');
21
+ }
22
+
23
+ function nowDatetime() {
24
+ return new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
25
+ }
26
+
27
+ function canonicalQuery(url) {
28
+ return [...url.searchParams.entries()]
29
+ .sort(([a], [b]) => a.localeCompare(b))
30
+ .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
31
+ .join('&');
32
+ }
33
+
34
+ function computeAws4Headers(url, credentials, options = {}) {
35
+ const parsedUrl = new URL(url);
36
+ const datetime = nowDatetime();
37
+ const date = datetime.slice(0, 8);
38
+ const method = options.method ?? 'GET';
39
+ const body = options.body ?? '';
40
+ const bodyHash = sha256Hex(body);
41
+ const headers = {
42
+ ...(options.headers ?? {}),
43
+ host: parsedUrl.host,
44
+ 'x-amz-content-sha256': bodyHash,
45
+ 'x-amz-date': datetime,
46
+ 'x-amz-security-token': credentials.session_token,
47
+ };
48
+ const sortedHeaderKeys = Object.keys(headers).sort((a, b) => a.localeCompare(b));
49
+ const canonicalHeaders = sortedHeaderKeys
50
+ .map((key) => `${key}:${String(headers[key]).trim()}`)
51
+ .join('\n') + '\n';
52
+ const signedHeaders = sortedHeaderKeys.join(';');
53
+ const canonicalRequest = [
54
+ method,
55
+ parsedUrl.pathname || '/',
56
+ canonicalQuery(parsedUrl),
57
+ canonicalHeaders,
58
+ signedHeaders,
59
+ bodyHash,
60
+ ].join('\n');
61
+ const service = 'vod';
62
+ const region = 'cn-north-1';
63
+ const credentialScope = `${date}/${region}/${service}/aws4_request`;
64
+ const stringToSign = [
65
+ 'AWS4-HMAC-SHA256',
66
+ datetime,
67
+ credentialScope,
68
+ sha256Hex(canonicalRequest),
69
+ ].join('\n');
70
+ const kDate = hmacSha256(`AWS4${credentials.secret_access_key}`, date);
71
+ const kRegion = hmacSha256(kDate, region);
72
+ const kService = hmacSha256(kRegion, service);
73
+ const kSigning = hmacSha256(kService, 'aws4_request');
74
+ const signature = hmacSha256(kSigning, stringToSign).toString('hex');
75
+ return {
76
+ ...headers,
77
+ Authorization: `AWS4-HMAC-SHA256 Credential=${credentials.access_key_id}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`,
78
+ };
79
+ }
80
+
81
+ function extractUserIdFromSessionToken(sessionToken) {
82
+ try {
83
+ const raw = sessionToken.startsWith('STS2') ? sessionToken.slice(4) : sessionToken;
84
+ const decoded = JSON.parse(Buffer.from(raw, 'base64').toString('utf8'));
85
+ const policy = JSON.parse(decoded.PolicyString || '{}');
86
+ const condition = policy?.Statement?.[0]?.Condition;
87
+ if (typeof condition === 'string') {
88
+ const parsedCondition = JSON.parse(condition);
89
+ return parsedCondition.UserId || '';
90
+ }
91
+ } catch {
92
+ return '';
93
+ }
94
+ return '';
95
+ }
96
+
97
+ export async function getUploadAuthV5Credentials(page) {
98
+ const result = unwrapEvaluateResult(await page.evaluate(`fetch(${JSON.stringify(AUTH_V5_URL)}, { credentials: 'include' }).then(r => r.json())`));
99
+ if (!result || Array.isArray(result) || typeof result !== 'object') {
100
+ throw new CommandExecutionError(`获取抖音上传授权失败: ${JSON.stringify(result)}`);
101
+ }
102
+ if (result.status_code !== 0) {
103
+ const message = result.status_msg ?? result.message ?? 'unknown error';
104
+ if (result.status_code === 401 || result.status_code === 403 || /login|cookie|auth|captcha|verify|forbidden|permission|登录|登陆|权限|验证|验证码/i.test(String(message))) {
105
+ throw new AuthRequiredError('creator.douyin.com', `获取抖音上传授权失败: ${message}`);
106
+ }
107
+ throw new CommandExecutionError(`获取抖音上传授权失败: ${JSON.stringify(result)}`);
108
+ }
109
+ if (!result.auth) {
110
+ throw new CommandExecutionError(`获取抖音上传授权失败: ${JSON.stringify(result)}`);
111
+ }
112
+ let auth;
113
+ try {
114
+ auth = JSON.parse(result.auth);
115
+ } catch (error) {
116
+ throw new CommandExecutionError(`解析抖音上传授权失败: ${error instanceof Error ? error.message : String(error)}`);
117
+ }
118
+ if (!auth.AccessKeyID || !auth.SecretAccessKey || !auth.SessionToken) {
119
+ throw new CommandExecutionError('抖音上传授权缺少 AccessKeyID/SecretAccessKey/SessionToken');
120
+ }
121
+ return {
122
+ access_key_id: auth.AccessKeyID,
123
+ secret_access_key: auth.SecretAccessKey,
124
+ session_token: auth.SessionToken,
125
+ user_id: extractUserIdFromSessionToken(auth.SessionToken),
126
+ expired_time: auth.ExpiredTime,
127
+ current_time: auth.CurrentTime,
128
+ };
129
+ }
130
+
131
+ export async function applyVideoUploadInner(fileSize, credentials) {
132
+ const params = new URLSearchParams({
133
+ Action: 'ApplyUploadInner',
134
+ Version: '2020-11-19',
135
+ SpaceName: VOD_SPACE_NAME,
136
+ FileType: 'video',
137
+ IsInner: '1',
138
+ FileSize: String(fileSize),
139
+ });
140
+ const url = `${VOD_UPLOAD_HOST}?${params.toString()}`;
141
+ const res = await fetch(url, { headers: computeAws4Headers(url, credentials), signal: AbortSignal.timeout(30000) });
142
+ const text = await res.text();
143
+ let payload;
144
+ try {
145
+ payload = JSON.parse(text);
146
+ } catch {
147
+ throw new CommandExecutionError(`申请抖音上传地址失败,非 JSON 响应: HTTP ${res.status} ${text.slice(0, 300)}`);
148
+ }
149
+ const error = payload?.ResponseMetadata?.Error;
150
+ if (!res.ok || error) {
151
+ throw new CommandExecutionError(`申请抖音上传地址失败: HTTP ${res.status} ${JSON.stringify(error ?? payload)}`);
152
+ }
153
+ const uploadNode = payload?.Result?.InnerUploadAddress?.UploadNodes?.[0];
154
+ const storeInfo = uploadNode?.StoreInfos?.[0];
155
+ const videoId = payload?.Result?.Vid || uploadNode?.Vid;
156
+ const sessionKey = uploadNode?.SessionKey ?? storeInfo?.SessionKey ?? payload?.Result?.SessionKey;
157
+ if (!uploadNode?.UploadHost || !storeInfo?.StoreUri || !storeInfo?.Auth || !videoId || !sessionKey) {
158
+ throw new CommandExecutionError(`申请抖音上传地址响应缺少必要字段: ${JSON.stringify(payload).slice(0, 500)}`);
159
+ }
160
+ return {
161
+ video_id: videoId,
162
+ tos_upload_url: `https://${uploadNode.UploadHost}/${storeInfo.StoreUri}`,
163
+ auth: storeInfo.Auth,
164
+ session_key: sessionKey,
165
+ upload_header: uploadNode.UploadHeader ?? {},
166
+ user_id: credentials.user_id ?? '',
167
+ };
168
+ }
169
+
170
+
171
+ export async function commitVideoUploadInner(uploadInfo, credentials) {
172
+ if (!uploadInfo?.session_key) {
173
+ throw new CommandExecutionError('抖音上传提交缺少 SessionKey');
174
+ }
175
+ const params = new URLSearchParams({
176
+ Action: 'CommitUploadInner',
177
+ Version: '2020-11-19',
178
+ SpaceName: VOD_SPACE_NAME,
179
+ });
180
+ const url = `${VOD_UPLOAD_HOST}?${params.toString()}`;
181
+ const body = JSON.stringify({ SessionKey: uploadInfo.session_key });
182
+ const headers = computeAws4Headers(url, credentials, {
183
+ method: 'POST',
184
+ body,
185
+ headers: { 'content-type': 'application/json;charset=UTF-8' },
186
+ });
187
+ const res = await fetch(url, { method: 'POST', headers, body, signal: AbortSignal.timeout(30000) });
188
+ const text = await res.text();
189
+ let payload;
190
+ try {
191
+ payload = JSON.parse(text);
192
+ } catch {
193
+ throw new CommandExecutionError(`提交抖音上传失败,非 JSON 响应: HTTP ${res.status} ${text.slice(0, 300)}`);
194
+ }
195
+ const error = payload?.ResponseMetadata?.Error;
196
+ if (!res.ok || error) {
197
+ throw new CommandExecutionError(`提交抖音上传失败: HTTP ${res.status} ${JSON.stringify(error ?? payload)}`);
198
+ }
199
+ const result = payload?.Result?.Results?.[0] ?? payload?.Result ?? {};
200
+ const videoId = result.Vid ?? result.VideoId ?? result.VideoID ?? result.vid ?? uploadInfo.video_id;
201
+ if (!videoId) {
202
+ throw new CommandExecutionError(`提交抖音上传响应缺少 video id: ${JSON.stringify(payload).slice(0, 500)}`);
203
+ }
204
+ const meta = result.Meta ?? result.VideoMeta ?? {};
205
+ return {
206
+ video_id: videoId,
207
+ poster_uri: result.PosterUri ?? result.PosterURI ?? result.SnapshotUri ?? result.SnapshotURI ?? '',
208
+ width: Number(meta.Width ?? meta.width ?? 720) || 720,
209
+ height: Number(meta.Height ?? meta.height ?? 1280) || 1280,
210
+ raw: result,
211
+ };
212
+ }
@@ -0,0 +1,38 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
3
+ import { getUploadAuthV5Credentials, applyVideoUploadInner } from './vod-upload.js';
4
+
5
+ describe('douyin vod upload helpers', () => {
6
+ it('parses creator upload auth v5 credentials', async () => {
7
+ const page = { evaluate: async () => ({ status_code: 0, auth: JSON.stringify({ AccessKeyID: 'ak', SecretAccessKey: 'sk', SessionToken: 'token', ExpiredTime: 123, CurrentTime: 100 }) }) };
8
+ await expect(getUploadAuthV5Credentials(page)).resolves.toEqual({ access_key_id: 'ak', secret_access_key: 'sk', session_token: 'token', user_id: '', expired_time: 123, current_time: 100 });
9
+ });
10
+
11
+ it('unwraps browser bridge envelopes around upload auth payloads', async () => {
12
+ const payload = { status_code: 0, auth: JSON.stringify({ AccessKeyID: 'ak', SecretAccessKey: 'sk', SessionToken: 'token' }) };
13
+ const page = { evaluate: async () => ({ session: 'site:douyin:test', data: payload }) };
14
+ await expect(getUploadAuthV5Credentials(page)).resolves.toMatchObject({ access_key_id: 'ak', secret_access_key: 'sk', session_token: 'token' });
15
+ });
16
+
17
+ it('maps upload auth permission errors to AuthRequiredError', async () => {
18
+ const page = { evaluate: async () => ({ status_code: 401, status_msg: 'login required' }) };
19
+ await expect(getUploadAuthV5Credentials(page)).rejects.toBeInstanceOf(AuthRequiredError);
20
+ });
21
+
22
+ it('maps ApplyUploadInner response to TOS upload info', async () => {
23
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, status: 200, text: async () => JSON.stringify({ ResponseMetadata: { RequestId: 'req' }, Result: { InnerUploadAddress: { UploadNodes: [{ Vid: 'video-id', SessionKey: 'session-key', UploadHost: 'tos.example.com', StoreInfos: [{ StoreUri: 'obj/key.mp4', Auth: 'space-auth' }] }] } } }) });
24
+ await expect(applyVideoUploadInner(1234, { access_key_id: 'ak', secret_access_key: 'sk', session_token: 'token' })).resolves.toEqual({ video_id: 'video-id', tos_upload_url: 'https://tos.example.com/obj/key.mp4', auth: 'space-auth', session_key: 'session-key', upload_header: {}, user_id: '' });
25
+ const [url, init] = fetchSpy.mock.calls[0];
26
+ expect(String(url)).toContain('Action=ApplyUploadInner');
27
+ expect(String(url)).toContain('Version=2020-11-19');
28
+ expect(init.headers.Authorization).toContain('AWS4-HMAC-SHA256 Credential=ak/');
29
+ expect(init.headers['x-amz-security-token']).toBe('token');
30
+ fetchSpy.mockRestore();
31
+ });
32
+
33
+ it('surfaces VOD API errors with context', async () => {
34
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, status: 200, text: async () => JSON.stringify({ ResponseMetadata: { Error: { Code: 'AccessDenied', Message: 'denied' } } }) });
35
+ await expect(applyVideoUploadInner(1234, { access_key_id: 'ak', secret_access_key: 'sk', session_token: 'token' })).rejects.toBeInstanceOf(CommandExecutionError);
36
+ fetchSpy.mockRestore();
37
+ });
38
+ });
@@ -1,19 +1,152 @@
1
1
  import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
2
3
  import { browserFetch } from './_shared/browser-fetch.js';
4
+ import { requireObjectEvaluateResult } from './_shared/evaluate-result.js';
5
+
6
+ const CREATOR_MANAGE_URL = 'https://creator.douyin.com/creator-micro/content/manage';
7
+ const WORK_LIST_URL = '/janus/douyin/creator/pc/work_list?status=0&count=20&max_cursor=0&scene=star_atlas&device_platform=android&aid=1128';
8
+
9
+ function readAwemeId(raw) {
10
+ const value = String(raw ?? '').trim();
11
+ if (!value) {
12
+ throw new ArgumentError('douyin delete aweme_id cannot be empty');
13
+ }
14
+ if (!/^\d+$/.test(value)) {
15
+ throw new ArgumentError('douyin delete aweme_id must be a numeric id');
16
+ }
17
+ return value;
18
+ }
19
+
20
+ function sleep(ms) {
21
+ return new Promise((resolve) => setTimeout(resolve, ms));
22
+ }
23
+
24
+ async function deleteViaCreatorManage(page, workId) {
25
+ await page.goto(CREATOR_MANAGE_URL);
26
+ await sleep(3000);
27
+ await sleep(3000);
28
+ const result = requireObjectEvaluateResult(await page.evaluate(`
29
+ (async () => {
30
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
31
+ const targetId = ${JSON.stringify(String(workId))};
32
+ const textOf = (node) => (node && (node.innerText || node.textContent) || '').trim();
33
+ const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim();
34
+
35
+ async function loadTarget() {
36
+ const res = await fetch(${JSON.stringify(WORK_LIST_URL)}, { credentials: 'include' });
37
+ const payload = await res.json();
38
+ const list = Array.isArray(payload.aweme_list) ? payload.aweme_list : [];
39
+ const matches = list
40
+ .map((entry, index) => ({ entry, index }))
41
+ .filter(({ entry }) => String(entry.aweme_id || '') === targetId || String(entry.item_id || '') === targetId);
42
+ if (matches.length === 0) {
43
+ return { ok: false, reason: 'not_found', status_code: payload.status_code, count: list.length };
44
+ }
45
+ if (matches.length !== 1) {
46
+ return { ok: false, reason: 'target_not_unique', count: matches.length };
47
+ }
48
+ const { entry: item, index } = matches[0];
49
+ const title = normalize(item.desc || item.caption || item.title || item.item_title || '');
50
+ return { ok: true, item, index, listCount: list.length, title };
51
+ }
52
+
53
+ function visibleWorkCards() {
54
+ const candidates = Array.from(document.querySelectorAll('[class*="video-card"]'))
55
+ .filter((element) => {
56
+ const text = normalize(textOf(element));
57
+ return text.includes('删除作品') && text.includes('继续编辑');
58
+ });
59
+ return candidates.filter((candidate) => !candidates.some((other) => other !== candidate && other.contains(candidate)));
60
+ }
61
+
62
+ const target = await loadTarget();
63
+ if (!target.ok) return target;
64
+
65
+ const allTab = Array.from(document.querySelectorAll('button,[role="button"],span,div'))
66
+ .find((element) => /^全部作品$/.test(normalize(textOf(element))));
67
+ allTab?.click();
68
+ await sleep(1000);
69
+ for (let attempt = 0; attempt < 20; attempt += 1) {
70
+ const cards = visibleWorkCards();
71
+ if (cards.length >= target.listCount && cards[target.index]) {
72
+ const card = cards[target.index];
73
+ const deleteButton = Array.from(card.querySelectorAll('button,[role="button"],span,div'))
74
+ .find((element) => /^删除作品$/.test(normalize(textOf(element))));
75
+ if (!deleteButton) return { ok: false, reason: 'delete_button_not_found', aweme_id: target.item.aweme_id, item_id: target.item.item_id, index: target.index, cardCount: cards.length };
76
+ deleteButton.click();
77
+ await sleep(800);
78
+ const confirmButton = Array.from(document.querySelectorAll('button,[role="button"]'))
79
+ .find((element) => ['确定', '确认', '删除'].includes(normalize(textOf(element))));
80
+ if (!confirmButton) return { ok: false, reason: 'confirm_button_not_found', aweme_id: target.item.aweme_id, item_id: target.item.item_id };
81
+ confirmButton.click();
82
+ for (let wait = 0; wait < 20; wait += 1) {
83
+ await sleep(500);
84
+ const after = await loadTarget();
85
+ if (!after.ok && after.reason === 'not_found') {
86
+ return { ok: true, aweme_id: target.item.aweme_id, item_id: target.item.item_id, title: target.title };
87
+ }
88
+ }
89
+ return { ok: false, reason: 'delete_not_confirmed', aweme_id: target.item.aweme_id, item_id: target.item.item_id };
90
+ }
91
+ await sleep(500);
92
+ }
93
+ return { ok: false, reason: 'card_not_found', aweme_id: target.item.aweme_id, item_id: target.item.item_id, index: target.index, listCount: target.listCount };
94
+ })()
95
+ `), '抖音后台管理删除响应异常');
96
+
97
+ if (!result?.ok) {
98
+ throw new CommandExecutionError(`抖音后台管理删除失败: ${JSON.stringify(result)}`);
99
+ }
100
+ return result;
101
+ }
102
+
103
+ async function findWorkListItem(page, workId) {
104
+ const data = await browserFetch(page, 'GET', `https://creator.douyin.com${WORK_LIST_URL}`, { timeoutMs: 8000 });
105
+ const list = data.data?.work_list ?? data.aweme_list ?? data.work_list ?? [];
106
+ if (!Array.isArray(list)) {
107
+ throw new CommandExecutionError('抖音作品列表响应缺少 work_list/aweme_list');
108
+ }
109
+ return list.find((entry) => String(entry.aweme_id || '') === workId || String(entry.item_id || '') === workId) || null;
110
+ }
111
+
3
112
  cli({
4
113
  site: 'douyin',
5
114
  name: 'delete',
6
115
  access: 'write',
7
- description: '删除作品',
116
+ description: '删除作品(优先使用创作者后台作品管理;找不到时回退到旧删除接口)',
8
117
  domain: 'creator.douyin.com',
9
118
  strategy: Strategy.COOKIE,
119
+ siteSession: 'persistent',
10
120
  args: [
11
- { name: 'aweme_id', required: true, positional: true, help: '作品 ID' },
121
+ { name: 'aweme_id', required: true, positional: true, help: '作品 ID / item_id' },
12
122
  ],
13
123
  columns: ['status'],
14
124
  func: async (page, kwargs) => {
125
+ const awemeId = readAwemeId(kwargs.aweme_id);
126
+ try {
127
+ const deleted = await deleteViaCreatorManage(page, awemeId);
128
+ return [{ status: `✅ 已通过后台管理删除 ${deleted.aweme_id || awemeId}` }];
129
+ } catch (fallbackError) {
130
+ const fallbackMessage = fallbackError instanceof Error ? fallbackError.message : String(fallbackError);
131
+ if (!fallbackMessage.includes('"reason":"not_found"')) {
132
+ throw fallbackError;
133
+ }
134
+ }
135
+
136
+ const before = await findWorkListItem(page, awemeId);
137
+ if (!before) {
138
+ throw new CommandExecutionError(`抖音作品 ${awemeId} 未在作品列表中找到,未执行删除`);
139
+ }
15
140
  const url = 'https://creator.douyin.com/web/api/media/aweme/delete/?aid=1128';
16
- await browserFetch(page, 'POST', url, { body: { aweme_id: kwargs.aweme_id } });
17
- return [{ status: `✅ 已删除 ${kwargs.aweme_id}` }];
141
+ await browserFetch(page, 'POST', url, { body: { aweme_id: awemeId }, timeoutMs: 8000 });
142
+ const deadline = Date.now() + 10_000;
143
+ while (Date.now() < deadline) {
144
+ await sleep(500);
145
+ const after = await findWorkListItem(page, awemeId);
146
+ if (!after) {
147
+ return [{ status: `✅ 已删除 ${awemeId}` }];
148
+ }
149
+ }
150
+ throw new CommandExecutionError(`抖音作品 ${awemeId} 删除后仍在作品列表中,删除未确认`);
18
151
  },
19
152
  });
@@ -1,11 +1,100 @@
1
- import { describe, expect, it } from 'vitest';
1
+ import { readFileSync } from 'node:fs';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
2
4
  import { getRegistry } from '@jackwener/opencli/registry';
5
+
6
+ const mocks = vi.hoisted(() => ({
7
+ browserFetch: vi.fn(),
8
+ }));
9
+
10
+ vi.mock('./_shared/browser-fetch.js', () => ({ browserFetch: mocks.browserFetch }));
11
+
3
12
  import './delete.js';
13
+
14
+ function makePage({ evaluateResult, listBefore = [], listAfter = [] } = {}) {
15
+ let listCalls = 0;
16
+ mocks.browserFetch.mockImplementation(async (_page, method, url) => {
17
+ if (method === 'GET' && String(url).includes('/work_list?')) {
18
+ listCalls += 1;
19
+ return { aweme_list: listCalls === 1 ? listBefore : listAfter };
20
+ }
21
+ return { status_code: 0 };
22
+ });
23
+ return {
24
+ goto: vi.fn().mockResolvedValue(undefined),
25
+ evaluate: vi.fn().mockResolvedValue(evaluateResult ?? { ok: false, reason: 'not_found' }),
26
+ wait: vi.fn().mockResolvedValue(undefined),
27
+ };
28
+ }
29
+
4
30
  describe('douyin delete registration', () => {
31
+ const command = getRegistry().get('douyin/delete');
32
+
33
+ beforeEach(() => {
34
+ vi.clearAllMocks();
35
+ vi.useFakeTimers();
36
+ });
37
+
38
+ afterEach(() => {
39
+ vi.useRealTimers();
40
+ });
41
+
5
42
  it('registers the delete command', () => {
6
43
  const registry = getRegistry();
7
44
  const values = [...registry.values()];
8
45
  const cmd = values.find(c => c.site === 'douyin' && c.name === 'delete');
9
46
  expect(cmd).toBeDefined();
10
47
  });
48
+
49
+ it('uses work_list id/index matching instead of title matching for fallback deletion', () => {
50
+ const source = readFileSync(new URL('./delete.js', import.meta.url), 'utf8');
51
+ expect(source).toContain('target_not_unique');
52
+ expect(source).toContain("String(entry.aweme_id || '') === targetId");
53
+ expect(source).toContain('cards[target.index]');
54
+ expect(source).not.toContain('text.includes(target.title)');
55
+ });
56
+
57
+ it('validates aweme_id before navigation', async () => {
58
+ const page = makePage();
59
+ await expect(command.func(page, { aweme_id: '' })).rejects.toBeInstanceOf(ArgumentError);
60
+ await expect(command.func(page, { aweme_id: 'abc' })).rejects.toBeInstanceOf(ArgumentError);
61
+ expect(page.goto).not.toHaveBeenCalled();
62
+ });
63
+
64
+ it('does not treat a missing work as successful delete', async () => {
65
+ const page = makePage({ listBefore: [], listAfter: [] });
66
+ const promise = command.func(page, { aweme_id: '123' });
67
+ const assertion = expect(promise).rejects.toBeInstanceOf(CommandExecutionError);
68
+ await vi.advanceTimersByTimeAsync(7000);
69
+ await assertion;
70
+ });
71
+
72
+ it('unwraps Browser Bridge envelopes around creator manage delete results', async () => {
73
+ const page = makePage({ evaluateResult: { session: 'site:douyin:test', data: { ok: true, aweme_id: '123' } } });
74
+ const promise = command.func(page, { aweme_id: '123' });
75
+ const assertion = expect(promise).resolves.toEqual([{ status: '✅ 已通过后台管理删除 123' }]);
76
+ await vi.advanceTimersByTimeAsync(7000);
77
+ await assertion;
78
+ expect(mocks.browserFetch).not.toHaveBeenCalled();
79
+ });
80
+
81
+ it('throws typed on malformed creator manage delete result', async () => {
82
+ const page = makePage({ evaluateResult: 'bad-shape' });
83
+ const promise = command.func(page, { aweme_id: '123' });
84
+ const assertion = expect(promise).rejects.toBeInstanceOf(CommandExecutionError);
85
+ await vi.advanceTimersByTimeAsync(7000);
86
+ await assertion;
87
+ expect(mocks.browserFetch).not.toHaveBeenCalled();
88
+ });
89
+
90
+ it('returns success only after fallback delete postcondition removes the target', async () => {
91
+ const page = makePage({
92
+ listBefore: [{ aweme_id: '123' }],
93
+ listAfter: [],
94
+ });
95
+ const promise = command.func(page, { aweme_id: '123' });
96
+ const assertion = expect(promise).resolves.toEqual([{ status: '✅ 已删除 123' }]);
97
+ await vi.advanceTimersByTimeAsync(8000);
98
+ await assertion;
99
+ });
11
100
  });
@@ -1,6 +1,40 @@
1
1
  import { cli, Strategy } from '@jackwener/opencli/registry';
2
2
  import { browserFetch } from './_shared/browser-fetch.js';
3
- import { ArgumentError } from '@jackwener/opencli/errors';
3
+ import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
4
+
5
+ function isPlainObject(value) {
6
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
7
+ }
8
+
9
+ function requireListField(res, field, action) {
10
+ if (!isPlainObject(res)) {
11
+ throw new CommandExecutionError(`douyin hashtag ${action}: API returned malformed payload`);
12
+ }
13
+ const list = res[field];
14
+ if (list === undefined || list === null) return [];
15
+ if (!Array.isArray(list)) {
16
+ throw new CommandExecutionError(`douyin hashtag ${action}: API returned malformed "${field}"`);
17
+ }
18
+ return list;
19
+ }
20
+
21
+ function validateHashtagArgs(kwargs) {
22
+ const action = kwargs.action;
23
+ if (action === 'search') {
24
+ const keyword = String(kwargs.keyword ?? '').trim();
25
+ if (!keyword) {
26
+ throw new ArgumentError('douyin hashtag search 需要 --keyword <关键词>', '示例: opencli douyin hashtag search --keyword 美食');
27
+ }
28
+ return;
29
+ }
30
+ if (action === 'suggest') {
31
+ const cover = String(kwargs.cover ?? '').trim();
32
+ if (!cover) {
33
+ throw new ArgumentError('douyin hashtag suggest 需要 --cover <cover_uri>', 'suggest 基于已上传的视频封面做 AI 推荐, 不是关键词搜索. 关键词搜索请用 `douyin hashtag search --keyword <词>`.');
34
+ }
35
+ }
36
+ }
37
+
4
38
  cli({
5
39
  site: 'douyin',
6
40
  name: 'hashtag',
@@ -9,43 +43,70 @@ cli({
9
43
  domain: 'creator.douyin.com',
10
44
  strategy: Strategy.COOKIE,
11
45
  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: '封面 URIsuggest 使用)' },
46
+ { name: 'action', required: true, positional: true, choices: ['search', 'suggest', 'hot'], help: 'search=关键词搜索 (--keyword 必填), suggest=AI推荐 (--cover 必填), hot=热点词 (--keyword 可选)' },
47
+ { name: 'keyword', default: '', help: '搜索关键词. search 必填; hot 可选; suggest 不使用 (传 --cover)' },
48
+ { name: 'cover', default: '', help: '封面 URI (cover_uri). suggest 必填; 其它 action 不使用' },
15
49
  { name: 'limit', type: 'int', default: 10 },
16
50
  ],
17
51
  columns: ['name', 'id', 'view_count'],
52
+ validateArgs: validateHashtagArgs,
18
53
  func: async (page, kwargs) => {
54
+ validateHashtagArgs(kwargs);
19
55
  const action = kwargs.action;
20
56
  if (action === 'search') {
21
- const url = `https://creator.douyin.com/aweme/v1/challenge/search/?keyword=${encodeURIComponent(kwargs.keyword)}&count=${kwargs.limit}&aid=1128`;
57
+ const keyword = String(kwargs.keyword ?? '').trim();
58
+ const url = `https://creator.douyin.com/aweme/v1/challenge/search/?keyword=${encodeURIComponent(keyword)}&count=${kwargs.limit}&aid=1128`;
22
59
  const res = await browserFetch(page, 'GET', url);
23
- return (res.challenge_list ?? []).map(c => ({
24
- name: c.challenge_info.cha_name,
25
- id: c.challenge_info.cid,
26
- view_count: c.challenge_info.view_count,
27
- }));
60
+ const list = requireListField(res, 'challenge_list', 'search');
61
+ const rows = list.flatMap(c => {
62
+ const info = c?.challenge_info;
63
+ if (!isPlainObject(info)) return [];
64
+ return [{
65
+ name: info.cha_name,
66
+ id: info.cid,
67
+ view_count: info.view_count,
68
+ }];
69
+ });
70
+ if (list.length > 0 && rows.length === 0) {
71
+ throw new CommandExecutionError('douyin hashtag search: API returned challenges but none had stable challenge_info shape');
72
+ }
73
+ return rows;
28
74
  }
29
75
  if (action === 'suggest') {
30
- const url = `https://creator.douyin.com/web/api/media/hashtag/rec/?cover_uri=${encodeURIComponent(kwargs.cover)}&aid=1128`;
76
+ const cover = String(kwargs.cover ?? '').trim();
77
+ const url = `https://creator.douyin.com/web/api/media/hashtag/rec/?cover_uri=${encodeURIComponent(cover)}&aid=1128`;
31
78
  const res = await browserFetch(page, 'GET', url);
32
- return (res.hashtag_list ?? []).map(h => ({ name: h.name, id: h.id, view_count: h.view_count }));
79
+ const list = requireListField(res, 'hashtag_list', 'suggest');
80
+ return list.map(h => ({ name: h?.name ?? '', id: h?.id ?? '', view_count: h?.view_count ?? 0 }));
33
81
  }
34
82
  if (action === 'hot') {
35
- const kw = kwargs.keyword;
83
+ const kw = String(kwargs.keyword ?? '').trim();
36
84
  const url = `https://creator.douyin.com/aweme/v1/hotspot/recommend/?${kw ? `keyword=${encodeURIComponent(kw)}&` : ''}aid=1128`;
37
85
  const res = await browserFetch(page, 'GET', url);
38
- const items = res.hotspot_list
39
- ?? res.all_sentences?.map(h => ({
40
- sentence: h.word ?? '',
41
- hot_value: h.hot_value,
42
- sentence_id: h.sentence_id ?? '',
43
- }))
44
- ?? [];
86
+ if (!isPlainObject(res)) {
87
+ throw new CommandExecutionError('douyin hashtag hot: API returned malformed payload');
88
+ }
89
+ const hotspotList = res.hotspot_list;
90
+ const allSentences = res.all_sentences;
91
+ if (hotspotList !== undefined && hotspotList !== null && !Array.isArray(hotspotList)) {
92
+ throw new CommandExecutionError('douyin hashtag hot: API returned malformed "hotspot_list"');
93
+ }
94
+ if (allSentences !== undefined && allSentences !== null && !Array.isArray(allSentences)) {
95
+ throw new CommandExecutionError('douyin hashtag hot: API returned malformed "all_sentences"');
96
+ }
97
+ const items = Array.isArray(hotspotList)
98
+ ? hotspotList
99
+ : Array.isArray(allSentences)
100
+ ? allSentences.map(h => ({
101
+ sentence: h?.word ?? '',
102
+ hot_value: h?.hot_value,
103
+ sentence_id: h?.sentence_id ?? '',
104
+ }))
105
+ : [];
45
106
  return items.slice(0, kwargs.limit).map(h => ({
46
- name: h.sentence,
47
- id: 'sentence_id' in h ? h.sentence_id : '',
48
- view_count: h.hot_value,
107
+ name: h?.sentence ?? '',
108
+ id: h && 'sentence_id' in h ? h.sentence_id : '',
109
+ view_count: h?.hot_value ?? 0,
49
110
  }));
50
111
  }
51
112
  throw new ArgumentError(`未知的 action: ${action}`);