@jackwener/opencli 1.7.12 → 1.7.14

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 (419) hide show
  1. package/README.md +8 -7
  2. package/README.zh-CN.md +9 -8
  3. package/cli-manifest.json +12128 -6665
  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/quote.js +139 -0
  298. package/clis/twitter/quote.test.js +106 -0
  299. package/clis/twitter/reply-dm.js +1 -1
  300. package/clis/twitter/reply.test.js +1 -29
  301. package/clis/twitter/retweet.js +99 -0
  302. package/clis/twitter/retweet.test.js +69 -0
  303. package/clis/twitter/shared.js +38 -0
  304. package/clis/twitter/shared.test.js +28 -1
  305. package/clis/twitter/unlike.js +87 -0
  306. package/clis/twitter/unlike.test.js +72 -0
  307. package/clis/twitter/unretweet.js +99 -0
  308. package/clis/twitter/unretweet.test.js +69 -0
  309. package/clis/uisdc/news.js +105 -0
  310. package/clis/uisdc/news.test.js +66 -0
  311. package/clis/wanfang/search.js +0 -1
  312. package/clis/web/read.js +47 -17
  313. package/clis/web/read.test.js +101 -1
  314. package/clis/weixin/create-draft.js +1 -1
  315. package/clis/weixin/drafts.js +1 -1
  316. package/clis/weixin/drafts.test.js +5 -1
  317. package/clis/weixin/search.js +157 -0
  318. package/clis/weixin/search.test.js +227 -0
  319. package/clis/wikidata/entity.js +60 -0
  320. package/clis/wikidata/search.js +50 -0
  321. package/clis/wikidata/utils.js +117 -0
  322. package/clis/wikidata/wikidata.test.js +83 -0
  323. package/clis/wikipedia/page.js +95 -0
  324. package/clis/wttr/current.js +63 -0
  325. package/clis/wttr/forecast.js +71 -0
  326. package/clis/wttr/utils.js +50 -0
  327. package/clis/wttr/wttr.test.js +84 -0
  328. package/clis/xianyu/chat.js +16 -4
  329. package/clis/xianyu/chat.test.js +64 -0
  330. package/clis/xianyu/publish.js +485 -0
  331. package/clis/xianyu/publish.test.js +220 -0
  332. package/clis/xiaoe/catalog.js +105 -40
  333. package/clis/xiaoe/content.js +164 -29
  334. package/clis/xiaoe/courses.js +86 -29
  335. package/clis/xiaoe/xiaoe.test.js +486 -0
  336. package/clis/xiaohongshu/creator-notes-summary.js +1 -1
  337. package/clis/xiaohongshu/publish.js +16 -3
  338. package/clis/xiaohongshu/publish.test.js +46 -1
  339. package/clis/youtube/transcript.js +13 -19
  340. package/clis/youtube/transcript.test.js +17 -0
  341. package/clis/yuanbao/ask.js +17 -66
  342. package/clis/yuanbao/ask.test.js +5 -5
  343. package/clis/yuanbao/detail.js +65 -0
  344. package/clis/yuanbao/history.js +51 -0
  345. package/clis/yuanbao/new.js +1 -0
  346. package/clis/yuanbao/read.js +38 -0
  347. package/clis/yuanbao/send.js +57 -0
  348. package/clis/yuanbao/shared.js +297 -5
  349. package/clis/yuanbao/shared.test.js +80 -0
  350. package/clis/yuanbao/status.js +44 -0
  351. package/clis/zlibrary/commands.test.js +1 -11
  352. package/dist/src/browser/base-page.d.ts +9 -0
  353. package/dist/src/browser/base-page.js +44 -1
  354. package/dist/src/browser/base-page.test.js +66 -0
  355. package/dist/src/browser/bridge.js +47 -45
  356. package/dist/src/browser/cdp.d.ts +1 -0
  357. package/dist/src/browser/cdp.js +51 -9
  358. package/dist/src/browser/daemon-client.d.ts +4 -0
  359. package/dist/src/browser/errors.js +1 -1
  360. package/dist/src/browser/page.d.ts +1 -1
  361. package/dist/src/browser/page.js +3 -1
  362. package/dist/src/browser/page.test.js +29 -0
  363. package/dist/src/browser/target-errors.d.ts +2 -1
  364. package/dist/src/browser/target-errors.js +1 -0
  365. package/dist/src/browser/target-resolver.d.ts +25 -0
  366. package/dist/src/browser/target-resolver.js +43 -0
  367. package/dist/src/browser.test.js +18 -0
  368. package/dist/src/build-manifest.js +9 -4
  369. package/dist/src/build-manifest.test.js +2 -8
  370. package/dist/src/capabilityRouting.d.ts +16 -1
  371. package/dist/src/capabilityRouting.js +24 -1
  372. package/dist/src/capabilityRouting.test.js +19 -1
  373. package/dist/src/cli.js +76 -11
  374. package/dist/src/cli.test.js +241 -1
  375. package/dist/src/commanderAdapter.js +23 -9
  376. package/dist/src/commanderAdapter.test.js +0 -1
  377. package/dist/src/discovery.js +2 -5
  378. package/dist/src/errors.js +1 -1
  379. package/dist/src/execution.d.ts +1 -1
  380. package/dist/src/execution.js +111 -27
  381. package/dist/src/execution.test.js +326 -17
  382. package/dist/src/help.d.ts +27 -2
  383. package/dist/src/help.js +196 -23
  384. package/dist/src/help.test.d.ts +1 -0
  385. package/dist/src/help.test.js +54 -0
  386. package/dist/src/main.js +14 -1
  387. package/dist/src/manifest-types.d.ts +5 -3
  388. package/dist/src/pipeline/executor.js +1 -1
  389. package/dist/src/pipeline/executor.test.js +8 -0
  390. package/dist/src/pipeline/registry.d.ts +9 -0
  391. package/dist/src/pipeline/registry.js +13 -1
  392. package/dist/src/pipeline/steps/browser.d.ts +1 -0
  393. package/dist/src/pipeline/steps/browser.js +10 -0
  394. package/dist/src/pipeline/steps/download.test.js +1 -0
  395. package/dist/src/registry-api.d.ts +1 -1
  396. package/dist/src/registry.d.ts +12 -11
  397. package/dist/src/registry.js +16 -6
  398. package/dist/src/registry.test.js +2 -2
  399. package/dist/src/runtime.d.ts +2 -1
  400. package/dist/src/runtime.js +1 -1
  401. package/dist/src/serialization.d.ts +2 -2
  402. package/dist/src/serialization.js +4 -6
  403. package/dist/src/serialization.test.js +17 -0
  404. package/dist/src/types.d.ts +17 -0
  405. package/dist/src/validate.js +15 -11
  406. package/dist/src/validate.test.d.ts +9 -0
  407. package/dist/src/validate.test.js +90 -0
  408. package/package.json +1 -1
  409. package/scripts/fetch-adapters.js +1 -1
  410. package/scripts/typed-error-lint-baseline.json +5 -77
  411. package/clis/ctrip/search.test.js +0 -64
  412. package/clis/gov-policy/commands.test.js +0 -27
  413. package/clis/linux-do/category.js +0 -37
  414. package/clis/linux-do/hot.js +0 -26
  415. package/clis/linux-do/latest.js +0 -19
  416. package/clis/pixiv/test-utils.js +0 -23
  417. package/clis/toutiao/articles.test.js +0 -30
  418. package/dist/src/analysis.d.ts +0 -40
  419. package/dist/src/analysis.js +0 -172
