@jackwener/opencli 1.7.12 → 1.7.13

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 (407) hide show
  1. package/README.md +8 -7
  2. package/README.zh-CN.md +9 -8
  3. package/cli-manifest.json +12194 -6843
  4. package/clis/1point3acres/digest.js +35 -0
  5. package/clis/1point3acres/forum.js +51 -0
  6. package/clis/1point3acres/forums.js +44 -0
  7. package/clis/1point3acres/hot.js +35 -0
  8. package/clis/1point3acres/latest.js +35 -0
  9. package/clis/1point3acres/notifications.js +64 -0
  10. package/clis/1point3acres/search.js +71 -0
  11. package/clis/1point3acres/thread.js +117 -0
  12. package/clis/1point3acres/user.js +77 -0
  13. package/clis/1point3acres/utils.js +247 -0
  14. package/clis/_shared/desktop-commands.js +4 -0
  15. package/clis/aibase/news.js +110 -0
  16. package/clis/aibase/news.test.js +59 -0
  17. package/clis/amazon/discussion.test.js +1 -28
  18. package/clis/antigravity/watch.js +3 -2
  19. package/clis/arxiv/author.js +44 -0
  20. package/clis/baidu-scholar/search.js +0 -1
  21. package/clis/bbc/topic.js +57 -0
  22. package/clis/bbc/utils.js +79 -0
  23. package/clis/chaoxing/assignments.js +1 -1
  24. package/clis/chaoxing/exams.js +1 -1
  25. package/clis/chatgpt/ask.js +57 -0
  26. package/clis/chatgpt/commands.test.js +45 -0
  27. package/clis/chatgpt/detail.js +46 -0
  28. package/clis/chatgpt/history.js +39 -0
  29. package/clis/chatgpt/image.js +12 -11
  30. package/clis/chatgpt/image.test.js +23 -0
  31. package/clis/chatgpt/new.js +25 -0
  32. package/clis/chatgpt/read.js +43 -0
  33. package/clis/chatgpt/send.js +46 -0
  34. package/clis/chatgpt/status.js +29 -0
  35. package/clis/chatgpt/utils.js +294 -4
  36. package/clis/chatgpt/utils.test.js +13 -0
  37. package/clis/chatgpt-app/ask.js +6 -3
  38. package/clis/chatwise/ask.js +16 -43
  39. package/clis/chatwise/composer.test.js +186 -0
  40. package/clis/chatwise/send.js +2 -24
  41. package/clis/chatwise/utils.js +143 -0
  42. package/clis/claude/ask.js +1 -1
  43. package/clis/claude/detail.js +1 -0
  44. package/clis/claude/history.js +1 -0
  45. package/clis/claude/new.js +1 -0
  46. package/clis/claude/read.js +1 -0
  47. package/clis/claude/send.js +1 -0
  48. package/clis/claude/status.js +1 -0
  49. package/clis/codex/ask.js +15 -9
  50. package/clis/codex/history.js +16 -33
  51. package/clis/codex/projects.js +28 -0
  52. package/clis/codex/read.js +10 -4
  53. package/clis/codex/send.js +10 -3
  54. package/clis/codex/sidebar.js +356 -0
  55. package/clis/codex/sidebar.test.js +329 -0
  56. package/clis/coingecko/categories.js +75 -0
  57. package/clis/coingecko/coin.js +107 -0
  58. package/clis/coingecko/coingecko.test.js +109 -0
  59. package/clis/coingecko/derivatives.js +84 -0
  60. package/clis/coingecko/exchanges.js +74 -0
  61. package/clis/coingecko/global.js +71 -0
  62. package/clis/coingecko/top.js +64 -0
  63. package/clis/coingecko/trending.js +55 -0
  64. package/clis/coupang/add-to-cart.js +21 -13
  65. package/clis/coupang/coupang.test.js +159 -0
  66. package/clis/coupang/product.js +257 -0
  67. package/clis/coupang/search.js +38 -16
  68. package/clis/coupang/utils.js +55 -1
  69. package/clis/crates/crate.js +62 -0
  70. package/clis/crates/search.js +44 -0
  71. package/clis/crates/utils.js +72 -0
  72. package/clis/ctrip/ctrip.test.js +234 -0
  73. package/clis/ctrip/hotel-suggest.js +45 -0
  74. package/clis/ctrip/search.js +22 -68
  75. package/clis/ctrip/utils.js +175 -0
  76. package/clis/cursor/ask.js +6 -3
  77. package/clis/dblp/author.js +133 -0
  78. package/clis/dblp/venue.js +64 -0
  79. package/clis/deepseek/ask.js +12 -7
  80. package/clis/deepseek/ask.test.js +13 -13
  81. package/clis/deepseek/detail.js +38 -0
  82. package/clis/deepseek/detail.test.js +81 -0
  83. package/clis/deepseek/history.js +1 -0
  84. package/clis/deepseek/new.js +1 -0
  85. package/clis/deepseek/read.js +1 -0
  86. package/clis/deepseek/send.js +140 -0
  87. package/clis/deepseek/send.test.js +107 -0
  88. package/clis/deepseek/status.js +1 -0
  89. package/clis/deepseek/utils.js +66 -0
  90. package/clis/deepseek/utils.test.js +107 -1
  91. package/clis/defillama/defillama.test.js +99 -0
  92. package/clis/defillama/protocol.js +84 -0
  93. package/clis/defillama/protocols.js +55 -0
  94. package/clis/defillama/utils.js +99 -0
  95. package/clis/devto/latest.js +74 -0
  96. package/clis/dockerhub/image.js +52 -0
  97. package/clis/dockerhub/search.js +47 -0
  98. package/clis/dockerhub/utils.js +100 -0
  99. package/clis/doubao/ask.js +7 -3
  100. package/clis/doubao/detail.js +1 -0
  101. package/clis/doubao/history.js +1 -0
  102. package/clis/doubao/meeting-summary.js +1 -0
  103. package/clis/doubao/meeting-transcript.js +1 -0
  104. package/clis/doubao/new.js +1 -0
  105. package/clis/doubao/read.js +1 -0
  106. package/clis/doubao/send.js +1 -0
  107. package/clis/doubao/status.js +1 -0
  108. package/clis/douyin/draft.test.js +1 -30
  109. package/clis/endoflife/endoflife.test.js +51 -0
  110. package/clis/endoflife/product.js +55 -0
  111. package/clis/endoflife/utils.js +89 -0
  112. package/clis/facebook/__fixtures__/notifications-page.html +13 -0
  113. package/clis/facebook/notifications.js +326 -30
  114. package/clis/facebook/notifications.test.js +458 -0
  115. package/clis/flathub/app.js +71 -0
  116. package/clis/flathub/flathub.test.js +90 -0
  117. package/clis/flathub/search.js +80 -0
  118. package/clis/flathub/utils.js +114 -0
  119. package/clis/gemini/ask.js +7 -3
  120. package/clis/gemini/ask.test.js +2 -2
  121. package/clis/gemini/deep-research-result.js +6 -2
  122. package/clis/gemini/deep-research-result.test.js +15 -14
  123. package/clis/gemini/deep-research.js +8 -4
  124. package/clis/gemini/deep-research.test.js +15 -18
  125. package/clis/gemini/image.js +7 -2
  126. package/clis/gemini/new.js +1 -0
  127. package/clis/gemini/utils.js +0 -4
  128. package/clis/google-scholar/cite.js +0 -1
  129. package/clis/google-scholar/profile.js +0 -1
  130. package/clis/google-scholar/search.js +0 -1
  131. package/clis/goproxy/goproxy.test.js +103 -0
  132. package/clis/goproxy/module.js +47 -0
  133. package/clis/goproxy/utils.js +165 -0
  134. package/clis/goproxy/versions.js +59 -0
  135. package/clis/gov-law/recent.js +0 -1
  136. package/clis/gov-law/search.js +0 -1
  137. package/clis/gov-policy/__fixtures__/recent.html +16 -0
  138. package/clis/gov-policy/__fixtures__/search.html +41 -0
  139. package/clis/gov-policy/gov-policy.test.js +224 -0
  140. package/clis/gov-policy/recent.js +66 -24
  141. package/clis/gov-policy/search.js +65 -23
  142. package/clis/gov-policy/utils.js +54 -0
  143. package/clis/grok/ask.js +49 -265
  144. package/clis/grok/ask.test.js +21 -46
  145. package/clis/grok/detail.js +60 -0
  146. package/clis/grok/history.js +48 -0
  147. package/clis/grok/{image.ts → image.js} +56 -70
  148. package/clis/grok/image.test.ts +20 -0
  149. package/clis/grok/new.js +20 -0
  150. package/clis/grok/read.js +39 -0
  151. package/clis/grok/send.js +50 -0
  152. package/clis/grok/status.js +41 -0
  153. package/clis/grok/utils.js +326 -0
  154. package/clis/grok/utils.test.js +103 -0
  155. package/clis/hf/datasets.js +88 -0
  156. package/clis/hf/hf.test.js +16 -0
  157. package/clis/hf/models.js +91 -0
  158. package/clis/hf/paper.js +79 -0
  159. package/clis/hf/spaces.js +101 -0
  160. package/clis/hf/top.js +1 -0
  161. package/clis/homebrew/cask.js +39 -0
  162. package/clis/homebrew/formula.js +41 -0
  163. package/clis/homebrew/popular.js +54 -0
  164. package/clis/homebrew/utils.js +100 -0
  165. package/clis/hupu/__fixtures__/hot-home.html +64 -0
  166. package/clis/hupu/detail.js +0 -1
  167. package/clis/hupu/hot.js +156 -35
  168. package/clis/hupu/hot.test.js +224 -0
  169. package/clis/hupu/search.js +0 -1
  170. package/clis/instagram/note.js +1 -1
  171. package/clis/instagram/note.test.js +1 -29
  172. package/clis/instagram/post.js +1 -1
  173. package/clis/instagram/post.test.js +1 -1
  174. package/clis/instagram/reel.js +1 -1
  175. package/clis/instagram/story.js +1 -1
  176. package/clis/instagram/story.test.js +1 -34
  177. package/clis/jd/commands.test.js +1 -24
  178. package/clis/lichess/lichess.test.js +85 -0
  179. package/clis/lichess/top.js +46 -0
  180. package/clis/lichess/user.js +91 -0
  181. package/clis/lichess/utils.js +97 -0
  182. package/clis/linkedin/search.js +107 -10
  183. package/clis/linkedin/search.test.js +222 -0
  184. package/clis/linux-do/feed.js +2 -5
  185. package/clis/linux-do/feed.test.js +35 -0
  186. package/clis/lobsters/domain.js +92 -0
  187. package/clis/maven/artifact.js +49 -0
  188. package/clis/maven/search.js +51 -0
  189. package/clis/maven/utils.js +110 -0
  190. package/clis/mdn/search.js +97 -0
  191. package/clis/medium/tag.js +135 -0
  192. package/clis/npm/downloads.js +59 -0
  193. package/clis/npm/package.js +70 -0
  194. package/clis/npm/search.js +49 -0
  195. package/clis/npm/utils.js +76 -0
  196. package/clis/nuget/nuget.test.js +111 -0
  197. package/clis/nuget/package.js +101 -0
  198. package/clis/nuget/search.js +69 -0
  199. package/clis/nuget/utils.js +87 -0
  200. package/clis/nvd/cve.js +121 -0
  201. package/clis/oeis/oeis.test.js +88 -0
  202. package/clis/oeis/search.js +63 -0
  203. package/clis/oeis/sequence.js +71 -0
  204. package/clis/oeis/utils.js +88 -0
  205. package/clis/openalex/search.js +69 -0
  206. package/clis/openalex/utils.js +160 -0
  207. package/clis/openalex/work.js +65 -0
  208. package/clis/openfda/drug-label.js +74 -0
  209. package/clis/openfda/food-recall.js +65 -0
  210. package/clis/openfda/openfda.test.js +114 -0
  211. package/clis/openfda/utils.js +67 -0
  212. package/clis/osv/osv.test.js +97 -0
  213. package/clis/osv/query.js +72 -0
  214. package/clis/osv/utils.js +169 -0
  215. package/clis/osv/vulnerability.js +54 -0
  216. package/clis/packagist/package.js +49 -0
  217. package/clis/packagist/search.js +43 -0
  218. package/clis/packagist/utils.js +113 -0
  219. package/clis/paperreview/feedback.js +1 -1
  220. package/clis/paperreview/review.js +1 -1
  221. package/clis/paperreview/submit.js +1 -1
  222. package/clis/pixiv/download.test.js +1 -1
  223. package/clis/pixiv/illusts.test.js +1 -1
  224. package/clis/pixiv/search.test.js +1 -1
  225. package/clis/pubmed/article.js +50 -0
  226. package/clis/pubmed/author.js +64 -0
  227. package/clis/pubmed/citations.js +36 -0
  228. package/clis/pubmed/pubmed.test.js +276 -0
  229. package/clis/pubmed/related.js +45 -0
  230. package/clis/pubmed/search.js +75 -0
  231. package/clis/pubmed/utils.js +309 -0
  232. package/clis/pypi/downloads.js +66 -0
  233. package/clis/pypi/package.js +79 -0
  234. package/clis/pypi/utils.js +55 -0
  235. package/clis/quark/mv.js +1 -1
  236. package/clis/quark/save.js +1 -1
  237. package/clis/qwen/ask.js +85 -0
  238. package/clis/qwen/detail.js +62 -0
  239. package/clis/qwen/history.js +61 -0
  240. package/clis/qwen/image.js +179 -0
  241. package/clis/qwen/new.js +23 -0
  242. package/clis/qwen/read.js +41 -0
  243. package/clis/qwen/send.js +55 -0
  244. package/clis/qwen/status.js +37 -0
  245. package/clis/qwen/utils.js +409 -0
  246. package/clis/qwen/utils.test.js +45 -0
  247. package/clis/rest-countries/country.js +65 -0
  248. package/clis/rest-countries/region.js +64 -0
  249. package/clis/rest-countries/rest-countries.test.js +83 -0
  250. package/clis/rest-countries/utils.js +126 -0
  251. package/clis/reuters/article-detail.js +53 -0
  252. package/clis/reuters/reuters.test.js +299 -0
  253. package/clis/reuters/search.js +45 -34
  254. package/clis/reuters/utils.js +159 -0
  255. package/clis/rfc/rfc.js +52 -0
  256. package/clis/rfc/rfc.test.js +74 -0
  257. package/clis/rfc/utils.js +72 -0
  258. package/clis/rubygems/gem.js +42 -0
  259. package/clis/rubygems/search.js +47 -0
  260. package/clis/rubygems/utils.js +86 -0
  261. package/clis/stackoverflow/related.js +66 -0
  262. package/clis/stackoverflow/stackoverflow.test.js +58 -0
  263. package/clis/stackoverflow/tag.js +60 -0
  264. package/clis/stackoverflow/user.js +50 -0
  265. package/clis/stackoverflow/utils.js +118 -0
  266. package/clis/steam/app.js +67 -0
  267. package/clis/steam/search.js +58 -0
  268. package/clis/steam/steam.test.js +46 -0
  269. package/clis/steam/utils.js +107 -0
  270. package/clis/taobao/commands.test.js +1 -24
  271. package/clis/test-utils.js +61 -0
  272. package/clis/tieba/hot.js +0 -1
  273. package/clis/tiktok/comment.js +128 -41
  274. package/clis/tiktok/creator-videos.js +270 -0
  275. package/clis/tiktok/creator-videos.test.js +113 -0
  276. package/clis/tiktok/explore.js +137 -29
  277. package/clis/tiktok/follow.js +115 -33
  278. package/clis/tiktok/following.js +157 -36
  279. package/clis/tiktok/friends.js +139 -37
  280. package/clis/tiktok/live.js +137 -41
  281. package/clis/tiktok/notifications.js +141 -38
  282. package/clis/tiktok/refactor.test.js +389 -0
  283. package/clis/tiktok/unfollow.js +124 -38
  284. package/clis/tiktok/user.js +203 -29
  285. package/clis/tiktok/utils.js +505 -0
  286. package/clis/tiktok/write-refactor.test.js +370 -0
  287. package/clis/toutiao/articles.js +36 -62
  288. package/clis/toutiao/hot.js +63 -0
  289. package/clis/toutiao/toutiao.test.js +378 -0
  290. package/clis/toutiao/utils.js +161 -0
  291. package/clis/tvmaze/search.js +61 -0
  292. package/clis/tvmaze/show.js +60 -0
  293. package/clis/tvmaze/tvmaze.test.js +93 -0
  294. package/clis/tvmaze/utils.js +110 -0
  295. package/clis/twitter/accept.js +1 -1
  296. package/clis/twitter/followers.js +134 -69
  297. package/clis/twitter/reply-dm.js +1 -1
  298. package/clis/twitter/reply.test.js +1 -29
  299. package/clis/uisdc/news.js +105 -0
  300. package/clis/uisdc/news.test.js +66 -0
  301. package/clis/wanfang/search.js +0 -1
  302. package/clis/web/read.js +47 -17
  303. package/clis/web/read.test.js +101 -1
  304. package/clis/weixin/create-draft.js +1 -1
  305. package/clis/weixin/drafts.js +1 -1
  306. package/clis/weixin/drafts.test.js +5 -1
  307. package/clis/weixin/search.js +157 -0
  308. package/clis/weixin/search.test.js +227 -0
  309. package/clis/wikidata/entity.js +60 -0
  310. package/clis/wikidata/search.js +50 -0
  311. package/clis/wikidata/utils.js +117 -0
  312. package/clis/wikidata/wikidata.test.js +83 -0
  313. package/clis/wikipedia/page.js +95 -0
  314. package/clis/wttr/current.js +63 -0
  315. package/clis/wttr/forecast.js +71 -0
  316. package/clis/wttr/utils.js +50 -0
  317. package/clis/wttr/wttr.test.js +84 -0
  318. package/clis/xianyu/chat.js +16 -4
  319. package/clis/xianyu/chat.test.js +64 -0
  320. package/clis/xianyu/publish.js +485 -0
  321. package/clis/xianyu/publish.test.js +220 -0
  322. package/clis/xiaoe/catalog.js +105 -40
  323. package/clis/xiaoe/content.js +164 -29
  324. package/clis/xiaoe/courses.js +86 -29
  325. package/clis/xiaoe/xiaoe.test.js +486 -0
  326. package/clis/xiaohongshu/creator-notes-summary.js +1 -1
  327. package/clis/xiaohongshu/publish.js +16 -3
  328. package/clis/xiaohongshu/publish.test.js +46 -1
  329. package/clis/youtube/transcript.js +13 -19
  330. package/clis/youtube/transcript.test.js +17 -0
  331. package/clis/yuanbao/ask.js +17 -66
  332. package/clis/yuanbao/ask.test.js +5 -5
  333. package/clis/yuanbao/detail.js +65 -0
  334. package/clis/yuanbao/history.js +51 -0
  335. package/clis/yuanbao/new.js +1 -0
  336. package/clis/yuanbao/read.js +38 -0
  337. package/clis/yuanbao/send.js +57 -0
  338. package/clis/yuanbao/shared.js +297 -5
  339. package/clis/yuanbao/shared.test.js +80 -0
  340. package/clis/yuanbao/status.js +44 -0
  341. package/clis/zlibrary/commands.test.js +1 -11
  342. package/dist/src/browser/base-page.d.ts +9 -0
  343. package/dist/src/browser/base-page.js +44 -1
  344. package/dist/src/browser/base-page.test.js +66 -0
  345. package/dist/src/browser/cdp.d.ts +1 -0
  346. package/dist/src/browser/cdp.js +51 -9
  347. package/dist/src/browser/daemon-client.d.ts +4 -0
  348. package/dist/src/browser/errors.js +1 -1
  349. package/dist/src/browser/page.d.ts +1 -1
  350. package/dist/src/browser/page.js +3 -1
  351. package/dist/src/browser/page.test.js +29 -0
  352. package/dist/src/browser/target-errors.d.ts +2 -1
  353. package/dist/src/browser/target-errors.js +1 -0
  354. package/dist/src/browser/target-resolver.d.ts +25 -0
  355. package/dist/src/browser/target-resolver.js +43 -0
  356. package/dist/src/build-manifest.js +9 -4
  357. package/dist/src/build-manifest.test.js +2 -8
  358. package/dist/src/capabilityRouting.d.ts +16 -1
  359. package/dist/src/capabilityRouting.js +24 -1
  360. package/dist/src/capabilityRouting.test.js +19 -1
  361. package/dist/src/cli.js +76 -11
  362. package/dist/src/cli.test.js +150 -0
  363. package/dist/src/commanderAdapter.js +0 -5
  364. package/dist/src/commanderAdapter.test.js +0 -1
  365. package/dist/src/discovery.js +2 -5
  366. package/dist/src/errors.js +1 -1
  367. package/dist/src/execution.d.ts +1 -1
  368. package/dist/src/execution.js +111 -27
  369. package/dist/src/execution.test.js +326 -17
  370. package/dist/src/help.d.ts +23 -2
  371. package/dist/src/help.js +41 -19
  372. package/dist/src/help.test.d.ts +1 -0
  373. package/dist/src/help.test.js +54 -0
  374. package/dist/src/main.js +14 -1
  375. package/dist/src/manifest-types.d.ts +5 -3
  376. package/dist/src/pipeline/executor.js +1 -1
  377. package/dist/src/pipeline/executor.test.js +8 -0
  378. package/dist/src/pipeline/registry.d.ts +9 -0
  379. package/dist/src/pipeline/registry.js +13 -1
  380. package/dist/src/pipeline/steps/browser.d.ts +1 -0
  381. package/dist/src/pipeline/steps/browser.js +10 -0
  382. package/dist/src/pipeline/steps/download.test.js +1 -0
  383. package/dist/src/registry-api.d.ts +1 -1
  384. package/dist/src/registry.d.ts +12 -11
  385. package/dist/src/registry.js +16 -6
  386. package/dist/src/registry.test.js +2 -2
  387. package/dist/src/runtime.d.ts +2 -1
  388. package/dist/src/runtime.js +1 -1
  389. package/dist/src/serialization.d.ts +2 -2
  390. package/dist/src/serialization.js +4 -6
  391. package/dist/src/serialization.test.js +17 -0
  392. package/dist/src/types.d.ts +17 -0
  393. package/dist/src/validate.js +15 -11
  394. package/dist/src/validate.test.d.ts +9 -0
  395. package/dist/src/validate.test.js +90 -0
  396. package/package.json +1 -1
  397. package/scripts/fetch-adapters.js +1 -1
  398. package/scripts/typed-error-lint-baseline.json +5 -77
  399. package/clis/ctrip/search.test.js +0 -64
  400. package/clis/gov-policy/commands.test.js +0 -27
  401. package/clis/linux-do/category.js +0 -37
  402. package/clis/linux-do/hot.js +0 -26
  403. package/clis/linux-do/latest.js +0 -19
  404. package/clis/pixiv/test-utils.js +0 -23
  405. package/clis/toutiao/articles.test.js +0 -30
  406. package/dist/src/analysis.d.ts +0 -40
  407. package/dist/src/analysis.js +0 -172
