@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,260 @@
1
+ /**
2
+ * Xiaohongshu delete-note: remove a published note via creator center UI.
3
+ *
4
+ * Flow:
5
+ * 1. Navigate to creator note-manager
6
+ * 2. Switch to "已发布" tab (delete is only available on published notes;
7
+ * "审核中" and "未通过" rows do not expose a web delete entry, only mobile)
8
+ * 3. Locate the row whose `data-impression` JSON contains the target noteId
9
+ * 4. Click the inline `<span class="control data-del">` action
10
+ * 5. Click "确定" in the `.d-modal-footer` confirmation modal
11
+ * 6. Poll for the row disappearing from the list
12
+ *
13
+ * Requires: logged into creator.xiaohongshu.com in Chrome.
14
+ */
15
+ import { cli, Strategy } from '@jackwener/opencli/registry';
16
+ import { ArgumentError, AuthRequiredError, CliError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
17
+ const NOTE_MANAGER_URL = 'https://creator.xiaohongshu.com/new/note-manager';
18
+ const ROW_SETTLE_MS = 3000;
19
+ const MODAL_SETTLE_MS = 2000;
20
+ const VERIFY_TIMEOUT_MS = 10_000;
21
+ const VERIFY_POLL_MS = 1000;
22
+ const NOTE_ID_RE = /^[0-9a-f]{24}$/i;
23
+ function unwrapEvaluateResult(payload) {
24
+ if (payload && typeof payload === 'object' && 'session' in payload && 'data' in payload) {
25
+ return payload.data;
26
+ }
27
+ return payload;
28
+ }
29
+ function requireEvaluateString(payload, context) {
30
+ if (typeof payload !== 'string') {
31
+ throw new CommandExecutionError(`xiaohongshu/delete-note: malformed ${context} payload`);
32
+ }
33
+ return payload;
34
+ }
35
+ function requireEvaluateBoolean(payload, context) {
36
+ if (typeof payload !== 'boolean') {
37
+ throw new CommandExecutionError(`xiaohongshu/delete-note: malformed ${context} payload`);
38
+ }
39
+ return payload;
40
+ }
41
+ function requireEvaluateObject(payload, context) {
42
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
43
+ throw new CommandExecutionError(`xiaohongshu/delete-note: malformed ${context} payload`);
44
+ }
45
+ return payload;
46
+ }
47
+ function requireActionResult(payload, context) {
48
+ const result = requireEvaluateObject(payload, context);
49
+ if (typeof result.ok !== 'boolean') {
50
+ throw new CommandExecutionError(`xiaohongshu/delete-note: malformed ${context} payload`);
51
+ }
52
+ return result;
53
+ }
54
+ function isXiaohongshuHost(hostname) {
55
+ const host = String(hostname || '').toLowerCase();
56
+ return host === 'xiaohongshu.com' || host.endsWith('.xiaohongshu.com');
57
+ }
58
+ function isSupportedQueryNoteUrl(url) {
59
+ return url.hostname.toLowerCase() === 'creator.xiaohongshu.com'
60
+ && url.pathname.replace(/\/+$/, '') === '/statistics/note-detail';
61
+ }
62
+ function normalizeNoteId(input) {
63
+ const raw = String(input ?? '').trim();
64
+ if (!raw) {
65
+ throw new ArgumentError('xiaohongshu/delete-note: note-id cannot be empty');
66
+ }
67
+ if (NOTE_ID_RE.test(raw))
68
+ return raw.toLowerCase();
69
+ if (!/^https:\/\//i.test(raw)) {
70
+ throw new ArgumentError('xiaohongshu/delete-note: note-id must be a 24-character Xiaohongshu note ID or an exact Xiaohongshu note URL');
71
+ }
72
+ let url;
73
+ try {
74
+ url = new URL(raw);
75
+ }
76
+ catch {
77
+ throw new ArgumentError('xiaohongshu/delete-note: invalid note URL');
78
+ }
79
+ if (url.protocol !== 'https:' || url.username || url.password || url.port || !isXiaohongshuHost(url.hostname)) {
80
+ throw new ArgumentError('xiaohongshu/delete-note: note URL must be an exact https://*.xiaohongshu.com URL');
81
+ }
82
+ const queryId = url.searchParams.get('noteId') || url.searchParams.get('note_id');
83
+ if (queryId && NOTE_ID_RE.test(queryId) && isSupportedQueryNoteUrl(url))
84
+ return queryId.toLowerCase();
85
+ const pathMatch = url.pathname.match(/^\/(?:explore|note|search_result|discovery\/item)\/([0-9a-f]{24})\/?$/i)
86
+ || url.pathname.match(/^\/user\/profile\/[^/?#]+\/([0-9a-f]{24})\/?$/i);
87
+ if (pathMatch)
88
+ return pathMatch[1].toLowerCase();
89
+ throw new ArgumentError('xiaohongshu/delete-note: note URL must contain a 24-character note ID');
90
+ }
91
+ function buildLocateAndMaybeDeleteScript(noteId, shouldClick) {
92
+ return `
93
+ (cfg => {
94
+ const { targetId, shouldClick } = cfg;
95
+ const isVisible = (el) => !!el && el.offsetParent !== null;
96
+ const matchesNoteId = (impressionRaw) => {
97
+ if (!impressionRaw) return false;
98
+ try {
99
+ const parsed = JSON.parse(impressionRaw);
100
+ const id = parsed && parsed.noteTarget && parsed.noteTarget.value && parsed.noteTarget.value.noteId;
101
+ return typeof id === 'string' && id === targetId;
102
+ } catch {
103
+ return false;
104
+ }
105
+ };
106
+ const notes = Array.from(document.querySelectorAll('.note')).filter(isVisible);
107
+ for (const note of notes) {
108
+ if (matchesNoteId(note.getAttribute('data-impression'))) {
109
+ const del = note.querySelector('span.control.data-del');
110
+ if (!del || !isVisible(del)) {
111
+ return { ok: false, kind: 'no_delete_action', visibleRows: notes.length };
112
+ }
113
+ if (!shouldClick) {
114
+ return { ok: true, clicked: false };
115
+ }
116
+ del.click();
117
+ return { ok: true, clicked: true };
118
+ }
119
+ }
120
+ return { ok: false, kind: 'not_found', visibleRows: notes.length };
121
+ })(${JSON.stringify({ targetId: noteId, shouldClick })})
122
+ `;
123
+ }
124
+ function buildVerifyGoneScript(noteId) {
125
+ return `
126
+ (targetId => {
127
+ const matchesNoteId = (impressionRaw) => {
128
+ if (!impressionRaw) return false;
129
+ try {
130
+ const parsed = JSON.parse(impressionRaw);
131
+ const id = parsed && parsed.noteTarget && parsed.noteTarget.value && parsed.noteTarget.value.noteId;
132
+ return typeof id === 'string' && id === targetId;
133
+ } catch {
134
+ return false;
135
+ }
136
+ };
137
+ const notes = Array.from(document.querySelectorAll('.note'));
138
+ return notes.some((n) => matchesNoteId(n.getAttribute('data-impression')));
139
+ })(${JSON.stringify(noteId)})
140
+ `;
141
+ }
142
+ cli({
143
+ site: 'xiaohongshu',
144
+ name: 'delete-note',
145
+ access: 'write',
146
+ description: '删除小红书已发布笔记 (creator center UI automation)',
147
+ domain: 'creator.xiaohongshu.com',
148
+ strategy: Strategy.COOKIE,
149
+ navigateBefore: false,
150
+ browser: true,
151
+ args: [
152
+ {
153
+ name: 'note-id',
154
+ required: true,
155
+ positional: true,
156
+ help: 'Note ID (e.g. 6a08ba0b000000000702a893 from xiaohongshu creator-notes / URL)',
157
+ },
158
+ {
159
+ name: 'execute',
160
+ type: 'boolean',
161
+ default: false,
162
+ help: 'Actually click delete + confirm. Default is dry-run target verification only.',
163
+ },
164
+ ],
165
+ columns: ['status', 'note_id', 'message'],
166
+ func: async (page, kwargs) => {
167
+ try {
168
+ const noteId = normalizeNoteId(kwargs['note-id']);
169
+ const execute = kwargs.execute === true;
170
+ await page.goto(NOTE_MANAGER_URL);
171
+ await page.wait({ time: ROW_SETTLE_MS / 1000 });
172
+ // Detect login redirect (creator.xiaohongshu.com bounces to /login on auth failure)
173
+ const currentUrl = requireEvaluateString(unwrapEvaluateResult(await page.evaluate('() => location.href')), 'current-url');
174
+ if (typeof currentUrl === 'string' && /\/login(?:[/?#]|$)/i.test(new URL(currentUrl).pathname + new URL(currentUrl).search)) {
175
+ throw new AuthRequiredError('creator.xiaohongshu.com');
176
+ }
177
+ // Step 1: ensure 已发布 tab is active (delete only exposed there).
178
+ const tabClicked = requireEvaluateBoolean(unwrapEvaluateResult(await page.evaluate(`
179
+ () => {
180
+ const isVisible = (el) => !!el && el.offsetParent !== null;
181
+ for (const el of document.querySelectorAll('a, button, [role="tab"], div')) {
182
+ const text = (el.innerText || el.textContent || '').trim();
183
+ if (text === '已发布' && isVisible(el)) {
184
+ el.click();
185
+ return true;
186
+ }
187
+ }
188
+ return false;
189
+ }
190
+ `)), 'published-tab');
191
+ if (!tabClicked) {
192
+ throw new CommandExecutionError('xiaohongshu/delete-note: 已发布 tab not found on note-manager; xhs creator UI may have changed.');
193
+ }
194
+ await page.wait({ time: ROW_SETTLE_MS / 1000 });
195
+ // Step 2: locate the .note row whose data-impression JSON carries the
196
+ // exact `noteId` field. Dry-run stops here; execute clicks delete.
197
+ // Substring matching on the raw attribute would risk matching unrelated
198
+ // fields whose values happen to share the noteId prefix, so parse the JSON
199
+ // and compare `noteTarget.value.noteId` explicitly.
200
+ const initResult = requireActionResult(unwrapEvaluateResult(await page.evaluate(buildLocateAndMaybeDeleteScript(noteId, execute))), 'locate-note');
201
+ if (!initResult?.ok) {
202
+ if (initResult?.kind === 'not_found') {
203
+ throw new EmptyResultError('xiaohongshu/delete-note', `Note ${noteId} not visible in the 已发布 tab. Verify the note belongs to the logged-in account and has cleared review (审核中 / 未通过 rows have no web delete entry).`);
204
+ }
205
+ if (initResult?.kind === 'no_delete_action') {
206
+ throw new CommandExecutionError(`xiaohongshu/delete-note: note ${noteId} row found but no delete action visible; xhs creator UI may have changed.`);
207
+ }
208
+ throw new CommandExecutionError('xiaohongshu/delete-note: failed to locate note row');
209
+ }
210
+ if (!execute) {
211
+ return [{ status: 'dry-run', note_id: noteId, message: 'Target note row and delete action verified. Re-run with --execute to delete.' }];
212
+ }
213
+ await page.wait({ time: MODAL_SETTLE_MS / 1000 });
214
+ // Step 3: click "确定" in the `.d-modal-footer` confirmation modal.
215
+ const confirmResult = requireActionResult(unwrapEvaluateResult(await page.evaluate(`
216
+ () => {
217
+ const isVisible = (el) => !!el && el.offsetParent !== null;
218
+ const footer = Array.from(document.querySelectorAll('.d-modal-footer')).find(isVisible);
219
+ if (!footer) return { ok: false, kind: 'no_modal' };
220
+ const buttons = Array.from(footer.querySelectorAll('button, [role="button"]')).filter(isVisible);
221
+ const confirmBtn = buttons.find((b) => (b.innerText || b.textContent || '').trim() === '确定');
222
+ if (!confirmBtn) return { ok: false, kind: 'no_confirm', labels: buttons.map(b => (b.innerText || '').trim()) };
223
+ confirmBtn.click();
224
+ return { ok: true };
225
+ }
226
+ `)), 'confirm-modal');
227
+ if (!confirmResult?.ok) {
228
+ throw new CommandExecutionError(`xiaohongshu/delete-note: confirmation modal step failed (${confirmResult?.kind ?? 'unknown'})`);
229
+ }
230
+ // Step 4: poll for row removal (proves the delete actually committed,
231
+ // not just the modal was clicked). Iteration-bounded rather than
232
+ // wall-clock so tests with a mocked `page.wait` exhaust the loop
233
+ // quickly instead of stalling on real time.
234
+ const VERIFY_ITERATIONS = Math.ceil(VERIFY_TIMEOUT_MS / VERIFY_POLL_MS);
235
+ let stillPresent = true;
236
+ for (let i = 0; i < VERIFY_ITERATIONS; i++) {
237
+ await page.wait({ time: VERIFY_POLL_MS / 1000 });
238
+ const probe = requireEvaluateBoolean(unwrapEvaluateResult(await page.evaluate(buildVerifyGoneScript(noteId))), 'verify-gone');
239
+ if (probe === false) {
240
+ stillPresent = false;
241
+ break;
242
+ }
243
+ }
244
+ if (stillPresent) {
245
+ throw new CommandExecutionError(`xiaohongshu/delete-note: note ${noteId} still visible after confirm click; deletion may not have committed.`);
246
+ }
247
+ return [{ status: 'deleted', note_id: noteId, message: 'Delete confirmed and note row disappeared.' }];
248
+ }
249
+ catch (err) {
250
+ if (err instanceof CliError)
251
+ throw err;
252
+ throw new CommandExecutionError(`xiaohongshu/delete-note failed: ${err?.message ?? String(err)}`);
253
+ }
254
+ },
255
+ });
256
+ export const __test__ = {
257
+ normalizeNoteId,
258
+ buildLocateAndMaybeDeleteScript,
259
+ buildVerifyGoneScript,
260
+ };
@@ -0,0 +1,172 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { JSDOM } from 'jsdom';
3
+ import { getRegistry } from '@jackwener/opencli/registry';
4
+ import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
5
+
6
+ import { __test__ } from './delete-note.js';
7
+
8
+ function makePage(evaluateResults = []) {
9
+ const evaluate = vi.fn();
10
+ for (const r of evaluateResults) evaluate.mockResolvedValueOnce(r);
11
+ evaluate.mockResolvedValue(undefined);
12
+ return {
13
+ goto: vi.fn().mockResolvedValue(undefined),
14
+ wait: vi.fn().mockResolvedValue(undefined),
15
+ evaluate,
16
+ };
17
+ }
18
+
19
+ describe('xiaohongshu delete-note command', () => {
20
+ const getCommand = () => getRegistry().get('xiaohongshu/delete-note');
21
+ const validId = '6a08ba0b000000000702a893';
22
+
23
+ it('returns deleted status when delete + confirm + verify all succeed', async () => {
24
+ const page = makePage([
25
+ 'https://creator.xiaohongshu.com/new/note-manager', // currentUrl
26
+ true, // 已发布 tab click
27
+ { ok: true, clicked: true }, // initResult: row found + delete clicked
28
+ { ok: true }, // confirmResult
29
+ false, // verify probe: row gone
30
+ ]);
31
+ const result = await getCommand().func(page, { 'note-id': validId, execute: true });
32
+ expect(result).toEqual([
33
+ { status: 'deleted', note_id: validId, message: 'Delete confirmed and note row disappeared.' },
34
+ ]);
35
+ expect(page.goto).toHaveBeenCalledWith('https://creator.xiaohongshu.com/new/note-manager');
36
+ });
37
+
38
+ it('dry-runs by default after verifying the exact target and delete affordance', async () => {
39
+ const page = makePage([
40
+ 'https://creator.xiaohongshu.com/new/note-manager',
41
+ true,
42
+ { ok: true, clicked: false },
43
+ ]);
44
+ const result = await getCommand().func(page, { 'note-id': validId });
45
+ expect(result).toEqual([
46
+ { status: 'dry-run', note_id: validId, message: 'Target note row and delete action verified. Re-run with --execute to delete.' },
47
+ ]);
48
+ expect(page.evaluate).toHaveBeenCalledTimes(3);
49
+ });
50
+
51
+ it('unwraps browser bridge envelopes at every evaluate boundary', async () => {
52
+ const page = makePage([
53
+ { session: 's', data: 'https://creator.xiaohongshu.com/new/note-manager' },
54
+ { session: 's', data: true },
55
+ { session: 's', data: { ok: true, clicked: true } },
56
+ { session: 's', data: { ok: true } },
57
+ { session: 's', data: false },
58
+ ]);
59
+ const result = await getCommand().func(page, { 'note-id': validId, execute: true });
60
+ expect(result[0]).toMatchObject({ status: 'deleted', note_id: validId });
61
+ });
62
+
63
+ it('normalizes exact Xiaohongshu note IDs from supported URL forms', () => {
64
+ expect(__test__.normalizeNoteId(validId.toUpperCase())).toBe(validId);
65
+ expect(__test__.normalizeNoteId(`https://www.xiaohongshu.com/explore/${validId}?xsec_token=t`)).toBe(validId);
66
+ expect(__test__.normalizeNoteId(`https://creator.xiaohongshu.com/statistics/note-detail?noteId=${validId}`)).toBe(validId);
67
+ });
68
+
69
+ it('throws ArgumentError for missing or ambiguous note identity before navigation', async () => {
70
+ const page = makePage();
71
+ await expect(getCommand().func(page, { 'note-id': '' })).rejects.toBeInstanceOf(ArgumentError);
72
+ await expect(getCommand().func(page, { 'note-id': ' ' })).rejects.toBeInstanceOf(ArgumentError);
73
+ await expect(getCommand().func(page, { 'note-id': 'x' })).rejects.toBeInstanceOf(ArgumentError);
74
+ await expect(getCommand().func(page, { 'note-id': 'https://evil.com/explore/6a08ba0b000000000702a893' })).rejects.toBeInstanceOf(ArgumentError);
75
+ await expect(getCommand().func(page, { 'note-id': 'https://xhslink.com/abc' })).rejects.toBeInstanceOf(ArgumentError);
76
+ await expect(getCommand().func(page, { 'note-id': `https://www.xiaohongshu.com/anything?noteId=${validId}` })).rejects.toBeInstanceOf(ArgumentError);
77
+ expect(page.goto).not.toHaveBeenCalled();
78
+ });
79
+
80
+ it('throws CommandExecutionError for malformed evaluate payloads instead of trusting truthy objects', async () => {
81
+ await expect(getCommand().func(makePage([
82
+ { session: 's', data: { href: 'https://creator.xiaohongshu.com/new/note-manager' } },
83
+ ]), { 'note-id': validId })).rejects.toThrowError(/malformed current-url payload/);
84
+
85
+ await expect(getCommand().func(makePage([
86
+ 'https://creator.xiaohongshu.com/new/note-manager',
87
+ { session: 's', data: { ok: true } },
88
+ ]), { 'note-id': validId })).rejects.toThrowError(/malformed published-tab payload/);
89
+
90
+ await expect(getCommand().func(makePage([
91
+ 'https://creator.xiaohongshu.com/new/note-manager',
92
+ true,
93
+ { session: 's', data: { ok: 'yes', clicked: false } },
94
+ ]), { 'note-id': validId })).rejects.toThrowError(/malformed locate-note payload/);
95
+ });
96
+
97
+ it('throws AuthRequiredError when redirected to login', async () => {
98
+ const page = makePage([
99
+ 'https://creator.xiaohongshu.com/login?redirectReason=401',
100
+ ]);
101
+ await expect(getCommand().func(page, { 'note-id': validId })).rejects.toBeInstanceOf(AuthRequiredError);
102
+ });
103
+
104
+ it('throws CommandExecutionError when 已发布 tab cannot be clicked (UI drift)', async () => {
105
+ const page = makePage([
106
+ 'https://creator.xiaohongshu.com/new/note-manager',
107
+ false, // tab click returns false
108
+ ]);
109
+ await expect(getCommand().func(page, { 'note-id': validId })).rejects.toThrowError(/已发布 tab not found/);
110
+ });
111
+
112
+ it('throws EmptyResultError when the note row is not in the 已发布 tab', async () => {
113
+ const page = makePage([
114
+ 'https://creator.xiaohongshu.com/new/note-manager',
115
+ true,
116
+ { ok: false, kind: 'not_found', visibleRows: 0 },
117
+ ]);
118
+ await expect(getCommand().func(page, { 'note-id': validId })).rejects.toBeInstanceOf(EmptyResultError);
119
+ });
120
+
121
+ it('throws CommandExecutionError when the row has no visible delete action', async () => {
122
+ const page = makePage([
123
+ 'https://creator.xiaohongshu.com/new/note-manager',
124
+ true,
125
+ { ok: false, kind: 'no_delete_action', visibleRows: 1 },
126
+ ]);
127
+ await expect(getCommand().func(page, { 'note-id': validId })).rejects.toThrowError(/no delete action/i);
128
+ });
129
+
130
+ it('throws CommandExecutionError when the confirmation modal does not appear', async () => {
131
+ const page = makePage([
132
+ 'https://creator.xiaohongshu.com/new/note-manager',
133
+ true,
134
+ { ok: true },
135
+ { ok: false, kind: 'no_modal' },
136
+ ]);
137
+ await expect(getCommand().func(page, { 'note-id': validId, execute: true })).rejects.toThrowError(/no_modal/);
138
+ });
139
+
140
+ it('throws CommandExecutionError when row stays visible after confirm (delete did not commit)', async () => {
141
+ // verify probes return true (note still present) for the entire poll window.
142
+ const probes = Array(15).fill(true);
143
+ const page = makePage([
144
+ 'https://creator.xiaohongshu.com/new/note-manager',
145
+ true,
146
+ { ok: true },
147
+ { ok: true },
148
+ ...probes,
149
+ ]);
150
+ await expect(getCommand().func(page, { 'note-id': validId, execute: true })).rejects.toThrowError(/still visible after confirm/i);
151
+ });
152
+
153
+ it('executes the generated row locator without substring-matching other impression fields', () => {
154
+ const otherId = '6a08ba0b000000000702a894';
155
+ const dom = new JSDOM(`
156
+ <div class="note" data-impression='{"noteTarget":{"value":{"noteId":"${otherId}"}},"title":"${validId}"}'>
157
+ <span class="control data-del">删除</span>
158
+ </div>
159
+ <div class="note" data-impression='{"noteTarget":{"value":{"noteId":"${validId}"}}}'>
160
+ <span class="control data-del">删除</span>
161
+ </div>
162
+ `, { runScripts: 'outside-only' });
163
+ Object.defineProperty(dom.window.HTMLElement.prototype, 'offsetParent', {
164
+ configurable: true,
165
+ get() {
166
+ return this.ownerDocument.body;
167
+ },
168
+ });
169
+ const result = dom.window.eval(__test__.buildLocateAndMaybeDeleteScript(validId, false));
170
+ expect(result).toEqual({ ok: true, clicked: false });
171
+ });
172
+ });
@@ -52,49 +52,108 @@ export function buildDownloadExtractJs(noteId) {
52
52
  const authorEl = document.querySelector('.username, .author-name, .name');
53
53
  result.author = authorEl?.textContent?.trim() || 'unknown';
54
54
 
55
- // Get images - try multiple selectors
56
- const imageSelectors = [
57
- '.swiper-slide img',
58
- '.carousel-image img',
59
- '.note-slider img',
60
- '.note-image img',
61
- '.image-wrapper img',
62
- '#noteContainer .media-container img[src*="xhscdn"]',
63
- 'img[src*="ci.xiaohongshu.com"]'
64
- ];
55
+ // Get images: prefer canonical carousel order from __INITIAL_STATE__
56
+ // so the saved order matches what the user sees on the platform (#1514).
57
+ // DOM extraction is used only as a fallback because multiple selectors,
58
+ // hidden / duplicated / preloaded slides, and lazy rendering can reorder
59
+ // the discovered nodes away from the platform's display order.
65
60
 
66
- const imageUrls = new Set();
67
- for (const selector of imageSelectors) {
68
- document.querySelectorAll(selector).forEach(img => {
69
- let src = img.src || img.getAttribute('data-src') || '';
70
- if (src && (src.includes('xhscdn') || src.includes('xiaohongshu') || src.includes('rednote'))) {
71
- src = src.split('?')[0];
72
- src = src.replace(/\\/imageView\\d+\\/\\d+\\/w\\/\\d+/, '');
73
- imageUrls.add(src);
61
+ const normalizeImageUrl = (raw) => {
62
+ if (!raw || typeof raw !== 'string') return '';
63
+ let src = raw.split('?')[0];
64
+ src = src.replace(/\\/imageView\\d+\\/\\d+\\/w\\/\\d+/, '');
65
+ return src;
66
+ };
67
+ const orderedImageUrls = [];
68
+ const seenImageUrls = new Set();
69
+ const pushImage = (url) => {
70
+ if (!url || seenImageUrls.has(url)) return;
71
+ seenImageUrls.add(url);
72
+ orderedImageUrls.push(url);
73
+ };
74
+
75
+ const getStructuredNotes = () => {
76
+ const state = window.__INITIAL_STATE__;
77
+ const noteData = state?.note?.noteDetailMap || state?.note?.note || {};
78
+ if (!noteData || typeof noteData !== 'object') return [];
79
+ const currentIds = [...new Set([result.noteId, '${noteId}'].filter(Boolean))];
80
+ const notes = [];
81
+ for (const id of currentIds) {
82
+ const entry = noteData[id];
83
+ const note = entry?.note || entry;
84
+ if (note && typeof note === 'object') notes.push(note);
85
+ }
86
+ // Compatibility fallback for legacy single-note stores. Do not use this
87
+ // when keyed detail maps contain multiple notes, or carousel order can
88
+ // be polluted by preloaded/previous note entries.
89
+ const keys = Object.keys(noteData);
90
+ if (notes.length === 0 && keys.length === 1) {
91
+ const entry = noteData[keys[0]];
92
+ const note = entry?.note || entry;
93
+ if (note && typeof note === 'object') notes.push(note);
94
+ }
95
+ return notes;
96
+ };
97
+
98
+ // Method 1: walk __INITIAL_STATE__.note.noteDetailMap[id].note.imageList
99
+ // in array order. Each entry exposes urlDefault as the canonical CDN URL.
100
+ let imageInitialStateUsed = false;
101
+ try {
102
+ for (const note of getStructuredNotes()) {
103
+ const list = Array.isArray(note?.imageList) ? note.imageList : [];
104
+ for (const item of list) {
105
+ const candidate = item?.urlDefault || item?.urlPre || item?.url
106
+ || item?.infoList?.find(i => i?.imageScene === 'WB_DFT')?.url
107
+ || item?.infoList?.[0]?.url
108
+ || '';
109
+ const src = normalizeImageUrl(candidate);
110
+ if (src && (src.includes('xhscdn') || src.includes('xiaohongshu') || src.includes('rednote'))) {
111
+ pushImage(src);
112
+ imageInitialStateUsed = true;
113
+ }
74
114
  }
75
- });
115
+ }
116
+ } catch(e) {}
117
+
118
+ // Method 2: fallback to DOM scraping when the structured state is missing
119
+ // (e.g. preview pages without full SSR hydration). Order may differ from
120
+ // the carousel; surface it anyway rather than returning zero images.
121
+ if (!imageInitialStateUsed) {
122
+ const imageSelectors = [
123
+ '.swiper-slide img',
124
+ '.carousel-image img',
125
+ '.note-slider img',
126
+ '.note-image img',
127
+ '.image-wrapper img',
128
+ '#noteContainer .media-container img[src*="xhscdn"]',
129
+ 'img[src*="ci.xiaohongshu.com"]'
130
+ ];
131
+ for (const selector of imageSelectors) {
132
+ document.querySelectorAll(selector).forEach(img => {
133
+ const raw = img.src || img.getAttribute('data-src') || '';
134
+ const src = normalizeImageUrl(raw);
135
+ if (src && (src.includes('xhscdn') || src.includes('xiaohongshu') || src.includes('rednote'))) {
136
+ pushImage(src);
137
+ }
138
+ });
139
+ }
76
140
  }
77
141
 
78
142
  // Get video — prefer real URL from page state over blob: URLs
79
143
 
80
144
  // Method 1: Extract from __INITIAL_STATE__ (SSR hydration data)
81
145
  try {
82
- const state = window.__INITIAL_STATE__;
83
- if (state) {
84
- const noteData = state.note?.noteDetailMap || state.note?.note || {};
85
- for (const key of Object.keys(noteData)) {
86
- const note = noteData[key]?.note || noteData[key];
87
- const video = note?.video;
88
- if (video) {
89
- const vUrl = video.url || video.originVideoKey || video.consumer?.originVideoKey;
90
- if (vUrl) {
91
- const fullUrl = vUrl.startsWith('http') ? vUrl : 'https://sns-video-bd.xhscdn.com/' + vUrl;
92
- pushMedia('video', fullUrl);
93
- }
94
- const streams = video.media?.stream?.h264 || [];
95
- for (const stream of streams) {
96
- if (stream.masterUrl) pushMedia('video', stream.masterUrl);
97
- }
146
+ for (const note of getStructuredNotes()) {
147
+ const video = note?.video;
148
+ if (video) {
149
+ const vUrl = video.url || video.originVideoKey || video.consumer?.originVideoKey;
150
+ if (vUrl) {
151
+ const fullUrl = vUrl.startsWith('http') ? vUrl : 'https://sns-video-bd.xhscdn.com/' + vUrl;
152
+ pushMedia('video', fullUrl);
153
+ }
154
+ const streams = video.media?.stream?.h264 || [];
155
+ for (const stream of streams) {
156
+ if (stream.masterUrl) pushMedia('video', stream.masterUrl);
98
157
  }
99
158
  }
100
159
  }
@@ -135,10 +194,9 @@ export function buildDownloadExtractJs(noteId) {
135
194
  }
136
195
  }
137
196
 
138
- // Add images to media
139
- imageUrls.forEach(url => {
140
- pushMedia('image', url);
141
- });
197
+ // Preserve the pre-existing media type order (videos first, then images)
198
+ // while keeping image carousel order stable within the image batch.
199
+ orderedImageUrls.forEach(url => pushMedia('image', url));
142
200
 
143
201
  return result;
144
202
  })()