@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
@@ -10,7 +10,9 @@ vi.mock('@jackwener/opencli/download', () => ({
10
10
  formatCookieHeader: mockFormatCookieHeader,
11
11
  }));
12
12
  import { getRegistry } from '@jackwener/opencli/registry';
13
+ import { JSDOM } from 'jsdom';
13
14
  import './download.js';
15
+ import { buildDownloadExtractJs } from './download.js';
14
16
  function createPageMock(evaluateResult) {
15
17
  return {
16
18
  goto: vi.fn().mockResolvedValue(undefined),
@@ -113,3 +115,202 @@ describe('xiaohongshu download', () => {
113
115
  expect(mockDownloadMedia).not.toHaveBeenCalled();
114
116
  });
115
117
  });
118
+
119
+ describe('xiaohongshu download buildDownloadExtractJs carousel ordering (JSDOM)', () => {
120
+ function runExtract({ html = '', initialState = null, url = 'https://www.xiaohongshu.com/explore/69f9716c000000003601f90e' } = {}) {
121
+ const dom = new JSDOM(`<!doctype html><html><body>${html}</body></html>`, { url, runScripts: 'outside-only' });
122
+ if (initialState) {
123
+ dom.window.__INITIAL_STATE__ = initialState;
124
+ }
125
+ const js = buildDownloadExtractJs('69f9716c000000003601f90e');
126
+ return dom.window.eval(js);
127
+ }
128
+
129
+ it('preserves carousel order from __INITIAL_STATE__ imageList over DOM discovery order', () => {
130
+ const initialState = {
131
+ note: {
132
+ noteDetailMap: {
133
+ '69f9716c000000003601f90e': {
134
+ note: {
135
+ imageList: [
136
+ { urlDefault: 'https://sns-img-bd.xhscdn.com/canonical-cover.jpg' },
137
+ { urlDefault: 'https://sns-img-bd.xhscdn.com/canonical-second.jpg' },
138
+ { urlDefault: 'https://sns-img-bd.xhscdn.com/canonical-third.jpg' },
139
+ ],
140
+ },
141
+ },
142
+ },
143
+ },
144
+ };
145
+ // DOM has the same images but in a DIFFERENT order: repro for #1514.
146
+ const html = `
147
+ <div class="swiper-slide"><img src="https://sns-img-bd.xhscdn.com/canonical-second.jpg" /></div>
148
+ <div class="swiper-slide"><img src="https://sns-img-bd.xhscdn.com/canonical-cover.jpg" /></div>
149
+ <div class="swiper-slide"><img src="https://sns-img-bd.xhscdn.com/canonical-third.jpg" /></div>
150
+ `;
151
+ const result = runExtract({ html, initialState });
152
+ const images = result.media.filter((m) => m.type === 'image');
153
+ expect(images.map((m) => m.url)).toEqual([
154
+ 'https://sns-img-bd.xhscdn.com/canonical-cover.jpg',
155
+ 'https://sns-img-bd.xhscdn.com/canonical-second.jpg',
156
+ 'https://sns-img-bd.xhscdn.com/canonical-third.jpg',
157
+ ]);
158
+ });
159
+
160
+ it('prefers urlDefault but falls back to urlPre / url / infoList.WB_DFT / infoList[0]', () => {
161
+ const initialState = {
162
+ note: {
163
+ noteDetailMap: {
164
+ '69f9716c000000003601f90e': {
165
+ note: {
166
+ imageList: [
167
+ { urlDefault: 'https://sns-img-bd.xhscdn.com/a.jpg' },
168
+ { urlPre: 'https://sns-img-bd.xhscdn.com/b.jpg' },
169
+ { url: 'https://sns-img-bd.xhscdn.com/c.jpg' },
170
+ { infoList: [{ imageScene: 'WB_PRV', url: 'https://sns-img-bd.xhscdn.com/d-low.jpg' }, { imageScene: 'WB_DFT', url: 'https://sns-img-bd.xhscdn.com/d.jpg' }] },
171
+ { infoList: [{ url: 'https://sns-img-bd.xhscdn.com/e.jpg' }] },
172
+ ],
173
+ },
174
+ },
175
+ },
176
+ },
177
+ };
178
+ const result = runExtract({ initialState });
179
+ const urls = result.media.filter((m) => m.type === 'image').map((m) => m.url);
180
+ expect(urls).toEqual([
181
+ 'https://sns-img-bd.xhscdn.com/a.jpg',
182
+ 'https://sns-img-bd.xhscdn.com/b.jpg',
183
+ 'https://sns-img-bd.xhscdn.com/c.jpg',
184
+ 'https://sns-img-bd.xhscdn.com/d.jpg',
185
+ 'https://sns-img-bd.xhscdn.com/e.jpg',
186
+ ]);
187
+ });
188
+
189
+ it('strips imageView resize params + query strings from canonical urls', () => {
190
+ const initialState = {
191
+ note: {
192
+ noteDetailMap: {
193
+ '69f9716c000000003601f90e': {
194
+ note: {
195
+ imageList: [
196
+ { urlDefault: 'https://sns-img-bd.xhscdn.com/raw/imageView2/2/w/1080/cover.jpg?expires=123' },
197
+ ],
198
+ },
199
+ },
200
+ },
201
+ },
202
+ };
203
+ const result = runExtract({ initialState });
204
+ const urls = result.media.filter((m) => m.type === 'image').map((m) => m.url);
205
+ expect(urls).toEqual(['https://sns-img-bd.xhscdn.com/raw/cover.jpg']);
206
+ });
207
+
208
+ it('falls back to DOM extraction when __INITIAL_STATE__ omits imageList', () => {
209
+ const html = `
210
+ <div class="swiper-slide"><img src="https://sns-img-bd.xhscdn.com/dom-1.jpg" /></div>
211
+ <div class="swiper-slide"><img src="https://sns-img-bd.xhscdn.com/dom-2.jpg" /></div>
212
+ `;
213
+ const result = runExtract({ html });
214
+ const urls = result.media.filter((m) => m.type === 'image').map((m) => m.url);
215
+ expect(urls).toEqual([
216
+ 'https://sns-img-bd.xhscdn.com/dom-1.jpg',
217
+ 'https://sns-img-bd.xhscdn.com/dom-2.jpg',
218
+ ]);
219
+ });
220
+
221
+ it('skips non-xhscdn / non-xiaohongshu / non-rednote urls in initial state', () => {
222
+ const initialState = {
223
+ note: {
224
+ noteDetailMap: {
225
+ '69f9716c000000003601f90e': {
226
+ note: {
227
+ imageList: [
228
+ { urlDefault: 'https://sns-img-bd.xhscdn.com/keep.jpg' },
229
+ { urlDefault: 'https://imgur.com/drop.jpg' },
230
+ { urlDefault: '' },
231
+ ],
232
+ },
233
+ },
234
+ },
235
+ },
236
+ };
237
+ const result = runExtract({ initialState });
238
+ const urls = result.media.filter((m) => m.type === 'image').map((m) => m.url);
239
+ expect(urls).toEqual(['https://sns-img-bd.xhscdn.com/keep.jpg']);
240
+ });
241
+
242
+ it('does not run DOM fallback when initial state yielded any image (preserves canonical order)', () => {
243
+ const initialState = {
244
+ note: {
245
+ noteDetailMap: {
246
+ '69f9716c000000003601f90e': {
247
+ note: {
248
+ imageList: [
249
+ { urlDefault: 'https://sns-img-bd.xhscdn.com/canonical-only.jpg' },
250
+ ],
251
+ },
252
+ },
253
+ },
254
+ },
255
+ };
256
+ const html = `
257
+ <div class="swiper-slide"><img src="https://sns-img-bd.xhscdn.com/dom-extra.jpg" /></div>
258
+ `;
259
+ const result = runExtract({ html, initialState });
260
+ const urls = result.media.filter((m) => m.type === 'image').map((m) => m.url);
261
+ expect(urls).toEqual(['https://sns-img-bd.xhscdn.com/canonical-only.jpg']);
262
+ });
263
+
264
+ it('uses only the current note entry from multi-note initial state maps', () => {
265
+ const initialState = {
266
+ note: {
267
+ noteDetailMap: {
268
+ othernote0000000000000001: {
269
+ note: {
270
+ imageList: [{ urlDefault: 'https://sns-img-bd.xhscdn.com/other.jpg' }],
271
+ video: { url: 'https://sns-video-bd.xhscdn.com/other.mp4' },
272
+ },
273
+ },
274
+ '69f9716c000000003601f90e': {
275
+ note: {
276
+ imageList: [
277
+ { urlDefault: 'https://sns-img-bd.xhscdn.com/current-1.jpg' },
278
+ { urlDefault: 'https://sns-img-bd.xhscdn.com/current-2.jpg' },
279
+ ],
280
+ video: { url: 'https://sns-video-bd.xhscdn.com/current.mp4' },
281
+ },
282
+ },
283
+ },
284
+ },
285
+ };
286
+ const result = runExtract({ initialState });
287
+ const images = result.media.filter((m) => m.type === 'image').map((m) => m.url);
288
+ const videos = result.media.filter((m) => m.type === 'video').map((m) => m.url);
289
+ expect(images).toEqual([
290
+ 'https://sns-img-bd.xhscdn.com/current-1.jpg',
291
+ 'https://sns-img-bd.xhscdn.com/current-2.jpg',
292
+ ]);
293
+ expect(videos).toEqual(['https://sns-video-bd.xhscdn.com/current.mp4']);
294
+ });
295
+
296
+ it('still resolves videos from __INITIAL_STATE__ alongside the image fix', () => {
297
+ const initialState = {
298
+ note: {
299
+ noteDetailMap: {
300
+ '69f9716c000000003601f90e': {
301
+ note: {
302
+ imageList: [{ urlDefault: 'https://sns-img-bd.xhscdn.com/cover.jpg' }],
303
+ video: { url: 'https://sns-video-bd.xhscdn.com/test.mp4' },
304
+ },
305
+ },
306
+ },
307
+ },
308
+ };
309
+ const result = runExtract({ initialState });
310
+ const images = result.media.filter((m) => m.type === 'image').map((m) => m.url);
311
+ const videos = result.media.filter((m) => m.type === 'video').map((m) => m.url);
312
+ expect(images).toEqual(['https://sns-img-bd.xhscdn.com/cover.jpg']);
313
+ expect(videos).toEqual(['https://sns-video-bd.xhscdn.com/test.mp4']);
314
+ expect(result.media.map((m) => m.type)).toEqual(['video', 'image']);
315
+ });
316
+ });
@@ -22,6 +22,14 @@ const PUBLISH_URL = 'https://creator.xiaohongshu.com/publish/publish?from=menu_l
22
22
  const MAX_IMAGES = 9;
