@jackwener/opencli 1.5.6 → 1.5.7

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 (334) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +4 -2
  3. package/README.zh-CN.md +4 -1
  4. package/SKILL.md +879 -0
  5. package/dist/browser/cdp.d.ts +1 -0
  6. package/dist/browser/cdp.js +30 -27
  7. package/dist/browser/daemon-client.d.ts +7 -1
  8. package/dist/browser/daemon-client.js +3 -0
  9. package/dist/browser/dom-helpers.js +1 -0
  10. package/dist/browser/dom-helpers.test.js +14 -1
  11. package/dist/browser/mcp.js +18 -13
  12. package/dist/browser/page.js +22 -2
  13. package/dist/browser/page.test.d.ts +1 -0
  14. package/dist/browser/page.test.js +44 -0
  15. package/dist/browser/stealth.js +198 -0
  16. package/dist/browser/stealth.test.d.ts +1 -0
  17. package/dist/browser/stealth.test.js +134 -0
  18. package/dist/browser.test.js +1 -1
  19. package/dist/build-manifest.d.ts +1 -0
  20. package/dist/build-manifest.js +5 -1
  21. package/dist/build-manifest.test.js +2 -0
  22. package/dist/cli-manifest.json +544 -137
  23. package/dist/cli.js +20 -3
  24. package/dist/clis/antigravity/serve.d.ts +1 -1
  25. package/dist/clis/antigravity/serve.js +5 -8
  26. package/dist/clis/bilibili/subtitle.js +4 -0
  27. package/dist/clis/bilibili/subtitle.test.d.ts +1 -0
  28. package/dist/clis/bilibili/subtitle.test.js +48 -0
  29. package/dist/clis/chatwise/ask.js +0 -2
  30. package/dist/clis/chatwise/export.js +0 -2
  31. package/dist/clis/chatwise/history.js +0 -2
  32. package/dist/clis/chatwise/model.js +0 -2
  33. package/dist/clis/chatwise/new.js +1 -2
  34. package/dist/clis/chatwise/read.js +0 -2
  35. package/dist/clis/chatwise/screenshot.js +1 -2
  36. package/dist/clis/chatwise/send.js +0 -2
  37. package/dist/clis/chatwise/status.js +1 -2
  38. package/dist/clis/ctrip/search.d.ts +13 -0
  39. package/dist/clis/ctrip/search.js +73 -48
  40. package/dist/clis/ctrip/search.test.d.ts +1 -0
  41. package/dist/clis/ctrip/search.test.js +64 -0
  42. package/dist/clis/douyin/_shared/sts2.js +8 -2
  43. package/dist/clis/douyin/_shared/sts2.test.d.ts +1 -0
  44. package/dist/clis/douyin/_shared/sts2.test.js +27 -0
  45. package/dist/clis/douyin/activities.js +4 -2
  46. package/dist/clis/douyin/activities.test.js +34 -1
  47. package/dist/clis/douyin/collections.js +1 -1
  48. package/dist/clis/douyin/collections.test.js +24 -2
  49. package/dist/clis/douyin/draft.d.ts +8 -11
  50. package/dist/clis/douyin/draft.js +302 -185
  51. package/dist/clis/douyin/draft.test.d.ts +1 -1
  52. package/dist/clis/douyin/draft.test.js +357 -2
  53. package/dist/clis/douyin/hashtag.js +9 -2
  54. package/dist/clis/douyin/hashtag.test.js +35 -2
  55. package/dist/clis/douyin/profile.js +1 -1
  56. package/dist/clis/douyin/profile.test.js +36 -1
  57. package/dist/clis/douyin/videos.js +22 -5
  58. package/dist/clis/douyin/videos.test.js +45 -2
  59. package/dist/clis/facebook/search.test.d.ts +5 -0
  60. package/dist/clis/facebook/search.test.js +60 -0
  61. package/dist/clis/facebook/search.yaml +4 -3
  62. package/dist/clis/instagram/download.d.ts +16 -0
  63. package/dist/clis/instagram/download.js +225 -0
  64. package/dist/clis/instagram/download.test.d.ts +1 -0
  65. package/dist/clis/instagram/download.test.js +118 -0
  66. package/dist/clis/notebooklm/bind-current.d.ts +1 -0
  67. package/dist/clis/notebooklm/bind-current.js +29 -0
  68. package/dist/clis/notebooklm/bind-current.test.d.ts +1 -0
  69. package/dist/clis/notebooklm/bind-current.test.js +35 -0
  70. package/dist/clis/notebooklm/binding.test.d.ts +1 -0
  71. package/dist/clis/notebooklm/binding.test.js +44 -0
  72. package/dist/clis/notebooklm/compat.test.d.ts +3 -0
  73. package/dist/clis/notebooklm/compat.test.js +16 -0
  74. package/dist/clis/notebooklm/current.d.ts +1 -0
  75. package/dist/clis/notebooklm/current.js +28 -0
  76. package/dist/clis/notebooklm/get.d.ts +1 -0
  77. package/dist/clis/notebooklm/get.js +37 -0
  78. package/dist/clis/notebooklm/history.d.ts +1 -0
  79. package/dist/clis/notebooklm/history.js +25 -0
  80. package/dist/clis/notebooklm/history.test.d.ts +1 -0
  81. package/dist/clis/notebooklm/history.test.js +58 -0
  82. package/dist/clis/notebooklm/list.d.ts +1 -0
  83. package/dist/clis/notebooklm/list.js +35 -0
  84. package/dist/clis/notebooklm/note-list.d.ts +1 -0
  85. package/dist/clis/notebooklm/note-list.js +28 -0
  86. package/dist/clis/notebooklm/note-list.test.d.ts +1 -0
  87. package/dist/clis/notebooklm/note-list.test.js +56 -0
  88. package/dist/clis/notebooklm/notes-get.d.ts +1 -0
  89. package/dist/clis/notebooklm/notes-get.js +47 -0
  90. package/dist/clis/notebooklm/notes-get.test.d.ts +1 -0
  91. package/dist/clis/notebooklm/notes-get.test.js +72 -0
  92. package/dist/clis/notebooklm/rpc.d.ts +36 -0
  93. package/dist/clis/notebooklm/rpc.js +189 -0
  94. package/dist/clis/notebooklm/rpc.test.d.ts +1 -0
  95. package/dist/clis/notebooklm/rpc.test.js +105 -0
  96. package/dist/clis/notebooklm/shared.d.ts +87 -0
  97. package/dist/clis/notebooklm/shared.js +3 -0
  98. package/dist/clis/notebooklm/source-fulltext.d.ts +1 -0
  99. package/dist/clis/notebooklm/source-fulltext.js +44 -0
  100. package/dist/clis/notebooklm/source-fulltext.test.d.ts +1 -0
  101. package/dist/clis/notebooklm/source-fulltext.test.js +106 -0
  102. package/dist/clis/notebooklm/source-get.d.ts +1 -0
  103. package/dist/clis/notebooklm/source-get.js +40 -0
  104. package/dist/clis/notebooklm/source-get.test.d.ts +1 -0
  105. package/dist/clis/notebooklm/source-get.test.js +84 -0
  106. package/dist/clis/notebooklm/source-guide.d.ts +1 -0
  107. package/dist/clis/notebooklm/source-guide.js +44 -0
  108. package/dist/clis/notebooklm/source-guide.test.d.ts +1 -0
  109. package/dist/clis/notebooklm/source-guide.test.js +104 -0
  110. package/dist/clis/notebooklm/source-list.d.ts +1 -0
  111. package/dist/clis/notebooklm/source-list.js +30 -0
  112. package/dist/clis/notebooklm/status.d.ts +1 -0
  113. package/dist/clis/notebooklm/status.js +31 -0
  114. package/dist/clis/notebooklm/summary.d.ts +1 -0
  115. package/dist/clis/notebooklm/summary.js +30 -0
  116. package/dist/clis/notebooklm/summary.test.d.ts +1 -0
  117. package/dist/clis/notebooklm/summary.test.js +78 -0
  118. package/dist/clis/notebooklm/utils.d.ts +37 -0
  119. package/dist/clis/notebooklm/utils.js +739 -0
  120. package/dist/clis/notebooklm/utils.test.d.ts +1 -0
  121. package/dist/clis/notebooklm/utils.test.js +390 -0
  122. package/dist/clis/substack/utils.d.ts +4 -0
  123. package/dist/clis/substack/utils.js +8 -2
  124. package/dist/clis/substack/utils.test.d.ts +1 -0
  125. package/dist/clis/substack/utils.test.js +46 -0
  126. package/dist/clis/v2ex/hot.yaml +4 -1
  127. package/dist/clis/v2ex/latest.yaml +4 -1
  128. package/dist/clis/v2ex/topic.yaml +6 -1
  129. package/dist/clis/weixin/download.d.ts +9 -0
  130. package/dist/clis/weixin/download.js +76 -6
  131. package/dist/clis/weread/book.js +108 -2
  132. package/dist/clis/weread/commands.test.js +262 -152
  133. package/dist/clis/weread/utils.d.ts +10 -0
  134. package/dist/clis/weread/utils.js +27 -7
  135. package/dist/clis/xiaohongshu/comments.d.ts +3 -0
  136. package/dist/clis/xiaohongshu/comments.js +76 -17
  137. package/dist/clis/xiaohongshu/comments.test.js +70 -9
  138. package/dist/clis/xiaohongshu/download.d.ts +4 -1
  139. package/dist/clis/xiaohongshu/download.js +83 -22
  140. package/dist/clis/xiaohongshu/download.test.d.ts +1 -0
  141. package/dist/clis/xiaohongshu/download.test.js +75 -0
  142. package/dist/clis/xiaohongshu/note-helpers.d.ts +12 -0
  143. package/dist/clis/xiaohongshu/note-helpers.js +23 -0
  144. package/dist/clis/xiaohongshu/note.d.ts +7 -0
  145. package/dist/clis/xiaohongshu/note.js +76 -0
  146. package/dist/clis/xiaohongshu/note.test.d.ts +1 -0
  147. package/dist/clis/xiaohongshu/note.test.js +136 -0
  148. package/dist/clis/xiaohongshu/search.js +9 -0
  149. package/dist/clis/xiaohongshu/search.test.js +10 -4
  150. package/dist/clis/youtube/search.js +57 -17
  151. package/dist/clis/zhihu/question.js +19 -17
  152. package/dist/clis/zhihu/question.test.d.ts +1 -0
  153. package/dist/clis/zhihu/question.test.js +54 -0
  154. package/dist/commanderAdapter.js +9 -0
  155. package/dist/commanderAdapter.test.js +25 -0
  156. package/dist/commands/daemon.d.ts +9 -0
  157. package/dist/commands/daemon.js +124 -0
  158. package/dist/commands/daemon.test.d.ts +1 -0
  159. package/dist/commands/daemon.test.js +185 -0
  160. package/dist/completion.js +3 -1
  161. package/dist/constants.d.ts +2 -0
  162. package/dist/constants.js +2 -0
  163. package/dist/daemon.d.ts +1 -1
  164. package/dist/daemon.js +25 -14
  165. package/dist/daemon.test.d.ts +1 -0
  166. package/dist/daemon.test.js +65 -0
  167. package/dist/discovery.d.ts +9 -0
  168. package/dist/discovery.js +47 -2
  169. package/dist/electron-apps.d.ts +29 -0
  170. package/dist/electron-apps.js +65 -0
  171. package/dist/electron-apps.test.d.ts +1 -0
  172. package/dist/electron-apps.test.js +43 -0
  173. package/dist/engine.test.js +41 -9
  174. package/dist/execution.js +20 -16
  175. package/dist/idle-manager.d.ts +19 -0
  176. package/dist/idle-manager.js +54 -0
  177. package/dist/launcher.d.ts +36 -0
  178. package/dist/launcher.js +152 -0
  179. package/dist/launcher.test.d.ts +1 -0
  180. package/dist/launcher.test.js +57 -0
  181. package/dist/main.js +3 -3
  182. package/dist/registry.d.ts +1 -0
  183. package/dist/registry.js +31 -3
  184. package/dist/registry.test.js +13 -0
  185. package/dist/runtime.d.ts +5 -3
  186. package/dist/runtime.js +12 -5
  187. package/dist/serialization.d.ts +1 -0
  188. package/dist/serialization.js +3 -0
  189. package/dist/serialization.test.js +17 -1
  190. package/dist/tui.d.ts +7 -0
  191. package/dist/tui.js +52 -0
  192. package/dist/tui.test.d.ts +1 -0
  193. package/dist/tui.test.js +19 -0
  194. package/dist/weixin-download.test.js +14 -0
  195. package/docs/.vitepress/config.mts +1 -0
  196. package/docs/adapters/browser/notebooklm.md +69 -0
  197. package/docs/adapters/browser/xiaohongshu.md +19 -10
  198. package/docs/adapters/index.md +67 -66
  199. package/docs/guide/browser-bridge.md +12 -0
  200. package/docs/guide/troubleshooting.md +9 -4
  201. package/docs/superpowers/plans/2026-03-31-daemon-lifecycle-redesign.md +857 -0
  202. package/docs/superpowers/specs/2026-03-31-daemon-lifecycle-redesign.md +208 -0
  203. package/docs/zh/guide/browser-bridge.md +12 -0
  204. package/extension/dist/background.js +794 -513
  205. package/extension/src/background.test.ts +202 -2
  206. package/extension/src/background.ts +174 -10
  207. package/extension/src/cdp.ts +12 -0
  208. package/extension/src/protocol.ts +7 -5
  209. package/package.json +1 -1
  210. package/src/browser/cdp.ts +24 -17
  211. package/src/browser/daemon-client.ts +7 -1
  212. package/src/browser/dom-helpers.test.ts +15 -1
  213. package/src/browser/dom-helpers.ts +1 -0
  214. package/src/browser/mcp.ts +18 -13
  215. package/src/browser/page.test.ts +58 -0
  216. package/src/browser/page.ts +18 -2
  217. package/src/browser/stealth.test.ts +153 -0
  218. package/src/browser/stealth.ts +198 -0
  219. package/src/browser.test.ts +1 -1
  220. package/src/build-manifest.test.ts +2 -0
  221. package/src/build-manifest.ts +6 -1
  222. package/src/cli.ts +21 -3
  223. package/src/clis/antigravity/SKILL.md +3 -12
  224. package/src/clis/antigravity/serve.ts +5 -10
  225. package/src/clis/bilibili/subtitle.test.ts +60 -0
  226. package/src/clis/bilibili/subtitle.ts +4 -0
  227. package/src/clis/chatwise/ask.ts +0 -2
  228. package/src/clis/chatwise/export.ts +0 -2
  229. package/src/clis/chatwise/history.ts +0 -2
  230. package/src/clis/chatwise/model.ts +0 -2
  231. package/src/clis/chatwise/new.ts +1 -2
  232. package/src/clis/chatwise/read.ts +0 -2
  233. package/src/clis/chatwise/screenshot.ts +1 -2
  234. package/src/clis/chatwise/send.ts +0 -2
  235. package/src/clis/chatwise/status.ts +1 -2
  236. package/src/clis/ctrip/search.test.ts +73 -0
  237. package/src/clis/ctrip/search.ts +97 -47
  238. package/src/clis/douyin/_shared/sts2.test.ts +31 -0
  239. package/src/clis/douyin/_shared/sts2.ts +11 -3
  240. package/src/clis/douyin/activities.test.ts +41 -1
  241. package/src/clis/douyin/activities.ts +12 -3
  242. package/src/clis/douyin/collections.test.ts +35 -2
  243. package/src/clis/douyin/collections.ts +1 -1
  244. package/src/clis/douyin/draft.test.ts +444 -2
  245. package/src/clis/douyin/draft.ts +382 -218
  246. package/src/clis/douyin/hashtag.test.ts +42 -2
  247. package/src/clis/douyin/hashtag.ts +11 -3
  248. package/src/clis/douyin/profile.test.ts +43 -1
  249. package/src/clis/douyin/profile.ts +9 -2
  250. package/src/clis/douyin/videos.test.ts +52 -2
  251. package/src/clis/douyin/videos.ts +49 -15
  252. package/src/clis/facebook/search.test.ts +70 -0
  253. package/src/clis/facebook/search.yaml +4 -3
  254. package/src/clis/instagram/download.test.ts +159 -0
  255. package/src/clis/instagram/download.ts +286 -0
  256. package/src/clis/notebooklm/bind-current.test.ts +43 -0
  257. package/src/clis/notebooklm/bind-current.ts +36 -0
  258. package/src/clis/notebooklm/binding.test.ts +53 -0
  259. package/src/clis/notebooklm/compat.test.ts +19 -0
  260. package/src/clis/notebooklm/current.ts +38 -0
  261. package/src/clis/notebooklm/get.ts +53 -0
  262. package/src/clis/notebooklm/history.test.ts +70 -0
  263. package/src/clis/notebooklm/history.ts +36 -0
  264. package/src/clis/notebooklm/list.ts +40 -0
  265. package/src/clis/notebooklm/note-list.test.ts +64 -0
  266. package/src/clis/notebooklm/note-list.ts +42 -0
  267. package/src/clis/notebooklm/notes-get.test.ts +88 -0
  268. package/src/clis/notebooklm/notes-get.ts +67 -0
  269. package/src/clis/notebooklm/rpc.test.ts +126 -0
  270. package/src/clis/notebooklm/rpc.ts +286 -0
  271. package/src/clis/notebooklm/shared.ts +98 -0
  272. package/src/clis/notebooklm/source-fulltext.test.ts +123 -0
  273. package/src/clis/notebooklm/source-fulltext.ts +69 -0
  274. package/src/clis/notebooklm/source-get.test.ts +100 -0
  275. package/src/clis/notebooklm/source-get.ts +60 -0
  276. package/src/clis/notebooklm/source-guide.test.ts +121 -0
  277. package/src/clis/notebooklm/source-guide.ts +69 -0
  278. package/src/clis/notebooklm/source-list.ts +45 -0
  279. package/src/clis/notebooklm/status.ts +34 -0
  280. package/src/clis/notebooklm/summary.test.ts +94 -0
  281. package/src/clis/notebooklm/summary.ts +45 -0
  282. package/src/clis/notebooklm/utils.test.ts +446 -0
  283. package/src/clis/notebooklm/utils.ts +893 -0
  284. package/src/clis/substack/utils.test.ts +54 -0
  285. package/src/clis/substack/utils.ts +10 -2
  286. package/src/clis/v2ex/hot.yaml +4 -1
  287. package/src/clis/v2ex/latest.yaml +4 -1
  288. package/src/clis/v2ex/topic.yaml +6 -1
  289. package/src/clis/weixin/download.ts +95 -6
  290. package/src/clis/weread/book.ts +142 -2
  291. package/src/clis/weread/commands.test.ts +314 -154
  292. package/src/clis/weread/utils.ts +33 -4
  293. package/src/clis/xiaohongshu/comments.test.ts +85 -9
  294. package/src/clis/xiaohongshu/comments.ts +76 -17
  295. package/src/clis/xiaohongshu/download.test.ts +96 -0
  296. package/src/clis/xiaohongshu/download.ts +83 -22
  297. package/src/clis/xiaohongshu/note-helpers.ts +25 -0
  298. package/src/clis/xiaohongshu/note.test.ts +164 -0
  299. package/src/clis/xiaohongshu/note.ts +86 -0
  300. package/src/clis/xiaohongshu/search.test.ts +11 -4
  301. package/src/clis/xiaohongshu/search.ts +13 -0
  302. package/src/clis/youtube/search.ts +57 -17
  303. package/src/clis/zhihu/question.test.ts +71 -0
  304. package/src/clis/zhihu/question.ts +27 -15
  305. package/src/commanderAdapter.test.ts +30 -0
  306. package/src/commanderAdapter.ts +7 -0
  307. package/src/commands/daemon.test.ts +238 -0
  308. package/src/commands/daemon.ts +135 -0
  309. package/src/completion.ts +2 -1
  310. package/src/constants.ts +3 -0
  311. package/src/daemon.test.ts +88 -0
  312. package/src/daemon.ts +26 -14
  313. package/src/discovery.ts +52 -2
  314. package/src/electron-apps.test.ts +50 -0
  315. package/src/electron-apps.ts +89 -0
  316. package/src/engine.test.ts +45 -9
  317. package/src/execution.ts +24 -19
  318. package/src/idle-manager.ts +60 -0
  319. package/src/launcher.test.ts +67 -0
  320. package/src/launcher.ts +185 -0
  321. package/src/main.ts +3 -2
  322. package/src/registry.test.ts +15 -0
  323. package/src/registry.ts +32 -3
  324. package/src/runtime.ts +13 -7
  325. package/src/serialization.test.ts +19 -1
  326. package/src/serialization.ts +2 -0
  327. package/src/tui.test.ts +23 -0
  328. package/src/tui.ts +65 -0
  329. package/src/weixin-download.test.ts +27 -0
  330. package/tests/e2e/browser-public-extended.test.ts +6 -2
  331. package/chatwise-opencli.ps1 +0 -82
  332. package/dist/clis/chatwise/shared.d.ts +0 -2
  333. package/dist/clis/chatwise/shared.js +0 -6
  334. package/src/clis/chatwise/shared.ts +0 -8
