@jackwener/opencli 1.6.0 → 1.6.2

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 (390) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/CONTRIBUTING.md +1 -1
  3. package/README.md +27 -45
  4. package/README.zh-CN.md +32 -34
  5. package/autoresearch/browse-tasks.json +18 -20
  6. package/autoresearch/commands/debug.ts +163 -0
  7. package/autoresearch/commands/fix.ts +145 -0
  8. package/autoresearch/commands/plan.ts +88 -0
  9. package/autoresearch/commands/run.ts +138 -0
  10. package/autoresearch/config.ts +82 -0
  11. package/autoresearch/engine.ts +359 -0
  12. package/autoresearch/eval-all.ts +127 -0
  13. package/autoresearch/eval-browse.ts +1 -1
  14. package/autoresearch/eval-publish.ts +238 -0
  15. package/autoresearch/eval-save.ts +249 -0
  16. package/autoresearch/eval-skill.ts +14 -8
  17. package/autoresearch/eval-v2ex.ts +220 -0
  18. package/autoresearch/eval-zhihu.ts +230 -0
  19. package/autoresearch/logger.ts +69 -0
  20. package/autoresearch/presets/combined-reliability.ts +27 -0
  21. package/autoresearch/presets/index.ts +23 -0
  22. package/autoresearch/presets/operate-reliability.ts +24 -0
  23. package/autoresearch/presets/save-reliability.ts +26 -0
  24. package/autoresearch/presets/skill-quality.ts +20 -0
  25. package/autoresearch/presets/v2ex-reliability.ts +24 -0
  26. package/autoresearch/presets/zhihu-reliability.ts +25 -0
  27. package/autoresearch/publish-tasks.json +345 -0
  28. package/autoresearch/run-save.sh +11 -0
  29. package/autoresearch/save-adapters/xhs-explore-deep.ts +64 -0
  30. package/autoresearch/save-adapters/xhs-note-comments.ts +61 -0
  31. package/autoresearch/save-adapters/xhs-search-full.ts +62 -0
  32. package/autoresearch/save-adapters/zhihu-hot-detail.ts +52 -0
  33. package/autoresearch/save-adapters/zhihu-question-full.ts +57 -0
  34. package/autoresearch/save-adapters/zhihu-search-detail.ts +53 -0
  35. package/autoresearch/save-tasks.json +281 -0
  36. package/autoresearch/v2ex-tasks.json +899 -0
  37. package/autoresearch/zhihu-tasks.json +848 -0
  38. package/bun.lock +615 -0
  39. package/dist/browser/base-page.d.ts +4 -2
  40. package/dist/browser/base-page.js +37 -4
  41. package/dist/browser/bridge.js +10 -8
  42. package/dist/browser/cdp.js +2 -6
  43. package/dist/browser/daemon-client.d.ts +11 -1
  44. package/dist/browser/daemon-client.js +3 -0
  45. package/dist/browser/dom-helpers.d.ts +4 -2
  46. package/dist/browser/dom-helpers.js +42 -31
  47. package/dist/browser/dom-snapshot.js +23 -1
  48. package/dist/browser/page.d.ts +7 -2
  49. package/dist/browser/page.js +112 -30
  50. package/dist/browser.test.js +1 -1
  51. package/dist/build-manifest.d.ts +1 -0
  52. package/dist/build-manifest.js +1 -0
  53. package/dist/cli-manifest.json +1133 -182
  54. package/dist/cli.d.ts +2 -0
  55. package/dist/cli.js +48 -7
  56. package/dist/cli.test.d.ts +1 -0
  57. package/dist/cli.test.js +88 -0
  58. package/dist/clis/1688/item.d.ts +70 -0
  59. package/dist/clis/1688/item.js +187 -0
  60. package/dist/clis/1688/item.test.d.ts +1 -0
  61. package/dist/clis/1688/item.test.js +67 -0
  62. package/dist/clis/1688/search.d.ts +56 -0
  63. package/dist/clis/1688/search.js +309 -0
  64. package/dist/clis/1688/search.test.d.ts +1 -0
  65. package/dist/clis/1688/search.test.js +75 -0
  66. package/dist/clis/1688/shared.d.ts +112 -0
  67. package/dist/clis/1688/shared.js +514 -0
  68. package/dist/clis/1688/shared.test.d.ts +1 -0
  69. package/dist/clis/1688/shared.test.js +57 -0
  70. package/dist/clis/1688/store.d.ts +45 -0
  71. package/dist/clis/1688/store.js +226 -0
  72. package/dist/clis/1688/store.test.d.ts +1 -0
  73. package/dist/clis/1688/store.test.js +62 -0
  74. package/dist/clis/amazon/bestsellers.d.ts +0 -20
  75. package/dist/clis/amazon/bestsellers.js +6 -129
  76. package/dist/clis/amazon/bestsellers.test.js +12 -3
  77. package/dist/clis/amazon/movers-shakers.d.ts +1 -0
  78. package/dist/clis/amazon/movers-shakers.js +7 -0
  79. package/dist/clis/amazon/new-releases.d.ts +1 -0
  80. package/dist/clis/amazon/new-releases.js +7 -0
  81. package/dist/clis/amazon/rankings.d.ts +59 -0
  82. package/dist/clis/amazon/rankings.js +226 -0
  83. package/dist/clis/amazon/rankings.test.d.ts +1 -0
  84. package/dist/clis/amazon/rankings.test.js +41 -0
  85. package/dist/clis/amazon/shared.d.ts +11 -0
  86. package/dist/clis/amazon/shared.js +121 -11
  87. package/dist/clis/amazon/shared.test.js +11 -0
  88. package/dist/clis/bilibili/comments.js +2 -2
  89. package/dist/clis/bilibili/comments.test.js +3 -2
  90. package/dist/clis/bilibili/download.js +2 -1
  91. package/dist/clis/bilibili/subtitle.js +4 -3
  92. package/dist/clis/bilibili/subtitle.test.js +2 -1
  93. package/dist/clis/bilibili/utils.d.ts +5 -0
  94. package/dist/clis/bilibili/utils.js +30 -0
  95. package/dist/clis/bilibili/utils.test.d.ts +1 -0
  96. package/dist/clis/bilibili/utils.test.js +17 -0
  97. package/dist/clis/douban/marks.js +1 -1
  98. package/dist/clis/douban/subject.yaml +50 -19
  99. package/dist/clis/doubao/utils.js +32 -12
  100. package/dist/clis/douyin/_shared/browser-fetch.test.js +0 -1
  101. package/dist/clis/douyin/_shared/transcode.test.js +0 -2
  102. package/dist/clis/douyin/draft.test.js +0 -2
  103. package/dist/clis/facebook/search.test.js +0 -2
  104. package/dist/clis/gemini/ask.js +9 -3
  105. package/dist/clis/gemini/ask.test.d.ts +1 -0
  106. package/dist/clis/gemini/ask.test.js +100 -0
  107. package/dist/clis/gemini/reply-state.test.d.ts +1 -0
  108. package/dist/clis/gemini/reply-state.test.js +641 -0
  109. package/dist/clis/gemini/utils.d.ts +44 -1
  110. package/dist/clis/gemini/utils.js +528 -61
  111. package/dist/clis/gemini/utils.test.js +149 -2
  112. package/dist/clis/hupu/detail.d.ts +1 -0
  113. package/dist/clis/hupu/detail.js +72 -0
  114. package/dist/clis/hupu/hot.yaml +43 -0
  115. package/dist/clis/hupu/like.d.ts +1 -0
  116. package/dist/clis/hupu/like.js +75 -0
  117. package/dist/clis/hupu/reply.d.ts +1 -0
  118. package/dist/clis/hupu/reply.js +71 -0
  119. package/dist/clis/hupu/search.d.ts +1 -0
  120. package/dist/clis/hupu/search.js +59 -0
  121. package/dist/clis/hupu/unlike.d.ts +1 -0
  122. package/dist/clis/hupu/unlike.js +75 -0
  123. package/dist/clis/hupu/utils.d.ts +20 -0
  124. package/dist/clis/hupu/utils.js +319 -0
  125. package/dist/clis/instagram/_shared/private-publish.d.ts +138 -0
  126. package/dist/clis/instagram/_shared/private-publish.js +1030 -0
  127. package/dist/clis/instagram/_shared/private-publish.test.d.ts +1 -0
  128. package/dist/clis/instagram/_shared/private-publish.test.js +705 -0
  129. package/dist/clis/instagram/_shared/protocol-capture.d.ts +26 -0
  130. package/dist/clis/instagram/_shared/protocol-capture.js +282 -0
  131. package/dist/clis/instagram/_shared/protocol-capture.test.d.ts +1 -0
  132. package/dist/clis/instagram/_shared/protocol-capture.test.js +114 -0
  133. package/dist/clis/instagram/_shared/runtime-info.d.ts +9 -0
  134. package/dist/clis/instagram/_shared/runtime-info.js +81 -0
  135. package/dist/clis/instagram/note.d.ts +1 -0
  136. package/dist/clis/instagram/note.js +222 -0
  137. package/dist/clis/instagram/note.test.d.ts +1 -0
  138. package/dist/clis/instagram/note.test.js +81 -0
  139. package/dist/clis/instagram/post.d.ts +4 -0
  140. package/dist/clis/instagram/post.js +1496 -0
  141. package/dist/clis/instagram/post.test.d.ts +1 -0
  142. package/dist/clis/instagram/post.test.js +1647 -0
  143. package/dist/clis/instagram/reel.d.ts +1 -0
  144. package/dist/clis/instagram/reel.js +826 -0
  145. package/dist/clis/instagram/reel.test.d.ts +1 -0
  146. package/dist/clis/instagram/reel.test.js +167 -0
  147. package/dist/clis/instagram/story.d.ts +1 -0
  148. package/dist/clis/instagram/story.js +115 -0
  149. package/dist/clis/instagram/story.test.d.ts +1 -0
  150. package/dist/clis/instagram/story.test.js +167 -0
  151. package/dist/clis/sinafinance/stock-rank.d.ts +4 -0
  152. package/dist/clis/sinafinance/stock-rank.js +65 -0
  153. package/dist/clis/substack/utils.test.js +0 -2
  154. package/dist/clis/twitter/post.js +72 -45
  155. package/dist/clis/twitter/post.test.d.ts +1 -0
  156. package/dist/clis/twitter/post.test.js +116 -0
  157. package/dist/clis/twitter/reply.d.ts +12 -0
  158. package/dist/clis/twitter/reply.js +257 -35
  159. package/dist/clis/twitter/reply.test.d.ts +1 -0
  160. package/dist/clis/twitter/reply.test.js +151 -0
  161. package/dist/clis/twitter/search.js +67 -5
  162. package/dist/clis/twitter/search.test.js +83 -5
  163. package/dist/clis/xianyu/chat.d.ts +7 -0
  164. package/dist/clis/xianyu/chat.js +146 -0
  165. package/dist/clis/xianyu/chat.test.d.ts +1 -0
  166. package/dist/clis/xianyu/chat.test.js +15 -0
  167. package/dist/clis/xianyu/item.d.ts +7 -0
  168. package/dist/clis/xianyu/item.js +152 -0
  169. package/dist/clis/xianyu/item.test.d.ts +1 -0
  170. package/dist/clis/xianyu/item.test.js +56 -0
  171. package/dist/clis/xianyu/search.d.ts +10 -0
  172. package/dist/clis/xianyu/search.js +134 -0
  173. package/dist/clis/xianyu/search.test.d.ts +1 -0
  174. package/dist/clis/xianyu/search.test.js +17 -0
  175. package/dist/clis/xianyu/utils.d.ts +1 -0
  176. package/dist/clis/xianyu/utils.js +8 -0
  177. package/dist/clis/xiaoe/catalog.yaml +129 -0
  178. package/dist/clis/xiaoe/content.yaml +43 -0
  179. package/dist/clis/xiaoe/courses.yaml +73 -0
  180. package/dist/clis/xiaoe/detail.yaml +39 -0
  181. package/dist/clis/xiaoe/play-url.yaml +124 -0
  182. package/dist/clis/xiaohongshu/comments.test.js +0 -2
  183. package/dist/clis/xiaohongshu/creator-note-detail.test.js +0 -2
  184. package/dist/clis/xiaohongshu/creator-notes.test.js +0 -2
  185. package/dist/clis/xiaohongshu/download.test.js +0 -2
  186. package/dist/clis/xiaohongshu/note.test.js +0 -2
  187. package/dist/clis/xiaohongshu/publish.test.js +0 -2
  188. package/dist/clis/xiaohongshu/search.js +29 -20
  189. package/dist/clis/xiaohongshu/search.test.js +56 -48
  190. package/dist/clis/yuanbao/ask.d.ts +21 -0
  191. package/dist/clis/yuanbao/ask.js +427 -0
  192. package/dist/clis/yuanbao/ask.test.d.ts +1 -0
  193. package/dist/clis/yuanbao/ask.test.js +124 -0
  194. package/dist/clis/yuanbao/new.d.ts +1 -0
  195. package/dist/clis/yuanbao/new.js +70 -0
  196. package/dist/clis/yuanbao/new.test.d.ts +1 -0
  197. package/dist/clis/yuanbao/new.test.js +30 -0
  198. package/dist/clis/yuanbao/shared.d.ts +13 -0
  199. package/dist/clis/yuanbao/shared.js +49 -0
  200. package/dist/clis/zhihu/question.js +30 -19
  201. package/dist/clis/zhihu/question.test.js +34 -16
  202. package/dist/commanderAdapter.js +8 -4
  203. package/dist/commanderAdapter.test.js +42 -0
  204. package/dist/completion.js +3 -1
  205. package/dist/completion.test.d.ts +1 -0
  206. package/dist/completion.test.js +23 -0
  207. package/dist/doctor.js +1 -1
  208. package/dist/electron-apps.d.ts +2 -0
  209. package/dist/electron-apps.js +7 -1
  210. package/dist/errors.js +1 -1
  211. package/dist/execution.js +25 -35
  212. package/dist/explore.js +1 -1
  213. package/dist/launcher.d.ts +4 -0
  214. package/dist/launcher.js +64 -8
  215. package/dist/launcher.test.js +88 -7
  216. package/dist/output.d.ts +2 -0
  217. package/dist/output.js +10 -1
  218. package/dist/output.test.d.ts +0 -3
  219. package/dist/output.test.js +59 -92
  220. package/dist/pipeline/executor.test.js +0 -2
  221. package/dist/pipeline/steps/download.test.js +0 -2
  222. package/dist/registry.d.ts +2 -0
  223. package/dist/serialization.d.ts +1 -0
  224. package/dist/serialization.js +1 -0
  225. package/dist/types.d.ts +9 -2
  226. package/docs/.vitepress/config.mts +4 -0
  227. package/docs/adapters/browser/1688.md +52 -0
  228. package/docs/adapters/browser/36kr.md +2 -1
  229. package/docs/adapters/browser/doubao.md +5 -1
  230. package/docs/adapters/browser/hupu.md +53 -0
  231. package/docs/adapters/browser/sinafinance.md +32 -2
  232. package/docs/adapters/browser/weibo.md +6 -1
  233. package/docs/adapters/browser/wikipedia.md +2 -0
  234. package/docs/adapters/browser/xianyu.md +42 -0
  235. package/docs/adapters/browser/xiaoe.md +44 -0
  236. package/docs/adapters/browser/yuanbao.md +64 -0
  237. package/docs/adapters/index.md +14 -5
  238. package/docs/comparison.md +1 -1
  239. package/docs/developer/ai-workflow.md +2 -2
  240. package/docs/developer/contributing.md +1 -1
  241. package/docs/developer/testing.md +2 -0
  242. package/docs/guide/plugins.md +1 -0
  243. package/docs/guide/troubleshooting.md +11 -0
  244. package/docs/superpowers/specs/2026-04-03-v2ex-autoresearch-design.md +41 -0
  245. package/docs/zh/guide/plugins.md +1 -0
  246. package/extension/dist/background.js +1127 -0
  247. package/extension/src/background.test.ts +39 -0
  248. package/extension/src/background.ts +223 -34
  249. package/extension/src/cdp.ts +194 -4
  250. package/extension/src/protocol.ts +22 -1
  251. package/package.json +3 -2
  252. package/scripts/postinstall.js +1 -1
  253. package/skills/opencli-explorer/SKILL.md +1 -1
  254. package/skills/opencli-oneshot/SKILL.md +2 -2
  255. package/skills/opencli-operate/SKILL.md +120 -27
  256. package/skills/opencli-usage/SKILL.md +31 -20
  257. package/skills/opencli-usage/browser.md +114 -16
  258. package/skills/opencli-usage/public-api.md +32 -3
  259. package/skills/smart-search/SKILL.md +156 -0
  260. package/skills/smart-search/references/sources-ai.md +74 -0
  261. package/skills/smart-search/references/sources-info.md +43 -0
  262. package/skills/smart-search/references/sources-media.md +50 -0
  263. package/skills/smart-search/references/sources-other.md +42 -0
  264. package/skills/smart-search/references/sources-shopping.md +31 -0
  265. package/skills/smart-search/references/sources-social.md +51 -0
  266. package/skills/smart-search/references/sources-tech.md +42 -0
  267. package/skills/smart-search/references/sources-travel.md +20 -0
  268. package/src/browser/base-page.ts +41 -6
  269. package/src/browser/bridge.ts +11 -8
  270. package/src/browser/cdp.ts +1 -8
  271. package/src/browser/daemon-client.ts +11 -1
  272. package/src/browser/dom-helpers.ts +43 -31
  273. package/src/browser/dom-snapshot.ts +23 -1
  274. package/src/browser/page.ts +115 -31
  275. package/src/browser.test.ts +1 -1
  276. package/src/build-manifest.ts +2 -0
  277. package/src/cli.test.ts +133 -0
  278. package/src/cli.ts +73 -11
  279. package/src/clis/1688/item.test.ts +69 -0
  280. package/src/clis/1688/item.ts +282 -0
  281. package/src/clis/1688/search.test.ts +81 -0
  282. package/src/clis/1688/search.ts +402 -0
  283. package/src/clis/1688/shared.test.ts +75 -0
  284. package/src/clis/1688/shared.ts +623 -0
  285. package/src/clis/1688/store.test.ts +69 -0
  286. package/src/clis/1688/store.ts +300 -0
  287. package/src/clis/amazon/bestsellers.test.ts +12 -3
  288. package/src/clis/amazon/bestsellers.ts +6 -178
  289. package/src/clis/amazon/movers-shakers.ts +8 -0
  290. package/src/clis/amazon/new-releases.ts +8 -0
  291. package/src/clis/amazon/rankings.test.ts +47 -0
  292. package/src/clis/amazon/rankings.ts +312 -0
  293. package/src/clis/amazon/shared.test.ts +16 -0
  294. package/src/clis/amazon/shared.ts +134 -12
  295. package/src/clis/bilibili/comments.test.ts +4 -3
  296. package/src/clis/bilibili/comments.ts +2 -2
  297. package/src/clis/bilibili/download.ts +2 -1
  298. package/src/clis/bilibili/subtitle.test.ts +2 -1
  299. package/src/clis/bilibili/subtitle.ts +4 -3
  300. package/src/clis/bilibili/utils.test.ts +21 -0
  301. package/src/clis/bilibili/utils.ts +27 -0
  302. package/src/clis/douban/marks.ts +1 -1
  303. package/src/clis/douban/subject.yaml +50 -19
  304. package/src/clis/doubao/utils.ts +32 -12
  305. package/src/clis/douyin/_shared/browser-fetch.test.ts +0 -1
  306. package/src/clis/douyin/_shared/transcode.test.ts +0 -2
  307. package/src/clis/douyin/draft.test.ts +0 -2
  308. package/src/clis/facebook/search.test.ts +0 -2
  309. package/src/clis/gemini/ask.test.ts +116 -0
  310. package/src/clis/gemini/ask.ts +10 -3
  311. package/src/clis/gemini/reply-state.test.ts +708 -0
  312. package/src/clis/gemini/utils.test.ts +184 -2
  313. package/src/clis/gemini/utils.ts +588 -60
  314. package/src/clis/hupu/detail.ts +126 -0
  315. package/src/clis/hupu/hot.yaml +43 -0
  316. package/src/clis/hupu/like.ts +76 -0
  317. package/src/clis/hupu/reply.ts +76 -0
  318. package/src/clis/hupu/search.ts +95 -0
  319. package/src/clis/hupu/unlike.ts +76 -0
  320. package/src/clis/hupu/utils.ts +381 -0
  321. package/src/clis/instagram/_shared/private-publish.test.ts +827 -0
  322. package/src/clis/instagram/_shared/private-publish.ts +1303 -0
  323. package/src/clis/instagram/_shared/protocol-capture.test.ts +148 -0
  324. package/src/clis/instagram/_shared/protocol-capture.ts +321 -0
  325. package/src/clis/instagram/_shared/runtime-info.ts +91 -0
  326. package/src/clis/instagram/note.test.ts +96 -0
  327. package/src/clis/instagram/note.ts +254 -0
  328. package/src/clis/instagram/post.test.ts +1716 -0
  329. package/src/clis/instagram/post.ts +1620 -0
  330. package/src/clis/instagram/reel.test.ts +191 -0
  331. package/src/clis/instagram/reel.ts +886 -0
  332. package/src/clis/instagram/story.test.ts +191 -0
  333. package/src/clis/instagram/story.ts +151 -0
  334. package/src/clis/sinafinance/stock-rank.ts +68 -0
  335. package/src/clis/substack/utils.test.ts +0 -2
  336. package/src/clis/twitter/post.test.ts +157 -0
  337. package/src/clis/twitter/post.ts +82 -48
  338. package/src/clis/twitter/reply.test.ts +177 -0
  339. package/src/clis/twitter/reply.ts +285 -39
  340. package/src/clis/twitter/search.test.ts +88 -5
  341. package/src/clis/twitter/search.ts +68 -5
  342. package/src/clis/xianyu/chat.test.ts +20 -0
  343. package/src/clis/xianyu/chat.ts +175 -0
  344. package/src/clis/xianyu/item.test.ts +67 -0
  345. package/src/clis/xianyu/item.ts +172 -0
  346. package/src/clis/xianyu/search.test.ts +22 -0
  347. package/src/clis/xianyu/search.ts +151 -0
  348. package/src/clis/xianyu/utils.ts +9 -0
  349. package/src/clis/xiaoe/catalog.yaml +129 -0
  350. package/src/clis/xiaoe/content.yaml +43 -0
  351. package/src/clis/xiaoe/courses.yaml +73 -0
  352. package/src/clis/xiaoe/detail.yaml +39 -0
  353. package/src/clis/xiaoe/play-url.yaml +124 -0
  354. package/src/clis/xiaohongshu/comments.test.ts +0 -2
  355. package/src/clis/xiaohongshu/creator-note-detail.test.ts +0 -2
  356. package/src/clis/xiaohongshu/creator-notes.test.ts +0 -2
  357. package/src/clis/xiaohongshu/download.test.ts +0 -2
  358. package/src/clis/xiaohongshu/note.test.ts +0 -2
  359. package/src/clis/xiaohongshu/publish.test.ts +0 -2
  360. package/src/clis/xiaohongshu/search.test.ts +59 -48
  361. package/src/clis/xiaohongshu/search.ts +31 -21
  362. package/src/clis/yuanbao/ask.test.ts +156 -0
  363. package/src/clis/yuanbao/ask.ts +522 -0
  364. package/src/clis/yuanbao/new.test.ts +36 -0
  365. package/src/clis/yuanbao/new.ts +81 -0
  366. package/src/clis/yuanbao/shared.ts +57 -0
  367. package/src/clis/zhihu/question.test.ts +42 -17
  368. package/src/clis/zhihu/question.ts +31 -26
  369. package/src/commanderAdapter.test.ts +51 -0
  370. package/src/commanderAdapter.ts +8 -4
  371. package/src/completion.test.ts +30 -0
  372. package/src/completion.ts +3 -1
  373. package/src/doctor.ts +1 -1
  374. package/src/electron-apps.ts +9 -1
  375. package/src/errors.ts +1 -1
  376. package/src/execution.ts +26 -30
  377. package/src/explore.ts +1 -1
  378. package/src/launcher.test.ts +121 -7
  379. package/src/launcher.ts +87 -9
  380. package/src/output.test.ts +50 -90
  381. package/src/output.ts +10 -1
  382. package/src/pipeline/executor.test.ts +0 -2
  383. package/src/pipeline/steps/download.test.ts +0 -2
  384. package/src/registry.ts +2 -0
  385. package/src/serialization.ts +2 -0
  386. package/src/types.ts +9 -2
  387. package/tests/e2e/browser-auth.test.ts +9 -0
  388. package/CLI-EXPLORER.md +0 -724
  389. package/CLI-ONESHOT.md +0 -216
  390. package/SKILL.md +0 -59