23
23
  const MAX_TITLE_LEN = 20;
24
24
  const UPLOAD_SETTLE_MS = 3000;
25
+ /**
26
+ * XHS creator center wraps the publish/save button in an `<xhs-publish-btn>`
27
+ * web component backed by a CLOSED shadow root. Host-level `.click()` does
28
+ * not dispatch into the internal handler. Invoke these instance methods on
29
+ * the host element to trigger publish / save-draft directly (#1606).
30
+ */
31
+ const PUBLISH_METHOD_NAMES = ['_onPublish', 'onPublish', '_onSubmit', '_handlePublish'];
32
+ const DRAFT_METHOD_NAMES = ['_onSave', '_onSaveDraft', '_onDraft'];
25
33
  /** Selectors for the title field, ordered by priority across current UI variants. */
26
34
  const TITLE_SELECTORS = [
27
35
  // Some creator-center variants expose the title as contenteditable,
@@ -610,26 +618,58 @@ cli({
610
618
  }
611
619
  // ── Step 7: Publish or save draft ─────────────────────────────────────────
612
620
  const actionLabels = isDraft ? ['暂存离开', '存草稿'] : ['发布', '发布笔记'];
613
- const btnClicked = await page.evaluate(`
614
- (labels => {
621
+ const invokeResult = await page.evaluate(`
622
+ (cfg => {
623
+ const { isDraftMode, publishNames, draftNames, labels } = cfg;
624
+ const isVisible = (el) => {
625
+ if (!el || el.offsetParent === null) return false;
626
+ const rect = el.getBoundingClientRect();
627
+ return rect.width > 0 && rect.height > 0;
628
+ };
629
+ // Path 1: web component method invoke on <xhs-publish-btn>.
630
+ const hosts = Array.from(document.querySelectorAll('xhs-publish-btn')).filter(isVisible);
631
+ const wanted = isDraftMode ? draftNames : publishNames;
632
+ // Try every host + every candidate; do NOT bail on the first throw
633
+ // (multiple hosts can exist, and a later name may succeed).
634
+ let lastMethodError = null;
635
+ for (const host of hosts) {
636
+ for (const name of wanted) {
637
+ if (typeof host[name] !== 'function') continue;
638
+ try {
639
+ host[name]();
640
+ return { ok: true, via: 'method', name };
641
+ } catch (err) {
642
+ lastMethodError = String(err && err.message || err);
643
+ }
644
+ }
645
+ }
646
+ // Path 2: legacy <button>/[role=button] text-match click fallback.
615
647
  const buttons = document.querySelectorAll('button, [role="button"]');
616
648
  for (const btn of buttons) {
617
649
  const text = (btn.innerText || btn.textContent || '').trim();
618
650
  if (
619
651
  labels.some(l => text === l || text.includes(l)) &&
620
- btn.offsetParent !== null &&
652
+ isVisible(btn) &&
621
653
  !btn.disabled
622
654
  ) {
623
655
  btn.click();
624
- return true;
656
+ return { ok: true, via: 'click', text };
625
657
  }
626
658
  }
627
- return false;
628
- })(${JSON.stringify(actionLabels)})
659
+ return { ok: false, via: 'none', hosts: hosts.length, lastMethodError };
660
+ })(${JSON.stringify({
661
+ isDraftMode: isDraft,
662
+ publishNames: PUBLISH_METHOD_NAMES,
663
+ draftNames: DRAFT_METHOD_NAMES,
664
+ labels: actionLabels,
665
+ })})
629
666
  `);
630
- if (!btnClicked) {
667
+ if (!invokeResult?.ok) {
631
668
  await page.screenshot({ path: '/tmp/xhs_publish_submit_debug.png' });
632
- throw new Error(`Could not find "${actionLabels[0]}" button. ` +
669
+ const viaClause = invokeResult?.via ? ` (via=${invokeResult.via})` : '';
670
+ const errorClause = invokeResult?.error ? `, error=${invokeResult.error}` : '';
671
+ const lastMethodClause = invokeResult?.lastMethodError ? `, lastMethodError=${invokeResult.lastMethodError}` : '';
672
+ throw new Error(`Could not trigger "${actionLabels[0]}" action${viaClause}${errorClause}${lastMethodClause}. ` +
633
673
  'Debug screenshot: /tmp/xhs_publish_submit_debug.png');
634
674
  }
635
675
  // ── Step 8: Verify success ─────────────────────────────────────────────────
@@ -76,8 +76,10 @@ describe('xiaohongshu publish', () => {
76
76
  ? { ok: true, sel: '[contenteditable="true"][placeholder*="标题"]', kind: 'contenteditable', actual: '标题走原生输入' }
77
77
  : { ok: true, sel: '[contenteditable="true"][class*="content"]', kind: 'contenteditable', actual: '正文也走原生输入' };
78
78
  }
79
+ if (code.includes('xhs-publish-btn'))
80
+ return { ok: true, via: 'click', text: '发布' };
79
81
  if (code.includes('labels.some'))
80
- return true;
82
+ return false;
81
83
  if (code.includes('for (const el of document.querySelectorAll'))
82
84
  return '发布成功';
83
85
  throw new Error(`Unhandled evaluate call: ${code.slice(0, 120)}`);
@@ -128,8 +130,10 @@ describe('xiaohongshu publish', () => {
128
130
  return { ok: false, actual: '' };
129
131
  if (code.includes('(function(selectors, text)'))
130
132
  return { ok: true, sel: '[contenteditable="true"][placeholder*="标题"]', kind: 'contenteditable', actual: '' };
133
+ if (code.includes('xhs-publish-btn'))
134
+ return { ok: true, via: 'click', text: '发布' };
131
135
  if (code.includes('labels.some'))
132
- return true;
136
+ return false;
133
137
  if (code.includes('for (const el of document.querySelectorAll'))
134
138
  return '发布成功';
135
139
  throw new Error(`Unhandled evaluate call: ${code.slice(0, 120)}`);
@@ -177,8 +181,10 @@ describe('xiaohongshu publish', () => {
177
181
  ? { ok: true, actual: '原生失败后回退' }
178
182
  : { ok: true, actual: '正文也回退' };
179
183
  }
184
+ if (code.includes('xhs-publish-btn'))
185
+ return { ok: true, via: 'click', text: '发布' };
180
186
  if (code.includes('labels.some'))
181
- return true;
187
+ return false;
182
188
  if (code.includes('for (const el of document.querySelectorAll'))
183
189
  return '发布成功';
184
190
  throw new Error(`Unhandled evaluate call: ${code.slice(0, 120)}`);
@@ -227,8 +233,10 @@ describe('xiaohongshu publish', () => {
227
233
  return code.includes('input[maxlength')
228
234
  ? { ok: false, actual: '' }
229
235
  : { ok: true, actual: '正文' };
236
+ if (code.includes('xhs-publish-btn'))
237
+ return { ok: true, via: 'click', text: '发布' };
230
238
  if (code.includes('labels.some'))
231
- return true;
239
+ return false;
232
240
  if (code.includes('for (const el of document.querySelectorAll'))
233
241
  return '发布成功';
234
242
  throw new Error(`Unhandled evaluate call: ${code.slice(0, 120)}`);
@@ -259,7 +267,7 @@ describe('xiaohongshu publish', () => {
259
267
  { ok: true, actual: 'CDP上传优先' },
260
268
  { ok: true, sel: '[contenteditable="true"][class*="content"]', kind: 'contenteditable' },
261
269
  { ok: true, actual: '优先走 setFileInput 主路径' },
262
- true,
270
+ { ok: true, via: 'click', text: '发布' },
263
271
  'https://creator.xiaohongshu.com/publish/success',
264
272
  '发布成功',
265
273
  ], {
@@ -301,7 +309,7 @@ describe('xiaohongshu publish', () => {
301
309
  { ok: true, actual: 'CDP被拒后回退' },
302
310
  { ok: true, sel: '[contenteditable="true"][class*="content"]', kind: 'contenteditable' },
303
311
  { ok: true, actual: 'DataTransfer fallback path' },
304
- true,
312
+ { ok: true, via: 'click', text: '发布' },
305
313
  'https://creator.xiaohongshu.com/publish/success',
306
314
  '发布成功',
307
315
  ], {
@@ -366,7 +374,7 @@ describe('xiaohongshu publish', () => {
366
374
  { ok: true, actual: 'DeepSeek别乱问' },
367
375
  { ok: true, sel: '[contenteditable="true"][class*="content"]', kind: 'contenteditable' },
368
376
  { ok: true, actual: '一篇真实一点的小红书正文' },
369
- true,
377
+ { ok: true, via: 'click', text: '发布' },
370
378
  'https://creator.xiaohongshu.com/publish/success',
371
379
  '发布成功',
372
380
  ]);
@@ -390,6 +398,49 @@ describe('xiaohongshu publish', () => {
390
398
  },
391
399
  ]);
392
400
  });
401
+ it('uses the shadow-DOM method-invoke path when xhs-publish-btn handler succeeds', async () => {
402
+ // Mirrors the previous "selects the image-text tab and publishes successfully"
403
+ // mock sequence but returns `via: 'method', name: '_onPublish'` for the publish
404
+ // trigger evaluate, exercising the shadow-DOM web-component handler path
405
+ // (the primary #1606 fix). Without this case the fix's main path is uncovered.
406
+ const cmd = getRegistry().get('xiaohongshu/publish');
407
+ expect(cmd?.func).toBeTypeOf('function');
408
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-xhs-publish-'));
409
+ const imagePath = path.join(tempDir, 'demo.jpg');
410
+ fs.writeFileSync(imagePath, Buffer.from([0xff, 0xd8, 0xff, 0xd9]));
411
+ const page = createPageMock([
412
+ 'https://creator.xiaohongshu.com/publish/publish?from=menu_left',
413
+ { ok: true, target: '上传图文', text: '上传图文' },
414
+ { state: 'editor_ready', hasTitleInput: true, hasImageInput: true, hasVideoSurface: false },
415
+ { ok: true, count: 1 },
416
+ false,
417
+ true, // waitForEditForm: editor appeared
418
+ { ok: true, sel: 'input[maxlength="20"]', kind: 'input' },
419
+ { ok: true, actual: 'shadow-dom-test' },
420
+ { ok: true, sel: '[contenteditable="true"][class*="content"]', kind: 'contenteditable' },
421
+ { ok: true, actual: '走 method-invoke 路径' },
422
+ { ok: true, via: 'method', name: '_onPublish' }, // shadow-DOM handler success
423
+ 'https://creator.xiaohongshu.com/publish/success',
424
+ '发布成功',
425
+ ]);
426
+ const result = await cmd.func(page, {
427
+ title: 'shadow-dom-test',
428
+ content: '走 method-invoke 路径',
429
+ images: imagePath,
430
+ topics: '',
431
+ draft: false,
432
+ });
433
+ expect(result).toEqual([
434
+ {
435
+ status: '✅ 发布成功',
436
+ detail: '"shadow-dom-test" · 1张图片 · 发布成功',
437
+ },
438
+ ]);
439
+ // The publish-trigger evaluate must have been the shadow-DOM probe (contains
440
+ // 'xhs-publish-btn'), not the legacy `button.click()` fallback alone.
441
+ const evaluateCalls = page.evaluate.mock.calls.map((args) => String(args[0]));
442
+ expect(evaluateCalls.some((code) => code.includes('xhs-publish-btn'))).toBe(true);
443
+ });
393
444
  it('fails early with a clear error when still on the video page', async () => {
394
445
  const cmd = getRegistry().get('xiaohongshu/publish');
395
446
  expect(cmd?.func).toBeTypeOf('function');
@@ -431,7 +482,7 @@ describe('xiaohongshu publish', () => {
431
482
  { ok: true, actual: '延迟切换也能过' },
432
483
  { ok: true, sel: '[contenteditable="true"][class*="content"]', kind: 'contenteditable' },
433
484
  { ok: true, actual: '图文页切换慢一点也继续等' },
434
- true,
485
+ { ok: true, via: 'click', text: '发布' },
435
486
  'https://creator.xiaohongshu.com/publish/success',
436
487
  '发布成功',
437
488
  ]);
@@ -479,8 +530,10 @@ describe('xiaohongshu publish', () => {
479
530
  ? { ok: true, actual: '停留在发布页也算成功' }
480
531
  : { ok: true, actual: '草稿成功提示' };
481
532
  }
533
+ if (code.includes('xhs-publish-btn'))
534
+ return { ok: true, via: 'click', text: '发布' };
482
535
  if (code.includes('labels.some'))
483
- return true;
536
+ return false;
484
537
  if (code.includes('for (const el of document.querySelectorAll')) {
485
538
  return code.includes('保存成功') ? '保存成功' : '';
486
539
  }
@@ -529,8 +582,10 @@ describe('xiaohongshu publish', () => {
529
582
  ? { ok: true, actual: '发布提示不该复用草稿成功' }
530
583
  : { ok: true, actual: '发布成功提示' };
531
584
  }
585
+ if (code.includes('xhs-publish-btn'))
586
+ return { ok: true, via: 'click', text: '发布' };
532
587
  if (code.includes('labels.some'))
533
- return true;
588
+ return false;
534
589
  if (code.includes('for (const el of document.querySelectorAll')) {
535
590
  return code.includes('保存成功') ? '保存成功' : '';
536
591
  }
@@ -1,4 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest';
2
+ import { CommandExecutionError } from '@jackwener/opencli/errors';
3
+ import { assertReadableUserSnapshot } from './user.js';
2
4
  import { buildXhsNoteUrl, extractXhsUserNotes, flattenXhsNoteGroups, normalizeXhsUserId, } from './user-helpers.js';
3
5
  describe('normalizeXhsUserId', () => {
4
6
  it('extracts the profile id from a full Xiaohongshu URL', () => {
@@ -117,3 +119,42 @@ describe('extractXhsUserNotes', () => {
117
119
  expect(rows[0]?.url).toBe('https://www.rednote.com/user/profile/user-red/note-red?xsec_token=tok&xsec_source=pc_user');
118
120
  });
119
121
  });
122
+
123
+ describe('assertReadableUserSnapshot', () => {
124
+ it('accepts an explicit empty notes array from a readable user store', () => {
125
+ expect(() => assertReadableUserSnapshot({
126
+ storePresent: true,
127
+ notesPresent: true,
128
+ pageDataPresent: false,
129
+ noteGroups: [],
130
+ pageData: {},
131
+ })).not.toThrow();
132
+ });
133
+ it('fails typed when the user store is missing instead of treating parser drift as empty', () => {
134
+ expect(() => assertReadableUserSnapshot({
135
+ storePresent: false,
136
+ notesPresent: false,
137
+ pageDataPresent: false,
138
+ noteGroups: [],
139
+ pageData: {},
140
+ })).toThrow(CommandExecutionError);
141
+ });
142
+ it('fails typed when profile metadata exists but the notes array is missing', () => {
143
+ expect(() => assertReadableUserSnapshot({
144
+ storePresent: true,
145
+ notesPresent: false,
146
+ pageDataPresent: true,
147
+ noteGroups: [],
148
+ pageData: { user: { nickname: 'Alice' } },
149
+ })).toThrow(CommandExecutionError);
150
+ });
151
+ it('fails typed when notesPresent metadata and cloned noteGroups disagree', () => {
152
+ expect(() => assertReadableUserSnapshot({
153
+ storePresent: true,
154
+ notesPresent: true,
155
+ pageDataPresent: false,
156
+ noteGroups: null,
157
+ pageData: {},
158
+ })).toThrow(CommandExecutionError);
159
+ });
160
+ });
@@ -1,4 +1,5 @@
1
1
  import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
2
3
  import { extractXhsUserNotes, normalizeXhsUserId } from './user-helpers.js';
3
4
  /**
4
5
  * Host-agnostic IIFE that snapshots the user profile's Pinia store. Exported
@@ -14,16 +15,33 @@ export const USER_SNAPSHOT_JS = `
14
15
  }