@@ -37,25 +37,23 @@ describe('xiaohongshu comments', () => {
37
37
  const page = createPageMock({
38
38
  loginWall: false,
39
39
  results: [
40
- { author: 'Alice', text: 'Great note!', likes: 10, time: '2024-01-01' },
41
- { author: 'Bob', text: 'Very helpful', likes: 0, time: '2024-01-02' },
40
+ { author: 'Alice', text: 'Great note!', likes: 10, time: '2024-01-01', is_reply: false, reply_to: '' },
41
+ { author: 'Bob', text: 'Very helpful', likes: 0, time: '2024-01-02', is_reply: false, reply_to: '' },
42
42
  ],
43
43
  });
44
44
 
45
45
  const result = (await command!.func!(page, { 'note-id': '69aadbcb000000002202f131', limit: 5 })) as any[];
46
46
 
47
47
  expect((page.goto as any).mock.calls[0][0]).toContain('/explore/69aadbcb000000002202f131');
48
- expect(result).toEqual([
49
- { rank: 1, author: 'Alice', text: 'Great note!', likes: 10, time: '2024-01-01' },
50
- { rank: 2, author: 'Bob', text: 'Very helpful', likes: 0, time: '2024-01-02' },
51
- ]);
52
- expect(result[0]).not.toHaveProperty('loginWall');
48
+ expect(result).toHaveLength(2);
49
+ expect(result[0]).toMatchObject({ rank: 1, author: 'Alice', text: 'Great note!', likes: 10 });
50
+ expect(result[1]).toMatchObject({ rank: 2, author: 'Bob', text: 'Very helpful', likes: 0 });
53
51
  });
