@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
@@ -9,6 +9,7 @@
9
9
  * Requires: logged into creator.xiaohongshu.com in Chrome.
10
10
  */
11
11
  import { cli, Strategy } from '@jackwener/opencli/registry';
12
+ import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
12
13
  const NOTE_DETAIL_DATETIME_RE = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/;
13
14
  const NOTE_DETAIL_METRICS = [
14
15
  { label: '曝光数', section: '基础数据' },
@@ -246,37 +247,170 @@ const DETAIL_API_ENDPOINTS = [
246
247
  { suffix: '/api/galaxy/creator/datacenter/note/base', key: 'noteBase' },
247
248
  { suffix: '/api/galaxy/creator/datacenter/note/analyze/audience/trend', key: 'audienceTrend' },
248
249
  { suffix: '/api/galaxy/creator/datacenter/note/audience/source/detail', key: 'audienceSourceDetail' },
249
- { suffix: '/api/galaxy/creator/datacenter/note/audience', key: 'audienceSource' },
250
+ { suffix: '/api/galaxy/creator/datacenter/note/audience/source', key: 'audienceSource' },
250
251
  ];
252
+ const CAPTURE_POLL_ATTEMPTS = 20;
253
+ const CAPTURE_POLL_INTERVAL_S = 0.5;
254
+ function detailApiEndpointForUrl(url) {
255
+ if (!url)
256
+ return null;
257
+ try {
258
+ const parsed = new URL(String(url), 'https://creator.xiaohongshu.com');
259
+ return DETAIL_API_ENDPOINTS.find((endpoint) => parsed.pathname === endpoint.suffix) ?? null;
260
+ }
261
+ catch {
262
+ return null;
263
+ }
264
+ }
265
+ function findCapturedUrl(captureMap, suffix) {
266
+ return Object.keys(captureMap).find((url) => detailApiEndpointForUrl(url)?.suffix === suffix);
267
+ }
268
+ function isPlainObject(value) {
269
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
270
+ }
271
+ function assertOptionalArray(payload, key, suffix) {
272
+ if (key in payload && !Array.isArray(payload[key])) {
273
+ throw new CommandExecutionError(`xiaohongshu creator-note-detail: signed API ${suffix} returned malformed ${key}`);
274
+ }
275
+ }
276
+ function assertOptionalPlainObject(payload, key, suffix) {
277
+ if (key in payload && !isPlainObject(payload[key])) {
278
+ throw new CommandExecutionError(`xiaohongshu creator-note-detail: signed API ${suffix} returned malformed ${key}`);
279
+ }
280
+ }
281
+ function validateCapturedPayload(payload, endpoint) {
282
+ const suffix = endpoint.suffix;
283
+ if (!isPlainObject(payload)) {
284
+ throw new CommandExecutionError(`xiaohongshu creator-note-detail: signed API ${suffix} returned a malformed payload`);
285
+ }
286
+ if (endpoint.key === 'noteBase') {
287
+ assertOptionalPlainObject(payload, 'hour', suffix);
288
+ assertOptionalPlainObject(payload, 'day', suffix);
289
+ }
290
+ if (endpoint.key === 'audienceSource') {
291
+ assertOptionalArray(payload, 'source', suffix);
292
+ }
293
+ if (endpoint.key === 'audienceSourceDetail') {
294
+ for (const key of ['gender', 'age', 'city', 'interest']) {
295
+ assertOptionalArray(payload, key, suffix);
296
+ }
297
+ }
298
+ return payload;
299
+ }
300
+ function parseCapturedJson(capture, endpoint) {
301
+ const suffix = endpoint.suffix;
302
+ if (!capture || typeof capture !== 'object') {
303
+ throw new CommandExecutionError(`xiaohongshu creator-note-detail: malformed capture for ${suffix}`);
304
+ }
305
+ if (capture.ok !== true) {
306
+ throw new CommandExecutionError(`xiaohongshu creator-note-detail: signed API ${suffix} returned HTTP ${capture.status ?? 'non-2xx'}`);
307
+ }
308
+ if (typeof capture.body !== 'string') {
309
+ throw new CommandExecutionError(`xiaohongshu creator-note-detail: signed API ${suffix} returned a non-text body`);
310
+ }
311
+ try {
312
+ const envelope = JSON.parse(capture.body);
313
+ const payload = isPlainObject(envelope) && Object.hasOwn(envelope, 'data') ? envelope.data : envelope;
314
+ return validateCapturedPayload(payload, endpoint);
315
+ }
316
+ catch {
317
+ throw new CommandExecutionError(`xiaohongshu creator-note-detail: signed API ${suffix} returned invalid JSON or payload shape`);
318
+ }
319
+ }
320
+ // Capture the dashboard's signed datacenter/note responses on window.__xhsCapture
321
+ // since a direct fetch() from page.evaluate bypasses the x-s signing and gets 406.
322
+ async function installXhsFetchCaptureHook(page) {
323
+ await page.evaluate(`(() => {
324
+ const targetPaths = ${JSON.stringify(DETAIL_API_ENDPOINTS.map((endpoint) => endpoint.suffix))};
325
+ const shouldCapture = (url) => {
326
+ try {
327
+ return targetPaths.includes(new URL(String(url), window.location.origin).pathname);
328
+ } catch (_) {
329
+ return false;
330
+ }
331
+ };
332
+ // Reset the buffer every call so stale captures from a previous run on
333
+ // the same tab cannot leak into the current navigation's harvest.
334
+ window.__xhsCapture = {};
335
+ if (window.__xhsCaptureInstalled) return;
336
+ window.__xhsCaptureInstalled = true;
337
+ const origFetch = window.fetch;
338
+ window.fetch = async function(...args) {
339
+ const resp = await origFetch.apply(this, args);
340
+ try {
341
+ const url = typeof args[0] === 'string' ? args[0] : (args[0] && args[0].url) || '';
342
+ if (shouldCapture(url)) {
343
+ resp.clone().text().then((body) => {
344
+ try { window.__xhsCapture[url] = { status: resp.status, ok: resp.ok, body }; } catch (_) {}
345
+ }).catch(() => {});
346
+ }
347
+ } catch (_) {}
348
+ return resp;
349
+ };
350
+ const OrigXHR = window.XMLHttpRequest;
351
+ function HookedXHR() {
352
+ const xhr = new OrigXHR();
353
+ const origOpen = xhr.open;
354
+ let capturedUrl = '';
355
+ xhr.open = function(method, url, ...rest) {
356
+ capturedUrl = url;
357
+ return origOpen.call(this, method, url, ...rest);
358
+ };
359
+ xhr.addEventListener('load', () => {
360
+ try {
361
+ if (shouldCapture(capturedUrl)) {
362
+ window.__xhsCapture[capturedUrl] = { status: xhr.status, ok: xhr.status >= 200 && xhr.status < 300, body: xhr.responseText };
363
+ }
364
+ } catch (_) {}
365
+ });
366
+ return xhr;
367
+ }
368
+ HookedXHR.prototype = OrigXHR.prototype;
369
+ // Preserve readyState constants (UNSENT / OPENED / HEADERS_RECEIVED / LOADING / DONE)
370
+ // since dashboard code may read XMLHttpRequest.DONE etc against the constructor.
371
+ for (const key of ['UNSENT', 'OPENED', 'HEADERS_RECEIVED', 'LOADING', 'DONE']) {
372
+ if (key in OrigXHR) HookedXHR[key] = OrigXHR[key];
373
+ }
374
+ window.XMLHttpRequest = HookedXHR;
375
+ })()`);
376
+ }
251
377
  async function captureNoteDetailPayload(page, noteId) {
252
- const payload = {};
253
- let captured = 0;
254
- // Try to fetch each API endpoint through the page context (uses the browser's cookies)
255
- for (const { suffix, key } of DETAIL_API_ENDPOINTS) {
256
- await page.wait({ time: 0.5 + Math.random() });
257
- const apiUrl = `${suffix}?note_id=${noteId}`;
378
+ await installXhsFetchCaptureHook(page);
379
+ // SPA-navigate inside the dashboard so the React router re-fires the
380
+ // signed datacenter/note/* requests under our hook. A second page.goto
381
+ // would wipe the hook before the first auto-fetch can land.
382
+ await page.evaluate(`(() => {
383
+ const target = '/statistics/note-detail?noteId=' + ${JSON.stringify(noteId)};
384
+ history.pushState({}, '', target);
385
+ window.dispatchEvent(new PopStateEvent('popstate'));
386
+ })()`);
387
+ const wantedSuffixes = DETAIL_API_ENDPOINTS.map((endpoint) => endpoint.suffix);
388
+ let captureMap = {};
389
+ for (let i = 0; i < CAPTURE_POLL_ATTEMPTS; i++) {
390
+ await page.wait(CAPTURE_POLL_INTERVAL_S);
391
+ let raw;
258
392
  try {
259
- const data = await page.evaluate(`
260
- async () => {
261
- try {
262
- const resp = await fetch(${JSON.stringify(apiUrl)}, { credentials: 'include' });
263
- if (!resp.ok) return null;
264
- const json = await resp.json();
265
- return JSON.stringify(json.data ?? {});
266
- } catch { return null; }
393
+ raw = await page.evaluate('JSON.stringify(window.__xhsCapture || {})');
394
+ captureMap = typeof raw === 'string' ? JSON.parse(raw) : {};
395
+ }
396
+ catch {
397
+ throw new CommandExecutionError('xiaohongshu creator-note-detail: failed to read signed datacenter/note capture buffer');
267
398
  }
268
- `);
269
- if (data && typeof data === 'string') {
270
- try {
271
- payload[key] = JSON.parse(data);
272
- captured++;
273
- }
274
- catch { }
275
- }
399
+ if (!captureMap || typeof captureMap !== 'object' || Array.isArray(captureMap)) {
400
+ throw new CommandExecutionError('xiaohongshu creator-note-detail: malformed signed datacenter/note capture buffer');
276
401
  }
277
- catch { }
402
+ const captured = wantedSuffixes.filter((suffix) => findCapturedUrl(captureMap, suffix));
403
+ if (captured.length === wantedSuffixes.length)
404
+ break;
405
+ }
406
+ const payload = {};
407
+ for (const endpoint of DETAIL_API_ENDPOINTS) {
408
+ const matchUrl = findCapturedUrl(captureMap, endpoint.suffix);
409
+ if (!matchUrl)
410
+ continue;
411
+ payload[endpoint.key] = parseCapturedJson(captureMap[matchUrl], endpoint);
278
412
  }
279
- return captured > 0 ? payload : null;
413
+ return Object.keys(payload).length > 0 ? payload : null;
280
414
  }
281
415
  async function captureNoteDetailDomData(page) {
282
416
  const result = await page.evaluate(`() => {
@@ -307,14 +441,18 @@ async function captureNoteDetailDomData(page) {
307
441
  return result;
308
442
  }
309
443
  export async function fetchCreatorNoteDetailRows(page, noteId) {
310
- await page.goto(`https://creator.xiaohongshu.com/statistics/note-detail?noteId=${encodeURIComponent(noteId)}`);
444
+ // Land on the dashboard root first so the React app boots before the
445
+ // note-specific signed APIs fire. captureNoteDetailPayload then installs
446
+ // the fetch+XHR hook and SPA-navigates to /statistics/note-detail under
447
+ // it, which is what surfaces the audience / trend rows.
448
+ await page.goto('https://creator.xiaohongshu.com/statistics');
449
+ const apiPayload = await captureNoteDetailPayload(page, noteId);
311
450
  const domData = await captureNoteDetailDomData(page).catch(() => null);
312
451
  let rows = parseCreatorNoteDetailDomData(domData, noteId);
313
452
  if (rows.length === 0) {
314
453
  const bodyText = await page.evaluate('() => document.body.innerText');
315
454
  rows = parseCreatorNoteDetailText(typeof bodyText === 'string' ? bodyText : '', noteId);
316
455
  }
317
- const apiPayload = await captureNoteDetailPayload(page, noteId).catch(() => null);
318
456
  appendTrendRows(rows, apiPayload ?? undefined);
319
457
  appendAudienceRows(rows, apiPayload ?? undefined);
320
458
  return rows;
@@ -337,7 +475,7 @@ cli({
337
475
  const rows = await fetchCreatorNoteDetailRows(page, noteId);
338
476
  const hasCoreMetric = rows.some((row) => row.section !== '笔记信息' && row.value);
339
477
  if (!hasCoreMetric) {
340
- throw new Error('No note detail data found. Check note_id and login status for creator.xiaohongshu.com.');
478
+ throw new EmptyResultError('xiaohongshu creator-note-detail', 'No note detail data found. Check note_id and login status for creator.xiaohongshu.com.');
341
479
  }
342
480
  return rows;
343
481
  },
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
+ import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
2
3
  import { getRegistry } from '@jackwener/opencli/registry';
3
4
  import { appendAudienceRows, appendTrendRows, parseCreatorNoteDetailDomData, parseCreatorNoteDetailText } from './creator-note-detail.js';
4
5
  import './creator-note-detail.js';
@@ -207,40 +208,44 @@ describe('xiaohongshu creator-note-detail', () => {
207
208
  it('navigates to the note detail page and returns parsed rows', async () => {
208
209
  const cmd = getRegistry().get('xiaohongshu/creator-note-detail');
209
210
  expect(cmd?.func).toBeTypeOf('function');
210
- const page = createPageMock([
211
- {
212
- title: '示例笔记',
213
- infoText: '示例笔记\n2026-03-19 12:00\n切换笔记',
214
- sections: [
215
- {
216
- title: '基础数据',
217
- metrics: [
218
- { label: '曝光数', value: '100', extra: '粉丝占比 10%' },
219
- { label: '观看数', value: '50', extra: '粉丝占比 20%' },
220
- { label: '封面点击率', value: '12%', extra: '粉丝 11%' },
221
- { label: '平均观看时长', value: '30秒', extra: '粉丝 31秒' },
222
- { label: '涨粉数', value: '2', extra: '' },
223
- ],
224
- },
225
- {
226
- title: '互动数据',
227
- metrics: [
228
- { label: '点赞数', value: '8', extra: '粉丝占比 25%' },
229
- { label: '评论数', value: '1', extra: '粉丝占比 0%' },
230
- { label: '收藏数', value: '3', extra: '粉丝占比 50%' },
231
- { label: '分享数', value: '0', extra: '粉丝占比 0%' },
232
- ],
233
- },
234
- ],
235
- },
236
- null,
237
- null,
238
- null,
239
- null,
240
- ]);
211
+ const domData = {
212
+ title: '示例笔记',
213
+ infoText: '示例笔记\n2026-03-19 12:00\n切换笔记',
214
+ sections: [
215
+ {
216
+ title: '基础数据',
217
+ metrics: [
218
+ { label: '曝光数', value: '100', extra: '粉丝占比 10%' },
219
+ { label: '观看数', value: '50', extra: '粉丝占比 20%' },
220
+ { label: '封面点击率', value: '12%', extra: '粉丝 11%' },
221
+ { label: '平均观看时长', value: '30秒', extra: '粉丝 31秒' },
222
+ { label: '涨粉数', value: '2', extra: '' },
223
+ ],
224
+ },
225
+ {
226
+ title: '互动数据',
227
+ metrics: [
228
+ { label: '点赞数', value: '8', extra: '粉丝占比 25%' },
229
+ { label: '评论数', value: '1', extra: '粉丝占比 0%' },
230
+ { label: '收藏数', value: '3', extra: '粉丝占比 50%' },
231
+ { label: '分享数', value: '0', extra: '粉丝占比 0%' },
232
+ ],
233
+ },
234
+ ],
235
+ };
236
+ const page = createPageMock(undefined);
237
+ page.evaluate = vi.fn(async (script) => {
238
+ const s = String(script);
239
+ if (s.includes('window.__xhsCapture =')) return undefined;
240
+ if (s.includes('history.pushState')) return undefined;
241
+ if (s.includes('JSON.stringify(window.__xhsCapture')) return JSON.stringify({});
242
+ if (s.includes("document.querySelector('.note-title')")) return domData;
243
+ if (s.includes('document.body.innerText')) return '';
244
+ return undefined;
245
+ });
241
246
  const result = await cmd.func(page, { 'note-id': 'demo-note-id' });
242
- expect(page.goto.mock.calls[0][0]).toBe('https://creator.xiaohongshu.com/statistics/note-detail?noteId=demo-note-id');
243
- expect(page.evaluate.mock.calls[0][0]).toContain("document.querySelector('.note-title')");
247
+ expect(page.goto.mock.calls[0][0]).toBe('https://creator.xiaohongshu.com/statistics');
248
+ expect(page.evaluate.mock.calls[0][0]).toContain('window.__xhsCapture =');
244
249
  expect(result).toEqual([
245
250
  { section: '笔记信息', metric: 'note_id', value: 'demo-note-id', extra: '' },
246
251
  { section: '笔记信息', metric: 'title', value: '示例笔记', extra: '' },
@@ -256,7 +261,7 @@ describe('xiaohongshu creator-note-detail', () => {
256
261
  { section: '互动数据', metric: '分享数', value: '0', extra: '粉丝占比 0%' },
257
262
  ]);
258
263
  });
259
- it('waits between creator detail API fetches to avoid burst traffic', async () => {
264
+ it('polls the capture buffer while the dashboard fires its signed datacenter/note/* requests', async () => {
260
265
  const cmd = getRegistry().get('xiaohongshu/creator-note-detail');
261
266
  const domData = {
262
267
  title: '示例笔记',
@@ -283,9 +288,164 @@ describe('xiaohongshu creator-note-detail', () => {
283
288
  },
284
289
  ],
285
290
  };
286
- const page = createPageMock([domData, null, null, null, null]);
291
+ const page = createPageMock(undefined);
292
+ page.evaluate = vi.fn(async (script) => {
293
+ const s = String(script);
294
+ if (s.includes('window.__xhsCapture =')) return undefined;
295
+ if (s.includes('history.pushState')) return undefined;
296
+ if (s.includes('JSON.stringify(window.__xhsCapture')) return JSON.stringify({});
297
+ if (s.includes("document.querySelector('.note-title')")) return domData;
298
+ return undefined;
299
+ });
287
300
  await cmd.func(page, { 'note-id': 'demo-note-id' });
288
- expect(page.wait).toHaveBeenCalledWith(expect.objectContaining({ time: expect.any(Number) }));
301
+ // Capture loop polls until the deadline expires (no hits with empty mock).
289
302
  expect(page.wait.mock.calls.length).toBeGreaterThanOrEqual(4);
303
+ const captureProbeCalls = page.evaluate.mock.calls.filter(([script]) => String(script).includes('JSON.stringify(window.__xhsCapture'));
304
+ expect(captureProbeCalls.length).toBeGreaterThanOrEqual(1);
305
+ });
306
+ it('matches signed API captures by exact pathname so source/detail cannot shadow source', async () => {
307
+ const cmd = getRegistry().get('xiaohongshu/creator-note-detail');
308
+ const domData = {
309
+ title: '示例笔记',
310
+ infoText: '示例笔记\n2026-03-19 12:00\n切换笔记',
311
+ sections: [
312
+ {
313
+ title: '基础数据',
314
+ metrics: [
315
+ { label: '曝光数', value: '100', extra: '粉丝占比 10%' },
316
+ ],
317
+ },
318
+ ],
319
+ };
320
+ const detailCapture = [
321
+ 'https://creator.xiaohongshu.com/api/galaxy/creator/datacenter/note/audience/source/detail?note_id=demo-note-id',
322
+ {
323
+ status: 200,
324
+ ok: true,
325
+ body: JSON.stringify({ data: { gender: [{ title: '女性', value: 64 }] } }),
326
+ },
327
+ ];
328
+ const sourceCapture = [
329
+ 'https://creator.xiaohongshu.com/api/galaxy/creator/datacenter/note/audience/source?note_id=demo-note-id',
330
+ {
331
+ status: 200,
332
+ ok: true,
333
+ body: JSON.stringify({
334
+ data: {
335
+ source: [
336
+ {
337
+ title: '首页推荐',
338
+ value_with_double: 88.8,
339
+ info: { imp_count: 1000, view_count: 400, interaction_count: 20 },
340
+ },
341
+ ],
342
+ },
343
+ }),
344
+ },
345
+ ];
346
+ const baseCapture = [
347
+ 'https://creator.xiaohongshu.com/api/galaxy/creator/datacenter/note/base?note_id=demo-note-id',
348
+ {
349
+ status: 200,
350
+ ok: true,
351
+ body: JSON.stringify({ data: { hour: { view_list: [{ date: new Date('2026-03-19T12:00:00+08:00').getTime(), count: 7 }] } } }),
352
+ },
353
+ ];
354
+ const trendCapture = [
355
+ 'https://creator.xiaohongshu.com/api/galaxy/creator/datacenter/note/analyze/audience/trend?note_id=demo-note-id',
356
+ {
357
+ status: 200,
358
+ ok: true,
359
+ body: JSON.stringify({ data: { no_data: false, no_data_tip_msg: '趋势可用' } }),
360
+ },
361
+ ];
362
+ for (const orderedCaptures of [
363
+ [detailCapture, sourceCapture, baseCapture, trendCapture],
364
+ [sourceCapture, detailCapture, baseCapture, trendCapture],
365
+ ]) {
366
+ const captureMap = Object.fromEntries(orderedCaptures);
367
+ const page = createPageMock(undefined);
368
+ page.evaluate = vi.fn(async (script) => {
369
+ const s = String(script);
370
+ if (s.includes('window.__xhsCapture =')) return undefined;
371
+ if (s.includes('history.pushState')) return undefined;
372
+ if (s.includes('JSON.stringify(window.__xhsCapture')) return JSON.stringify(captureMap);
373
+ if (s.includes("document.querySelector('.note-title')")) return domData;
374
+ return undefined;
375
+ });
376
+ const result = await cmd.func(page, { 'note-id': 'demo-note-id' });
377
+ expect(result).toEqual(expect.arrayContaining([
378
+ { section: '观看来源', metric: '首页推荐', value: '88.8%', extra: '曝光 1000 · 观看 400 · 互动 20' },
379
+ { section: '观众画像', metric: '性别/女性', value: '64%', extra: '' },
380
+ { section: '趋势数据', metric: '按小时/观看数', value: '1 points', extra: '03-19 12:00=7' },
381
+ ]));
382
+ }
383
+ });
384
+ it('throws a typed error when a captured signed API returns non-2xx', async () => {
385
+ const cmd = getRegistry().get('xiaohongshu/creator-note-detail');
386
+ const captureMap = {
387
+ 'https://creator.xiaohongshu.com/api/galaxy/creator/datacenter/note/base?note_id=demo-note-id': {
388
+ status: 406,
389
+ ok: false,
390
+ body: '{"msg":"not acceptable"}',
391
+ },
392
+ };
393
+ const page = createPageMock(undefined);
394
+ page.evaluate = vi.fn(async (script) => {
395
+ const s = String(script);
396
+ if (s.includes('window.__xhsCapture =')) return undefined;
397
+ if (s.includes('history.pushState')) return undefined;
398
+ if (s.includes('JSON.stringify(window.__xhsCapture')) return JSON.stringify(captureMap);
399
+ return null;
400
+ });
401
+ await expect(cmd.func(page, { 'note-id': 'demo-note-id' })).rejects.toBeInstanceOf(CommandExecutionError);
402
+ });
403
+ it('throws a typed error for wrong-shaped signed API payloads instead of falling back to DOM rows', async () => {
404
+ const cmd = getRegistry().get('xiaohongshu/creator-note-detail');
405
+ const domData = {
406
+ title: '示例笔记',
407
+ infoText: '示例笔记\n2026-03-19 12:00\n切换笔记',
408
+ sections: [
409
+ {
410
+ title: '基础数据',
411
+ metrics: [
412
+ { label: '曝光数', value: '100', extra: '粉丝占比 10%' },
413
+ ],
414
+ },
415
+ ],
416
+ };
417
+ for (const body of [
418
+ JSON.stringify({ data: null }),
419
+ JSON.stringify({ data: [] }),
420
+ JSON.stringify({ data: { source: {} } }),
421
+ ]) {
422
+ const captureMap = {
423
+ 'https://creator.xiaohongshu.com/api/galaxy/creator/datacenter/note/audience/source?note_id=demo-note-id': {
424
+ status: 200,
425
+ ok: true,
426
+ body,
427
+ },
428
+ };
429
+ const page = createPageMock(undefined);
430
+ page.evaluate = vi.fn(async (script) => {
431
+ const s = String(script);
432
+ if (s.includes('window.__xhsCapture =')) return undefined;
433
+ if (s.includes('history.pushState')) return undefined;
434
+ if (s.includes('JSON.stringify(window.__xhsCapture')) return JSON.stringify(captureMap);
435
+ if (s.includes("document.querySelector('.note-title')")) return domData;
436
+ return null;
437
+ });
438
+ await expect(cmd.func(page, { 'note-id': 'demo-note-id' })).rejects.toBeInstanceOf(CommandExecutionError);
439
+ }
440
+ });
441
+ it('throws EmptyResultError when the detail page exposes no metrics', async () => {
442
+ const cmd = getRegistry().get('xiaohongshu/creator-note-detail');
443
+ const page = createPageMock(undefined);
444
+ page.evaluate = vi.fn()
445
+ .mockResolvedValueOnce(null)
446
+ .mockResolvedValueOnce('笔记数据详情\n暂无数据')
447
+ .mockResolvedValue(null);
448
+
449
+ await expect(cmd.func(page, { 'note-id': 'demo-note-id' })).rejects.toBeInstanceOf(EmptyResultError);
290
450
  });
291
451
  });
@@ -5,6 +5,7 @@
5
5
  * returns one summary row per note, suitable for quick review or downstream JSON use.
6
6
  */
7
7
  import { cli, Strategy } from '@jackwener/opencli/registry';
8
+ import { EmptyResultError } from '@jackwener/opencli/errors';
8
9
  import { fetchCreatorNotes } from './creator-notes.js';
9
10
  import { fetchCreatorNoteDetailRows } from './creator-note-detail.js';
10
11
  function findDetailValue(rows, metric) {
@@ -61,7 +62,7 @@ cli({
61
62
  const limit = kwargs.limit || 3;
62
63
  const notes = await fetchCreatorNotes(page, limit);
63
64
  if (!notes.length) {
64
- throw new Error('No notes found. Are you logged into creator.xiaohongshu.com?');
65
+ throw new EmptyResultError('xiaohongshu creator-notes-summary', 'No notes found. Ensure you are logged into creator.xiaohongshu.com and the account has published notes.');
65
66
  }
66
67
  const results = [];
67
68
  for (const [index, note] of notes.entries()) {
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
+ import { EmptyResultError } from '@jackwener/opencli/errors';
2
3
  import { summarizeCreatorNote } from './creator-notes-summary.js';
3
4
  import { getRegistry } from '@jackwener/opencli/registry';
4
5
  import * as creatorNotesModule from './creator-notes.js';
@@ -84,4 +85,10 @@ describe('xiaohongshu creator-notes-summary', () => {
84
85
  expect(page.wait).toHaveBeenCalledWith(expect.objectContaining({ time: expect.any(Number) }));
85
86
  expect(page.wait.mock.calls).toHaveLength(1);
86
87
  });
88
+ it('throws EmptyResultError when there are no notes to summarize', async () => {
89
+ const cmd = getRegistry().get('xiaohongshu/creator-notes-summary');
90
+ vi.spyOn(creatorNotesModule, 'fetchCreatorNotes').mockResolvedValue([]);
91
+
92
+ await expect(cmd.func({ wait: vi.fn() }, { limit: 2 })).rejects.toBeInstanceOf(EmptyResultError);
93
+ });
87
94
  });