15
16
  };
16
17
 
17
- const userStore = window.__INITIAL_STATE__?.user || {};
18
+ const userStore = window.__INITIAL_STATE__?.user;
19
+ const hasUserStore = Boolean(userStore && typeof userStore === 'object');
20
+ const rawNotes = hasUserStore ? (userStore.notes?._value || userStore.notes) : undefined;
21
+ const rawPageData = hasUserStore ? (userStore.userPageData?._value || userStore.userPageData) : undefined;
18
22
  return {
19
- noteGroups: safeClone(userStore.notes?._value || userStore.notes || []),
20
- pageData: safeClone(userStore.userPageData?._value || userStore.userPageData || {}),
23
+ noteGroups: safeClone(rawNotes || []),
24
+ pageData: safeClone(rawPageData || {}),
25
+ storePresent: hasUserStore,
26
+ notesPresent: Array.isArray(rawNotes),
27
+ pageDataPresent: Boolean(rawPageData && typeof rawPageData === 'object' && Object.keys(rawPageData).length > 0),
21
28
  };
22
29
  })()
23
30
  `;
24
31
  async function readUserSnapshot(page) {
25
32
  return await page.evaluate(USER_SNAPSHOT_JS);
26
33
  }
34
+ export function assertReadableUserSnapshot(snapshot) {
35
+ if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot)) {
36
+ throw new CommandExecutionError('Malformed Xiaohongshu user snapshot');
37
+ }
38
+ if (snapshot.storePresent !== true) {
39
+ throw new CommandExecutionError('Malformed Xiaohongshu user snapshot: user store was not found');
40
+ }
41
+ if (snapshot.notesPresent !== true || !Array.isArray(snapshot.noteGroups)) {
42
+ throw new CommandExecutionError('Malformed Xiaohongshu user snapshot: notes array was not found');
43
+ }
44
+ }
27
45
  export const command = cli({
28
46
  site: 'xiaohongshu',
29
47
  name: 'user',
@@ -43,12 +61,14 @@ export const command = cli({
43
61
  const limit = Math.max(1, Number(kwargs.limit ?? 15));
44
62
  await page.goto(`https://www.xiaohongshu.com/user/profile/${userId}`);
