@jackwener/opencli 1.4.1 → 1.5.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 (369) hide show
  1. package/.github/workflows/build-extension.yml +2 -6
  2. package/.github/workflows/ci.yml +21 -1
  3. package/README.md +35 -6
  4. package/README.zh-CN.md +12 -5
  5. package/SKILL.md +2 -0
  6. package/dist/browser/cdp.d.ts +2 -1
  7. package/dist/browser/cdp.js +5 -0
  8. package/dist/browser/discover.d.ts +4 -1
  9. package/dist/browser/discover.js +6 -2
  10. package/dist/browser/errors.d.ts +2 -2
  11. package/dist/browser/errors.js +4 -12
  12. package/dist/browser/mcp.d.ts +2 -1
  13. package/dist/browser/page.d.ts +3 -0
  14. package/dist/browser/page.js +24 -1
  15. package/dist/build-manifest.d.ts +2 -0
  16. package/dist/build-manifest.js +39 -14
  17. package/dist/build-manifest.test.js +21 -0
  18. package/dist/capabilityRouting.d.ts +2 -0
  19. package/dist/capabilityRouting.js +2 -1
  20. package/dist/cli-manifest.json +1567 -108
  21. package/dist/cli.js +68 -6
  22. package/dist/clis/36kr/article.d.ts +1 -0
  23. package/dist/clis/36kr/article.js +62 -0
  24. package/dist/clis/36kr/hot.d.ts +3 -0
  25. package/dist/clis/36kr/hot.js +80 -0
  26. package/dist/clis/36kr/hot.test.d.ts +1 -0
  27. package/dist/clis/36kr/hot.test.js +15 -0
  28. package/dist/clis/36kr/news.d.ts +1 -0
  29. package/dist/clis/36kr/news.js +51 -0
  30. package/dist/clis/36kr/news.test.d.ts +1 -0
  31. package/dist/clis/36kr/news.test.js +85 -0
  32. package/dist/clis/36kr/search.d.ts +1 -0
  33. package/dist/clis/36kr/search.js +72 -0
  34. package/dist/clis/bilibili/comments.d.ts +5 -0
  35. package/dist/clis/bilibili/comments.js +40 -0
  36. package/dist/clis/bilibili/comments.test.d.ts +1 -0
  37. package/dist/clis/bilibili/comments.test.js +82 -0
  38. package/dist/clis/bluesky/feeds.yaml +29 -0
  39. package/dist/clis/bluesky/followers.yaml +33 -0
  40. package/dist/clis/bluesky/following.yaml +33 -0
  41. package/dist/clis/bluesky/profile.yaml +27 -0
  42. package/dist/clis/bluesky/search.yaml +34 -0
  43. package/dist/clis/bluesky/starter-packs.yaml +34 -0
  44. package/dist/clis/bluesky/thread.yaml +32 -0
  45. package/dist/clis/bluesky/trending.yaml +27 -0
  46. package/dist/clis/bluesky/user.yaml +34 -0
  47. package/dist/clis/chatgpt/ask.js +29 -14
  48. package/dist/clis/chatgpt/ax.d.ts +6 -0
  49. package/dist/clis/chatgpt/ax.js +172 -1
  50. package/dist/clis/chatgpt/model.d.ts +1 -0
  51. package/dist/clis/chatgpt/model.js +24 -0
  52. package/dist/clis/chatgpt/send.js +12 -3
  53. package/dist/clis/douban/download.d.ts +1 -0
  54. package/dist/clis/douban/download.js +67 -0
  55. package/dist/clis/douban/download.test.d.ts +1 -0
  56. package/dist/clis/douban/download.test.js +170 -0
  57. package/dist/clis/douban/photos.d.ts +1 -0
  58. package/dist/clis/douban/photos.js +34 -0
  59. package/dist/clis/douban/utils.d.ts +25 -0
  60. package/dist/clis/douban/utils.js +190 -1
  61. package/dist/clis/douban/utils.test.d.ts +1 -0
  62. package/dist/clis/douban/utils.test.js +64 -0
  63. package/dist/clis/imdb/person.d.ts +1 -0
  64. package/dist/clis/imdb/person.js +203 -0
  65. package/dist/clis/imdb/reviews.d.ts +1 -0
  66. package/dist/clis/imdb/reviews.js +88 -0
  67. package/dist/clis/imdb/search.d.ts +1 -0
  68. package/dist/clis/imdb/search.js +161 -0
  69. package/dist/clis/imdb/title.d.ts +1 -0
  70. package/dist/clis/imdb/title.js +93 -0
  71. package/dist/clis/imdb/top.d.ts +1 -0
  72. package/dist/clis/imdb/top.js +53 -0
  73. package/dist/clis/imdb/trending.d.ts +1 -0
  74. package/dist/clis/imdb/trending.js +52 -0
  75. package/dist/clis/imdb/utils.d.ts +46 -0
  76. package/dist/clis/imdb/utils.js +285 -0
  77. package/dist/clis/imdb/utils.test.d.ts +1 -0
  78. package/dist/clis/imdb/utils.test.js +88 -0
  79. package/dist/clis/jd/item.d.ts +4 -0
  80. package/dist/clis/jd/item.js +16 -15
  81. package/dist/clis/jd/item.test.js +16 -1
  82. package/dist/clis/linux-do/categories.yaml +38 -9
  83. package/dist/clis/linux-do/category.d.ts +1 -0
  84. package/dist/clis/linux-do/category.js +36 -0
  85. package/dist/clis/linux-do/feed.d.ts +45 -0
  86. package/dist/clis/linux-do/feed.js +397 -0
  87. package/dist/clis/linux-do/feed.test.d.ts +1 -0
  88. package/dist/clis/linux-do/feed.test.js +118 -0
  89. package/dist/clis/linux-do/hot.d.ts +1 -0
  90. package/dist/clis/linux-do/hot.js +25 -0
  91. package/dist/clis/linux-do/latest.d.ts +1 -0
  92. package/dist/clis/linux-do/latest.js +18 -0
  93. package/dist/clis/linux-do/tags.yaml +41 -0
  94. package/dist/clis/linux-do/topic.yaml +41 -3
  95. package/dist/clis/linux-do/user-posts.yaml +67 -0
  96. package/dist/clis/linux-do/user-topics.yaml +54 -0
  97. package/dist/clis/paperreview/commands.test.d.ts +3 -0
  98. package/dist/clis/paperreview/commands.test.js +243 -0
  99. package/dist/clis/paperreview/feedback.d.ts +1 -0
  100. package/dist/clis/paperreview/feedback.js +52 -0
  101. package/dist/clis/paperreview/review.d.ts +1 -0
  102. package/dist/clis/paperreview/review.js +37 -0
  103. package/dist/clis/paperreview/submit.d.ts +1 -0
  104. package/dist/clis/paperreview/submit.js +85 -0
  105. package/dist/clis/paperreview/utils.d.ts +46 -0
  106. package/dist/clis/paperreview/utils.js +197 -0
  107. package/dist/clis/paperreview/utils.test.d.ts +1 -0
  108. package/dist/clis/paperreview/utils.test.js +49 -0
  109. package/dist/clis/producthunt/browse.d.ts +1 -0
  110. package/dist/clis/producthunt/browse.js +99 -0
  111. package/dist/clis/producthunt/hot.d.ts +1 -0
  112. package/dist/clis/producthunt/hot.js +110 -0
  113. package/dist/clis/producthunt/posts.d.ts +1 -0
  114. package/dist/clis/producthunt/posts.js +28 -0
  115. package/dist/clis/producthunt/today.d.ts +1 -0
  116. package/dist/clis/producthunt/today.js +35 -0
  117. package/dist/clis/producthunt/utils.d.ts +29 -0
  118. package/dist/clis/producthunt/utils.js +99 -0
  119. package/dist/clis/producthunt/utils.test.d.ts +1 -0
  120. package/dist/clis/producthunt/utils.test.js +64 -0
  121. package/dist/clis/twitter/article.js +4 -28
  122. package/dist/clis/twitter/likes.d.ts +24 -0
  123. package/dist/clis/twitter/likes.js +217 -0
  124. package/dist/clis/twitter/likes.test.d.ts +1 -0
  125. package/dist/clis/twitter/likes.test.js +85 -0
  126. package/dist/clis/twitter/profile.js +4 -28
  127. package/dist/clis/twitter/search.js +2 -1
  128. package/dist/clis/twitter/search.test.js +2 -0
  129. package/dist/clis/twitter/shared.d.ts +6 -0
  130. package/dist/clis/twitter/shared.js +35 -0
  131. package/dist/clis/twitter/timeline.js +2 -13
  132. package/dist/clis/twitter/trending.js +29 -61
  133. package/dist/clis/v2ex/hot.yaml +17 -3
  134. package/dist/clis/weixin/download.d.ts +17 -0
  135. package/dist/clis/weixin/download.js +88 -20
  136. package/dist/clis/weread/book.js +2 -2
  137. package/dist/clis/weread/commands.test.d.ts +3 -0
  138. package/dist/clis/weread/commands.test.js +43 -0
  139. package/dist/clis/weread/highlights.js +2 -2
  140. package/dist/clis/weread/notebooks.js +2 -2
  141. package/dist/clis/weread/notes.js +3 -3
  142. package/dist/clis/weread/shelf.js +2 -2
  143. package/dist/clis/weread/utils.d.ts +4 -4
  144. package/dist/clis/weread/utils.js +32 -14
  145. package/dist/clis/weread/utils.test.js +1 -28
  146. package/dist/clis/xiaohongshu/comments.d.ts +5 -0
  147. package/dist/clis/xiaohongshu/comments.js +74 -0
  148. package/dist/clis/xiaohongshu/comments.test.d.ts +1 -0
  149. package/dist/clis/xiaohongshu/comments.test.js +79 -0
  150. package/dist/clis/xiaohongshu/publish.js +179 -47
  151. package/dist/clis/xiaohongshu/publish.test.d.ts +1 -0
  152. package/dist/clis/xiaohongshu/publish.test.js +131 -0
  153. package/dist/clis/xiaohongshu/search.d.ts +8 -1
  154. package/dist/clis/xiaohongshu/search.js +20 -1
  155. package/dist/clis/xiaohongshu/search.test.d.ts +1 -1
  156. package/dist/clis/xiaohongshu/search.test.js +32 -1
  157. package/dist/commanderAdapter.d.ts +1 -0
  158. package/dist/commanderAdapter.js +176 -29
  159. package/dist/commanderAdapter.test.d.ts +1 -0
  160. package/dist/commanderAdapter.test.js +62 -0
  161. package/dist/daemon.js +17 -1
  162. package/dist/discovery.js +48 -42
  163. package/dist/doctor.d.ts +2 -2
  164. package/dist/doctor.js +11 -4
  165. package/dist/download/index.js +63 -51
  166. package/dist/download/index.test.js +17 -4
  167. package/dist/engine.test.js +42 -0
  168. package/dist/errors.d.ts +4 -2
  169. package/dist/errors.js +17 -34
  170. package/dist/execution.d.ts +1 -3
  171. package/dist/execution.js +66 -8
  172. package/dist/execution.test.d.ts +1 -0
  173. package/dist/execution.test.js +40 -0
  174. package/dist/external.js +6 -1
  175. package/dist/hooks.js +2 -0
  176. package/dist/main.js +6 -0
  177. package/dist/output.js +5 -1
  178. package/dist/pipeline/executor.js +3 -4
  179. package/dist/plugin-manifest.d.ts +70 -0
  180. package/dist/plugin-manifest.js +160 -0
  181. package/dist/plugin-manifest.test.d.ts +4 -0
  182. package/dist/plugin-manifest.test.js +179 -0
  183. package/dist/plugin-scaffold.d.ts +28 -0
  184. package/dist/plugin-scaffold.js +142 -0
  185. package/dist/plugin-scaffold.test.d.ts +4 -0
  186. package/dist/plugin-scaffold.test.js +83 -0
  187. package/dist/plugin.d.ts +82 -11
  188. package/dist/plugin.js +870 -84
  189. package/dist/plugin.test.js +1032 -17
  190. package/dist/registry.d.ts +4 -0
  191. package/dist/registry.js +2 -0
  192. package/dist/runtime-detect.d.ts +21 -0
  193. package/dist/runtime-detect.js +32 -0
  194. package/dist/runtime-detect.test.d.ts +1 -0
  195. package/dist/runtime-detect.test.js +27 -0
  196. package/dist/runtime.d.ts +1 -0
  197. package/dist/runtime.js +2 -2
  198. package/dist/serialization.d.ts +2 -0
  199. package/dist/serialization.js +6 -0
  200. package/dist/types.d.ts +3 -0
  201. package/dist/update-check.d.ts +22 -0
  202. package/dist/update-check.js +112 -0
  203. package/dist/weixin-download.test.d.ts +1 -0
  204. package/dist/weixin-download.test.js +30 -0
  205. package/dist/weread-private-api-regression.test.d.ts +1 -0
  206. package/dist/weread-private-api-regression.test.js +122 -0
  207. package/dist/yaml-schema.d.ts +3 -0
  208. package/dist/yaml-schema.js +18 -1
  209. package/docs/.vitepress/config.mts +4 -0
  210. package/docs/adapters/browser/36kr.md +47 -0
  211. package/docs/adapters/browser/bluesky.md +53 -0
  212. package/docs/adapters/browser/douban.md +14 -0
  213. package/docs/adapters/browser/imdb.md +47 -0
  214. package/docs/adapters/browser/jd.md +2 -2
  215. package/docs/adapters/browser/linux-do.md +181 -20
  216. package/docs/adapters/browser/paperreview.md +43 -0
  217. package/docs/adapters/browser/producthunt.md +49 -0
  218. package/docs/adapters/desktop/chatgpt.md +5 -0
  219. package/docs/adapters/index.md +6 -2
  220. package/docs/advanced/download.md +4 -0
  221. package/docs/advanced/rate-limiter-plugin.md +99 -0
  222. package/docs/guide/electron-app-cli.md +200 -0
  223. package/docs/guide/getting-started.md +1 -0
  224. package/docs/guide/plugins.md +97 -0
  225. package/docs/zh/guide/electron-app-cli.md +188 -0
  226. package/docs/zh/guide/getting-started.md +1 -0
  227. package/docs/zh/guide/plugins.md +65 -0
  228. package/extension/package.json +1 -0
  229. package/extension/scripts/package-release.mjs +179 -0
  230. package/extension/src/background.ts +2 -0
  231. package/package.json +4 -1
  232. package/scripts/postinstall.js +10 -0
  233. package/src/browser/cdp.ts +8 -1
  234. package/src/browser/discover.ts +8 -3
  235. package/src/browser/errors.ts +13 -14
  236. package/src/browser/mcp.ts +2 -1
  237. package/src/browser/page.ts +24 -1
  238. package/src/build-manifest.test.ts +23 -0
  239. package/src/build-manifest.ts +40 -15
  240. package/src/capabilityRouting.ts +2 -1
  241. package/src/cli.ts +69 -6
  242. package/src/clis/36kr/article.ts +69 -0
  243. package/src/clis/36kr/hot.test.ts +19 -0
  244. package/src/clis/36kr/hot.ts +100 -0
  245. package/src/clis/36kr/news.test.ts +90 -0
  246. package/src/clis/36kr/news.ts +54 -0
  247. package/src/clis/36kr/search.ts +78 -0
  248. package/src/clis/bilibili/comments.test.ts +102 -0
  249. package/src/clis/bilibili/comments.ts +44 -0
  250. package/src/clis/bluesky/feeds.yaml +29 -0
  251. package/src/clis/bluesky/followers.yaml +33 -0
  252. package/src/clis/bluesky/following.yaml +33 -0
  253. package/src/clis/bluesky/profile.yaml +27 -0
  254. package/src/clis/bluesky/search.yaml +34 -0
  255. package/src/clis/bluesky/starter-packs.yaml +34 -0
  256. package/src/clis/bluesky/thread.yaml +32 -0
  257. package/src/clis/bluesky/trending.yaml +27 -0
  258. package/src/clis/bluesky/user.yaml +34 -0
  259. package/src/clis/chatgpt/ask.ts +28 -14
  260. package/src/clis/chatgpt/ax.ts +180 -1
  261. package/src/clis/chatgpt/model.ts +27 -0
  262. package/src/clis/chatgpt/send.ts +16 -6
  263. package/src/clis/douban/download.test.ts +196 -0
  264. package/src/clis/douban/download.ts +78 -0
  265. package/src/clis/douban/photos.ts +36 -0
  266. package/src/clis/douban/utils.test.ts +97 -0
  267. package/src/clis/douban/utils.ts +232 -1
  268. package/src/clis/imdb/person.ts +232 -0
  269. package/src/clis/imdb/reviews.ts +111 -0
  270. package/src/clis/imdb/search.ts +179 -0
  271. package/src/clis/imdb/title.ts +121 -0
  272. package/src/clis/imdb/top.ts +67 -0
  273. package/src/clis/imdb/trending.ts +66 -0
  274. package/src/clis/imdb/utils.test.ts +117 -0
  275. package/src/clis/imdb/utils.ts +305 -0
  276. package/src/clis/jd/item.test.ts +18 -1
  277. package/src/clis/jd/item.ts +18 -15
  278. package/src/clis/linux-do/categories.yaml +38 -9
  279. package/src/clis/linux-do/category.ts +37 -0
  280. package/src/clis/linux-do/feed.test.ts +132 -0
  281. package/src/clis/linux-do/feed.ts +501 -0
  282. package/src/clis/linux-do/hot.ts +26 -0
  283. package/src/clis/linux-do/latest.ts +19 -0
  284. package/src/clis/linux-do/tags.yaml +41 -0
  285. package/src/clis/linux-do/topic.yaml +41 -3
  286. package/src/clis/linux-do/user-posts.yaml +67 -0
  287. package/src/clis/linux-do/user-topics.yaml +54 -0
  288. package/src/clis/paperreview/commands.test.ts +283 -0
  289. package/src/clis/paperreview/feedback.ts +64 -0
  290. package/src/clis/paperreview/review.ts +47 -0
  291. package/src/clis/paperreview/submit.ts +119 -0
  292. package/src/clis/paperreview/utils.test.ts +68 -0
  293. package/src/clis/paperreview/utils.ts +276 -0
  294. package/src/clis/producthunt/browse.ts +109 -0
  295. package/src/clis/producthunt/hot.ts +127 -0
  296. package/src/clis/producthunt/posts.ts +29 -0
  297. package/src/clis/producthunt/today.ts +37 -0
  298. package/src/clis/producthunt/utils.test.ts +72 -0
  299. package/src/clis/producthunt/utils.ts +122 -0
  300. package/src/clis/twitter/article.ts +5 -28
  301. package/src/clis/twitter/likes.test.ts +91 -0
  302. package/src/clis/twitter/likes.ts +256 -0
  303. package/src/clis/twitter/profile.ts +5 -28
  304. package/src/clis/twitter/search.test.ts +2 -0
  305. package/src/clis/twitter/search.ts +3 -1
  306. package/src/clis/twitter/shared.ts +45 -0
  307. package/src/clis/twitter/timeline.ts +2 -13
  308. package/src/clis/twitter/trending.ts +29 -77
  309. package/src/clis/v2ex/hot.yaml +17 -3
  310. package/src/clis/weixin/download.ts +114 -20
  311. package/src/clis/weread/book.ts +2 -2
  312. package/src/clis/weread/commands.test.ts +57 -0
  313. package/src/clis/weread/highlights.ts +2 -2
  314. package/src/clis/weread/notebooks.ts +2 -2
  315. package/src/clis/weread/notes.ts +3 -3
  316. package/src/clis/weread/shelf.ts +2 -2
  317. package/src/clis/weread/utils.test.ts +1 -32
  318. package/src/clis/weread/utils.ts +41 -16
  319. package/src/clis/xiaohongshu/comments.test.ts +96 -0
  320. package/src/clis/xiaohongshu/comments.ts +81 -0
  321. package/src/clis/xiaohongshu/publish.test.ts +151 -0
  322. package/src/clis/xiaohongshu/publish.ts +206 -54
  323. package/src/clis/xiaohongshu/search.test.ts +39 -1
  324. package/src/clis/xiaohongshu/search.ts +19 -1
  325. package/src/commanderAdapter.test.ts +78 -0
  326. package/src/commanderAdapter.ts +188 -24
  327. package/src/daemon.ts +19 -1
  328. package/src/discovery.ts +49 -48
  329. package/src/doctor.ts +15 -5
  330. package/src/download/index.test.ts +14 -4
  331. package/src/download/index.ts +67 -55
  332. package/src/engine.test.ts +38 -0
  333. package/src/errors.ts +26 -63
  334. package/src/execution.test.ts +47 -0
  335. package/src/execution.ts +67 -9
  336. package/src/external.ts +6 -1
  337. package/src/hooks.ts +1 -0
  338. package/src/main.ts +7 -0
  339. package/src/output.ts +3 -1
  340. package/src/pipeline/executor.ts +4 -6
  341. package/src/plugin-manifest.test.ts +223 -0
  342. package/src/plugin-manifest.ts +206 -0
  343. package/src/plugin-scaffold.test.ts +98 -0
  344. package/src/plugin-scaffold.ts +170 -0
  345. package/src/plugin.test.ts +1104 -17
  346. package/src/plugin.ts +1101 -86
  347. package/src/registry.ts +6 -1
  348. package/src/runtime-detect.test.ts +30 -0
  349. package/src/runtime-detect.ts +36 -0
  350. package/src/runtime.ts +3 -3
  351. package/src/serialization.ts +4 -0
  352. package/src/types.ts +3 -0
  353. package/src/update-check.ts +114 -0
  354. package/src/weixin-download.test.ts +64 -0
  355. package/src/weread-private-api-regression.test.ts +150 -0
  356. package/src/yaml-schema.ts +20 -0
  357. package/tests/e2e/browser-auth.test.ts +13 -9
  358. package/tests/e2e/browser-public-extended.test.ts +1 -1
  359. package/tests/e2e/browser-public.test.ts +62 -4
  360. package/tests/e2e/helpers.ts +2 -1
  361. package/tests/e2e/public-commands.test.ts +37 -3
  362. package/tests/smoke/api-health.test.ts +1 -1
  363. package/vitest.config.ts +10 -0
  364. package/dist/clis/linux-do/category.yaml +0 -51
  365. package/dist/clis/linux-do/hot.yaml +0 -50
  366. package/dist/clis/linux-do/latest.yaml +0 -40
  367. package/src/clis/linux-do/category.yaml +0 -51
  368. package/src/clis/linux-do/hot.yaml +0 -50
  369. package/src/clis/linux-do/latest.yaml +0 -40