@@ -0,0 +1,93 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { getRegistry } from '@jackwener/opencli/registry';
3
+ import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
4
+ import './search.js';
5
+ import './show.js';
6
+
7
+ afterEach(() => {
8
+ vi.unstubAllGlobals();
9
+ vi.restoreAllMocks();
10
+ });
11
+
12
+ describe('tvmaze search adapter', () => {
13
+ const cmd = getRegistry().get('tvmaze/search');
14
+
15
+ it('rejects empty query and bad limit before fetching', async () => {
16
+ const fetchMock = vi.fn();
17
+ vi.stubGlobal('fetch', fetchMock);
18
+
19
+ await expect(cmd.func({ query: '', limit: 5 })).rejects.toThrow(ArgumentError);
20
+ await expect(cmd.func({ query: 'foo', limit: 0 })).rejects.toThrow(ArgumentError);
21
+ await expect(cmd.func({ query: 'foo', limit: 100 })).rejects.toThrow(ArgumentError);
22
+ expect(fetchMock).not.toHaveBeenCalled();
23
+ });
24
+
25
+ it('maps HTTP 429 to CommandExecutionError', async () => {
26
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('rate limited', { status: 429 })));
27
+ await expect(cmd.func({ query: 'foo', limit: 5 })).rejects.toThrow(CommandExecutionError);
28
+ });
29
+
30
+ it('throws EmptyResultError when no shows match', async () => {
31
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('[]', { status: 200 })));
32
+ await expect(cmd.func({ query: 'asdfghjkl', limit: 5 })).rejects.toThrow(EmptyResultError);
33
+ });
34
+
35
+ it('strips HTML from summary and ids round-trip into tvmaze show <id>', async () => {
36
+ const list = [{
37
+ score: 1.2,
38
+ show: {
39
+ id: 169, name: 'Breaking Bad', type: 'Scripted', language: 'English',
40
+ genres: ['Drama'], status: 'Ended', premiered: '2008-01-20', ended: '2019-10-11',
41
+ network: { name: 'AMC' }, rating: { average: 9.2 },
42
+ summary: '<p><b>Breaking Bad</b> is a show with &amp; entities &#39;here&#39;, &#x27;hex&#x27;, and &hellip;.</p>',
43
+ url: 'https://www.tvmaze.com/shows/169/breaking-bad',
44
+ },
45
+ }];
46
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify(list), { status: 200 })));
47
+
48
+ const rows = await cmd.func({ query: 'breaking bad', limit: 5 });
49
+ expect(rows[0]).toMatchObject({
50
+ rank: 1, id: 169, name: 'Breaking Bad', network: 'AMC', rating: 9.2, matchScore: 1.2,
51
+ });
52
+ expect(rows[0].summary).toBe("Breaking Bad is a show with & entities 'here', 'hex', and ….");
53
+ // id round-trip
54
+ expect(typeof rows[0].id).toBe('number');
55
+ });
56
+ });
57
+
58
+ describe('tvmaze show adapter', () => {
59
+ const cmd = getRegistry().get('tvmaze/show');
60
+
61
+ it('rejects non-positive id before fetching', async () => {
62
+ const fetchMock = vi.fn();
63
+ vi.stubGlobal('fetch', fetchMock);
64
+
65
+ await expect(cmd.func({ id: 0 })).rejects.toThrow(ArgumentError);
66
+ await expect(cmd.func({ id: -5 })).rejects.toThrow(ArgumentError);
67
+ await expect(cmd.func({ id: 'abc' })).rejects.toThrow(ArgumentError);
68
+ expect(fetchMock).not.toHaveBeenCalled();
69
+ });
70
+
71
+ it('maps HTTP 404 to EmptyResultError', async () => {
72
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('not found', { status: 404 })));
73
+ await expect(cmd.func({ id: 99999999 })).rejects.toThrow(EmptyResultError);
74
+ });
75
+
76
+ it('returns full show detail with externals + schedule', async () => {
77
+ const show = {
78
+ id: 169, name: 'Breaking Bad', type: 'Scripted', language: 'English', genres: ['Drama'],
79
+ status: 'Ended', premiered: '2008-01-20', ended: '2019-10-11', runtime: 60, averageRuntime: 60,
80
+ network: { name: 'AMC', country: { name: 'United States' } }, schedule: { time: '22:00', days: ['Sunday'] },
81
+ rating: { average: 9.2 }, externals: { imdb: 'tt0903747', thetvdb: 81189 },
82
+ officialSite: 'http://www.amc.com/shows/breaking-bad',
83
+ summary: '<p>Plain.</p>', url: 'https://www.tvmaze.com/shows/169/breaking-bad',
84
+ };
85
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify(show), { status: 200 })));
86
+
87
+ const rows = await cmd.func({ id: 169 });
88
+ expect(rows[0]).toMatchObject({
89
+ id: 169, name: 'Breaking Bad', country: 'United States', schedule: 'Sunday 22:00',
90
+ imdb: 'tt0903747', thetvdb: 81189, rating: 9.2, summary: 'Plain.',
91
+ });
92
+ });
93
+ });
@@ -0,0 +1,110 @@
1
+ // Shared helpers for the TVmaze adapters.
2
+ //
3
+ // TVmaze publishes a free, unauthenticated REST API at https://api.tvmaze.com.
4
+ // Docs: https://www.tvmaze.com/api
5
+ import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
6
+
7
+ export const TVMAZE_BASE = 'https://api.tvmaze.com';
8
+ const UA = 'opencli-tvmaze-adapter (+https://github.com/jackwener/opencli)';
9
+
10
+ export function requireString(value, label) {
11
+ const s = String(value ?? '').trim();
12
+ if (!s) throw new ArgumentError(`tvmaze ${label} cannot be empty`);
13
+ return s;
14
+ }
15
+
16
+ export function requireShowId(value) {
17
+ const raw = value;
18
+ const n = typeof raw === 'number' ? raw : Number(String(raw ?? '').trim());
19
+ if (!Number.isInteger(n) || n <= 0) {
20
+ throw new ArgumentError(
21
+ 'tvmaze show id is required and must be a positive integer',
22
+ 'TVmaze show ids appear in the URL: https://www.tvmaze.com/shows/<id>/<slug>.',
23
+ );
24
+ }
25
+ return n;
26
+ }
27
+
28
+ export function requireBoundedInt(value, defaultValue, maxValue, label = 'limit') {
29
+ const raw = value ?? defaultValue;
30
+ const n = typeof raw === 'number' ? raw : Number(raw);
31
+ if (!Number.isInteger(n) || n <= 0) {
32
+ throw new ArgumentError(`tvmaze ${label} must be a positive integer`);
33
+ }
34
+ if (n > maxValue) {
35
+ throw new ArgumentError(`tvmaze ${label} must be <= ${maxValue}`);
36
+ }
37
+ return n;
38
+ }
39
+
40
+ export async function tvmazeFetch(url, label) {
41
+ let resp;
42
+ try {
43
+ resp = await fetch(url, { headers: { 'user-agent': UA, accept: 'application/json' } });
44
+ }
45
+ catch (err) {
46
+ throw new CommandExecutionError(
47
+ `${label} request failed: ${err?.message ?? err}`,
48
+ 'Check that api.tvmaze.com is reachable from this network.',
49
+ );
50
+ }
51
+ if (resp.status === 404) {
52
+ throw new EmptyResultError(label, `TVmaze returned 404 for ${url}.`);
53
+ }
54
+ if (resp.status === 429) {
55
+ throw new CommandExecutionError(
56
+ `${label} returned HTTP 429 (rate limited)`,
57
+ 'TVmaze caps unauthenticated traffic at ~20 req/10s; wait a few seconds and retry.',
58
+ );
59
+ }
60
+ if (!resp.ok) {
61
+ throw new CommandExecutionError(`${label} returned HTTP ${resp.status}`);
62
+ }
63
+ let body;
64
+ try {
65
+ body = await resp.json();
66
+ }
67
+ catch (err) {
68
+ throw new CommandExecutionError(`${label} returned malformed JSON: ${err?.message ?? err}`);
69
+ }
70
+ return body;
71
+ }
72
+
73
+ const HTML_ENTITY_MAP = {
74
+ amp: '&',
75
+ lt: '<',
76
+ gt: '>',
77
+ quot: '"',
78
+ apos: "'",
79
+ nbsp: ' ',
80
+ rsquo: '’',
81
+ lsquo: '‘',
82
+ rdquo: '”',
83
+ ldquo: '“',
84
+ hellip: '…',
85
+ ndash: '–',
86
+ mdash: '—',
87
+ };
88
+
89
+ // TVmaze ships HTML in `summary` ("<p><b>...</b> ...</p>"). Strip tags + decode
90
+ // named and numeric entities so output is plain text.
91
+ export function stripHtml(html) {
92
+ if (html == null) return '';
93
+ let s = String(html);
94
+ s = s.replace(/<[^>]+>/g, '');
95
+ s = s
96
+ .replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => {
97
+ const code = parseInt(hex, 16);
98
+ return Number.isFinite(code) ? String.fromCodePoint(code) : '';
99
+ })
100
+ .replace(/&#(\d+);/g, (_, dec) => {
101
+ const code = parseInt(dec, 10);
102
+ return Number.isFinite(code) ? String.fromCodePoint(code) : '';
103
+ })
104
+ .replace(/&([a-zA-Z]+);/g, (match, name) => HTML_ENTITY_MAP[name] ?? match);
105
+ return s.replace(/\s+/g, ' ').trim();
106
+ }
107
+
108
+ export function joinList(value) {
109
+ return Array.isArray(value) ? value.filter(Boolean).join(', ') : '';
110
+ }
@@ -8,10 +8,10 @@ cli({
8
8
  domain: 'x.com',
9
9
  strategy: Strategy.UI,
10
10
  browser: true,
11
- timeoutSeconds: 600, // 10 min — batch operation iterating many conversations
12
11
  args: [
13
12
  { name: 'query', type: 'string', required: true, positional: true, help: 'Keywords to match (comma-separated for OR, e.g. "群,微信")' },
14
13
  { name: 'max', type: 'int', required: false, default: 20, help: 'Maximum number of requests to accept (default: 20)' },
14
+ { name: 'timeout', type: 'int', required: false, default: 600, help: 'Max seconds for the overall command (default: 600 — batch op)' },
15
15
  ],
16
16
  columns: ['index', 'status', 'user', 'message'],
17
17
  func: async (page, kwargs) => {
@@ -1,103 +1,168 @@
1
- import { AuthRequiredError, selectorError } from '@jackwener/opencli/errors';
1
+ import { ArgumentError, AuthRequiredError, selectorError, EmptyResultError } from '@jackwener/opencli/errors';
2
2
  import { cli, Strategy } from '@jackwener/opencli/registry';
3
+
4
+ /**
5
+ * Extract follower rows from Twitter/X follower-list SPA cells.
6
+ *
7
+ * Verified DOM shape on x.com followers list (confirmed live 2026-05-05):
8
+ * - `[data-testid="UserCell"]` per follower
9
+ * - `[data-testid="UserAvatar-Container-<handle>"]` — stable handle source
10
+ * - `[data-testid="<userId>-follow"]` — follow button (i18n-independent)
11
+ * - `[data-testid="userFollowIndicator"]` — "Follows you" badge when present
12
+ * - the bio (when present) is rendered into `cell.innerText` but has NO
13
+ * dedicated testid in the list view (`UserDescription` only appears on
14
+ * the standalone profile page, NOT inside follower-list cells).
15
+ *
16
+ * Strategy: subtract the i18n-variable button / badge texts from the lines of
17
+ * `cell.innerText`, treat the first remaining `@…` line as the handle, the
18
+ * first non-handle line as display name, and the rest as bio. We avoid
19
+ * locale-coupled string matching (`"Follow"` / `"关注"` / `"Folgen"`).
20
+ *
21
+ * Note: Twitter does NOT render follower COUNTS in the list view, so the
22
+ * `followers` column is omitted from the output schema. Use
23
+ * `opencli twitter profile <user>` to read a per-user follower count.
24
+ */
25
+ async function extractFollowersFromDOM(page) {
26
+ const script = `() => {
27
+ const cells = document.querySelectorAll('[data-testid="UserCell"]');
28
+ const out = [];
29
+ for (const cell of cells) {
30
+ // Collect i18n-variable UI strings to strip from the cell text.
31
+ const stripTexts = new Set();
32
+ const buttons = cell.querySelectorAll(
33
+ '[data-testid$="-follow"],[data-testid$="-unfollow"],[data-testid="userFollowIndicator"]'
34
+ );
35
+ for (const el of buttons) {
36
+ const t = (el.innerText || '').trim();
37
+ if (t) stripTexts.add(t);
38
+ }
39
+ const lines = (cell.innerText || '')
40
+ .split('\\n')
41
+ .map(s => s.trim())
42
+ .filter(Boolean)
43
+ .filter(l => !stripTexts.has(l));
44
+ // Pull the @handle line; fall back to UserAvatar-Container-<handle>.
45
+ let screen_name = '';
46
+ const remaining = [];
47
+ for (const l of lines) {
48
+ if (!screen_name && l.startsWith('@')) {
49
+ screen_name = l.slice(1).split(/\\s/)[0];
50
+ } else {
51
+ remaining.push(l);
52
+ }
53
+ }
54
+ if (!screen_name) {
55
+ const av = cell.querySelector('[data-testid^="UserAvatar-Container-"]');
56
+ const tid = av ? av.getAttribute('data-testid') || '' : '';
57
+ if (tid.startsWith('UserAvatar-Container-')) {
58
+ screen_name = tid.slice('UserAvatar-Container-'.length);
59
+ }
60
+ }
61
+ // First non-handle line is display name (may equal handle when the user hasn't set one).
62
+ const name = remaining[0] || screen_name;
63
+ // Lines past the display name form the bio.
64
+ const bio = remaining.slice(1).join(' ').replace(/\\s+/g, ' ').trim();
65
+ if (screen_name) {
66
+ out.push({ screen_name, name, bio });
67
+ }
68
+ }
69
+ return out;
70
+ }`;
71
+ return page.evaluate(script);
72
+ }
73
+
74
+ function normalizeScreenName(value) {
75
+ return String(value ?? '').trim().replace(/^\/+/, '').replace(/^@+/, '');
76
+ }
77
+
3
78
  cli({
4
79
  site: 'twitter',
5
80
  name: 'followers',
6
81
  access: 'read',
7
82
  description: 'Get accounts following a Twitter/X user',
8
83
  domain: 'x.com',
9
- strategy: Strategy.INTERCEPT,
84
+ strategy: Strategy.UI,
10
85
  browser: true,
11
86
  args: [
12
87
  { name: 'user', positional: true, type: 'string', required: false },
13
88
  { name: 'limit', type: 'int', default: 50 },
14
89
  ],
15
- columns: ['screen_name', 'name', 'bio', 'followers'],
90
+ // `followers` (count) is NOT exposed: the SPA followers-list view does not
91
+ // render it. Use `twitter profile <user>` for per-user follower counts.
92
+ columns: ['screen_name', 'name', 'bio'],
16
93
  func: async (page, kwargs) => {
17
- let targetUser = kwargs.user;
18
- // If no user is specified, figure out the logged-in user's handle
94
+ const limit = kwargs.limit;
95
+ if (!Number.isInteger(limit) || limit <= 0) {
96
+ throw new ArgumentError('limit must be a positive integer');
97
+ }
98
+
99
+ let targetUser = normalizeScreenName(kwargs.user);
19
100
  if (!targetUser) {
20
101
  await page.goto('https://x.com/home');
21
102
  await page.wait({ selector: '[data-testid="primaryColumn"]' });
22
103
  const href = await page.evaluate(`() => {
23
- const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
24
- return link ? link.getAttribute('href') : null;
25
- }`);
104
+ const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
105
+ return link ? link.getAttribute('href') : null;
106
+ }`);
26
107
  if (!href) {
27
108
  throw new AuthRequiredError('x.com', 'Could not find logged-in user profile link. Are you logged in?');
28
109
  }
29
- targetUser = href.replace('/', '');
110
+ targetUser = normalizeScreenName(href);
30
111
  }
112
+ if (!targetUser) {
113
+ throw new ArgumentError('twitter followers user cannot be empty', 'Example: opencli twitter followers @elonmusk --limit 100');
114
+ }
115
+
31
116
  // 1. Navigate to profile page
32
117
  await page.goto(`https://x.com/${targetUser}`);
33
118
  await page.wait(3);
34
- // 2. Install interceptor BEFORE SPA navigation.
35
- // goto() resets JS context, but SPA click preserves it.
36
- await page.installInterceptor('Followers');
37
- // 3. Click the followers link via SPA navigation (preserves interceptor).
38
- // Twitter uses /verified_followers instead of /followers now.
119
+
120
+ // 2. Click the followers tab via SPA navigation (preserves session/state).
121
+ // Twitter sometimes only renders /verified_followers on profiles with
122
+ // badge filtering enabled; try the canonical link first, fall back.
39
123
  const safeUser = JSON.stringify(targetUser);
40
124
  const clicked = await page.evaluate(`() => {
41
- const target = ${safeUser};
42
- const selectors = [
43
- 'a[href="/' + target + '/verified_followers"]',
44
- 'a[href="/' + target + '/followers"]',
45
- ];
46
- for (const sel of selectors) {
47
- const link = document.querySelector(sel);
48
- if (link) { link.click(); return true; }
49
- }
50
- return false;
51
- }`);
125
+ const target = ${safeUser};
126
+ const selectors = [
127
+ 'a[href="/' + target + '/followers"]',
128
+ 'a[href="/' + target + '/verified_followers"]',
129
+ ];
130
+ for (const sel of selectors) {
131
+ const link = document.querySelector(sel);
132
+ if (link) { link.click(); return true; }
133
+ }
134
+ return false;
135
+ }`);
52
136
  if (!clicked) {
53
137
  throw selectorError('Twitter followers link', 'Twitter may have changed the layout.');
54
138
  }
55
- await page.waitForCapture(5);
56
- // 4. Scroll to trigger pagination API calls
57
- await page.autoScroll({ times: Math.ceil(kwargs.limit / 20), delayMs: 2000 });
58
- // 5. Retrieve intercepted data
59
- const requests = await page.getInterceptedRequests();
60
- const requestList = Array.isArray(requests) ? requests : [];
61
- if (requestList.length === 0) {
62
- return [];
63
- }
64
- let results = [];
65
- for (const req of requestList) {
66
- try {
67
- // GraphQL response: { data: { user: { result: { timeline: ... } } } }
68
- let instructions = req.data?.user?.result?.timeline?.timeline?.instructions;
69
- if (!instructions)
70
- continue;
71
- let addEntries = instructions.find((i) => i.type === 'TimelineAddEntries');
72
- if (!addEntries) {
73
- addEntries = instructions.find((i) => i.entries && Array.isArray(i.entries));
74
- }
75
- if (!addEntries)
76
- continue;
77
- for (const entry of addEntries.entries) {
78
- if (!entry.entryId.startsWith('user-'))
79
- continue;
80
- const item = entry.content?.itemContent?.user_results?.result;
81
- if (!item || item.__typename !== 'User')
82
- continue;
83
- const core = item.core || {};
84
- const legacy = item.legacy || {};
85
- results.push({
86
- screen_name: core.screen_name || legacy.screen_name || 'unknown',
87
- name: core.name || legacy.name || 'unknown',
88
- bio: legacy.description || item.profile_bio?.description || '',
89
- followers: legacy.followers_count || legacy.normal_followers_count || 0
90
- });
91
- }
139
+
140
+ // 3. Wait for follower cells to appear
141
+ await page.wait({ selector: '[data-testid="UserCell"]', timeout: 10000 });
142
+
143
+ // 4. Extract from DOM, scroll to load more, dedupe by screen_name
144
+ const allFollowers = [];
145
+ const seen = new Set();
146
+ let sameCount = 0;
147
+ while (allFollowers.length < limit && sameCount < 3) {
148
+ const followers = await extractFollowersFromDOM(page);
149
+ const newFollowers = followers.filter(f => !seen.has(f.screen_name));
150
+ for (const f of newFollowers) {
151
+ seen.add(f.screen_name);
152
+ allFollowers.push(f);
92
153
  }
93
- catch (e) {
94
- // ignore parsing errors for individual payloads
154
+ if (newFollowers.length === 0) {
155
+ sameCount++;
156
+ } else {
157
+ sameCount = 0;
95
158
  }
159
+ if (allFollowers.length >= limit) break;
160
+ await page.autoScroll({ times: 1, delayMs: 500 });
161
+ await page.wait(2);
162
+ }
163
+ if (allFollowers.length === 0) {
164
+ throw new EmptyResultError('twitter followers', `No followers found for @${targetUser}`);
96
165
  }
97
- // Deduplicate by screen_name
98
- const unique = new Map();
99
- results.forEach(r => unique.set(r.screen_name, r));
100
- const deduplicatedResults = Array.from(unique.values());
101
- return deduplicatedResults.slice(0, kwargs.limit);
166
+ return allFollowers.slice(0, limit);
102
167
  }
103
168
  });
@@ -8,11 +8,11 @@ cli({
8
8
  domain: 'x.com',
9
9
  strategy: Strategy.UI,
10
10
  browser: true,
11
- timeoutSeconds: 600, // 10 min — batch operation
12
11
  args: [
13
12
  { name: 'text', type: 'string', required: true, positional: true, help: 'Message text to send (e.g. "我的微信 wxkabi")' },
14
13
  { name: 'max', type: 'int', required: false, default: 20, help: 'Maximum number of conversations to reply to (default: 20)' },
15
14
  { name: 'skip-replied', type: 'boolean', required: false, default: true, help: 'Skip conversations where you already sent the same text (default: true)' },
15
+ { name: 'timeout', type: 'int', required: false, default: 600, help: 'Max seconds for the overall command (default: 600 — batch op)' },
16
16
  ],
17
17
  columns: ['index', 'status', 'user', 'message'],
18
18
  func: async (page, kwargs) => {
@@ -4,35 +4,7 @@ import * as path from 'node:path';
4
4
  import { describe, expect, it, vi } from 'vitest';
5
5
  import { getRegistry } from '@jackwener/opencli/registry';
6
6
  import { __test__ } from './reply.js';
7
- function createPageMock(evaluateResults, overrides = {}) {
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
- selectTab: vi.fn().mockResolvedValue(undefined),
24
- networkRequests: vi.fn().mockResolvedValue([]),
25
- consoleMessages: vi.fn().mockResolvedValue([]),
26
- scroll: vi.fn().mockResolvedValue(undefined),
27
- autoScroll: vi.fn().mockResolvedValue(undefined),
28
- installInterceptor: vi.fn().mockResolvedValue(undefined),
29
- getInterceptedRequests: vi.fn().mockResolvedValue([]),
30
- getCookies: vi.fn().mockResolvedValue([]),
31
- screenshot: vi.fn().mockResolvedValue(''),
32
- waitForCapture: vi.fn().mockResolvedValue(undefined),
33
- ...overrides,
34
- };
35
- }
7
+ import { createPageMock } from '../test-utils.js';
36
8
  describe('twitter reply command', () => {
37
9
  it('uses the dedicated reply composer for text-only replies too', async () => {
38
10
  const cmd = getRegistry().get('twitter/reply');
@@ -0,0 +1,105 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { ArgumentError, CommandExecutionError, EmptyResultError, getErrorMessage } from '@jackwener/opencli/errors';
3
+
4
+ const UISDC_NEWS_URL = 'https://www.uisdc.com/news';
5
+ const DEFAULT_LIMIT = 20;
6
+ const MAX_LIMIT = 50;
7
+
8
+ function normalizeLimit(value) {
9
+ const raw = value ?? DEFAULT_LIMIT;
10
+ const limit = Number(raw);
11
+ if (!Number.isInteger(limit) || limit <= 0) {
12
+ throw new ArgumentError('limit must be a positive integer', `Example: opencli uisdc news --limit ${DEFAULT_LIMIT}`);
13
+ }
14
+ if (limit > MAX_LIMIT) {
15
+ throw new ArgumentError(`limit must be <= ${MAX_LIMIT}`, `Example: opencli uisdc news --limit ${MAX_LIMIT}`);
16
+ }
17
+ return limit;
18
+ }
19
+
20
+ function normalizeText(value) {
21
+ return String(value ?? '').replace(/\s+/g, ' ').trim();
22
+ }
23
+
24
+ function buildExtractUisdcNewsJs() {
25
+ return `
26
+ (() => {
27
+ const cards = Array.from(document.querySelectorAll(
28
+ '.news-list > .news-item:first-child > .item-content > .dubao-items > .dubao-item'
29
+ ));
30
+ if (cards.length === 0) {
31
+ return {
32
+ ok: false,
33
+ reason: 'selector-missing',
34
+ title: document.title || '',
35
+ bodyText: (document.body?.innerText || document.body?.textContent || '').slice(0, 500),
36
+ };
37
+ }
38
+ const rows = cards.map((el, index) => {
39
+ const anchor = el.querySelector('a[href]');
40
+ return {
41
+ rank: index + 1,
42
+ title: el.querySelector('.dubao-title')?.textContent || '',
43
+ summary: el.querySelector('.dubao-content')?.textContent || '',
44
+ url: anchor ? new URL(anchor.getAttribute('href'), location.href).href : '',
45
+ };
46
+ });
47
+ return { ok: true, rows };
48
+ })()
49
+ `;
50
+ }
51
+
52
+ function toRows(payload, limit) {
53
+ if (!payload || typeof payload !== 'object') {
54
+ throw new CommandExecutionError('UISDC news page returned an unreadable payload');
55
+ }
56
+ if (!payload.ok) {
57
+ const reason = typeof payload.reason === 'string' && payload.reason.trim() ? payload.reason.trim() : 'selector-drift';
58
+ throw new CommandExecutionError(
59
+ `UISDC news selector drift: ${reason}`,
60
+ payload.title ? `Page title: ${payload.title}` : undefined,
61
+ );
62
+ }
63
+ const rows = (Array.isArray(payload.rows) ? payload.rows : [])
64
+ .map((row, index) => ({
65
+ rank: index + 1,
66
+ title: normalizeText(row.title),
67
+ summary: normalizeText(row.summary),
68
+ url: normalizeText(row.url),
69
+ }))
70
+ .filter((row) => row.title && row.url);
71
+ if (rows.length === 0) {
72
+ throw new EmptyResultError('uisdc news', 'UISDC news page loaded, but no news rows with title and URL were extracted.');
73
+ }
74
+ return rows.slice(0, limit).map((row, index) => ({ ...row, rank: index + 1 }));
75
+ }
76
+
77
+ async function loadUisdcNews(page, args) {
78
+ const limit = normalizeLimit(args.limit);
79
+ await page.goto(UISDC_NEWS_URL, { waitUntil: 'load', settleMs: 3000 });
80
+ const payload = await page.evaluate(buildExtractUisdcNewsJs()).catch((error) => {
81
+ throw new CommandExecutionError(`Failed to extract UISDC news: ${getErrorMessage(error)}`);
82
+ });
83
+ return toRows(payload, limit);
84
+ }
85
+
86
+ export const uisdcNewsCommand = cli({
87
+ site: 'uisdc',
88
+ name: 'news',
89
+ access: 'read',
90
+ description: '优设读报 - 最新 AI/设计行业新闻',
91
+ domain: 'www.uisdc.com',
92
+ strategy: Strategy.PUBLIC,
93
+ browser: true,
94
+ args: [
95
+ { name: 'limit', type: 'int', default: DEFAULT_LIMIT, help: `Number of news items to return (max ${MAX_LIMIT})` },
96
+ ],
97
+ columns: ['rank', 'title', 'summary', 'url'],
98
+ func: loadUisdcNews,
99
+ });
100
+
101
+ export const __test__ = {
102
+ buildExtractUisdcNewsJs,
103
+ normalizeLimit,
104
+ toRows,
105
+ };