45
63
  let snapshot = await readUserSnapshot(page);
64
+ assertReadableUserSnapshot(snapshot);
46
65
  let results = extractXhsUserNotes(snapshot ?? {}, userId);
47
66
  let previousCount = results.length;
48
67
  for (let i = 0; results.length < limit && i < 4; i += 1) {
49
68
  await page.autoScroll({ times: 1, delayMs: 1500 });
50
69
  await page.wait(1);
51
70
  snapshot = await readUserSnapshot(page);
71
+ assertReadableUserSnapshot(snapshot);
52
72
  const nextResults = extractXhsUserNotes(snapshot ?? {}, userId);
53
73
  if (nextResults.length <= previousCount)
54
74
  break;
@@ -56,7 +76,10 @@ export const command = cli({
56
76
  previousCount = nextResults.length;
57
77
  }
58
78
  if (results.length === 0) {
59
- throw new Error('No public notes found for this Xiaohongshu user.');
79
+ // bilibili subtitle 同模式:作者无公开内容是合法 empty 数据条件
80
+ // (销号 / 私密号 / 全删笔记),不是 fetch 失败。下游应识别 code
81
+ // EMPTY_RESULT 跳过 rate-limit 启发式、不计入 softFail 阈值。
82
+ throw new EmptyResultError('xiaohongshu user', '该用户没有公开笔记(可能销号 / 私密 / 全部删除)。');
60
83
  }
61
84
  return results.slice(0, limit);
62
85
  },
@@ -45,7 +45,7 @@ cli({
45
45
  });
46
46
  return [{
47
47
  title,
48
- podcast: ep.podcast?.title || '-',
48
+ podcast: ep.podcast?.title || '',
49
49
  status: result.success ? 'success' : 'failed',
50
50
  size: result.success ? formatBytes(result.size) : (result.error || 'unknown error'),
51
51
  file: result.success ? destPath : '-',
@@ -67,7 +67,7 @@ cli({
67
67
  }
68
68
  return [{
69
69
  title: episode.title || 'episode',
70
- podcast: episode.podcast?.title || '-',
70
+ podcast: episode.podcast?.title || '',
71
71
  status: 'success',
72
72
  segments: kwargs.text === false ? '-' : String(segmentCount),
73
73
  json_file: kwargs.json === false ? '-' : jsonPath,