@@ -0,0 +1,79 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { getRegistry } from '../../registry.js';
3
+ import './comments.js';
4
+ function createPageMock(evaluateResult) {
5
+ return {
6
+ goto: vi.fn().mockResolvedValue(undefined),
7
+ evaluate: vi.fn().mockResolvedValue(evaluateResult),
8
+ snapshot: vi.fn().mockResolvedValue(undefined),
9
+ click: vi.fn().mockResolvedValue(undefined),
10
+ typeText: vi.fn().mockResolvedValue(undefined),
11
+ pressKey: vi.fn().mockResolvedValue(undefined),
12
+ scrollTo: vi.fn().mockResolvedValue(undefined),
13
+ getFormState: vi.fn().mockResolvedValue({ forms: [], orphanFields: [] }),
14
+ wait: vi.fn().mockResolvedValue(undefined),
15
+ tabs: vi.fn().mockResolvedValue([]),
16
+ closeTab: vi.fn().mockResolvedValue(undefined),
17
+ newTab: vi.fn().mockResolvedValue(undefined),
18
+ selectTab: vi.fn().mockResolvedValue(undefined),
19
+ networkRequests: vi.fn().mockResolvedValue([]),
20
+ consoleMessages: vi.fn().mockResolvedValue([]),
21
+ scroll: vi.fn().mockResolvedValue(undefined),
22
+ autoScroll: vi.fn().mockResolvedValue(undefined),
23
+ installInterceptor: vi.fn().mockResolvedValue(undefined),
24
+ getInterceptedRequests: vi.fn().mockResolvedValue([]),
25
+ getCookies: vi.fn().mockResolvedValue([]),
26
+ screenshot: vi.fn().mockResolvedValue(''),
27
+ };
28
+ }
29
+ describe('xiaohongshu comments', () => {
30
+ const command = getRegistry().get('xiaohongshu/comments');
31
+ it('returns ranked comment rows', async () => {
32
+ const page = createPageMock({
33
+ loginWall: false,
34
+ results: [
35
+ { author: 'Alice', text: 'Great note!', likes: 10, time: '2024-01-01' },
36
+ { author: 'Bob', text: 'Very helpful', likes: 0, time: '2024-01-02' },
37
+ ],
38
+ });
39
+ const result = (await command.func(page, { 'note-id': '69aadbcb000000002202f131', limit: 5 }));
40
+ expect(page.goto.mock.calls[0][0]).toContain('/explore/69aadbcb000000002202f131');
41
+ expect(result).toEqual([
42
+ { rank: 1, author: 'Alice', text: 'Great note!', likes: 10, time: '2024-01-01' },
43
+ { rank: 2, author: 'Bob', text: 'Very helpful', likes: 0, time: '2024-01-02' },
44
+ ]);
45
+ expect(result[0]).not.toHaveProperty('loginWall');
46
+ });
47
+ it('strips /explore/ prefix from full URL input', async () => {
48
+ const page = createPageMock({
49
+ loginWall: false,
50
+ results: [{ author: 'Alice', text: 'Nice', likes: 1, time: '2024-01-01' }],
51
+ });
52
+ await command.func(page, {
53
+ 'note-id': 'https://www.xiaohongshu.com/explore/69aadbcb000000002202f131',
54
+ limit: 5,
55
+ });
56
+ expect(page.goto.mock.calls[0][0]).toContain('/explore/69aadbcb000000002202f131');
57
+ });
58
+ it('throws AuthRequiredError when login wall is detected', async () => {
59
+ const page = createPageMock({ loginWall: true, results: [] });
60
+ await expect(command.func(page, { 'note-id': 'abc123', limit: 5 })).rejects.toThrow('Note comments require login');
61
+ });
62
+ it('returns empty array when no comments are found', async () => {
63
+ const page = createPageMock({ loginWall: false, results: [] });
64
+ await expect(command.func(page, { 'note-id': 'abc123', limit: 5 })).resolves.toEqual([]);
65
+ });
66
+ it('respects the limit', async () => {
67
+ const manyComments = Array.from({ length: 10 }, (_, i) => ({
68
+ author: `User${i}`,
69
+ text: `Comment ${i}`,
70
+ likes: i,
71
+ time: '2024-01-01',
72
+ }));
73
+ const page = createPageMock({ loginWall: false, results: manyComments });
74
+ const result = (await command.func(page, { 'note-id': 'abc123', limit: 3 }));
75
+ expect(result).toHaveLength(3);
76
+ expect(result[0].rank).toBe(1);
77
+ expect(result[2].rank).toBe(3);
78
+ });
79
+ });
@@ -22,6 +22,21 @@ 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
+ /** Selectors for the title field, ordered by priority (new UI first). */
26
+ const TITLE_SELECTORS = [
27
+ // New creator center (2026-03) uses contenteditable for the title field.
28
+ // Placeholder observed: "填写标题会有更多赞哦"
29
+ '[contenteditable="true"][placeholder*="标题"]',
30
+ '[contenteditable="true"][placeholder*="赞"]',
31
+ '[contenteditable="true"][class*="title"]',
32
+ 'input[maxlength="20"]',
33
+ 'input[class*="title"]',
34
+ 'input[placeholder*="标题"]',
35
+ 'input[placeholder*="title" i]',
36
+ '.title-input input',
37
+ '.note-title input',
38
+ 'input[maxlength]',
39
+ ];
25
40
  /**
26
41
  * Read a local image and return the name, MIME type, and base64 content.
27
42
  * Throws if the file does not exist or the extension is unsupported.
@@ -57,14 +72,22 @@ async function injectImages(page, images) {
57
72
  (async () => {
58
73
  const images = ${payload};
59
74
 
60
- // Prefer image/* file inputs; fall back to the first available input.
75
+ // Only use image-capable file inputs. Do not fall back to a generic uploader,
76
+ // otherwise we can accidentally feed images into the video upload flow.
61
77
  const inputs = Array.from(document.querySelectorAll('input[type="file"]'));
62
78
  const input = inputs.find(el => {
63
79
  const accept = el.getAttribute('accept') || '';
64
- return accept.includes('image') || accept.includes('.jpg') || accept.includes('.png');
65
- }) || inputs[0];
80
+ return (
81
+ accept.includes('image') ||
82
+ accept.includes('.jpg') ||
83
+ accept.includes('.jpeg') ||
84
+ accept.includes('.png') ||
85
+ accept.includes('.gif') ||
86
+ accept.includes('.webp')
87
+ );
88
+ });
66
89
 
67
- if (!input) return { ok: false, count: 0, error: 'No file input found on page' };
90
+ if (!input) return { ok: false, count: 0, error: 'No image file input found on page' };
68
91
 
69
92
  const dt = new DataTransfer();
70
93
  for (const img of images) {
@@ -141,6 +164,121 @@ async function fillField(page, selectors, text, fieldName) {
141
164
  throw new Error(`Could not find ${fieldName} input. Debug screenshot: /tmp/xhs_publish_${fieldName}_debug.png`);
142
165
  }
143
166
  }
167
+ async function selectImageTextTab(page) {
168
+ const result = await page.evaluate(`
169
+ () => {
170
+ const isVisible = (el) => {
171
+ if (!el || el.offsetParent === null) return false;
172
+ const rect = el.getBoundingClientRect();
173
+ return rect.width > 0 && rect.height > 0;
174
+ };
175
+
176
+ const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim();
177
+ const selector = 'button, [role="tab"], [role="button"], a, label, div, span, li';
178
+ const nodes = Array.from(document.querySelectorAll(selector));
179
+ const targets = ['上传图文', '图文', '图片'];
180
+
181
+ for (const target of targets) {
182
+ for (const node of nodes) {
183
+ if (!isVisible(node)) continue;
184
+ const text = normalize(node.innerText || node.textContent || '');
185
+ if (!text || text.includes('视频')) continue;
186
+ if (text === target || text.startsWith(target) || text.includes(target)) {
187
+ const clickable = node.closest('button, [role="tab"], [role="button"], a, label') || node;
188
+ clickable.click();
189
+ return { ok: true, target, text };
190
+ }
191
+ }
192
+ }
193
+
194
+ const visibleTexts = [];
195
+ for (const node of nodes) {
196
+ if (!isVisible(node)) continue;
197
+ const text = normalize(node.innerText || node.textContent || '');
198
+ if (!text || text.length > 20) continue;
199
+ visibleTexts.push(text);
200
+ if (visibleTexts.length >= 20) break;
201
+ }
202
+ return { ok: false, visibleTexts };
203
+ }
204
+ `);
205
+ if (result?.ok) {
206
+ await page.wait({ time: 1 });
207
+ }
208
+ return result;
209
+ }
210
+ async function inspectPublishSurfaceState(page) {
211
+ return page.evaluate(`
212
+ () => {
213
+ const text = (document.body?.innerText || '').replace(/\s+/g, ' ').trim();
214
+ const hasTitleInput = !!Array.from(document.querySelectorAll('input, textarea')).find((el) => {
215
+ if (!el || el.offsetParent === null) return false;
216
+ const placeholder = (el.getAttribute('placeholder') || '').trim();
217
+ const cls = el.className ? String(el.className) : '';
218
+ const maxLength = Number(el.getAttribute('maxlength') || 0);
219
+ return (
220
+ placeholder.includes('标题') ||
221
+ /title/i.test(placeholder) ||
222
+ /title/i.test(cls) ||
223
+ maxLength === 20
224
+ );
225
+ });
226
+ const hasImageInput = !!Array.from(document.querySelectorAll('input[type="file"]')).find((el) => {
227
+ const accept = el.getAttribute('accept') || '';
228
+ return (
229
+ accept.includes('image') ||
230
+ accept.includes('.jpg') ||
231
+ accept.includes('.jpeg') ||
232
+ accept.includes('.png') ||
233
+ accept.includes('.gif') ||
234
+ accept.includes('.webp')
235
+ );
236
+ });
237
+ const hasVideoSurface = text.includes('拖拽视频到此处点击上传') || text.includes('上传视频');
238
+ const state = hasTitleInput ? 'editor_ready' : hasImageInput || !hasVideoSurface ? 'image_surface' : 'video_surface';
239
+ return { state, hasTitleInput, hasImageInput, hasVideoSurface };
240
+ }
241
+ `);
242
+ }
243
+ async function waitForPublishSurfaceState(page, maxWaitMs = 5_000) {
244
+ const pollMs = 500;
245
+ const maxAttempts = Math.max(1, Math.ceil(maxWaitMs / pollMs));
246
+ let surface = await inspectPublishSurfaceState(page);
247
+ for (let i = 0; i < maxAttempts; i++) {
248
+ if (surface.state !== 'video_surface') {
249
+ return surface;
250
+ }
251
+ if (i < maxAttempts - 1) {
252
+ await page.wait({ time: pollMs / 1_000 });
253
+ surface = await inspectPublishSurfaceState(page);
254
+ }
255
+ }
256
+ return surface;
257
+ }
258
+ /**
259
+ * Poll until the title/content editing form appears on the page.
260
+ * The new creator center UI only renders the editor after images are uploaded.
261
+ */
262
+ async function waitForEditForm(page, maxWaitMs = 10_000) {
263
+ const pollMs = 1_000;
264
+ const maxAttempts = Math.ceil(maxWaitMs / pollMs);
265
+ for (let i = 0; i < maxAttempts; i++) {
266
+ const found = await page.evaluate(`
267
+ (() => {
268
+ const sels = ${JSON.stringify(TITLE_SELECTORS)};
269
+ for (const sel of sels) {
270
+ const el = document.querySelector(sel);
271
+ if (el && el.offsetParent !== null) return true;
272
+ }
273
+ return false;
274
+ })()`);
275
+ if (found)
276
+ return true;
277
+ if (i < maxAttempts - 1)
278
+ await page.wait({ time: pollMs / 1_000 });
279
+ }
280
+ return false;
281
+ }
144
282
  cli({
145
283
  site: 'xiaohongshu',
146
284
  name: 'publish',
@@ -151,7 +289,7 @@ cli({
151
289
  args: [
152
290
  { name: 'title', required: true, help: '笔记标题 (最多20字)' },
153
291
  { name: 'content', required: true, positional: true, help: '笔记正文' },
154
- { name: 'images', required: false, help: '图片路径,逗号分隔,最多9张 (jpg/png/gif/webp)' },
292
+ { name: 'images', required: true, help: '图片路径,逗号分隔,最多9张 (jpg/png/gif/webp)' },
155
293
  { name: 'topics', required: false, help: '话题标签,逗号分隔,不含 # 号' },
156
294
  { name: 'draft', type: 'bool', default: false, help: '保存为草稿,不直接发布' },
157
295
  ],
@@ -175,6 +313,8 @@ cli({
175
313
  throw new Error(`Title is ${title.length} chars — must be ≤ ${MAX_TITLE_LEN}`);
176
314
  if (!content)
177
315
  throw new Error('Positional argument <content> is required');
316
+ if (imagePaths.length === 0)
317
+ throw new Error('At least one --images path is required. The creator center now requires images before showing the editor.');
178
318
  if (imagePaths.length > MAX_IMAGES)
179
319
  throw new Error(`Too many images: ${imagePaths.length} (max ${MAX_IMAGES})`);
180
320
  // Read images in Node.js context before navigating (fast-fail on bad paths)
@@ -189,43 +329,35 @@ cli({
189
329
  'Re-capture browser login via: opencli xiaohongshu creator-profile');
190
330
  }
191
331
  // ── Step 2: Select 图文 (image+text) note type if tabs are present ─────────
192
- const tabClicked = await page.evaluate(`
193
- () => {
194
- const allEls = document.querySelectorAll('[class*="tab"], [class*="note-type"], [class*="type-item"]');
195
- for (const el of allEls) {
196
- const text = el.innerText || el.textContent || '';
197
- if ((text.includes('图文') || text.includes('图片')) && el.offsetParent !== null) {
198
- el.click();
199
- return true;
200
- }
332
+ const tabResult = await selectImageTextTab(page);
333
+ const surface = await waitForPublishSurfaceState(page, tabResult?.ok ? 5_000 : 2_000);
334
+ if (surface.state === 'video_surface') {
335
+ await page.screenshot({ path: '/tmp/xhs_publish_tab_debug.png' });
336
+ const detail = tabResult?.ok
337
+ ? `clicked "${tabResult.text}"`
338
+ : `visible candidates: ${(tabResult?.visibleTexts || []).join(' | ') || 'none'}`;
339
+ throw new Error('Still on the video publish page after trying to select 图文. ' +
340
+ `Details: ${detail}. Debug screenshot: /tmp/xhs_publish_tab_debug.png`);
201
341
  }
202
- return false;
203
- }
204
- `);
205
- if (tabClicked)
206
- await page.wait({ time: 1 });
207
342
  // ── Step 3: Upload images ──────────────────────────────────────────────────
208
- if (imageData.length > 0) {
209
- const upload = await injectImages(page, imageData);
210
- if (!upload.ok) {
211
- await page.screenshot({ path: '/tmp/xhs_publish_upload_debug.png' });
212
- throw new Error(`Image injection failed: ${upload.error ?? 'unknown'}. ` +
213
- 'Debug screenshot: /tmp/xhs_publish_upload_debug.png');
214
- }
215
- // Allow XHS to process and upload images to its CDN
216
- await page.wait({ time: UPLOAD_SETTLE_MS / 1_000 });
217
- await waitForUploads(page);
343
+ const upload = await injectImages(page, imageData);
344
+ if (!upload.ok) {
345
+ await page.screenshot({ path: '/tmp/xhs_publish_upload_debug.png' });
346
+ throw new Error(`Image injection failed: ${upload.error ?? 'unknown'}. ` +
347
+ 'Debug screenshot: /tmp/xhs_publish_upload_debug.png');
348
+ }
349
+ // Allow XHS to process and upload images to its CDN
350
+ await page.wait({ time: UPLOAD_SETTLE_MS / 1_000 });
351
+ await waitForUploads(page);
352
+ // ── Step 3b: Wait for editor form to render ───────────────────────────────
353
+ const formReady = await waitForEditForm(page);
354
+ if (!formReady) {
355
+ await page.screenshot({ path: '/tmp/xhs_publish_form_debug.png' });
356
+ throw new Error('Editing form did not appear after image upload. The page layout may have changed. ' +
357
+ 'Debug screenshot: /tmp/xhs_publish_form_debug.png');
218
358
  }
219
359
  // ── Step 4: Fill title ─────────────────────────────────────────────────────
220
- await fillField(page, [
221
- 'input[maxlength="20"]',
222
- 'input[class*="title"]',
223
- 'input[placeholder*="标题"]',
224
- 'input[placeholder*="title" i]',
225
- '.title-input input',
226
- '.note-title input',
227
- 'input[maxlength]',
228
- ], title, 'title');
360
+ await fillField(page, TITLE_SELECTORS, title, 'title');
229
361
  await page.wait({ time: 0.5 });
230
362
  // ── Step 5: Fill content / body ────────────────────────────────────────────
231
363
  await fillField(page, [
@@ -237,7 +369,7 @@ cli({
237
369
  '.note-content [contenteditable="true"]',
238
370
  '.editor-content [contenteditable="true"]',
239
371
  // Broad fallback — last resort; filter out any title contenteditable
240
- '[contenteditable="true"]:not([placeholder*="标题"]):not([placeholder*="title" i])',
372
+ '[contenteditable="true"]:not([placeholder*="标题"]):not([placeholder*="赞"]):not([placeholder*="title" i])',
241
373
  ], content, 'content');
242
374
  await page.wait({ time: 0.5 });
243
375
  // ── Step 6: Add topic hashtags ─────────────────────────────────────────────
@@ -294,14 +426,14 @@ cli({
294
426
  await page.wait({ time: 0.5 });
295
427
  }
296
428
  // ── Step 7: Publish or save draft ─────────────────────────────────────────
297
- const actionLabel = isDraft ? '存草稿' : '发布';
429
+ const actionLabels = isDraft ? ['暂存离开', '存草稿'] : ['发布', '发布笔记'];
298
430
  const btnClicked = await page.evaluate(`
299
- (label => {
431
+ (labels => {
300
432
  const buttons = document.querySelectorAll('button, [role="button"]');
301
433
  for (const btn of buttons) {
302
434
  const text = (btn.innerText || btn.textContent || '').trim();
303
435
  if (
304
- (text === label || text.includes(label) || text === '发布笔记') &&
436
+ labels.some(l => text === l || text.includes(l)) &&
305
437
  btn.offsetParent !== null &&
306
438
  !btn.disabled
307
439
  ) {
@@ -310,11 +442,11 @@ cli({
310
442
  }
311
443
  }
312
444
  return false;
313
- })(${JSON.stringify(actionLabel)})
445
+ })(${JSON.stringify(actionLabels)})
314
446
  `);
315
447
  if (!btnClicked) {
316
448
  await page.screenshot({ path: '/tmp/xhs_publish_submit_debug.png' });
317
- throw new Error(`Could not find "${actionLabel}" button. ` +
449
+ throw new Error(`Could not find "${actionLabels[0]}" button. ` +
318
450
  'Debug screenshot: /tmp/xhs_publish_submit_debug.png');
319
451
  }
320
452
  // ── Step 8: Verify success ─────────────────────────────────────────────────
@@ -326,7 +458,7 @@ cli({
326
458
  const text = (el.innerText || '').trim();
327
459
  if (
328
460
  el.children.length === 0 &&
329
- (text.includes('发布成功') || text.includes('草稿已保存') || text.includes('上传成功'))
461
+ (text.includes('发布成功') || text.includes('草稿已保存') || text.includes('暂存成功') || text.includes('上传成功'))
330
462
  ) return text;
331
463
  }
332
464
  return '';
@@ -334,13 +466,13 @@ cli({
334
466
  `);
335
467
  const navigatedAway = !finalUrl.includes('/publish/publish');
336
468
  const isSuccess = successMsg.length > 0 || navigatedAway;
337
- const verb = isDraft ? '草稿已保存' : '发布成功';
469
+ const verb = isDraft ? '暂存成功' : '发布成功';
338
470
  return [
339
471
  {
340
472
  status: isSuccess ? `✅ ${verb}` : '⚠️ 操作完成,请在浏览器中确认',
341
473
  detail: [
342
474
  `"${title}"`,
343
- imageData.length ? `${imageData.length}张图片` : '无图',
475
+ `${imageData.length}张图片`,
344
476
  topics.length ? `话题: ${topics.join(' ')}` : '',
345
477
  successMsg || finalUrl || '',
346
478
  ]
@@ -0,0 +1 @@
1
+ import './publish.js';
@@ -0,0 +1,131 @@
1
+ import * as fs from 'node:fs';
2
+ import * as os from 'node:os';
3
+ import * as path from 'node:path';
4
+ import { describe, expect, it, vi } from 'vitest';
5
+ import { getRegistry } from '../../registry.js';
6
+ import './publish.js';
7
+ function createPageMock(evaluateResults) {
8
+ const evaluate = vi.fn();
9
+ for (const result of evaluateResults) {
10
+ evaluate.mockResolvedValueOnce(result);
11
+ }
12
+ return {
13
+ goto: vi.fn().mockResolvedValue(undefined),
14
+ evaluate,
15
+ snapshot: vi.fn().mockResolvedValue(undefined),
16
+ click: vi.fn().mockResolvedValue(undefined),
17
+ typeText: vi.fn().mockResolvedValue(undefined),
18
+ pressKey: vi.fn().mockResolvedValue(undefined),
19
+ scrollTo: vi.fn().mockResolvedValue(undefined),
20
+ getFormState: vi.fn().mockResolvedValue({ forms: [], orphanFields: [] }),
21
+ wait: vi.fn().mockResolvedValue(undefined),
22
+ tabs: vi.fn().mockResolvedValue([]),
23
+ closeTab: vi.fn().mockResolvedValue(undefined),
24
+ newTab: vi.fn().mockResolvedValue(undefined),
25
+ selectTab: vi.fn().mockResolvedValue(undefined),
26
+ networkRequests: vi.fn().mockResolvedValue([]),
27
+ consoleMessages: vi.fn().mockResolvedValue([]),
28
+ scroll: vi.fn().mockResolvedValue(undefined),
29
+ autoScroll: vi.fn().mockResolvedValue(undefined),
30
+ installInterceptor: vi.fn().mockResolvedValue(undefined),
31
+ getInterceptedRequests: vi.fn().mockResolvedValue([]),
32
+ getCookies: vi.fn().mockResolvedValue([]),
33
+ screenshot: vi.fn().mockResolvedValue(''),
34
+ };
35
+ }
36
+ describe('xiaohongshu publish', () => {
37
+ it('selects the image-text tab and publishes successfully', async () => {
38
+ const cmd = getRegistry().get('xiaohongshu/publish');
39
+ expect(cmd?.func).toBeTypeOf('function');
40
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-xhs-publish-'));
41
+ const imagePath = path.join(tempDir, 'demo.jpg');
42
+ fs.writeFileSync(imagePath, Buffer.from([0xff, 0xd8, 0xff, 0xd9]));
43
+ const page = createPageMock([
44
+ 'https://creator.xiaohongshu.com/publish/publish?from=menu_left',
45
+ { ok: true, target: '上传图文', text: '上传图文' },
46
+ { state: 'editor_ready', hasTitleInput: true, hasImageInput: true, hasVideoSurface: false },
47
+ { ok: true, count: 1 },
48
+ false,
49
+ true, // waitForEditForm: editor appeared
50
+ { ok: true, sel: 'input[maxlength="20"]' },
51
+ { ok: true, sel: '[contenteditable="true"][class*="content"]' },
52
+ true,
53
+ 'https://creator.xiaohongshu.com/publish/success',
54
+ '发布成功',
55
+ ]);
56
+ const result = await cmd.func(page, {
57
+ title: 'DeepSeek别乱问',
58
+ content: '一篇真实一点的小红书正文',
59
+ images: imagePath,
60
+ topics: '',
61
+ draft: false,
62
+ });
63
+ const evaluateCalls = page.evaluate.mock.calls.map((args) => String(args[0]));
64
+ expect(evaluateCalls.some((code) => code.includes("const targets = ['上传图文', '图文', '图片']"))).toBe(true);
65
+ expect(evaluateCalls.some((code) => code.includes("No image file input found on page"))).toBe(true);
66
+ expect(result).toEqual([
67
+ {
68
+ status: '✅ 发布成功',
69
+ detail: '"DeepSeek别乱问" · 1张图片 · 发布成功',
70
+ },
71
+ ]);
72
+ });
73
+ it('fails early with a clear error when still on the video page', async () => {
74
+ const cmd = getRegistry().get('xiaohongshu/publish');
75
+ expect(cmd?.func).toBeTypeOf('function');
76
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-xhs-publish-'));
77
+ const imagePath = path.join(tempDir, 'demo.jpg');
78
+ fs.writeFileSync(imagePath, Buffer.from([0xff, 0xd8, 0xff, 0xd9]));
79
+ const page = createPageMock([
80
+ 'https://creator.xiaohongshu.com/publish/publish?from=menu_left',
81
+ { ok: false, visibleTexts: ['上传视频', '上传图文'] },
82
+ { state: 'video_surface', hasTitleInput: false, hasImageInput: false, hasVideoSurface: true },
83
+ { state: 'video_surface', hasTitleInput: false, hasImageInput: false, hasVideoSurface: true },
84
+ { state: 'video_surface', hasTitleInput: false, hasImageInput: false, hasVideoSurface: true },
85
+ { state: 'video_surface', hasTitleInput: false, hasImageInput: false, hasVideoSurface: true },
86
+ ]);
87
+ await expect(cmd.func(page, {
88
+ title: 'DeepSeek别乱问',
89
+ content: '一篇真实一点的小红书正文',
90
+ images: imagePath,
91
+ topics: '',
92
+ draft: false,
93
+ })).rejects.toThrow('Still on the video publish page after trying to select 图文');
94
+ expect(page.screenshot).toHaveBeenCalledWith({ path: '/tmp/xhs_publish_tab_debug.png' });
95
+ });
96
+ it('waits for the image-text surface to appear after clicking the tab', async () => {
97
+ const cmd = getRegistry().get('xiaohongshu/publish');
98
+ expect(cmd?.func).toBeTypeOf('function');
99
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-xhs-publish-'));
100
+ const imagePath = path.join(tempDir, 'demo.jpg');
101
+ fs.writeFileSync(imagePath, Buffer.from([0xff, 0xd8, 0xff, 0xd9]));
102
+ const page = createPageMock([
103
+ 'https://creator.xiaohongshu.com/publish/publish?from=menu_left',
104
+ { ok: true, target: '上传图文', text: '上传图文' },
105
+ { state: 'video_surface', hasTitleInput: false, hasImageInput: false, hasVideoSurface: true },
106
+ { state: 'editor_ready', hasTitleInput: true, hasImageInput: true, hasVideoSurface: false },
107
+ { ok: true, count: 1 }, // injectImages
108
+ false, // waitForUploads: no progress indicator
109
+ true, // waitForEditForm: editor appeared
110
+ { ok: true, sel: 'input[maxlength="20"]' },
111
+ { ok: true, sel: '[contenteditable="true"][class*="content"]' },
112
+ true,
113
+ 'https://creator.xiaohongshu.com/publish/success',
114
+ '发布成功',
115
+ ]);
116
+ const result = await cmd.func(page, {
117
+ title: '延迟切换也能过',
118
+ content: '图文页切换慢一点也继续等',
119
+ images: imagePath,
120
+ topics: '',
121
+ draft: false,
122
+ });
123
+ expect(page.wait.mock.calls).toContainEqual([{ time: 0.5 }]);
124
+ expect(result).toEqual([
125
+ {
126
+ status: '✅ 发布成功',
127
+ detail: '"延迟切换也能过" · 1张图片 · 发布成功',
128
+ },
129
+ ]);
130
+ });
131
+ });
@@ -5,4 +5,11 @@
5
5
  * the search results page and extracts data from rendered DOM elements.
6
6
  * Ref: https://github.com/jackwener/opencli/issues/10
7
7
  */
8
- export {};
8
+ /**
9
+ * Extract approximate publish date from a Xiaohongshu note URL.
10
+ * XHS note IDs follow MongoDB ObjectID format where the first 8 hex
11
+ * characters encode a Unix timestamp (the moment the ID was generated,
12
+ * which closely matches publish time but is not an official API field).
13
+ * e.g. "697f6c74..." → 0x697f6c74 = 1769958516 → 2026-02-01
14
+ */
15
+ export declare function noteIdToDate(url: string): string;
@@ -7,6 +7,24 @@
7
7
  */
8
8
  import { cli, Strategy } from '../../registry.js';
9
9
  import { AuthRequiredError } from '../../errors.js';
10
+ /**
11
+ * Extract approximate publish date from a Xiaohongshu note URL.
12
+ * XHS note IDs follow MongoDB ObjectID format where the first 8 hex
13
+ * characters encode a Unix timestamp (the moment the ID was generated,
14
+ * which closely matches publish time but is not an official API field).
15
+ * e.g. "697f6c74..." → 0x697f6c74 = 1769958516 → 2026-02-01
16
+ */
17
+ export function noteIdToDate(url) {
18
+ const match = url.match(/\/(?:search_result|explore|note)\/([0-9a-f]{24})(?=[?#/]|$)/i);
19
+ if (!match)
20
+ return '';
21
+ const hex = match[1].substring(0, 8);
22
+ const ts = parseInt(hex, 16);
23
+ if (!ts || ts < 1_000_000_000 || ts > 4_000_000_000)
24
+ return '';
25
+ // Offset by UTC+8 (China Standard Time) so the date matches what XHS users see
26
+ return new Date((ts + 8 * 3600) * 1000).toISOString().slice(0, 10);
27
+ }
10
28
  cli({
11
29
  site: 'xiaohongshu',
12
30
  name: 'search',
@@ -17,7 +35,7 @@ cli({
17
35
  { name: 'query', required: true, positional: true, help: 'Search keyword' },
18
36
  { name: 'limit', type: 'int', default: 20, help: 'Number of results' },
19
37
  ],
20
- columns: ['rank', 'title', 'author', 'likes', 'url'],
38
+ columns: ['rank', 'title', 'author', 'likes', 'published_at', 'url'],
21
39
  func: async (page, kwargs) => {
22
40
  const keyword = encodeURIComponent(kwargs.query);
23
41
  await page.goto(`https://www.xiaohongshu.com/search_result?keyword=${keyword}&source=web_search_result_notes`);
@@ -89,6 +107,7 @@ cli({
89
107
  .map((item, i) => ({
90
108
  rank: i + 1,
91
109
  ...item,
110
+ published_at: noteIdToDate(item.url),
92
111
  }));
93
112
  },
94
113
  });
@@ -1 +1 @@
1
- import './search.js';
1
+ export {};
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
2
  import { getRegistry } from '../../registry.js';
3
- import './search.js';
3
+ import { noteIdToDate } from './search.js';
4
4
  function createPageMock(evaluateResults) {
5
5
  const evaluate = vi.fn();
6
6
  for (const result of evaluateResults) {
@@ -70,6 +70,7 @@ describe('xiaohongshu search', () => {
70
70
  title: '某鱼买FSD被坑了4万',
71
71
  author: '随风',
72
72
  likes: '261',
73
+ published_at: '2025-10-10',
73
74
  url: detailUrl,
74
75
  author_url: authorUrl,
75
76
  },
@@ -112,3 +113,33 @@ describe('xiaohongshu search', () => {
112
113
  expect(result[0]).toMatchObject({ rank: 1, title: 'Result A' });
113
114
  });
114
115
  });
116
+ describe('noteIdToDate (ObjectID timestamp parsing)', () => {
117
+ it('parses a known note ID to the correct China-timezone date', () => {
118
+ // 0x697f6c74 = 1769958516 → 2026-02-01 in UTC+8
119
+ expect(noteIdToDate('https://www.xiaohongshu.com/search_result/697f6c74000000002103de17')).toBe('2026-02-01');
120
+ // 0x68e90be8 → 2025-10-10 in UTC+8
121
+ expect(noteIdToDate('https://www.xiaohongshu.com/explore/68e90be80000000004022e66')).toBe('2025-10-10');
122
+ });
123
+ it('returns China date when UTC+8 crosses into the next day', () => {
124
+ // 0x69b739f0 = 2026-03-15 23:00 UTC = 2026-03-16 07:00 CST
125
+ // Without UTC+8 offset this would incorrectly return 2026-03-15
126
+ expect(noteIdToDate('https://www.xiaohongshu.com/search_result/69b739f00000000000000000')).toBe('2026-03-16');
127
+ });
128
+ it('handles /note/ path variant', () => {
129
+ expect(noteIdToDate('https://www.xiaohongshu.com/note/697f6c74000000002103de17')).toBe('2026-02-01');
130
+ });
131
+ it('handles URL with query parameters', () => {
132
+ expect(noteIdToDate('https://www.xiaohongshu.com/search_result/697f6c74000000002103de17?xsec_token=abc')).toBe('2026-02-01');
133
+ });
134
+ it('returns empty string for non-matching URLs', () => {
135
+ expect(noteIdToDate('https://www.xiaohongshu.com/user/profile/635a9c720000000018028b40')).toBe('');
136
+ expect(noteIdToDate('https://www.xiaohongshu.com/')).toBe('');
137
+ });
138
+ it('returns empty string for IDs shorter than 24 hex chars', () => {
139
+ expect(noteIdToDate('https://www.xiaohongshu.com/search_result/abcdef')).toBe('');
140
+ });
141
+ it('returns empty string when timestamp is out of range', () => {
142
+ // All zeros → ts = 0
143
+ expect(noteIdToDate('https://www.xiaohongshu.com/search_result/000000000000000000000000')).toBe('');
144
+ });
145
+ });
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import { Command } from 'commander';
13
13
  import { type CliCommand } from './registry.js';
14
+ export declare function normalizeArgValue(argType: string | undefined, value: unknown, name: string): unknown;
14
15
  /**
15
16
  * Register a single CliCommand as a Commander subcommand.
16
17
  */