@@ -153,15 +153,98 @@ describe('twitter search command', () => {
153
153
  expect(pushStateCall).toContain('f=top');
154
154
  });
155
155
 
156
+ it('falls back to search input when pushState fails twice', async () => {
157
+ const command = getRegistry().get('twitter/search');
158
+ expect(command?.func).toBeTypeOf('function');
159
+
160
+ const evaluate = vi.fn()
161
+ .mockResolvedValueOnce(undefined) // pushState attempt 1
162
+ .mockResolvedValueOnce('/explore') // pathname check 1 — not /search
163
+ .mockResolvedValueOnce(undefined) // pushState attempt 2
164
+ .mockResolvedValueOnce('/explore') // pathname check 2 — still not /search
165
+ .mockResolvedValueOnce({ ok: true }) // search input fallback succeeds
166
+ .mockResolvedValueOnce('/search'); // pathname check after fallback
167
+
168
+ const page = {
169
+ goto: vi.fn().mockResolvedValue(undefined),
170
+ wait: vi.fn().mockResolvedValue(undefined),
171
+ installInterceptor: vi.fn().mockResolvedValue(undefined),
172
+ evaluate,
173
+ autoScroll: vi.fn().mockResolvedValue(undefined),
174
+ getInterceptedRequests: vi.fn().mockResolvedValue([
175
+ {
176
+ data: {
177
+ search_by_raw_query: {
178
+ search_timeline: {
179
+ timeline: {
180
+ instructions: [
181
+ {
182
+ type: 'TimelineAddEntries',
183
+ entries: [
184
+ {
185
+ entryId: 'tweet-99',
186
+ content: {
187
+ itemContent: {
188
+ tweet_results: {
189
+ result: {
190
+ rest_id: '99',
191
+ legacy: {
192
+ full_text: 'fallback works',
193
+ favorite_count: 3,
194
+ created_at: 'Wed Apr 02 12:00:00 +0000 2026',
195
+ },
196
+ core: {
197
+ user_results: {
198
+ result: {
199
+ core: { screen_name: 'bob' },
200
+ },
201
+ },
202
+ },
203
+ views: { count: '5' },
204
+ },
205
+ },
206
+ },
207
+ },
208
+ },
209
+ ],
210
+ },
211
+ ],
212
+ },
213
+ },
214
+ },
215
+ },
216
+ },
217
+ ]),
218
+ };
219
+
220
+ const result = await command!.func!(page as any, { query: 'test fallback', filter: 'top', limit: 5 });
221
+
222
+ expect(result).toEqual([
223
+ {
224
+ id: '99',
225
+ author: 'bob',
226
+ text: 'fallback works',
227
+ created_at: 'Wed Apr 02 12:00:00 +0000 2026',
228
+ likes: 3,
229
+ views: '5',
230
+ url: 'https://x.com/i/status/99',
231
+ },
232
+ ]);
233
+ // 6 evaluate calls: 2x pushState + 2x pathname check + 1x fallback + 1x pathname check
234
+ expect(evaluate).toHaveBeenCalledTimes(6);
235
+ expect(page.autoScroll).toHaveBeenCalled();
236
+ });
237
+
156
238
  it('throws with the final path after both attempts fail', async () => {
157
239
  const command = getRegistry().get('twitter/search');
158
240
  expect(command?.func).toBeTypeOf('function');
159
241
 
160
242
  const evaluate = vi.fn()
161
- .mockResolvedValueOnce(undefined)
162
- .mockResolvedValueOnce('/explore')
163
- .mockResolvedValueOnce(undefined)
164
- .mockResolvedValueOnce('/login');
243
+ .mockResolvedValueOnce(undefined) // pushState attempt 1
244
+ .mockResolvedValueOnce('/explore') // pathname check 1
245
+ .mockResolvedValueOnce(undefined) // pushState attempt 2
246
+ .mockResolvedValueOnce('/login') // pathname check 2
247
+ .mockResolvedValueOnce({ ok: false }); // search input fallback
165
248
 
166
249
  const page = {
167
250
  goto: vi.fn().mockResolvedValue(undefined),
@@ -177,6 +260,6 @@ describe('twitter search command', () => {
177
260
  .toThrow('Final path: /login');
178
261
  expect(page.autoScroll).not.toHaveBeenCalled();
179
262
  expect(page.getInterceptedRequests).not.toHaveBeenCalled();
180
- expect(evaluate).toHaveBeenCalledTimes(4);
263
+ expect(evaluate).toHaveBeenCalledTimes(5);
181
264
  });
182
265
  });
@@ -3,16 +3,19 @@ import { cli, Strategy } from '../../registry.js';
3
3
  import type { IPage } from '../../types.js';
4
4
 
5
5
  /**
6
- * Trigger Twitter search SPA navigation and retry once on transient failures.
6
+ * Trigger Twitter search SPA navigation with fallback strategies.
7
7
  *
8
- * Twitter/X sometimes keeps the page on /explore for a short period even after
9
- * pushState + popstate. A second attempt is enough for the intermittent cases
10
- * reported in issue #353 while keeping the flow narrowly scoped.
8
+ * Primary: pushState + popstate (works in most environments).
9
+ * Fallback: Type into the search input and press Enter when pushState fails
10
+ * intermittently (e.g. due to Twitter A/B tests or timing races — see #690).
11
+ *
12
+ * Both strategies preserve the JS context so the fetch interceptor stays alive.
11
13
  */
12
14
  async function navigateToSearch(page: Pick<IPage, 'evaluate' | 'wait'>, query: string, filter: string): Promise<void> {
13
15
  const searchUrl = JSON.stringify(`/search?q=${encodeURIComponent(query)}&f=${filter}`);
14
16
  let lastPath = '';
15
17
 
18
+ // Strategy 1 (primary): pushState + popstate with retry
16
19
  for (let attempt = 1; attempt <= 2; attempt++) {
17
20
  await page.evaluate(`
18
21
  (() => {
@@ -20,7 +23,12 @@ async function navigateToSearch(page: Pick<IPage, 'evaluate' | 'wait'>, query: s
20
23
  window.dispatchEvent(new PopStateEvent('popstate', { state: {} }));
21
24
  })()
22
25
  `);
23
- await page.wait({ selector: '[data-testid="primaryColumn"]' });
26
+
27
+ try {
28
+ await page.wait({ selector: '[data-testid="primaryColumn"]' });
29
+ } catch {
30
+ // selector timeout — fall through to path check or next attempt
31
+ }
24
32
 
25
33
  lastPath = String(await page.evaluate('() => window.location.pathname') || '');
26
34
  if (lastPath.startsWith('/search')) {
@@ -32,6 +40,61 @@ async function navigateToSearch(page: Pick<IPage, 'evaluate' | 'wait'>, query: s
32
40
  }
33
41
  }
34
42
 
43
+ // Strategy 2 (fallback): Use the search input on /explore.
44
+ // The nativeSetter + Enter approach triggers Twitter's own form handler,
45
+ // performing SPA navigation without a full page reload.
46
+ const queryStr = JSON.stringify(query);
47
+ const navResult = await page.evaluate(`(async () => {
48
+ try {
49
+ const input = document.querySelector('[data-testid="SearchBox_Search_Input"]');
50
+ if (!input) return { ok: false };
51
+
52
+ input.focus();
53
+ await new Promise(r => setTimeout(r, 300));
54
+
55
+ const nativeSetter = Object.getOwnPropertyDescriptor(
56
+ window.HTMLInputElement.prototype, 'value'
57
+ )?.set;
58
+ if (!nativeSetter) return { ok: false };
59
+ nativeSetter.call(input, ${queryStr});
60
+ input.dispatchEvent(new Event('input', { bubbles: true }));
61
+ input.dispatchEvent(new Event('change', { bubbles: true }));
62
+ await new Promise(r => setTimeout(r, 500));
63
+
64
+ input.dispatchEvent(new KeyboardEvent('keydown', {
65
+ key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true
66
+ }));
67
+
68
+ return { ok: true };
69
+ } catch {
70
+ return { ok: false };
71
+ }
72
+ })()`);
73
+
74
+ if (navResult?.ok) {
75
+ try {
76
+ await page.wait({ selector: '[data-testid="primaryColumn"]' });
77
+ } catch {
78
+ // fall through to path check
79
+ }
80
+ lastPath = String(await page.evaluate('() => window.location.pathname') || '');
81
+ if (lastPath.startsWith('/search')) {
82
+ if (filter === 'live') {
83
+ await page.evaluate(`(() => {
84
+ const tabs = document.querySelectorAll('[role="tab"]');
85
+ for (const tab of tabs) {
86
+ if (tab.textContent.includes('Latest') || tab.textContent.includes('最新')) {
87
+ tab.click();
88
+ return;
89
+ }
90
+ }
91
+ })()`);
92
+ await page.wait(2);
93
+ }
94
+ return;
95
+ }
96
+ }
97
+
35
98
  throw new CommandExecutionError(
36
99
  `SPA navigation to /search failed. Final path: ${lastPath || '(empty)'}. Twitter may have changed its routing.`,
37
100
  );
@@ -0,0 +1,20 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { __test__ } from './chat.js';
3
+
4
+ describe('xianyu chat helpers', () => {
5
+ it('builds goofish im urls from ids', () => {
6
+ expect(__test__.buildChatUrl('1038951278192', '3650092411')).toBe(
7
+ 'https://www.goofish.com/im?itemId=1038951278192&peerUserId=3650092411',
8
+ );
9
+ });
10
+
11
+ it('normalizes numeric ids', () => {
12
+ expect(__test__.normalizeNumericId('1038951278192', 'item_id', '1038951278192')).toBe('1038951278192');
13
+ expect(__test__.normalizeNumericId(3650092411, 'user_id', '3650092411')).toBe('3650092411');
14
+ });
15
+
16
+ it('rejects non-numeric ids', () => {
17
+ expect(() => __test__.normalizeNumericId('abc', 'item_id', '1038951278192')).toThrow();
18
+ expect(() => __test__.normalizeNumericId('3650092411x', 'user_id', '3650092411')).toThrow();
19
+ });
20
+ });
@@ -0,0 +1,175 @@
1
+ import { AuthRequiredError, SelectorError } from '../../errors.js';
2
+ import { cli, Strategy } from '../../registry.js';
3
+ import { normalizeNumericId } from './utils.js';
4
+
5
+ function buildChatUrl(itemId: string, peerUserId: string): string {
6
+ return `https://www.goofish.com/im?itemId=${encodeURIComponent(itemId)}&peerUserId=${encodeURIComponent(peerUserId)}`;
7
+ }
8
+
9
+ function buildExtractChatStateEvaluate(): string {
10
+ return `
11
+ (() => {
12
+ const clean = (value) => (value || '').replace(/\\s+/g, ' ').trim();
13
+ const bodyText = document.body?.innerText || '';
14
+ const requiresAuth = /请先登录|登录后/.test(bodyText);
15
+
16
+ const textarea = document.querySelector('textarea');
17
+ const sendButton = Array.from(document.querySelectorAll('button'))
18
+ .find((btn) => clean(btn.textContent || '') === '发送');
19
+ const topbar = document.querySelector('[class*="message-topbar"]');
20
+ const itemCard = Array.from(document.querySelectorAll('a[href*="/item?id="]'))
21
+ .find((el) => el.closest('main'));
22
+ const itemTitleNode =
23
+ document.querySelector('[class*="container"] [class*="title"]')
24
+ || document.querySelector('[class*="item-main-info"] [class*="desc"]')
25
+ || document.querySelector('[class*="headSkuInfo"]')
26
+ || itemCard?.querySelector('[class*="title"]')
27
+ || itemCard?.previousElementSibling?.querySelector?.('[class*="title"]');
28
+
29
+ const messageRoot = document.querySelector('#message-list-scrollable');
30
+ const visibleMessages = Array.from(
31
+ (messageRoot || document).querySelectorAll('[class*="message"], [class*="msg"], [class*="bubble"]')
32
+ ).map((el) => clean(el.textContent || ''))
33
+ .filter(Boolean)
34
+ .filter((text) => !['发送', '闲鱼号', '立即购买'].includes(text))
35
+ .filter((text) => !/^消息\\d*\\+?$/.test(text))
36
+ .slice(-20);
37
+
38
+ return {
39
+ requiresAuth,
40
+ title: clean(document.title || ''),
41
+ peer_name: clean(topbar?.querySelector('[class*="text1"]')?.textContent || ''),
42
+ peer_masked_id: clean(topbar?.querySelector('[class*="text2"]')?.textContent || '').replace(/^\\(|\\)$/g, ''),
43
+ item_title: clean(itemTitleNode?.textContent || ''),
44
+ item_url: itemCard?.href || '',
45
+ price: clean(itemCard?.querySelector('[class*="money"]')?.textContent || ''),
46
+ location: clean(itemCard?.querySelector('[class*="delivery"] + [class*="delivery"], [class*="delivery"]:last-child')?.textContent || ''),
47
+ can_input: Boolean(textarea && !textarea.disabled),
48
+ can_send: Boolean(sendButton),
49
+ visible_messages: visibleMessages,
50
+ };
51
+ })()
52
+ `;
53
+ }
54
+
55
+ function buildSendMessageEvaluate(text: string): string {
56
+ return `
57
+ (() => {
58
+ const clean = (value) => (value || '').replace(/\\s+/g, ' ').trim();
59
+ const textarea = document.querySelector('textarea');
60
+ if (!textarea || textarea.disabled) {
61
+ return { ok: false, reason: 'input-not-found' };
62
+ }
63
+
64
+ const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set;
65
+ if (!setter) {
66
+ return { ok: false, reason: 'textarea-setter-not-found' };
67
+ }
68
+
69
+ textarea.focus();
70
+ setter.call(textarea, ${JSON.stringify(text)});
71
+ textarea.dispatchEvent(new Event('input', { bubbles: true }));
72
+ textarea.dispatchEvent(new Event('change', { bubbles: true }));
73
+
74
+ const sendButton = Array.from(document.querySelectorAll('button'))
75
+ .find((btn) => clean(btn.textContent || '') === '发送');
76
+ if (!sendButton) {
77
+ return { ok: false, reason: 'send-button-not-found' };
78
+ }
79
+
80
+ sendButton.click();
81
+ return { ok: true };
82
+ })()
83
+ `;
84
+ }
85
+
86
+ cli({
87
+ site: 'xianyu',
88
+ name: 'chat',
89
+ description: '打开闲鱼聊一聊会话,并可选发送消息',
90
+ domain: 'www.goofish.com',
91
+ strategy: Strategy.COOKIE,
92
+ navigateBefore: false,
93
+ browser: true,
94
+ args: [
95
+ { name: 'item_id', required: true, positional: true, help: '闲鱼商品 item_id' },
96
+ { name: 'user_id', required: true, positional: true, help: '聊一聊对方的 user_id / peerUserId' },
97
+ { name: 'text', help: 'Message to send after opening the chat' },
98
+ ],
99
+ columns: ['status', 'peer_name', 'item_title', 'price', 'location', 'message'],
100
+ func: async (page, kwargs) => {
101
+ const itemId = normalizeNumericId(kwargs.item_id, 'item_id', '1038951278192');
102
+ const userId = normalizeNumericId(kwargs.user_id, 'user_id', '3650092411');
103
+ const url = buildChatUrl(itemId, userId);
104
+ const text = String(kwargs.text || '').trim();
105
+
106
+ await page.goto(url);
107
+ await page.wait(2);
108
+
109
+ const state = await page.evaluate(buildExtractChatStateEvaluate()) as {
110
+ requiresAuth?: boolean;
111
+ title?: string;
112
+ peer_name?: string;
113
+ peer_masked_id?: string;
114
+ item_title?: string;
115
+ item_url?: string;
116
+ price?: string;
117
+ location?: string;
118
+ can_input?: boolean;
119
+ can_send?: boolean;
120
+ visible_messages?: string[];
121
+ };
122
+
123
+ if (state?.requiresAuth) {
124
+ throw new AuthRequiredError('www.goofish.com', 'Xianyu chat requires a logged-in browser session');
125
+ }
126
+
127
+ if (!state?.can_input) {
128
+ throw new SelectorError('闲鱼聊天输入框', '未找到可用的聊天输入框,请确认该会话页已正确加载');
129
+ }
130
+
131
+ if (!text) {
132
+ return [{
133
+ status: 'ready',
134
+ peer_name: state.peer_name || '',
135
+ item_title: state.item_title || '',
136
+ price: state.price || '',
137
+ location: state.location || '',
138
+ message: (state.visible_messages || []).slice(-1)[0] || '',
139
+ peer_user_id: userId,
140
+ item_id: itemId,
141
+ url,
142
+ item_url: state.item_url || '',
143
+ }];
144
+ }
145
+
146
+ const sent = await page.evaluate(buildSendMessageEvaluate(text)) as {
147
+ ok?: boolean;
148
+ reason?: string;
149
+ };
150
+
151
+ if (!sent?.ok) {
152
+ throw new SelectorError('闲鱼发送按钮', `消息发送失败:${sent?.reason || 'unknown-reason'}`);
153
+ }
154
+
155
+ await page.wait(1);
156
+
157
+ return [{
158
+ status: 'sent',
159
+ peer_name: state.peer_name || '',
160
+ item_title: state.item_title || '',
161
+ price: state.price || '',
162
+ location: state.location || '',
163
+ message: text,
164
+ peer_user_id: userId,
165
+ item_id: itemId,
166
+ url,
167
+ item_url: state.item_url || '',
168
+ }];
169
+ },
170
+ });
171
+
172
+ export const __test__ = {
173
+ normalizeNumericId,
174
+ buildChatUrl,
175
+ };
@@ -0,0 +1,67 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { AuthRequiredError, EmptyResultError, SelectorError } from '../../errors.js';
3
+ import { getRegistry } from '../../registry.js';
4
+ import type { IPage } from '../../types.js';
5
+ import { __test__ } from './item.js';
6
+ import './item.js';
7
+
8
+ function createPageMock(evaluateResult: unknown): IPage {
9
+ return {
10
+ goto: vi.fn().mockResolvedValue(undefined),
11
+ evaluate: vi.fn().mockResolvedValue(evaluateResult),
12
+ snapshot: vi.fn().mockResolvedValue(undefined),
13
+ click: vi.fn().mockResolvedValue(undefined),
14
+ typeText: vi.fn().mockResolvedValue(undefined),
15
+ pressKey: vi.fn().mockResolvedValue(undefined),
16
+ scrollTo: vi.fn().mockResolvedValue(undefined),
17
+ getFormState: vi.fn().mockResolvedValue({ forms: [], orphanFields: [] }),
18
+ wait: vi.fn().mockResolvedValue(undefined),
19
+ tabs: vi.fn().mockResolvedValue([]),
20
+ selectTab: vi.fn().mockResolvedValue(undefined),
21
+ networkRequests: vi.fn().mockResolvedValue([]),
22
+ consoleMessages: vi.fn().mockResolvedValue([]),
23
+ scroll: vi.fn().mockResolvedValue(undefined),
24
+ autoScroll: vi.fn().mockResolvedValue(undefined),
25
+ installInterceptor: vi.fn().mockResolvedValue(undefined),
26
+ getInterceptedRequests: vi.fn().mockResolvedValue([]),
27
+ getCookies: vi.fn().mockResolvedValue([]),
28
+ screenshot: vi.fn().mockResolvedValue(''),
29
+ waitForCapture: vi.fn().mockResolvedValue(undefined),
30
+ } as IPage;
31
+ }
32
+
33
+ describe('xianyu item helpers', () => {
34
+ it('normalizes numeric item ids', () => {
35
+ expect(__test__.normalizeNumericId('1040754408976', 'item_id', '1040754408976')).toBe('1040754408976');
36
+ expect(__test__.normalizeNumericId(1040754408976, 'item_id', '1040754408976')).toBe('1040754408976');
37
+ });
38
+
39
+ it('builds item urls', () => {
40
+ expect(__test__.buildItemUrl('1040754408976')).toBe(
41
+ 'https://www.goofish.com/item?id=1040754408976',
42
+ );
43
+ });
44
+
45
+ it('rejects invalid item ids', () => {
46
+ expect(() => __test__.normalizeNumericId('abc', 'item_id', '1040754408976')).toThrow();
47
+ });
48
+ });
49
+
50
+ describe('xianyu item command', () => {
51
+ const command = getRegistry().get('xianyu/item');
52
+
53
+ it('throws AuthRequiredError on login wall before mtop is available', async () => {
54
+ const page = createPageMock({ error: 'auth-required' });
55
+ await expect(command!.func!(page, { item_id: '1040754408976' })).rejects.toBeInstanceOf(AuthRequiredError);
56
+ });
57
+
58
+ it('throws EmptyResultError on verification or risk-control pages', async () => {
59
+ const page = createPageMock({ error: 'blocked' });
60
+ await expect(command!.func!(page, { item_id: '1040754408976' })).rejects.toBeInstanceOf(EmptyResultError);
61
+ });
62
+
63
+ it('keeps SelectorError for true mtop initialization failures', async () => {
64
+ const page = createPageMock({ error: 'mtop-not-ready' });
65
+ await expect(command!.func!(page, { item_id: '1040754408976' })).rejects.toBeInstanceOf(SelectorError);
66
+ });
67
+ });
@@ -0,0 +1,172 @@
1
+ import { AuthRequiredError, EmptyResultError, SelectorError } from '../../errors.js';
2
+ import { cli, Strategy } from '../../registry.js';
3
+ import { normalizeNumericId } from './utils.js';
4
+
5
+ function buildItemUrl(itemId: string): string {
6
+ return `https://www.goofish.com/item?id=${encodeURIComponent(itemId)}`;
7
+ }
8
+
9
+ function buildFetchItemEvaluate(itemId: string): string {
10
+ return `
11
+ (async () => {
12
+ const clean = (value) => String(value ?? '').replace(/\\s+/g, ' ').trim();
13
+ const extractRetCode = (ret) => {
14
+ const first = Array.isArray(ret) ? ret[0] : '';
15
+ return clean(first).split('::')[0] || '';
16
+ };
17
+
18
+ const waitFor = async (predicate, timeoutMs = 5000) => {
19
+ const start = Date.now();
20
+ while (Date.now() - start < timeoutMs) {
21
+ if (predicate()) return true;
22
+ await new Promise((r) => setTimeout(r, 150));
23
+ }
24
+ return false;
25
+ };
26
+
27
+ const bodyText = document.body?.innerText || '';
28
+ if (/请先登录|登录后/.test(bodyText)) {
29
+ return { error: 'auth-required' };
30
+ }
31
+
32
+ if (/验证码|安全验证|异常访问/.test(bodyText)) {
33
+ return { error: 'blocked' };
34
+ }
35
+
36
+ await waitFor(() => window.lib?.mtop?.request);
37
+ if (!window.lib || !window.lib.mtop || typeof window.lib.mtop.request !== 'function') {
38
+ return { error: 'mtop-not-ready' };
39
+ }
40
+
41
+ let response;
42
+ try {
43
+ response = await window.lib.mtop.request({
44
+ api: 'mtop.taobao.idle.pc.detail',
45
+ data: { itemId: ${JSON.stringify(itemId)} },
46
+ type: 'POST',
47
+ v: '1.0',
48
+ dataType: 'json',
49
+ needLogin: false,
50
+ needLoginPC: false,
51
+ sessionOption: 'AutoLoginOnly',
52
+ ecode: 0,
53
+ });
54
+ } catch (error) {
55
+ const ret = error?.ret || [];
56
+ return {
57
+ error: 'mtop-request-failed',
58
+ error_code: extractRetCode(ret),
59
+ error_message: clean(Array.isArray(ret) ? ret.join(' | ') : error?.message || error),
60
+ };
61
+ }
62
+
63
+ const retCode = extractRetCode(response?.ret || []);
64
+ if (retCode && retCode !== 'SUCCESS') {
65
+ return {
66
+ error: 'mtop-response-error',
67
+ error_code: retCode,
68
+ error_message: clean((response?.ret || []).join(' | ')),
69
+ };
70
+ }
71
+
72
+ const data = response?.data || {};
73
+ const item = data.itemDO || {};
74
+ const seller = data.sellerDO || {};
75
+ const labels = Array.isArray(item.itemLabelExtList) ? item.itemLabelExtList : [];
76
+ const findLabel = (name) => labels.find((label) => clean(label.propertyText) === name)?.text || '';
77
+ const images = Array.isArray(item.imageInfos)
78
+ ? item.imageInfos.map((entry) => entry?.url).filter(Boolean)
79
+ : [];
80
+
81
+ return {
82
+ item_id: clean(item.itemId || ${JSON.stringify(itemId)}),
83
+ title: clean(item.title || ''),
84
+ description: clean(item.desc || ''),
85
+ price: clean('¥' + (item.soldPrice || item.defaultPrice || '')).replace(/^¥\\s*$/, ''),
86
+ original_price: clean(item.originalPrice || ''),
87
+ want_count: String(item.wantCnt ?? ''),
88
+ collect_count: String(item.collectCnt ?? ''),
89
+ browse_count: String(item.browseCnt ?? ''),
90
+ status: clean(item.itemStatusStr || ''),
91
+ condition: clean(findLabel('成色')),
92
+ brand: clean(findLabel('品牌')),
93
+ category: clean(findLabel('分类')),
94
+ location: clean(seller.publishCity || seller.city || ''),
95
+ seller_name: clean(seller.nick || seller.uniqueName || ''),
96
+ seller_id: String(seller.sellerId || ''),
97
+ seller_score: clean(seller.xianyuSummary || ''),
98
+ reply_ratio_24h: clean(seller.replyRatio24h || ''),
99
+ reply_interval: clean(seller.replyInterval || ''),
100
+ item_url: ${JSON.stringify(buildItemUrl(itemId))},
101
+ seller_url: seller.sellerId ? 'https://www.goofish.com/personal?userId=' + seller.sellerId : '',
102
+ image_count: String(images.length),
103
+ image_urls: images,
104
+ };
105
+ })()
106
+ `;
107
+ }
108
+
109
+ cli({
110
+ site: 'xianyu',
111
+ name: 'item',
112
+ description: '查看闲鱼商品详情',
113
+ domain: 'www.goofish.com',
114
+ strategy: Strategy.COOKIE,
115
+ navigateBefore: false,
116
+ browser: true,
117
+ args: [
118
+ { name: 'item_id', required: true, positional: true, help: '闲鱼商品 item_id' },
119
+ ],
120
+ columns: ['item_id', 'title', 'price', 'condition', 'brand', 'location', 'seller_name', 'want_count'],
121
+ func: async (page, kwargs) => {
122
+ const itemId = normalizeNumericId(kwargs.item_id, 'item_id', '1040754408976');
123
+
124
+ await page.goto(buildItemUrl(itemId));
125
+ await page.wait(2);
126
+
127
+ const result = await page.evaluate(buildFetchItemEvaluate(itemId)) as {
128
+ error?: string;
129
+ error_code?: string;
130
+ error_message?: string;
131
+ title?: string;
132
+ item_id?: string;
133
+ } & Record<string, unknown>;
134
+
135
+ if (result?.error === 'auth-required') {
136
+ throw new AuthRequiredError('www.goofish.com', 'Xianyu item detail requires a logged-in browser session');
137
+ }
138
+
139
+ if (result?.error === 'blocked') {
140
+ throw new EmptyResultError('xianyu item', 'Xianyu item detail is blocked by verification or risk control');
141
+ }
142
+
143
+ if (result?.error === 'mtop-not-ready') {
144
+ throw new SelectorError('window.lib.mtop', '闲鱼页面未完成初始化,无法调用商品详情接口');
145
+ }
146
+
147
+ if (!result || typeof result !== 'object') {
148
+ throw new EmptyResultError('xianyu item', '闲鱼商品详情接口未返回有效数据');
149
+ }
150
+
151
+ const errorCode = String(result.error_code || '');
152
+ const errorMessage = String(result.error_message || '');
153
+ if (/FAIL_SYS_SESSION_EXPIRED|SESSION_EXPIRED|FAIL_SYS/.test(errorCode) || /FAIL_SYS_SESSION_EXPIRED|SESSION_EXPIRED/.test(errorMessage)) {
154
+ throw new AuthRequiredError('www.goofish.com', 'Xianyu item detail requires a logged-in browser session');
155
+ }
156
+
157
+ if (result.error) {
158
+ throw new EmptyResultError('xianyu item', errorMessage || `Xianyu item detail request failed: ${result.error}`);
159
+ }
160
+
161
+ if (!String(result.title || '').trim()) {
162
+ throw new EmptyResultError('xianyu item', 'No item detail was returned for the specified item_id');
163
+ }
164
+
165
+ return [result];
166
+ },
167
+ });
168
+
169
+ export const __test__ = {
170
+ normalizeNumericId,
171
+ buildItemUrl,
172
+ };
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { __test__ } from './search.js';
3
+
4
+ describe('xianyu search helpers', () => {
5
+ it('normalizes limit into supported range', () => {
6
+ expect(__test__.normalizeLimit(undefined)).toBe(20);
7
+ expect(__test__.normalizeLimit(0)).toBe(1);
8
+ expect(__test__.normalizeLimit(3.8)).toBe(3);
9
+ expect(__test__.normalizeLimit(999)).toBe(__test__.MAX_LIMIT);
10
+ });
11
+
12
+ it('builds search URLs with encoded queries', () => {
13
+ expect(__test__.buildSearchUrl('笔记本电脑')).toBe(
14
+ 'https://www.goofish.com/search?q=%E7%AC%94%E8%AE%B0%E6%9C%AC%E7%94%B5%E8%84%91',
15
+ );
16
+ });
17
+
18
+ it('extracts item ids from detail URLs', () => {
19
+ expect(__test__.itemIdFromUrl('https://www.goofish.com/item?id=954988715389&categoryId=126854525')).toBe('954988715389');
20
+ expect(__test__.itemIdFromUrl('https://www.goofish.com/search?q=test')).toBe('');
21
+ });
22
+ });