@@ -0,0 +1,389 @@
1
+ // Contract tests for the Phase-3 refactor that retired the legacy DOM-link
2
+ // scraping pattern across explore / user / friends / following / notifications / live.
3
+ // Each adapter is now func + Strategy.COOKIE + browser:true with shared helpers
4
+ // from utils.js — these tests pin down (a) registration metadata, (b) typed-
5
+ // error boundary, (c) build-script invariants (no raw user-input concatenation,
6
+ // expected endpoint markers, JSON.stringify-embedded inputs).
7
+
8
+ import { describe, expect, it, vi } from 'vitest';
9
+ import {
10
+ ArgumentError,
11
+ AuthRequiredError,
12
+ CommandExecutionError,
13
+ EmptyResultError,
14
+ } from '@jackwener/opencli/errors';
15
+ import { exploreCommand, __test__ as exploreTest } from './explore.js';
16
+ import { friendsCommand, __test__ as friendsTest } from './friends.js';
17
+ import { followingCommand, __test__ as followingTest } from './following.js';
18
+ import { notificationsCommand, __test__ as notificationsTest } from './notifications.js';
19
+ import { liveCommand, __test__ as liveTest } from './live.js';
20
+ import { userCommand, __test__ as userTest } from './user.js';
21
+ import {
22
+ BROWSER_HELPERS,
23
+ NOTIFICATION_TYPES,
24
+ VIDEO_ITEM_NORMALIZER,
25
+ USER_ITEM_NORMALIZER,
26
+ LIVE_ITEM_NORMALIZER,
27
+ NOTIFICATION_NORMALIZER,
28
+ normalizeUsername,
29
+ requireLimit,
30
+ requireNotificationType,
31
+ } from './utils.js';
32
+
33
+ function makePage(rows) {
34
+ return {
35
+ goto: vi.fn().mockResolvedValue(undefined),
36
+ wait: vi.fn().mockResolvedValue(undefined),
37
+ evaluate: vi.fn().mockResolvedValue(rows),
38
+ };
39
+ }
40
+
41
+ function makeFailingPage(error) {
42
+ return {
43
+ goto: vi.fn().mockResolvedValue(undefined),
44
+ wait: vi.fn().mockResolvedValue(undefined),
45
+ evaluate: vi.fn().mockRejectedValue(error),
46
+ };
47
+ }
48
+
49
+ const sampleVideoRow = {
50
+ index: 1,
51
+ id: '7350000000000000000',
52
+ author: 'creator',
53
+ url: 'https://www.tiktok.com/@creator/video/7350000000000000000',
54
+ cover: 'https://example.com/cover.jpg',
55
+ title: 'a fun clip',
56
+ desc: 'a fun clip',
57
+ plays: 12345,
58
+ likes: 678,
59
+ comments: 9,
60
+ shares: 1,
61
+ createTime: 1710000000,
62
+ };
63
+
64
+ const sampleUserVideoRow = {
65
+ ...sampleVideoRow,
66
+ source: 'profile-api',
67
+ };
68
+
69
+ const sampleUserRow = {
70
+ index: 1,
71
+ username: 'creator',
72
+ name: 'Creator',
73
+ secUid: 'MS4wLjA',
74
+ verified: false,
75
+ followers: 1000,
76
+ following: 50,
77
+ url: 'https://www.tiktok.com/@creator',
78
+ };
79
+
80
+ const sampleNotificationRow = {
81
+ index: 1,
82
+ id: 'notice-1',
83
+ from: 'someone',
84
+ text: 'liked your video',
85
+ createTime: 1710000000,
86
+ };
87
+
88
+ const sampleLiveRow = {
89
+ index: 1,
90
+ streamer: 'host1',
91
+ name: 'Host One',
92
+ title: 'cooking show',
93
+ viewers: 500,
94
+ likes: 0,
95
+ secUid: 'MS4wLjA',
96
+ url: 'https://www.tiktok.com/@host1/live',
97
+ };
98
+
99
+ describe('tiktok/utils', () => {
100
+ it('requireLimit rejects 0 / negative / non-integer / over-max with ArgumentError', () => {
101
+ expect(() => requireLimit(0, { fallback: 10, max: 50 })).toThrow(ArgumentError);
102
+ expect(() => requireLimit(-1, { fallback: 10, max: 50 })).toThrow(ArgumentError);
103
+ expect(() => requireLimit(1.5, { fallback: 10, max: 50 })).toThrow(ArgumentError);
104
+ expect(() => requireLimit(51, { fallback: 10, max: 50 })).toThrow(ArgumentError);
105
+ expect(requireLimit(undefined, { fallback: 10, max: 50 })).toBe(10);
106
+ expect(requireLimit(20, { fallback: 10, max: 50 })).toBe(20);
107
+ });
108
+
109
+ it('normalizeUsername strips @ and validates allowed charset', () => {
110
+ expect(normalizeUsername('@dictogo')).toBe('dictogo');
111
+ expect(normalizeUsername(' creator.name ')).toBe('creator.name');
112
+ expect(() => normalizeUsername('')).toThrow(ArgumentError);
113
+ expect(() => normalizeUsername('bad name')).toThrow(ArgumentError);
114
+ expect(() => normalizeUsername('bad/name')).toThrow(ArgumentError);
115
+ });
116
+
117
+ it('requireNotificationType maps known keys and rejects unknown via ArgumentError', () => {
118
+ expect(requireNotificationType('all')).toBe('all');
119
+ expect(requireNotificationType('LIKES')).toBe('likes');
120
+ expect(() => requireNotificationType('bogus')).toThrow(ArgumentError);
121
+ });
122
+
123
+ it('BROWSER_HELPERS is a string template that exposes the expected helper names', () => {
124
+ expect(typeof BROWSER_HELPERS).toBe('string');
125
+ for (const name of ['asNumber', 'cleanText', 'getCookie', 'fetchJson', 'assertTikTokApiSuccess', 'findUniversalData', 'walkObjects']) {
126
+ expect(BROWSER_HELPERS).toContain('function ' + name + '(');
127
+ }
128
+ const asNumber = new Function(`${BROWSER_HELPERS}; return asNumber;`)();
129
+ expect(asNumber(null)).toBeNull();
130
+ expect(asNumber('')).toBeNull();
131
+ expect(asNumber(0)).toBe(0);
132
+ const assertTikTokApiSuccess = new Function(`${BROWSER_HELPERS}; return assertTikTokApiSuccess;`)();
133
+ expect(() => assertTikTokApiSuccess({ status_code: 0 }, 'test')).not.toThrow();
134
+ expect(() => assertTikTokApiSuccess({ status_code: 8, status_msg: 'login required' }, 'test')).toThrow(/AUTH_REQUIRED/);
135
+ expect(() => assertTikTokApiSuccess({ status_code: 1001, status_msg: 'rate limited' }, 'test')).toThrow(/test API failed: rate limited/);
136
+ });
137
+
138
+ it('Item normalizers produce well-formed JS function declarations', () => {
139
+ expect(VIDEO_ITEM_NORMALIZER).toContain('function normalizeVideoItem(');
140
+ expect(USER_ITEM_NORMALIZER).toContain('function normalizeUserRow(');
141
+ expect(LIVE_ITEM_NORMALIZER).toContain('function normalizeLiveItem(');
142
+ expect(LIVE_ITEM_NORMALIZER).toContain('room.user_count ?? room.viewerCount');
143
+ expect(LIVE_ITEM_NORMALIZER).toContain('room.like_count ?? room.likeCount');
144
+ expect(NOTIFICATION_NORMALIZER).toContain('function normalizeNotification(');
145
+ });
146
+ });
147
+
148
+ describe('tiktok/explore (page-context refactor)', () => {
149
+ it('registers as read-only browser COOKIE adapter with full video columns', () => {
150
+ expect(exploreCommand.access).toBe('read');
151
+ expect(exploreCommand.browser).toBe(true);
152
+ expect(exploreCommand.strategy).toBe('cookie');
153
+ expect(exploreCommand.columns).toEqual([
154
+ 'index', 'id', 'author', 'url', 'cover', 'title', 'desc',
155
+ 'plays', 'likes', 'comments', 'shares', 'createTime',
156
+ ]);
157
+ });
158
+
159
+ it('validates --limit upfront before navigating (no silent clamp)', async () => {
160
+ const page = makePage([]);
161
+ await expect(exploreCommand.func(page, { limit: 0 })).rejects.toBeInstanceOf(ArgumentError);
162
+ await expect(exploreCommand.func(page, { limit: exploreTest.MAX_LIMIT + 1 })).rejects.toBeInstanceOf(ArgumentError);
163
+ expect(page.goto).not.toHaveBeenCalled();
164
+ });
165
+
166
+ it('navigates to /explore and returns the full video row shape from evaluate', async () => {
167
+ const page = makePage([sampleVideoRow, { ...sampleVideoRow, id: '7350000000000000001', index: 99 }]);
168
+ const rows = await exploreCommand.func(page, { limit: 5 });
169
+ expect(page.goto).toHaveBeenCalledWith('https://www.tiktok.com/explore', { waitUntil: 'load', settleMs: 5000 });
170
+ expect(rows).toEqual([sampleVideoRow, { ...sampleVideoRow, id: '7350000000000000001', index: 99 }]);
171
+ });
172
+
173
+ it('maps empty / unrecognised evaluate failures to typed errors', async () => {
174
+ await expect(exploreCommand.func(makePage([]), { limit: 5 })).rejects.toBeInstanceOf(EmptyResultError);
175
+ await expect(exploreCommand.func(makeFailingPage(new Error('No videos found on /explore')), { limit: 5 })).rejects.toBeInstanceOf(EmptyResultError);
176
+ await expect(exploreCommand.func(makeFailingPage(new Error('No videos found on /explore (recommend API failed: HTTP 500)')), { limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
177
+ await expect(exploreCommand.func(makeFailingPage(new Error('No videos found on /explore (recommend API failed: HTTP 403)')), { limit: 5 })).rejects.toBeInstanceOf(AuthRequiredError);
178
+ await expect(exploreCommand.func(makeFailingPage(new Error('No videos found on /explore (recommend API failed: invalid JSON)')), { limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
179
+ await expect(exploreCommand.func(makeFailingPage(new Error('boom')), { limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
180
+ });
181
+
182
+ it('build script targets recommend-feed state + recommend item_list endpoint', () => {
183
+ const script = exploreTest.buildExploreScript(15);
184
+ expect(script).toContain('/api/recommend/item_list/');
185
+ expect(script).toContain("assertTikTokApiSuccess(data, 'recommend')");
186
+ expect(script).toContain('findUniversalData');
187
+ expect(script).toContain('normalizeVideoItem');
188
+ });
189
+ });
190
+
191
+ describe('tiktok/user (page-context refactor)', () => {
192
+ it('registers as read-only browser COOKIE adapter with sourced video columns', () => {
193
+ expect(userCommand.access).toBe('read');
194
+ expect(userCommand.browser).toBe(true);
195
+ expect(userCommand.strategy).toBe('cookie');
196
+ expect(userCommand.columns).toEqual([
197
+ 'index', 'id', 'source', 'author', 'url', 'cover', 'title', 'desc',
198
+ 'plays', 'likes', 'comments', 'shares', 'createTime',
199
+ ]);
200
+ });
201
+
202
+ it('validates username and --limit upfront before navigating', async () => {
203
+ const page = makePage([]);
204
+ await expect(userCommand.func(page, { username: '', limit: 5 })).rejects.toBeInstanceOf(ArgumentError);
205
+ await expect(userCommand.func(page, { username: '@bad/name', limit: 5 })).rejects.toBeInstanceOf(ArgumentError);
206
+ await expect(userCommand.func(page, { username: 'dictogo', limit: 0 })).rejects.toBeInstanceOf(ArgumentError);
207
+ await expect(userCommand.func(page, { username: 'dictogo', limit: userTest.MAX_LIMIT + 1 })).rejects.toBeInstanceOf(ArgumentError);
208
+ expect(page.goto).not.toHaveBeenCalled();
209
+ });
210
+
211
+ it('navigates to the profile and returns full sourced video rows', async () => {
212
+ const page = makePage([sampleUserVideoRow]);
213
+ await expect(userCommand.func(page, { username: '@dictogo', limit: 5 })).resolves.toEqual([sampleUserVideoRow]);
214
+ expect(page.goto).toHaveBeenCalledWith('https://www.tiktok.com/@dictogo', { waitUntil: 'load', settleMs: 6000 });
215
+ });
216
+
217
+ it('maps empty, auth, upstream, and invalid JSON failures to typed errors', async () => {
218
+ await expect(userCommand.func(makePage([]), { username: 'dictogo', limit: 5 })).rejects.toBeInstanceOf(EmptyResultError);
219
+ await expect(userCommand.func(makeFailingPage(new Error('No videos found for @dictogo')), { username: 'dictogo', limit: 5 })).rejects.toBeInstanceOf(EmptyResultError);
220
+ await expect(userCommand.func(makeFailingPage(new Error('No videos found for @dictogo (profile/search API failed: HTTP 500)')), { username: 'dictogo', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
221
+ await expect(userCommand.func(makeFailingPage(new Error('No videos found for @dictogo (profile/search API failed: HTTP 403)')), { username: 'dictogo', limit: 5 })).rejects.toBeInstanceOf(AuthRequiredError);
222
+ await expect(userCommand.func(makeFailingPage(new Error('No videos found for @dictogo (profile/search API failed: invalid JSON)')), { username: 'dictogo', limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
223
+ });
224
+
225
+ it('build script resolves secUid, pages post-list, and exact-author search fallback', () => {
226
+ const script = userTest.buildUserScript('dictogo', 20);
227
+ expect(script).toContain('/api/user/detail/');
228
+ expect(script).toContain("assertTikTokApiSuccess(detail, 'user-detail')");
229
+ expect(script).toContain('/api/post/item_list/');
230
+ expect(script).toContain("assertTikTokApiSuccess(data, 'post-list')");
231
+ expect(script).toContain('/api/search/general/full/');
232
+ expect(script).toContain("assertTikTokApiSuccess(data, 'search')");
233
+ expect(script).toContain("'profile-api'");
234
+ expect(script).toContain("'bootstrap'");
235
+ expect(script).toContain("'search-fallback'");
236
+ expect(script).toContain(JSON.stringify('dictogo'));
237
+ expect(script).not.toContain('AUTH_REQUIRED: cannot resolve secUid');
238
+ });
239
+ });
240
+
241
+ describe('tiktok/friends (page-context refactor)', () => {
242
+ it('registers as read-only COOKIE adapter with user columns', () => {
243
+ expect(friendsCommand.access).toBe('read');
244
+ expect(friendsCommand.browser).toBe(true);
245
+ expect(friendsCommand.strategy).toBe('cookie');
246
+ expect(friendsCommand.columns).toEqual([
247
+ 'index', 'username', 'name', 'secUid', 'verified', 'followers', 'following', 'url',
248
+ ]);
249
+ });
250
+
251
+ it('validates --limit upfront and never navigates on bad input', async () => {
252
+ const page = makePage([]);
253
+ await expect(friendsCommand.func(page, { limit: -2 })).rejects.toBeInstanceOf(ArgumentError);
254
+ expect(page.goto).not.toHaveBeenCalled();
255
+ });
256
+
257
+ it('navigates to /friends and surfaces empty as EmptyResultError', async () => {
258
+ const page = makePage([sampleUserRow]);
259
+ await expect(friendsCommand.func(page, { limit: 5 })).resolves.toEqual([sampleUserRow]);
260
+ expect(page.goto).toHaveBeenCalledWith('https://www.tiktok.com/friends', { waitUntil: 'load', settleMs: 5000 });
261
+ await expect(friendsCommand.func(makePage([]), { limit: 5 })).rejects.toBeInstanceOf(EmptyResultError);
262
+ await expect(friendsCommand.func(makeFailingPage(new Error('No friend suggestions returned by TikTok (recommend-user API failed: HTTP 500)')), { limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
263
+ await expect(friendsCommand.func(makeFailingPage(new Error('No friend suggestions returned by TikTok (recommend-user API failed: HTTP 401)')), { limit: 5 })).rejects.toBeInstanceOf(AuthRequiredError);
264
+ await expect(friendsCommand.func(makeFailingPage(new Error('No friend suggestions returned by TikTok (recommend-user API failed: invalid JSON)')), { limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
265
+ });
266
+
267
+ it('build script targets recommend-user endpoint via state-then-API', () => {
268
+ const script = friendsTest.buildFriendsScript(20);
269
+ expect(script).toContain('/api/recommend/user/');
270
+ expect(script).toContain("assertTikTokApiSuccess(data, 'recommend-user')");
271
+ expect(script).toContain('findUniversalData');
272
+ expect(script).toContain('normalizeUserRow');
273
+ });
274
+ });
275
+
276
+ describe('tiktok/following (page-context refactor)', () => {
277
+ it('registers as read-only COOKIE adapter with user columns', () => {
278
+ expect(followingCommand.access).toBe('read');
279
+ expect(followingCommand.browser).toBe(true);
280
+ expect(followingCommand.strategy).toBe('cookie');
281
+ expect(followingCommand.columns).toEqual([
282
+ 'index', 'username', 'name', 'secUid', 'verified', 'followers', 'following', 'url',
283
+ ]);
284
+ });
285
+
286
+ it('validates --limit upfront', async () => {
287
+ const page = makePage([]);
288
+ await expect(followingCommand.func(page, { limit: 999 })).rejects.toBeInstanceOf(ArgumentError);
289
+ expect(page.goto).not.toHaveBeenCalled();
290
+ });
291
+
292
+ it('maps AUTH_REQUIRED sentinel to AuthRequiredError', async () => {
293
+ const page = makeFailingPage(new Error('AUTH_REQUIRED: cannot resolve viewer secUid (login required)'));
294
+ await expect(followingCommand.func(page, { limit: 5 })).rejects.toBeInstanceOf(AuthRequiredError);
295
+ await expect(followingCommand.func(makeFailingPage(new Error('No following entries returned (user-list API failed: HTTP 500)')), { limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
296
+ await expect(followingCommand.func(makeFailingPage(new Error('No following entries returned (user-list API failed: HTTP 403)')), { limit: 5 })).rejects.toBeInstanceOf(AuthRequiredError);
297
+ await expect(followingCommand.func(makeFailingPage(new Error('No following entries returned (user-list API failed: invalid JSON)')), { limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
298
+ });
299
+
300
+ it('navigates to /following and returns the full user row shape', async () => {
301
+ const page = makePage([sampleUserRow]);
302
+ await expect(followingCommand.func(page, { limit: 5 })).resolves.toEqual([sampleUserRow]);
303
+ expect(page.goto).toHaveBeenCalledWith('https://www.tiktok.com/following', { waitUntil: 'load', settleMs: 5000 });
304
+ });
305
+
306
+ it('build script resolves viewer secUid then pages user-list scene=21', () => {
307
+ const script = followingTest.buildFollowingScript(20);
308
+ expect(script).toContain('/api/user/list/');
309
+ expect(script).toContain("assertTikTokApiSuccess(data, 'user-list')");
310
+ expect(script).toContain("scene: '21'");
311
+ expect(script).toContain('findViewerSecUid');
312
+ expect(script).toContain('normalizeUserRow');
313
+ });
314
+ });
315
+
316
+ describe('tiktok/notifications (page-context refactor)', () => {
317
+ it('registers as read-only COOKIE adapter with notification columns', () => {
318
+ expect(notificationsCommand.access).toBe('read');
319
+ expect(notificationsCommand.browser).toBe(true);
320
+ expect(notificationsCommand.strategy).toBe('cookie');
321
+ expect(notificationsCommand.columns).toEqual([
322
+ 'index', 'id', 'from', 'text', 'createTime',
323
+ ]);
324
+ });
325
+
326
+ it('validates --limit and --type upfront before navigating', async () => {
327
+ const page = makePage([]);
328
+ await expect(notificationsCommand.func(page, { limit: 0, type: 'all' })).rejects.toBeInstanceOf(ArgumentError);
329
+ await expect(notificationsCommand.func(page, { limit: 5, type: 'bogus' })).rejects.toBeInstanceOf(ArgumentError);
330
+ expect(page.goto).not.toHaveBeenCalled();
331
+ });
332
+
333
+ it('maps AUTH_REQUIRED sentinel to AuthRequiredError, empty to EmptyResultError', async () => {
334
+ await expect(notificationsCommand.func(
335
+ makeFailingPage(new Error('AUTH_REQUIRED: TikTok inbox requires login (likes)')),
336
+ { limit: 5, type: 'likes' },
337
+ )).rejects.toBeInstanceOf(AuthRequiredError);
338
+ await expect(notificationsCommand.func(makePage([]), { limit: 5, type: 'all' })).rejects.toBeInstanceOf(EmptyResultError);
339
+ await expect(notificationsCommand.func(makeFailingPage(new Error('No notifications returned for all (notice API failed: HTTP 500)')), { limit: 5, type: 'all' })).rejects.toBeInstanceOf(CommandExecutionError);
340
+ await expect(notificationsCommand.func(makeFailingPage(new Error('No notifications returned for all (notice API failed: HTTP 401)')), { limit: 5, type: 'all' })).rejects.toBeInstanceOf(AuthRequiredError);
341
+ await expect(notificationsCommand.func(makeFailingPage(new Error('No notifications returned for all (notice API failed: invalid JSON)')), { limit: 5, type: 'all' })).rejects.toBeInstanceOf(CommandExecutionError);
342
+ const page = makePage([sampleNotificationRow]);
343
+ await expect(notificationsCommand.func(page, { limit: 5, type: 'comments' })).resolves.toEqual([sampleNotificationRow]);
344
+ });
345
+
346
+ it('embeds the requested notice_type code from NOTIFICATION_TYPES into the script', () => {
347
+ for (const [key, meta] of Object.entries(NOTIFICATION_TYPES)) {
348
+ const script = notificationsTest.buildNotificationsScript(15, key);
349
+ expect(script).toContain('const noticeType = ' + meta.code);
350
+ expect(script).toContain('/api/notice/multi/');
351
+ expect(script).toContain("assertTikTokApiSuccess(data, 'notice')");
352
+ }
353
+ });
354
+ });
355
+
356
+ describe('tiktok/live (page-context refactor)', () => {
357
+ it('registers as read-only COOKIE adapter with live columns', () => {
358
+ expect(liveCommand.access).toBe('read');
359
+ expect(liveCommand.browser).toBe(true);
360
+ expect(liveCommand.strategy).toBe('cookie');
361
+ expect(liveCommand.columns).toEqual([
362
+ 'index', 'streamer', 'name', 'title', 'viewers', 'likes', 'secUid', 'url',
363
+ ]);
364
+ });
365
+
366
+ it('validates --limit upfront', async () => {
367
+ const page = makePage([]);
368
+ await expect(liveCommand.func(page, { limit: -1 })).rejects.toBeInstanceOf(ArgumentError);
369
+ expect(page.goto).not.toHaveBeenCalled();
370
+ });
371
+
372
+ it('navigates to /live and surfaces empty as EmptyResultError', async () => {
373
+ const page = makePage([sampleLiveRow]);
374
+ await expect(liveCommand.func(page, { limit: 5 })).resolves.toEqual([sampleLiveRow]);
375
+ expect(page.goto).toHaveBeenCalledWith('https://www.tiktok.com/live', { waitUntil: 'load', settleMs: 5000 });
376
+ await expect(liveCommand.func(makePage([]), { limit: 5 })).rejects.toBeInstanceOf(EmptyResultError);
377
+ await expect(liveCommand.func(makeFailingPage(new Error('No live streams returned (live-discover API failed: HTTP 500)')), { limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
378
+ await expect(liveCommand.func(makeFailingPage(new Error('No live streams returned (live-discover API failed: HTTP 403)')), { limit: 5 })).rejects.toBeInstanceOf(AuthRequiredError);
379
+ await expect(liveCommand.func(makeFailingPage(new Error('No live streams returned (live-discover API failed: invalid JSON)')), { limit: 5 })).rejects.toBeInstanceOf(CommandExecutionError);
380
+ });
381
+
382
+ it('build script targets live-discover endpoint via state-then-API', () => {
383
+ const script = liveTest.buildLiveScript(15);
384
+ expect(script).toContain('/api/live/discover/get/');
385
+ expect(script).toContain("assertTikTokApiSuccess(data, 'live-discover')");
386
+ expect(script).toContain('findUniversalData');
387
+ expect(script).toContain('normalizeLiveItem');
388
+ });
389
+ });
@@ -1,45 +1,131 @@
1
- import { cli } from '@jackwener/opencli/registry';
2
- cli({
1
+ // Unfollow a TikTok user via in-page button click + state verification.
2
+ //
3
+ // Replaces the legacy pipeline-based adapter that returned silent failure
4
+ // rows like `{ status: 'Not following this user', username }`. Route 1
5
+ // keeps the live UI button + confirm-dialog flow (the page-context
6
+ // `/api/commit/follow/user/` write endpoint requires X-Bogus signing —
7
+ // out of scope for this PR), but every transition raises a typed error:
8
+ //
9
+ // ArgumentError — empty or malformed username
10
+ // AuthRequiredError — not logged in
11
+ // CommandExecutionError — Following button missing / confirm dialog
12
+ // missing / state verification fails / rate
13
+ // limit detected (retryable=true: server-side
14
+ // dedupes unfollow on retry)
15
+ //
16
+ // `result` row enum: `unfollowed` / `already-not-following`. Failures
17
+ // throw — never returned as a success row.
18
+
19
+ import { cli, Strategy } from '@jackwener/opencli/registry';
20
+ import {
21
+ BROWSER_HELPERS,
22
+ BUTTON_WALKER_HELPERS,
23
+ RETRYABLE_HINTS,
24
+ TIKTOK_HOST,
25
+ normalizeUsername,
26
+ throwButtonWalkerError,
27
+ } from './utils.js';
28
+
29
+ function buildUnfollowScript(username) {
30
+ return `
31
+ (async () => {
32
+ const username = ${JSON.stringify(username)};
33
+
34
+ ${BROWSER_HELPERS}
35
+ ${BUTTON_WALKER_HELPERS}
36
+
37
+ ensureLoggedInOrThrow();
38
+ ensureNoRateLimitOrThrow();
39
+
40
+ const FOLLOW_LABELS = ['Follow', '关注', 'フォロー'];
41
+ const FOLLOWING_LABELS = ['Following', '已关注', 'フォロー中'];
42
+ const FRIENDS_LABELS = ['Friends', '互关', 'フレンド'];
43
+ const RELATION_LABELS = FOLLOWING_LABELS.concat(FRIENDS_LABELS);
44
+ const CONFIRM_LABELS = ['Unfollow', '取消关注'];
45
+
46
+ // Idempotent fast path: not currently following.
47
+ if (!buttonExists(RELATION_LABELS) && buttonExists(FOLLOW_LABELS)) {
48
+ return [{ username, url: ${JSON.stringify(TIKTOK_HOST)} + '/@' + encodeURIComponent(username), result: 'already-not-following' }];
49
+ }
50
+
51
+ const relationBtn = findButtonByText(RELATION_LABELS);
52
+ if (!relationBtn) {
53
+ // Neither Follow nor Following — page may not have rendered, or
54
+ // private account / blocked: refuse to silently succeed.
55
+ throw new Error('BUTTON_NOT_FOUND: neither Follow nor Following button found (page not rendered, blocked, or selectors changed)');
56
+ }
57
+
58
+ const target = relationBtn.closest('button') || relationBtn.closest('[role="button"]') || relationBtn;
59
+ target.click();
60
+
61
+ // TikTok shows a confirm-unfollow dialog. Wait for it to render.
62
+ const dialogShown = await waitFor(() => buttonExists(CONFIRM_LABELS), { timeoutMs: 3000 });
63
+ if (dialogShown) {
64
+ const confirmBtn = findButtonByText(CONFIRM_LABELS);
65
+ if (!confirmBtn) {
66
+ throw new Error('BUTTON_NOT_FOUND: confirm-unfollow dialog detected but confirm button could not be located');
67
+ }
68
+ confirmBtn.click();
69
+ }
70
+
71
+ const flipped = await waitFor(
72
+ () => buttonExists(FOLLOW_LABELS) && !buttonExists(RELATION_LABELS),
73
+ { timeoutMs: 5000 },
74
+ );
75
+ if (!flipped) {
76
+ ensureNoRateLimitOrThrow();
77
+ throw new Error('STATE_VERIFY_FAIL: relation did not flip back to Follow within 5s; unfollow may not have been recorded');
78
+ }
79
+
80
+ ensureNoRateLimitOrThrow();
81
+
82
+ return [{
83
+ username,
84
+ url: ${JSON.stringify(TIKTOK_HOST)} + '/@' + encodeURIComponent(username),
85
+ result: 'unfollowed',
86
+ }];
87
+ })()
88
+ `;
89
+ }
90
+
91
+ async function unfollowUser(page, args) {
92
+ const username = normalizeUsername(args.username);
93
+ const throwFailure = (error) => throwButtonWalkerError(error, {
94
+ authMessage: 'TikTok requires login to unfollow users',
95
+ failureMessage: `Failed to unfollow @${username}`,
96
+ retryableHint: RETRYABLE_HINTS.relationFailure,
97
+ });
98
+ let rows;
99
+ try {
100
+ await page.goto(`${TIKTOK_HOST}/@${encodeURIComponent(username)}`, {
101
+ waitUntil: 'load',
102
+ settleMs: 5000,
103
+ });
104
+ rows = await page.evaluate(buildUnfollowScript(username));
105
+ } catch (error) {
106
+ throwFailure(error);
107
+ }
108
+ if (!Array.isArray(rows) || rows.length === 0) {
109
+ throwFailure(new Error(`STATE_VERIFY_FAIL: unfollow returned no row for @${username}`));
110
+ }
111
+ return rows;
112
+ }
113
+
114
+ export const unfollowCommand = cli({
3
115
  site: 'tiktok',
4
116
  name: 'unfollow',
5
117
  access: 'write',
6
- description: 'Unfollow a TikTok user',
118
+ description: 'Unfollow a TikTok user by username',
7
119
  domain: 'www.tiktok.com',
120
+ strategy: Strategy.COOKIE,
121
+ browser: true,
8
122
  args: [
9
- {
10
- name: 'username',
11
- required: true,
12
- positional: true,
13
- help: 'TikTok username (without @)',
14
- },
15
- ],
16
- columns: ['status', 'username'],
17
- pipeline: [
18
- { navigate: { url: 'https://www.tiktok.com/@${{ args.username }}', settleMs: 6000 } },
19
- { evaluate: `(async () => {
20
- const username = \${{ args.username | json }};
21
- const buttons = Array.from(document.querySelectorAll('button, [role="button"]'));
22
- const followingBtn = buttons.find(function(b) {
23
- var text = b.textContent.trim();
24
- return text === 'Following' || text === '已关注' || text === 'Friends' || text === '互关';
25
- });
26
- if (!followingBtn) {
27
- return [{ status: 'Not following this user', username: username }];
28
- }
29
- followingBtn.click();
30
- await new Promise(r => setTimeout(r, 2000));
31
- // Confirm unfollow if dialog appears
32
- var allBtns = Array.from(document.querySelectorAll('button'));
33
- var confirm = allBtns.find(function(b) {
34
- var t = b.textContent.trim();
35
- return t === 'Unfollow' || t === '取消关注';
36
- });
37
- if (confirm) {
38
- confirm.click();
39
- await new Promise(r => setTimeout(r, 1500));
40
- }
41
- return [{ status: 'Unfollowed', username: username }];
42
- })()
43
- ` },
123
+ { name: 'username', required: true, positional: true, help: 'TikTok username (without @)' },
44
124
  ],
125
+ columns: ['username', 'url', 'result'],
126
+ func: unfollowUser,
45
127
  });
128
+
129
+ export const __test__ = {
130
+ buildUnfollowScript,
131
+ };