54
52
 
55
53
  it('strips /explore/ prefix from full URL input', async () => {
56
54
  const page = createPageMock({
57
55
  loginWall: false,
58
- results: [{ author: 'Alice', text: 'Nice', likes: 1, time: '2024-01-01' }],
56
+ results: [{ author: 'Alice', text: 'Nice', likes: 1, time: '2024-01-01', is_reply: false, reply_to: '' }],
59
57
  });
60
58
 
61
59
  await command!.func!(page, {
@@ -66,6 +64,20 @@ describe('xiaohongshu comments', () => {
66
64
  expect((page.goto as any).mock.calls[0][0]).toContain('/explore/69aadbcb000000002202f131');
67
65
  });
68
66
 
67
+ it('preserves full search_result URL with xsec_token for navigation', async () => {
68
+ const page = createPageMock({
69
+ loginWall: false,
70
+ results: [{ author: 'Alice', text: 'Nice', likes: 1, time: '2024-01-01', is_reply: false, reply_to: '' }],
71
+ });
72
+
73
+ const fullUrl =
74
+ 'https://www.xiaohongshu.com/search_result/69aadbcb000000002202f131?xsec_token=abc&xsec_source=pc_search';
75
+
76
+ await command!.func!(page, { 'note-id': fullUrl, limit: 5 });
77
+
78
+ expect((page.goto as any).mock.calls[0][0]).toBe(fullUrl);
79
+ });
80
+
69
81
  it('throws AuthRequiredError when login wall is detected', async () => {
70
82
  const page = createPageMock({ loginWall: true, results: [] });
71
83
 
@@ -80,12 +92,14 @@ describe('xiaohongshu comments', () => {
80
92
  await expect(command!.func!(page, { 'note-id': 'abc123', limit: 5 })).resolves.toEqual([]);
81
93
  });
82
94
 
83
- it('respects the limit', async () => {
95
+ it('respects the limit for top-level comments', async () => {
84
96
  const manyComments = Array.from({ length: 10 }, (_, i) => ({
85
97
  author: `User${i}`,
86
98
  text: `Comment ${i}`,
87
99
  likes: i,
88
100
  time: '2024-01-01',
101
+ is_reply: false,
102
+ reply_to: '',
89
103
  }));
90
104
  const page = createPageMock({ loginWall: false, results: manyComments });
91
105
 
@@ -94,4 +108,66 @@ describe('xiaohongshu comments', () => {
94
108
  expect(result[0].rank).toBe(1);
95
109
  expect(result[2].rank).toBe(3);
96
110
  });
111
+
112
+ it('clamps invalid negative limits to a safe minimum', async () => {
113
+ const page = createPageMock({
114
+ loginWall: false,
115
+ results: [
116
+ { author: 'Alice', text: 'Great note!', likes: 10, time: '2024-01-01', is_reply: false, reply_to: '' },
117
+ { author: 'Bob', text: 'Very helpful', likes: 0, time: '2024-01-02', is_reply: false, reply_to: '' },
118
+ ],
119
+ });
120
+
121
+ const result = (await command!.func!(page, { 'note-id': 'abc123', limit: -3 })) as any[];
122
+
123
+ expect(result).toHaveLength(1);
124
+ expect(result[0]).toMatchObject({ rank: 1, author: 'Alice' });
125
+ });
126
+
127
+ describe('--with-replies', () => {
128
+ it('includes reply rows with is_reply=true and reply_to set', async () => {
129
+ const page = createPageMock({
130
+ loginWall: false,
131
+ results: [
132
+ { author: 'Alice', text: 'Main comment', likes: 10, time: '03-25', is_reply: false, reply_to: '' },
133
+ { author: 'Bob', text: 'Reply to Alice', likes: 3, time: '03-25', is_reply: true, reply_to: 'Alice' },
134
+ { author: 'Carol', text: 'Another top', likes: 5, time: '03-26', is_reply: false, reply_to: '' },
135
+ ],
136
+ });
137
+
138
+ const result = (await command!.func!(page, {
139
+ 'note-id': 'abc123', limit: 50, 'with-replies': true,
140
+ })) as any[];
141
+
142
+ expect(result).toHaveLength(3);
143
+ expect(result[0]).toMatchObject({ author: 'Alice', is_reply: false, reply_to: '' });
144
+ expect(result[1]).toMatchObject({ author: 'Bob', is_reply: true, reply_to: 'Alice' });
145
+ expect(result[2]).toMatchObject({ author: 'Carol', is_reply: false, reply_to: '' });
146
+
147
+ const script = (page.evaluate as any).mock.calls[0][0];
148
+ expect(script).toContain('共\\d+条回复');
149
+ expect(script).toContain('el.click()');
150
+ });
151
+
152
+ it('limits by top-level count, keeping attached replies', async () => {
153
+ const page = createPageMock({
154
+ loginWall: false,
155
+ results: [
156
+ { author: 'A', text: 'Top 1', likes: 0, time: '', is_reply: false, reply_to: '' },
157
+ { author: 'A1', text: 'Reply 1', likes: 0, time: '', is_reply: true, reply_to: 'A' },
158
+ { author: 'A2', text: 'Reply 2', likes: 0, time: '', is_reply: true, reply_to: 'A' },
159
+ { author: 'B', text: 'Top 2', likes: 0, time: '', is_reply: false, reply_to: '' },
160
+ { author: 'C', text: 'Top 3', likes: 0, time: '', is_reply: false, reply_to: '' },
161
+ ],
162
+ });
163
+
164
+ // Limit to 2 top-level comments — should include A + 2 replies + B = 4 rows
165
+ const result = (await command!.func!(page, {
166
+ 'note-id': 'abc123', limit: 2, 'with-replies': true,
167
+ })) as any[];
168
+
169
+ expect(result).toHaveLength(4);
170
+ expect(result.map((r: any) => r.author)).toEqual(['A', 'A1', 'A2', 'B']);
171
+ });
172
+ });
97
173
  });
@@ -1,36 +1,46 @@
1
1
  /**
2
2
  * Xiaohongshu comments — DOM extraction from note detail page.
3
3
  * XHS API requires signed requests, so we scrape the rendered DOM instead.
4
+ *
5
+ * Supports both top-level comments and nested replies (楼中楼) via
6
+ * the --with-replies flag.
4
7
  */
5
8
 
6
9
  import { cli, Strategy } from '../../registry.js';
7
10
  import { AuthRequiredError, EmptyResultError } from '../../errors.js';
11
+ import { parseNoteId, buildNoteUrl } from './note-helpers.js';
12
+
13
+ function parseCommentLimit(raw: unknown, fallback = 20): number {
14
+ const n = Number(raw);
15
+ if (!Number.isFinite(n)) return fallback;
16
+ return Math.max(1, Math.min(Math.floor(n), 50));
17
+ }
8
18
 
9
19
  cli({
10
20
  site: 'xiaohongshu',
11
21
  name: 'comments',
12
- description: '获取小红书笔记评论(仅主评论,不含楼中楼)',
22
+ description: '获取小红书笔记评论(支持楼中楼子回复)',
13
23
  domain: 'www.xiaohongshu.com',
14
24
  strategy: Strategy.COOKIE,
15
25
  args: [
16
- { name: 'note-id', required: true, positional: true, help: 'Note ID or full /explore/<id> URL' },
17
- { name: 'limit', type: 'int', default: 20, help: 'Number of comments (max 50)' },
26
+ { name: 'note-id', required: true, positional: true, help: 'Note ID or full URL (preserves xsec_token for access)' },
27
+ { name: 'limit', type: 'int', default: 20, help: 'Number of top-level comments (max 50)' },
28
+ { name: 'with-replies', type: 'boolean', default: false, help: 'Include nested replies (楼中楼)' },
18
29
  ],
19
- columns: ['rank', 'author', 'text', 'likes', 'time'],
30
+ columns: ['rank', 'author', 'text', 'likes', 'time', 'is_reply', 'reply_to'],
20
31
  func: async (page, kwargs) => {
21
- const limit = Math.min(Number(kwargs.limit) || 20, 50);
22
- let noteId = String(kwargs['note-id']).trim();
23
-
24
- // Accept full URLs: /explore/<id> or /note/<id>
25
- const urlMatch = noteId.match(/\/explore\/([a-f0-9]+)/) || noteId.match(/\/note\/([a-f0-9]+)/);
26
- if (urlMatch) noteId = urlMatch[1];
32
+ const limit = parseCommentLimit(kwargs.limit);
33
+ const withReplies = Boolean(kwargs['with-replies']);
34
+ const raw = String(kwargs['note-id']);
35
+ const noteId = parseNoteId(raw);
27
36
 
28
- await page.goto(`https://www.xiaohongshu.com/explore/${noteId}`);
37
+ await page.goto(buildNoteUrl(raw));
29
38
  await page.wait(3);
30
39
 
31
40
  const data = await page.evaluate(`
32
41
  (async () => {
33
42
  const wait = (ms) => new Promise(r => setTimeout(r, ms))
43
+ const withReplies = ${withReplies}
34
44
 
35
45
  // Check login state
36
46
  const loginWall = /登录后查看|请登录/.test(document.body.innerText || '')
@@ -45,6 +55,31 @@ cli({
45
55
  }
46
56
 
47
57
  const clean = (el) => (el?.textContent || '').replace(/\\s+/g, ' ').trim()
58
+ const parseLikes = (el) => {
59
+ const raw = clean(el)
60
+ return /^\\d+$/.test(raw) ? Number(raw) : 0
61
+ }
62
+ const expandReplyThreads = async (root) => {
63
+ if (!withReplies || !root) return
64
+ const clickedTexts = new Set()
65
+ for (let round = 0; round < 3; round++) {
66
+ const expanders = Array.from(root.querySelectorAll('button, [role="button"], span, div')).filter(el => {
67
+ if (!(el instanceof HTMLElement)) return false
68
+ const text = clean(el)
69
+ if (!text || text.length > 24) return false
70
+ if (!/(展开|更多回复|全部回复|查看.*回复|共\\d+条回复)/.test(text)) return false
71
+ if (clickedTexts.has(text)) return false
72
+ return true
73
+ })
74
+ if (!expanders.length) break
75
+ for (const el of expanders) {
76
+ const text = clean(el)
77
+ el.click()
78
+ clickedTexts.add(text)
79
+ await wait(300)
80
+ }
81
+ }
82
+ }
48
83
 
49
84
  const results = []
50
85
  const parents = document.querySelectorAll('.parent-comment')
@@ -54,13 +89,24 @@ cli({
54
89
 
55
90
  const author = clean(item.querySelector('.author-wrapper .name, .user-name'))
56
91
  const text = clean(item.querySelector('.content, .note-text'))
57
- // XHS shows text "赞" when likes = 0; only shows a number when > 0
58
- const likesRaw = clean(item.querySelector('.count'))
59
- const likes = /^\\d+$/.test(likesRaw) ? Number(likesRaw) : 0
92
+ const likes = parseLikes(item.querySelector('.count'))
60
93
  const time = clean(item.querySelector('.date, .time'))
61
94
 
62
95
  if (!text) continue
63
- results.push({ author, text, likes, time })
96
+ results.push({ author, text, likes, time, is_reply: false, reply_to: '' })
97
+
98
+ // Extract nested replies (楼中楼)
99
+ if (withReplies) {
100
+ await expandReplyThreads(p)
101
+ p.querySelectorAll('.reply-container .comment-item-sub, .sub-comment-list .comment-item').forEach(sub => {
102
+ const sAuthor = clean(sub.querySelector('.name, .user-name'))
103
+ const sText = clean(sub.querySelector('.content, .note-text'))
104
+ const sLikes = parseLikes(sub.querySelector('.count'))
105
+ const sTime = clean(sub.querySelector('.date, .time'))
106
+ if (!sText) return
107
+ results.push({ author: sAuthor, text: sText, likes: sLikes, time: sTime, is_reply: true, reply_to: author })
108
+ })
109
+ }
64
110
  }
65
111
 
66
112
  return { loginWall, results }
@@ -75,7 +121,20 @@ cli({
75
121
  throw new AuthRequiredError('www.xiaohongshu.com', 'Note comments require login');
76
122
  }
77
123
 
78
- const results: any[] = (data as any).results ?? [];
79
- return results.slice(0, limit).map((c: any, i: number) => ({ rank: i + 1, ...c }));
124
+ const all: any[] = (data as any).results ?? [];
125
+
126
+ // When limiting, count only top-level comments; their replies are included for free
127
+ if (withReplies) {
128
+ const limited: any[] = [];
129
+ let topCount = 0;
130
+ for (const c of all) {
131
+ if (!c.is_reply) topCount++;
132
+ if (topCount > limit) break;
133
+ limited.push(c);
134
+ }
135
+ return limited.map((c: any, i: number) => ({ rank: i + 1, ...c }));
136
+ }
137
+
138
+ return all.slice(0, limit).map((c: any, i: number) => ({ rank: i + 1, ...c }));
80
139
  },
81
140
  });
@@ -0,0 +1,96 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import type { IPage } from '../../types.js';
3
+
4
+ const { mockDownloadMedia, mockFormatCookieHeader } = vi.hoisted(() => ({
5
+ mockDownloadMedia: vi.fn(),
6
+ mockFormatCookieHeader: vi.fn(() => 'a=b'),
7
+ }));
8
+
9
+ vi.mock('../../download/media-download.js', () => ({
10
+ downloadMedia: mockDownloadMedia,
11
+ }));
12
+
13
+ vi.mock('../../download/index.js', () => ({
14
+ formatCookieHeader: mockFormatCookieHeader,
15
+ }));
16
+
17
+ import { getRegistry } from '../../registry.js';
18
+ import './download.js';
19
+
20
+ function createPageMock(evaluateResult: any): IPage {
21
+ return {
22
+ goto: vi.fn().mockResolvedValue(undefined),
23
+ evaluate: vi.fn().mockResolvedValue(evaluateResult),
24
+ snapshot: vi.fn().mockResolvedValue(undefined),
25
+ click: vi.fn().mockResolvedValue(undefined),
26
+ typeText: vi.fn().mockResolvedValue(undefined),
27
+ pressKey: vi.fn().mockResolvedValue(undefined),
28
+ scrollTo: vi.fn().mockResolvedValue(undefined),
29
+ getFormState: vi.fn().mockResolvedValue({ forms: [], orphanFields: [] }),
30
+ wait: vi.fn().mockResolvedValue(undefined),
31
+ tabs: vi.fn().mockResolvedValue([]),
32
+ closeTab: vi.fn().mockResolvedValue(undefined),
33
+ newTab: vi.fn().mockResolvedValue(undefined),
34
+ selectTab: vi.fn().mockResolvedValue(undefined),
35
+ networkRequests: vi.fn().mockResolvedValue([]),
36
+ consoleMessages: vi.fn().mockResolvedValue([]),
37
+ scroll: vi.fn().mockResolvedValue(undefined),
38
+ autoScroll: vi.fn().mockResolvedValue(undefined),
39
+ installInterceptor: vi.fn().mockResolvedValue(undefined),
40
+ getInterceptedRequests: vi.fn().mockResolvedValue([]),
41
+ getCookies: vi.fn().mockResolvedValue([{ name: 'sid', value: 'secret', domain: '.xiaohongshu.com' }]),
42
+ screenshot: vi.fn().mockResolvedValue(''),
43
+ waitForCapture: vi.fn().mockResolvedValue(undefined),
44
+ };
45
+ }
46
+
47
+ describe('xiaohongshu download', () => {
48
+ const command = getRegistry().get('xiaohongshu/download');
49
+
50
+ beforeEach(() => {
51
+ mockDownloadMedia.mockReset();
52
+ mockFormatCookieHeader.mockClear();
53
+ mockDownloadMedia.mockResolvedValue([{ index: 1, type: 'video', status: 'success', size: '1 MB' }]);
54
+ });
55
+
56
+ it('preserves short links for navigation but uses canonical note id for output naming', async () => {
57
+ const page = createPageMock({
58
+ noteId: '69bc166f000000001a02069a',
59
+ media: [{ type: 'video', url: 'https://sns-video-hw.xhscdn.com/example.mp4' }],
60
+ });
61
+
62
+ const shortUrl = 'http://xhslink.com/o/4MKEjsZnhCz';
63
+ await command!.func!(page, { 'note-id': shortUrl, output: './out' });
64
+
65
+ expect((page.goto as any).mock.calls[0][0]).toBe(shortUrl);
66
+ expect(mockDownloadMedia).toHaveBeenCalledWith(
67
+ [{ type: 'video', url: 'https://sns-video-hw.xhscdn.com/example.mp4' }],
68
+ expect.objectContaining({
69
+ output: './out',
70
+ subdir: '69bc166f000000001a02069a',
71
+ filenamePrefix: '69bc166f000000001a02069a',
72
+ cookies: 'a=b',
73
+ }),
74
+ );
75
+ });
76
+
77
+ it('preserves full note URL with xsec_token for navigation', async () => {
78
+ const page = createPageMock({
79
+ noteId: '69bc166f000000001a02069a',
80
+ media: [{ type: 'image', url: 'https://ci.xiaohongshu.com/example.jpg' }],
81
+ });
82
+
83
+ const fullUrl =
84
+ 'https://www.xiaohongshu.com/explore/69bc166f000000001a02069a?xsec_token=abc&xsec_source=pc_search';
85
+ await command!.func!(page, { 'note-id': fullUrl, output: './out' });
86
+
87
+ expect((page.goto as any).mock.calls[0][0]).toBe(fullUrl);
88
+ expect(mockDownloadMedia).toHaveBeenCalledWith(
89
+ [{ type: 'image', url: 'https://ci.xiaohongshu.com/example.jpg' }],
90
+ expect.objectContaining({
91
+ subdir: '69bc166f000000001a02069a',
92
+ filenamePrefix: '69bc166f000000001a02069a',
93
+ }),
94
+ );
95
+ });
96
+ });
@@ -2,12 +2,16 @@
2
2
  * Xiaohongshu download — download images and videos from a note.
3
3
  *
4
4
  * Usage:
5
- * opencli xiaohongshu download --note_id abc123 --output ./xhs
5
+ * opencli xiaohongshu download <note-id-or-url> --output ./xhs
6
+ *
7
+ * Accepts a bare note ID, a full xiaohongshu.com URL (with xsec_token),
8
+ * or a short link (http://xhslink.com/...).
6
9
  */
7
10
 
8
11
  import { cli, Strategy } from '../../registry.js';
9
12
  import { formatCookieHeader } from '../../download/index.js';
10
13
  import { downloadMedia } from '../../download/media-download.js';
14
+ import { buildNoteUrl, parseNoteId } from './note-helpers.js';
11
15
 
12
16
  cli({
13
17
  site: 'xiaohongshu',
@@ -16,16 +20,16 @@ cli({
16
20
  domain: 'www.xiaohongshu.com',
17
21
  strategy: Strategy.COOKIE,
18
22
  args: [
19
- { name: 'note-id', positional: true, required: true, help: 'Note ID (from URL)' },
23
+ { name: 'note-id', positional: true, required: true, help: 'Note ID, full URL, or short link' },
20
24
  { name: 'output', default: './xiaohongshu-downloads', help: 'Output directory' },
21
25
  ],
22
26
  columns: ['index', 'type', 'status', 'size'],
23
27
  func: async (page, kwargs) => {
24
- const noteId = kwargs['note-id'];
28
+ const rawInput = String(kwargs['note-id']);
25
29
  const output = kwargs.output;
30
+ const noteId = parseNoteId(rawInput);
26
31
 
27
- // Navigate to note page
28
- await page.goto(`https://www.xiaohongshu.com/explore/${noteId}`);
32
+ await page.goto(buildNoteUrl(rawInput));
29
33
 
30
34
  // Extract note info and media URLs
31
35
  const data = await page.evaluate(`
@@ -36,6 +40,18 @@ cli({
36
40
  author: '',
37
41
  media: []
38
42
  };
43
+ const seenMedia = new Set();
44
+ const pushMedia = (type, url) => {
45
+ if (!url) return;
46
+ const key = type + ':' + url;
47
+ if (seenMedia.has(key)) return;
48
+ seenMedia.add(key);
49
+ result.media.push({ type, url });
50
+ };
51
+ const locationMatch = (location.pathname || '').match(/\\/(?:explore|note|search_result|discovery\\/item)\\/([a-f0-9]+)/i);
52
+ if (locationMatch) {
53
+ result.noteId = locationMatch[1];
54
+ }
39
55
 
40
56
  // Get title
41
57
  const titleEl = document.querySelector('.title, #detail-title, .note-content .title');
@@ -61,7 +77,6 @@ cli({
61
77
  document.querySelectorAll(selector).forEach(img => {
62
78
  let src = img.src || img.getAttribute('data-src') || '';
63
79
  if (src && (src.includes('xhscdn') || src.includes('xiaohongshu'))) {
64
- // Convert to high quality URL (remove resize parameters)
65
80
  src = src.split('?')[0];
66
81
  src = src.replace(/\\/imageView\\d+\\/\\d+\\/w\\/\\d+/, '');
67
82
  imageUrls.add(src);
@@ -69,26 +84,69 @@ cli({
69
84
  });
70
85
  }
71
86
 
72
- // Get video if exists
73
- const videoSelectors = [
74
- 'video source',
75
- 'video[src]',
76
- '.player video',
77
- '.video-player video'
78
- ];
87
+ // Get video prefer real URL from page state over blob: URLs
79
88
 
80
- for (const selector of videoSelectors) {
81
- document.querySelectorAll(selector).forEach(v => {
82
- const src = v.src || v.getAttribute('src') || '';
83
- if (src) {
84
- result.media.push({ type: 'video', url: src });
89
+ // Method 1: Extract from __INITIAL_STATE__ (SSR hydration data)
90
+ try {
91
+ const state = window.__INITIAL_STATE__;
92
+ if (state) {
93
+ const noteData = state.note?.noteDetailMap || state.note?.note || {};
94
+ for (const key of Object.keys(noteData)) {
95
+ const note = noteData[key]?.note || noteData[key];
96
+ const video = note?.video;
97
+ if (video) {
98
+ const vUrl = video.url || video.originVideoKey || video.consumer?.originVideoKey;
99
+ if (vUrl) {
100
+ const fullUrl = vUrl.startsWith('http') ? vUrl : 'https://sns-video-bd.xhscdn.com/' + vUrl;
101
+ pushMedia('video', fullUrl);
102
+ }
103
+ const streams = video.media?.stream?.h264 || [];
104
+ for (const stream of streams) {
105
+ if (stream.masterUrl) pushMedia('video', stream.masterUrl);
106
+ }
107
+ }
85
108
  }
86
- });
109
+ }
110
+ } catch(e) {}
111
+
112
+ // Method 2: Extract video URLs from inline script JSON
113
+ if (result.media.filter(m => m.type === 'video').length === 0) {
114
+ try {
115
+ const scripts = document.querySelectorAll('script');
116
+ for (const s of scripts) {
117
+ const text = s.textContent || '';
118
+ const videoMatches = text.match(/https?:\\/\\/sns-video[^"'\\s]+\\.mp4[^"'\\s]*/g)
119
+ || text.match(/https?:\\/\\/[^"'\\s]*xhscdn[^"'\\s]*\\.mp4[^"'\\s]*/g);
120
+ if (videoMatches) {
121
+ videoMatches.forEach(url => {
122
+ pushMedia('video', url.replace(/\\\\u002F/g, '/'));
123
+ });
124
+ }
125
+ }
126
+ } catch(e) {}
127
+ }
128
+
129
+ // Method 3: Fallback to DOM video elements, skip blob: URLs
130
+ if (result.media.filter(m => m.type === 'video').length === 0) {
131
+ const videoSelectors = [
132
+ 'video source',
133
+ 'video[src]',
134
+ '.player video',
135
+ '.video-player video'
136
+ ];
137
+ for (const selector of videoSelectors) {
138
+ document.querySelectorAll(selector).forEach(v => {
139
+ const src = v.src || v.getAttribute('src') || '';
140
+ if (src && !src.startsWith('blob:')) {
141
+ pushMedia('video', src);
142
+ }
143
+ });
144
+ }
87
145
  }
88
146
 
89
147
  // Add images to media
90
148
  imageUrls.forEach(url => {
91
- result.media.push({ type: 'image', url: url });
149
+ pushMedia('image', url);
92
150
  });
93
151
 
94
152
  return result;
@@ -101,12 +159,15 @@ cli({
101
159
 
102
160
  // Extract cookies for authenticated downloads
103
161
  const cookies = formatCookieHeader(await page.getCookies({ domain: 'xiaohongshu.com' }));
162
+ const resolvedNoteId = typeof data.noteId === 'string' && data.noteId.trim()
163
+ ? data.noteId.trim()
164
+ : noteId;
104
165
 
105
166
  return downloadMedia(data.media, {
106
167
  output,
107
- subdir: noteId,
168
+ subdir: resolvedNoteId,
108
169
  cookies,
109
- filenamePrefix: noteId,
170
+ filenamePrefix: resolvedNoteId,
110
171
  timeout: 60000,
111
172
  });
112
173
  },
@@ -0,0 +1,25 @@
1
+ /** Side-effect-free helpers shared by xiaohongshu note and comments commands. */
2
+
3
+ /** Extract a bare note ID from a full URL or raw ID string. */
4
+ export function parseNoteId(input: string): string {
5
+ const trimmed = input.trim();
6
+ const match = trimmed.match(/\/(?:explore|note|search_result)\/([a-f0-9]+)/);
7
+ return match ? match[1] : trimmed;
8
+ }
9
+
10
+ /**
11
+ * Build the best navigation URL for a note.
12
+ *
13
+ * XHS blocks direct `/explore/<id>` access without a valid `xsec_token`.
14
+ * When the user passes a full URL (from search results), we preserve it
15
+ * so the browser navigates with the token intact. For bare IDs we fall
16
+ * back to the `/explore/<id>` path (works when cookies carry enough context).
17
+ */
18
+ export function buildNoteUrl(input: string): string {
19
+ const trimmed = input.trim();
20
+ if (/^https?:\/\//.test(trimmed)) {
21
+ // Full URL — navigate as-is; the browser will follow any redirects
22
+ return trimmed;
23
+ }
24
+ return `https://www.xiaohongshu.com/explore/${trimmed}`;
25
+ }