@jackwener/opencli 1.6.1 → 1.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (384) hide show
  1. package/CONTRIBUTING.md +1 -1
  2. package/README.md +27 -45
  3. package/README.zh-CN.md +32 -34
  4. package/autoresearch/browse-tasks.json +18 -20
  5. package/autoresearch/commands/debug.ts +163 -0
  6. package/autoresearch/commands/fix.ts +145 -0
  7. package/autoresearch/commands/plan.ts +88 -0
  8. package/autoresearch/commands/run.ts +138 -0
  9. package/autoresearch/config.ts +82 -0
  10. package/autoresearch/engine.ts +359 -0
  11. package/autoresearch/eval-all.ts +127 -0
  12. package/autoresearch/eval-browse.ts +1 -1
  13. package/autoresearch/eval-publish.ts +238 -0
  14. package/autoresearch/eval-save.ts +249 -0
  15. package/autoresearch/eval-skill.ts +14 -8
  16. package/autoresearch/eval-v2ex.ts +220 -0
  17. package/autoresearch/eval-zhihu.ts +230 -0
  18. package/autoresearch/logger.ts +69 -0
  19. package/autoresearch/presets/combined-reliability.ts +27 -0
  20. package/autoresearch/presets/index.ts +23 -0
  21. package/autoresearch/presets/operate-reliability.ts +24 -0
  22. package/autoresearch/presets/save-reliability.ts +26 -0
  23. package/autoresearch/presets/skill-quality.ts +20 -0
  24. package/autoresearch/presets/v2ex-reliability.ts +24 -0
  25. package/autoresearch/presets/zhihu-reliability.ts +25 -0
  26. package/autoresearch/publish-tasks.json +345 -0
  27. package/autoresearch/run-save.sh +11 -0
  28. package/autoresearch/save-adapters/xhs-explore-deep.ts +64 -0
  29. package/autoresearch/save-adapters/xhs-note-comments.ts +61 -0
  30. package/autoresearch/save-adapters/xhs-search-full.ts +62 -0
  31. package/autoresearch/save-adapters/zhihu-hot-detail.ts +52 -0
  32. package/autoresearch/save-adapters/zhihu-question-full.ts +57 -0
  33. package/autoresearch/save-adapters/zhihu-search-detail.ts +53 -0
  34. package/autoresearch/save-tasks.json +281 -0
  35. package/autoresearch/v2ex-tasks.json +899 -0
  36. package/autoresearch/zhihu-tasks.json +848 -0
  37. package/dist/browser/base-page.d.ts +4 -2
  38. package/dist/browser/base-page.js +37 -4
  39. package/dist/browser/bridge.js +10 -8
  40. package/dist/browser/cdp.js +2 -6
  41. package/dist/browser/daemon-client.d.ts +11 -1
  42. package/dist/browser/daemon-client.js +3 -0
  43. package/dist/browser/dom-helpers.d.ts +4 -2
  44. package/dist/browser/dom-helpers.js +42 -31
  45. package/dist/browser/dom-snapshot.js +23 -1
  46. package/dist/browser/page.d.ts +7 -2
  47. package/dist/browser/page.js +112 -30
  48. package/dist/browser.test.js +1 -1
  49. package/dist/build-manifest.d.ts +1 -0
  50. package/dist/build-manifest.js +1 -0
  51. package/dist/cli-manifest.json +1135 -184
  52. package/dist/cli.d.ts +2 -0
  53. package/dist/cli.js +48 -7
  54. package/dist/cli.test.d.ts +1 -0
  55. package/dist/cli.test.js +88 -0
  56. package/dist/clis/1688/item.d.ts +70 -0
  57. package/dist/clis/1688/item.js +187 -0
  58. package/dist/clis/1688/item.test.d.ts +1 -0
  59. package/dist/clis/1688/item.test.js +67 -0
  60. package/dist/clis/1688/search.d.ts +56 -0
  61. package/dist/clis/1688/search.js +309 -0
  62. package/dist/clis/1688/search.test.d.ts +1 -0
  63. package/dist/clis/1688/search.test.js +75 -0
  64. package/dist/clis/1688/shared.d.ts +112 -0
  65. package/dist/clis/1688/shared.js +514 -0
  66. package/dist/clis/1688/shared.test.d.ts +1 -0
  67. package/dist/clis/1688/shared.test.js +57 -0
  68. package/dist/clis/1688/store.d.ts +45 -0
  69. package/dist/clis/1688/store.js +226 -0
  70. package/dist/clis/1688/store.test.d.ts +1 -0
  71. package/dist/clis/1688/store.test.js +62 -0
  72. package/dist/clis/amazon/bestsellers.d.ts +0 -20
  73. package/dist/clis/amazon/bestsellers.js +6 -129
  74. package/dist/clis/amazon/bestsellers.test.js +12 -3
  75. package/dist/clis/amazon/movers-shakers.d.ts +1 -0
  76. package/dist/clis/amazon/movers-shakers.js +7 -0
  77. package/dist/clis/amazon/new-releases.d.ts +1 -0
  78. package/dist/clis/amazon/new-releases.js +7 -0
  79. package/dist/clis/amazon/rankings.d.ts +59 -0
  80. package/dist/clis/amazon/rankings.js +226 -0
  81. package/dist/clis/amazon/rankings.test.d.ts +1 -0
  82. package/dist/clis/amazon/rankings.test.js +41 -0
  83. package/dist/clis/amazon/shared.d.ts +11 -0
  84. package/dist/clis/amazon/shared.js +121 -11
  85. package/dist/clis/amazon/shared.test.js +11 -0
  86. package/dist/clis/bilibili/comments.js +2 -2
  87. package/dist/clis/bilibili/comments.test.js +3 -2
  88. package/dist/clis/bilibili/download.js +2 -1
  89. package/dist/clis/bilibili/subtitle.js +4 -3
  90. package/dist/clis/bilibili/subtitle.test.js +2 -1
  91. package/dist/clis/bilibili/utils.d.ts +5 -0
  92. package/dist/clis/bilibili/utils.js +30 -0
  93. package/dist/clis/bilibili/utils.test.d.ts +1 -0
  94. package/dist/clis/bilibili/utils.test.js +17 -0
  95. package/dist/clis/douban/marks.js +1 -1
  96. package/dist/clis/douban/subject.yaml +50 -19
  97. package/dist/clis/doubao/utils.js +32 -12
  98. package/dist/clis/douyin/_shared/browser-fetch.test.js +0 -1
  99. package/dist/clis/douyin/_shared/transcode.test.js +0 -2
  100. package/dist/clis/douyin/draft.test.js +0 -2
  101. package/dist/clis/facebook/search.test.js +0 -2
  102. package/dist/clis/gemini/ask.js +9 -3
  103. package/dist/clis/gemini/ask.test.d.ts +1 -0
  104. package/dist/clis/gemini/ask.test.js +100 -0
  105. package/dist/clis/gemini/reply-state.test.d.ts +1 -0
  106. package/dist/clis/gemini/reply-state.test.js +641 -0
  107. package/dist/clis/gemini/utils.d.ts +44 -1
  108. package/dist/clis/gemini/utils.js +528 -61
  109. package/dist/clis/gemini/utils.test.js +149 -2
  110. package/dist/clis/hupu/detail.d.ts +1 -0
  111. package/dist/clis/hupu/detail.js +72 -0
  112. package/dist/clis/hupu/hot.yaml +43 -0
  113. package/dist/clis/hupu/like.d.ts +1 -0
  114. package/dist/clis/hupu/like.js +75 -0
  115. package/dist/clis/hupu/reply.d.ts +1 -0
  116. package/dist/clis/hupu/reply.js +71 -0
  117. package/dist/clis/hupu/search.d.ts +1 -0
  118. package/dist/clis/hupu/search.js +59 -0
  119. package/dist/clis/hupu/unlike.d.ts +1 -0
  120. package/dist/clis/hupu/unlike.js +75 -0
  121. package/dist/clis/hupu/utils.d.ts +20 -0
  122. package/dist/clis/hupu/utils.js +319 -0
  123. package/dist/clis/instagram/_shared/private-publish.d.ts +138 -0
  124. package/dist/clis/instagram/_shared/private-publish.js +1030 -0
  125. package/dist/clis/instagram/_shared/private-publish.test.d.ts +1 -0
  126. package/dist/clis/instagram/_shared/private-publish.test.js +705 -0
  127. package/dist/clis/instagram/_shared/protocol-capture.d.ts +26 -0
  128. package/dist/clis/instagram/_shared/protocol-capture.js +282 -0
  129. package/dist/clis/instagram/_shared/protocol-capture.test.d.ts +1 -0
  130. package/dist/clis/instagram/_shared/protocol-capture.test.js +114 -0
  131. package/dist/clis/instagram/_shared/runtime-info.d.ts +9 -0
  132. package/dist/clis/instagram/_shared/runtime-info.js +81 -0
  133. package/dist/clis/instagram/note.d.ts +1 -0
  134. package/dist/clis/instagram/note.js +222 -0
  135. package/dist/clis/instagram/note.test.d.ts +1 -0
  136. package/dist/clis/instagram/note.test.js +81 -0
  137. package/dist/clis/instagram/post.d.ts +4 -0
  138. package/dist/clis/instagram/post.js +1496 -0
  139. package/dist/clis/instagram/post.test.d.ts +1 -0
  140. package/dist/clis/instagram/post.test.js +1647 -0
  141. package/dist/clis/instagram/reel.d.ts +1 -0
  142. package/dist/clis/instagram/reel.js +826 -0
  143. package/dist/clis/instagram/reel.test.d.ts +1 -0
  144. package/dist/clis/instagram/reel.test.js +167 -0
  145. package/dist/clis/instagram/story.d.ts +1 -0
  146. package/dist/clis/instagram/story.js +115 -0
  147. package/dist/clis/instagram/story.test.d.ts +1 -0
  148. package/dist/clis/instagram/story.test.js +167 -0
  149. package/dist/clis/sinafinance/stock-rank.d.ts +4 -0
  150. package/dist/clis/sinafinance/stock-rank.js +65 -0
  151. package/dist/clis/substack/utils.test.js +0 -2
  152. package/dist/clis/twitter/post.js +72 -45
  153. package/dist/clis/twitter/post.test.d.ts +1 -0
  154. package/dist/clis/twitter/post.test.js +116 -0
  155. package/dist/clis/twitter/reply.d.ts +12 -0
  156. package/dist/clis/twitter/reply.js +257 -35
  157. package/dist/clis/twitter/reply.test.d.ts +1 -0
  158. package/dist/clis/twitter/reply.test.js +151 -0
  159. package/dist/clis/xianyu/chat.d.ts +7 -0
  160. package/dist/clis/xianyu/chat.js +146 -0
  161. package/dist/clis/xianyu/chat.test.d.ts +1 -0
  162. package/dist/clis/xianyu/chat.test.js +15 -0
  163. package/dist/clis/xianyu/item.d.ts +7 -0
  164. package/dist/clis/xianyu/item.js +152 -0
  165. package/dist/clis/xianyu/item.test.d.ts +1 -0
  166. package/dist/clis/xianyu/item.test.js +56 -0
  167. package/dist/clis/xianyu/search.d.ts +10 -0
  168. package/dist/clis/xianyu/search.js +134 -0
  169. package/dist/clis/xianyu/search.test.d.ts +1 -0
  170. package/dist/clis/xianyu/search.test.js +17 -0
  171. package/dist/clis/xianyu/utils.d.ts +1 -0
  172. package/dist/clis/xianyu/utils.js +8 -0
  173. package/dist/clis/xiaoe/catalog.yaml +129 -0
  174. package/dist/clis/xiaoe/content.yaml +43 -0
  175. package/dist/clis/xiaoe/courses.yaml +73 -0
  176. package/dist/clis/xiaoe/detail.yaml +39 -0
  177. package/dist/clis/xiaoe/play-url.yaml +124 -0
  178. package/dist/clis/xiaohongshu/comments.test.js +0 -2
  179. package/dist/clis/xiaohongshu/creator-note-detail.test.js +0 -2
  180. package/dist/clis/xiaohongshu/creator-notes.test.js +0 -2
  181. package/dist/clis/xiaohongshu/download.test.js +0 -2
  182. package/dist/clis/xiaohongshu/note.test.js +0 -2
  183. package/dist/clis/xiaohongshu/publish.test.js +0 -2
  184. package/dist/clis/xiaohongshu/search.js +29 -20
  185. package/dist/clis/xiaohongshu/search.test.js +56 -48
  186. package/dist/clis/yuanbao/ask.d.ts +21 -0
  187. package/dist/clis/yuanbao/ask.js +427 -0
  188. package/dist/clis/yuanbao/ask.test.d.ts +1 -0
  189. package/dist/clis/yuanbao/ask.test.js +124 -0
  190. package/dist/clis/yuanbao/new.d.ts +1 -0
  191. package/dist/clis/yuanbao/new.js +70 -0
  192. package/dist/clis/yuanbao/new.test.d.ts +1 -0
  193. package/dist/clis/yuanbao/new.test.js +30 -0
  194. package/dist/clis/yuanbao/shared.d.ts +13 -0
  195. package/dist/clis/yuanbao/shared.js +49 -0
  196. package/dist/clis/zhihu/question.js +30 -19
  197. package/dist/clis/zhihu/question.test.js +34 -16
  198. package/dist/commanderAdapter.js +8 -4
  199. package/dist/commanderAdapter.test.js +42 -0
  200. package/dist/completion.js +3 -1
  201. package/dist/completion.test.d.ts +1 -0
  202. package/dist/completion.test.js +23 -0
  203. package/dist/doctor.js +1 -1
  204. package/dist/electron-apps.d.ts +2 -0
  205. package/dist/electron-apps.js +7 -1
  206. package/dist/errors.js +1 -1
  207. package/dist/execution.js +25 -35
  208. package/dist/explore.js +1 -1
  209. package/dist/launcher.d.ts +4 -0
  210. package/dist/launcher.js +64 -8
  211. package/dist/launcher.test.js +88 -7
  212. package/dist/output.d.ts +2 -0
  213. package/dist/output.js +10 -1
  214. package/dist/output.test.d.ts +0 -3
  215. package/dist/output.test.js +59 -92
  216. package/dist/pipeline/executor.test.js +0 -2
  217. package/dist/pipeline/steps/download.test.js +0 -2
  218. package/dist/registry.d.ts +2 -0
  219. package/dist/serialization.d.ts +1 -0
  220. package/dist/serialization.js +1 -0
  221. package/dist/types.d.ts +9 -2
  222. package/docs/.vitepress/config.mts +4 -0
  223. package/docs/adapters/browser/1688.md +52 -0
  224. package/docs/adapters/browser/36kr.md +2 -1
  225. package/docs/adapters/browser/doubao.md +5 -1
  226. package/docs/adapters/browser/hupu.md +53 -0
  227. package/docs/adapters/browser/sinafinance.md +32 -2
  228. package/docs/adapters/browser/weibo.md +6 -1
  229. package/docs/adapters/browser/wikipedia.md +2 -0
  230. package/docs/adapters/browser/xianyu.md +42 -0
  231. package/docs/adapters/browser/xiaoe.md +44 -0
  232. package/docs/adapters/browser/yuanbao.md +64 -0
  233. package/docs/adapters/index.md +14 -5
  234. package/docs/comparison.md +1 -1
  235. package/docs/developer/ai-workflow.md +2 -2
  236. package/docs/developer/contributing.md +1 -1
  237. package/docs/developer/testing.md +2 -0
  238. package/docs/guide/plugins.md +1 -0
  239. package/docs/guide/troubleshooting.md +11 -0
  240. package/docs/superpowers/specs/2026-04-03-v2ex-autoresearch-design.md +41 -0
  241. package/docs/zh/guide/plugins.md +1 -0
  242. package/extension/dist/background.js +1127 -0
  243. package/extension/src/background.test.ts +39 -0
  244. package/extension/src/background.ts +223 -34
  245. package/extension/src/cdp.ts +194 -4
  246. package/extension/src/protocol.ts +22 -1
  247. package/package.json +3 -2
  248. package/scripts/postinstall.js +1 -1
  249. package/skills/opencli-explorer/SKILL.md +1 -1
  250. package/skills/opencli-oneshot/SKILL.md +2 -2
  251. package/skills/opencli-operate/SKILL.md +120 -27
  252. package/skills/opencli-usage/SKILL.md +31 -20
  253. package/skills/opencli-usage/browser.md +114 -16
  254. package/skills/opencli-usage/public-api.md +32 -3
  255. package/skills/smart-search/SKILL.md +156 -0
  256. package/skills/smart-search/references/sources-ai.md +74 -0
  257. package/skills/smart-search/references/sources-info.md +43 -0
  258. package/skills/smart-search/references/sources-media.md +50 -0
  259. package/skills/smart-search/references/sources-other.md +42 -0
  260. package/skills/smart-search/references/sources-shopping.md +31 -0
  261. package/skills/smart-search/references/sources-social.md +51 -0
  262. package/skills/smart-search/references/sources-tech.md +42 -0
  263. package/skills/smart-search/references/sources-travel.md +20 -0
  264. package/src/browser/base-page.ts +41 -6
  265. package/src/browser/bridge.ts +11 -8
  266. package/src/browser/cdp.ts +1 -8
  267. package/src/browser/daemon-client.ts +11 -1
  268. package/src/browser/dom-helpers.ts +43 -31
  269. package/src/browser/dom-snapshot.ts +23 -1
  270. package/src/browser/page.ts +115 -31
  271. package/src/browser.test.ts +1 -1
  272. package/src/build-manifest.ts +2 -0
  273. package/src/cli.test.ts +133 -0
  274. package/src/cli.ts +73 -11
  275. package/src/clis/1688/item.test.ts +69 -0
  276. package/src/clis/1688/item.ts +282 -0
  277. package/src/clis/1688/search.test.ts +81 -0
  278. package/src/clis/1688/search.ts +402 -0
  279. package/src/clis/1688/shared.test.ts +75 -0
  280. package/src/clis/1688/shared.ts +623 -0
  281. package/src/clis/1688/store.test.ts +69 -0
  282. package/src/clis/1688/store.ts +300 -0
  283. package/src/clis/amazon/bestsellers.test.ts +12 -3
  284. package/src/clis/amazon/bestsellers.ts +6 -178
  285. package/src/clis/amazon/movers-shakers.ts +8 -0
  286. package/src/clis/amazon/new-releases.ts +8 -0
  287. package/src/clis/amazon/rankings.test.ts +47 -0
  288. package/src/clis/amazon/rankings.ts +312 -0
  289. package/src/clis/amazon/shared.test.ts +16 -0
  290. package/src/clis/amazon/shared.ts +134 -12
  291. package/src/clis/bilibili/comments.test.ts +4 -3
  292. package/src/clis/bilibili/comments.ts +2 -2
  293. package/src/clis/bilibili/download.ts +2 -1
  294. package/src/clis/bilibili/subtitle.test.ts +2 -1
  295. package/src/clis/bilibili/subtitle.ts +4 -3
  296. package/src/clis/bilibili/utils.test.ts +21 -0
  297. package/src/clis/bilibili/utils.ts +27 -0
  298. package/src/clis/douban/marks.ts +1 -1
  299. package/src/clis/douban/subject.yaml +50 -19
  300. package/src/clis/doubao/utils.ts +32 -12
  301. package/src/clis/douyin/_shared/browser-fetch.test.ts +0 -1
  302. package/src/clis/douyin/_shared/transcode.test.ts +0 -2
  303. package/src/clis/douyin/draft.test.ts +0 -2
  304. package/src/clis/facebook/search.test.ts +0 -2
  305. package/src/clis/gemini/ask.test.ts +116 -0
  306. package/src/clis/gemini/ask.ts +10 -3
  307. package/src/clis/gemini/reply-state.test.ts +708 -0
  308. package/src/clis/gemini/utils.test.ts +184 -2
  309. package/src/clis/gemini/utils.ts +588 -60
  310. package/src/clis/hupu/detail.ts +126 -0
  311. package/src/clis/hupu/hot.yaml +43 -0
  312. package/src/clis/hupu/like.ts +76 -0
  313. package/src/clis/hupu/reply.ts +76 -0
  314. package/src/clis/hupu/search.ts +95 -0
  315. package/src/clis/hupu/unlike.ts +76 -0
  316. package/src/clis/hupu/utils.ts +381 -0
  317. package/src/clis/instagram/_shared/private-publish.test.ts +827 -0
  318. package/src/clis/instagram/_shared/private-publish.ts +1303 -0
  319. package/src/clis/instagram/_shared/protocol-capture.test.ts +148 -0
  320. package/src/clis/instagram/_shared/protocol-capture.ts +321 -0
  321. package/src/clis/instagram/_shared/runtime-info.ts +91 -0
  322. package/src/clis/instagram/note.test.ts +96 -0
  323. package/src/clis/instagram/note.ts +254 -0
  324. package/src/clis/instagram/post.test.ts +1716 -0
  325. package/src/clis/instagram/post.ts +1620 -0
  326. package/src/clis/instagram/reel.test.ts +191 -0
  327. package/src/clis/instagram/reel.ts +886 -0
  328. package/src/clis/instagram/story.test.ts +191 -0
  329. package/src/clis/instagram/story.ts +151 -0
  330. package/src/clis/sinafinance/stock-rank.ts +68 -0
  331. package/src/clis/substack/utils.test.ts +0 -2
  332. package/src/clis/twitter/post.test.ts +157 -0
  333. package/src/clis/twitter/post.ts +82 -48
  334. package/src/clis/twitter/reply.test.ts +177 -0
  335. package/src/clis/twitter/reply.ts +285 -39
  336. package/src/clis/xianyu/chat.test.ts +20 -0
  337. package/src/clis/xianyu/chat.ts +175 -0
  338. package/src/clis/xianyu/item.test.ts +67 -0
  339. package/src/clis/xianyu/item.ts +172 -0
  340. package/src/clis/xianyu/search.test.ts +22 -0
  341. package/src/clis/xianyu/search.ts +151 -0
  342. package/src/clis/xianyu/utils.ts +9 -0
  343. package/src/clis/xiaoe/catalog.yaml +129 -0
  344. package/src/clis/xiaoe/content.yaml +43 -0
  345. package/src/clis/xiaoe/courses.yaml +73 -0
  346. package/src/clis/xiaoe/detail.yaml +39 -0
  347. package/src/clis/xiaoe/play-url.yaml +124 -0
  348. package/src/clis/xiaohongshu/comments.test.ts +0 -2
  349. package/src/clis/xiaohongshu/creator-note-detail.test.ts +0 -2
  350. package/src/clis/xiaohongshu/creator-notes.test.ts +0 -2
  351. package/src/clis/xiaohongshu/download.test.ts +0 -2
  352. package/src/clis/xiaohongshu/note.test.ts +0 -2
  353. package/src/clis/xiaohongshu/publish.test.ts +0 -2
  354. package/src/clis/xiaohongshu/search.test.ts +59 -48
  355. package/src/clis/xiaohongshu/search.ts +31 -21
  356. package/src/clis/yuanbao/ask.test.ts +156 -0
  357. package/src/clis/yuanbao/ask.ts +522 -0
  358. package/src/clis/yuanbao/new.test.ts +36 -0
  359. package/src/clis/yuanbao/new.ts +81 -0
  360. package/src/clis/yuanbao/shared.ts +57 -0
  361. package/src/clis/zhihu/question.test.ts +42 -17
  362. package/src/clis/zhihu/question.ts +31 -26
  363. package/src/commanderAdapter.test.ts +51 -0
  364. package/src/commanderAdapter.ts +8 -4
  365. package/src/completion.test.ts +30 -0
  366. package/src/completion.ts +3 -1
  367. package/src/doctor.ts +1 -1
  368. package/src/electron-apps.ts +9 -1
  369. package/src/errors.ts +1 -1
  370. package/src/execution.ts +26 -30
  371. package/src/explore.ts +1 -1
  372. package/src/launcher.test.ts +121 -7
  373. package/src/launcher.ts +87 -9
  374. package/src/output.test.ts +50 -90
  375. package/src/output.ts +10 -1
  376. package/src/pipeline/executor.test.ts +0 -2
  377. package/src/pipeline/steps/download.test.ts +0 -2
  378. package/src/registry.ts +2 -0
  379. package/src/serialization.ts +2 -0
  380. package/src/types.ts +9 -2
  381. package/tests/e2e/browser-auth.test.ts +9 -0
  382. package/CLI-EXPLORER.md +0 -724
  383. package/CLI-ONESHOT.md +0 -216
  384. package/SKILL.md +0 -59
@@ -1,5 +1,29 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
1
3
  import { cli, Strategy } from '../../registry.js';
2
4
  import { CommandExecutionError } from '../../errors.js';
5
+ const MAX_IMAGES = 4;
6
+ const UPLOAD_POLL_MS = 500;
7
+ const UPLOAD_TIMEOUT_MS = 30_000;
8
+ const SUPPORTED_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp']);
9
+ function validateImagePaths(raw) {
10
+ const paths = raw.split(',').map(s => s.trim()).filter(Boolean);
11
+ if (paths.length > MAX_IMAGES) {
12
+ throw new CommandExecutionError(`Too many images: ${paths.length} (max ${MAX_IMAGES})`);
13
+ }
14
+ return paths.map(p => {
15
+ const absPath = path.resolve(p);
16
+ const ext = path.extname(absPath).toLowerCase();
17
+ if (!SUPPORTED_EXTENSIONS.has(ext)) {
18
+ throw new CommandExecutionError(`Unsupported image format "${ext}". Supported: jpg, png, gif, webp`);
19
+ }
20
+ const stat = fs.statSync(absPath, { throwIfNoEntry: false });
21
+ if (!stat || !stat.isFile()) {
22
+ throw new CommandExecutionError(`Not a valid file: ${absPath}`);
23
+ }
24
+ return absPath;
25
+ });
26
+ }
3
27
  cli({
4
28
  site: 'twitter',
5
29
  name: 'post',
@@ -9,63 +33,66 @@ cli({
9
33
  browser: true,
10
34
  args: [
11
35
  { name: 'text', type: 'string', required: true, positional: true, help: 'The text content of the tweet' },
36
+ { name: 'images', type: 'string', required: false, help: 'Image paths, comma-separated, max 4 (jpg/png/gif/webp)' },
12
37
  ],
13
38
  columns: ['status', 'message', 'text'],
14
39
  func: async (page, kwargs) => {
15
40
  if (!page)
16
41
  throw new CommandExecutionError('Browser session required for twitter post');
17
- // 1. Navigate directly to the compose tweet modal
42
+ // Validate images upfront before any browser interaction
43
+ const absPaths = kwargs.images ? validateImagePaths(String(kwargs.images)) : [];
44
+ // 1. Navigate to compose modal
18
45
  await page.goto('https://x.com/compose/tweet');
19
- await page.wait(3); // Wait for the modal and React app to hydrate
20
- // 2. Automate typing and clicking
21
- const result = await page.evaluate(`(async () => {
46
+ await page.wait(3);
47
+ // 2. Type the text via clipboard paste (handles newlines in Draft.js)
48
+ const typeResult = await page.evaluate(`(async () => {
22
49
  try {
23
- // Find the active text area
24
50
  const box = document.querySelector('[data-testid="tweetTextarea_0"]');
25
- if (box) {
26
- box.focus();
27
- // Simulate a paste event to properly handle newlines in Draft.js/React
28
- const textToInsert = ${JSON.stringify(kwargs.text)};
29
- const dataTransfer = new DataTransfer();
30
- dataTransfer.setData('text/plain', textToInsert);
31
- box.dispatchEvent(new ClipboardEvent('paste', {
32
- clipboardData: dataTransfer,
33
- bubbles: true,
34
- cancelable: true
35
- }));
36
- } else {
37
- return { ok: false, message: 'Could not find the tweet composer text area.' };
51
+ if (!box) return { ok: false, message: 'Could not find the tweet composer text area.' };
52
+ box.focus();
53
+ const dt = new DataTransfer();
54
+ dt.setData('text/plain', ${JSON.stringify(kwargs.text)});
55
+ box.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));
56
+ return { ok: true };
57
+ } catch (e) { return { ok: false, message: String(e) }; }
58
+ })()`);
59
+ if (!typeResult.ok) {
60
+ return [{ status: 'failed', message: typeResult.message, text: kwargs.text }];
61
+ }
62
+ // 3. Attach images if provided
63
+ if (absPaths.length > 0) {
64
+ if (!page.setFileInput) {
65
+ throw new CommandExecutionError('Browser extension does not support file upload. Please update the extension.');
38
66
  }
39
-
40
- // Wait a brief moment for the button state to update
41
- await new Promise(r => setTimeout(r, 1000));
42
-
43
- // Click the post button
44
- const btn = document.querySelector('[data-testid="tweetButton"]');
45
- if (btn && !btn.disabled) {
46
- btn.click();
47
- return { ok: true, message: 'Tweet posted successfully.' };
48
- } else {
49
- // Sometimes it's rendered inline depending on the viewport
50
- const inlineBtn = document.querySelector('[data-testid="tweetButtonInline"]');
51
- if (inlineBtn && !inlineBtn.disabled) {
52
- inlineBtn.click();
53
- return { ok: true, message: 'Tweet posted successfully.' };
54
- }
55
- return { ok: false, message: 'Tweet button is disabled or not found.' };
67
+ await page.setFileInput(absPaths, 'input[data-testid="fileInput"]');
68
+ // Poll until attachments render and tweet button is enabled
69
+ const pollIterations = Math.ceil(UPLOAD_TIMEOUT_MS / UPLOAD_POLL_MS);
70
+ const uploaded = await page.evaluate(`(async () => {
71
+ for (let i = 0; i < ${JSON.stringify(pollIterations)}; i++) {
72
+ await new Promise(r => setTimeout(r, ${JSON.stringify(UPLOAD_POLL_MS)}));
73
+ const container = document.querySelector('[data-testid="attachments"]');
74
+ if (!container) continue;
75
+ if (container.querySelectorAll('[role="group"]').length !== ${JSON.stringify(absPaths.length)}) continue;
76
+ const btn = document.querySelector('[data-testid="tweetButton"]') || document.querySelector('[data-testid="tweetButtonInline"]');
77
+ if (btn && !btn.disabled) return true;
78
+ }
79
+ return false;
80
+ })()`);
81
+ if (!uploaded) {
82
+ return [{ status: 'failed', message: `Image upload timed out (${UPLOAD_TIMEOUT_MS / 1000}s).`, text: kwargs.text }];
56
83
  }
57
- } catch (e) {
58
- return { ok: false, message: e.toString() };
59
84
  }
85
+ // 4. Click the post button
86
+ await page.wait(1);
87
+ const result = await page.evaluate(`(async () => {
88
+ try {
89
+ const btn = document.querySelector('[data-testid="tweetButton"]') || document.querySelector('[data-testid="tweetButtonInline"]');
90
+ if (btn && !btn.disabled) { btn.click(); return { ok: true, message: 'Tweet posted successfully.' }; }
91
+ return { ok: false, message: 'Tweet button is disabled or not found.' };
92
+ } catch (e) { return { ok: false, message: String(e) }; }
60
93
  })()`);
61
- // 3. Wait a few seconds for the network request to finish sending
62
- if (result.ok) {
94
+ if (result.ok)
63
95
  await page.wait(3);
64
- }
65
- return [{
66
- status: result.ok ? 'success' : 'failed',
67
- message: result.message,
68
- text: kwargs.text
69
- }];
96
+ return [{ status: result.ok ? 'success' : 'failed', message: result.message, text: kwargs.text }];
70
97
  }
71
98
  });
@@ -0,0 +1 @@
1
+ import './post.js';
@@ -0,0 +1,116 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { getRegistry } from '../../registry.js';
3
+ import './post.js';
4
+ vi.mock('node:fs', async (importOriginal) => {
5
+ const actual = await importOriginal();
6
+ return {
7
+ ...actual,
8
+ statSync: vi.fn((p, _opts) => {
9
+ if (String(p).includes('missing'))
10
+ return undefined;
11
+ return { isFile: () => true };
12
+ }),
13
+ };
14
+ });
15
+ vi.mock('node:path', async (importOriginal) => {
16
+ const actual = await importOriginal();
17
+ return {
18
+ ...actual,
19
+ resolve: vi.fn((p) => `/abs/${p}`),
20
+ extname: vi.fn((p) => {
21
+ const m = p.match(/\.[^.]+$/);
22
+ return m ? m[0] : '';
23
+ }),
24
+ };
25
+ });
26
+ function makePage(overrides = {}) {
27
+ return {
28
+ goto: vi.fn().mockResolvedValue(undefined),
29
+ wait: vi.fn().mockResolvedValue(undefined),
30
+ evaluate: vi.fn().mockResolvedValue({ ok: true }),
31
+ setFileInput: vi.fn().mockResolvedValue(undefined),
32
+ ...overrides,
33
+ };
34
+ }
35
+ describe('twitter post command', () => {
36
+ const getCommand = () => getRegistry().get('twitter/post');
37
+ it('posts text-only tweet successfully', async () => {
38
+ const command = getCommand();
39
+ const page = makePage({
40
+ evaluate: vi.fn()
41
+ .mockResolvedValueOnce({ ok: true })
42
+ .mockResolvedValueOnce({ ok: true, message: 'Tweet posted successfully.' }),
43
+ });
44
+ const result = await command.func(page, { text: 'hello world' });
45
+ expect(result).toEqual([{ status: 'success', message: 'Tweet posted successfully.', text: 'hello world' }]);
46
+ expect(page.goto).toHaveBeenCalledWith('https://x.com/compose/tweet');
47
+ });
48
+ it('returns failed when text area not found', async () => {
49
+ const command = getCommand();
50
+ const page = makePage({
51
+ evaluate: vi.fn()
52
+ .mockResolvedValueOnce({ ok: false, message: 'Could not find the tweet composer text area.' }),
53
+ });
54
+ const result = await command.func(page, { text: 'hello' });
55
+ expect(result).toEqual([{ status: 'failed', message: 'Could not find the tweet composer text area.', text: 'hello' }]);
56
+ });
57
+ it('throws when more than 4 images', async () => {
58
+ const command = getCommand();
59
+ const page = makePage();
60
+ await expect(command.func(page, { text: 'hi', images: 'a.png,b.png,c.png,d.png,e.png' })).rejects.toThrow('Too many images: 5 (max 4)');
61
+ });
62
+ it('throws when image file does not exist', async () => {
63
+ const command = getCommand();
64
+ const page = makePage();
65
+ await expect(command.func(page, { text: 'hi', images: 'missing.png' })).rejects.toThrow('Not a valid file');
66
+ });
67
+ it('throws on unsupported image format', async () => {
68
+ const command = getCommand();
69
+ const page = makePage();
70
+ await expect(command.func(page, { text: 'hi', images: 'photo.bmp' })).rejects.toThrow('Unsupported image format');
71
+ });
72
+ it('throws when page.setFileInput is not available', async () => {
73
+ const command = getCommand();
74
+ const page = makePage({
75
+ evaluate: vi.fn().mockResolvedValueOnce({ ok: true }),
76
+ setFileInput: undefined,
77
+ });
78
+ await expect(command.func(page, { text: 'hi', images: 'a.png' })).rejects.toThrow('Browser extension does not support file upload');
79
+ });
80
+ it('posts with images when upload completes', async () => {
81
+ const command = getCommand();
82
+ const page = makePage({
83
+ evaluate: vi.fn()
84
+ .mockResolvedValueOnce({ ok: true }) // type text
85
+ .mockResolvedValueOnce(true) // upload polling returns true
86
+ .mockResolvedValueOnce({ ok: true, message: 'Tweet posted successfully.' }), // click post
87
+ });
88
+ const result = await command.func(page, { text: 'with images', images: 'a.png,b.png' });
89
+ expect(result).toEqual([{ status: 'success', message: 'Tweet posted successfully.', text: 'with images' }]);
90
+ expect(page.setFileInput).toHaveBeenCalled();
91
+ const uploadScript = page.evaluate.mock.calls[1][0];
92
+ expect(uploadScript).toContain('[data-testid="attachments"]');
93
+ expect(uploadScript).toContain('[role="group"]');
94
+ });
95
+ it('returns failed when image upload times out', async () => {
96
+ const command = getCommand();
97
+ const page = makePage({
98
+ evaluate: vi.fn()
99
+ .mockResolvedValueOnce({ ok: true })
100
+ .mockResolvedValueOnce(false),
101
+ });
102
+ const result = await command.func(page, { text: 'timeout', images: 'a.png' });
103
+ expect(result).toEqual([{ status: 'failed', message: 'Image upload timed out (30s).', text: 'timeout' }]);
104
+ });
105
+ it('validates images before navigating to compose page', async () => {
106
+ const command = getCommand();
107
+ const page = makePage();
108
+ await expect(command.func(page, { text: 'hi', images: 'missing.png' })).rejects.toThrow('Not a valid file');
109
+ // Should NOT have navigated since validation happens first
110
+ expect(page.goto).not.toHaveBeenCalled();
111
+ });
112
+ it('throws when no browser session', async () => {
113
+ const command = getCommand();
114
+ await expect(command.func(null, { text: 'hi' })).rejects.toThrow('Browser session required for twitter post');
115
+ });
116
+ });
@@ -1 +1,13 @@
1
+ declare function resolveImagePath(imagePath: string): string;
2
+ declare function extractTweetId(url: string): string;
3
+ declare function buildReplyComposerUrl(url: string): string;
4
+ declare function resolveImageExtension(url: string, contentType: string | null): string;
5
+ declare function downloadRemoteImage(imageUrl: string): Promise<string>;
6
+ export declare const __test__: {
7
+ buildReplyComposerUrl: typeof buildReplyComposerUrl;
8
+ downloadRemoteImage: typeof downloadRemoteImage;
9
+ extractTweetId: typeof extractTweetId;
10
+ resolveImageExtension: typeof resolveImageExtension;
11
+ resolveImagePath: typeof resolveImagePath;
12
+ };
1
13
  export {};
@@ -1,58 +1,280 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import * as os from 'node:os';
1
4
  import { CommandExecutionError } from '../../errors.js';
2
5
  import { cli, Strategy } from '../../registry.js';
6
+ const REPLY_FILE_INPUT_SELECTOR = 'input[type="file"][data-testid="fileInput"]';
7
+ const SUPPORTED_IMAGE_EXTENSIONS = new Set([
8
+ '.jpg',
9
+ '.jpeg',
10
+ '.png',
11
+ '.gif',
12
+ '.webp',
13
+ ]);
14
+ const MAX_IMAGE_SIZE_BYTES = 20 * 1024 * 1024; // 20 MB (Twitter allows 5MB images, 15MB GIFs)
15
+ const CONTENT_TYPE_TO_EXTENSION = {
16
+ 'image/jpeg': '.jpg',
17
+ 'image/jpg': '.jpg',
18
+ 'image/png': '.png',
19
+ 'image/gif': '.gif',
20
+ 'image/webp': '.webp',
21
+ };
22
+ function resolveImagePath(imagePath) {
23
+ const absPath = path.resolve(imagePath);
24
+ if (!fs.existsSync(absPath)) {
25
+ throw new Error(`Image file not found: ${absPath}`);
26
+ }
27
+ const ext = path.extname(absPath).toLowerCase();
28
+ if (!SUPPORTED_IMAGE_EXTENSIONS.has(ext)) {
29
+ throw new Error(`Unsupported image format "${ext}". Supported: jpg, jpeg, png, gif, webp`);
30
+ }
31
+ const stat = fs.statSync(absPath);
32
+ if (stat.size > MAX_IMAGE_SIZE_BYTES) {
33
+ throw new Error(`Image too large: ${(stat.size / 1024 / 1024).toFixed(1)} MB (max ${MAX_IMAGE_SIZE_BYTES / 1024 / 1024} MB)`);
34
+ }
35
+ return absPath;
36
+ }
37
+ function extractTweetId(url) {
38
+ let pathname = '';
39
+ try {
40
+ pathname = new URL(url).pathname;
41
+ }
42
+ catch {
43
+ throw new Error(`Invalid tweet URL: ${url}`);
44
+ }
45
+ const match = pathname.match(/\/status\/(\d+)/);
46
+ if (!match?.[1]) {
47
+ throw new Error(`Could not extract tweet ID from URL: ${url}`);
48
+ }
49
+ return match[1];
50
+ }
51
+ function buildReplyComposerUrl(url) {
52
+ return `https://x.com/compose/post?in_reply_to=${extractTweetId(url)}`;
53
+ }
54
+ function resolveImageExtension(url, contentType) {
55
+ const normalizedContentType = (contentType || '').split(';')[0].trim().toLowerCase();
56
+ if (normalizedContentType && CONTENT_TYPE_TO_EXTENSION[normalizedContentType]) {
57
+ return CONTENT_TYPE_TO_EXTENSION[normalizedContentType];
58
+ }
59
+ try {
60
+ const pathname = new URL(url).pathname;
61
+ const ext = path.extname(pathname).toLowerCase();
62
+ if (SUPPORTED_IMAGE_EXTENSIONS.has(ext))
63
+ return ext;
64
+ }
65
+ catch {
66
+ // Fall through to the final error below.
67
+ }
68
+ throw new Error(`Unsupported remote image format "${normalizedContentType || 'unknown'}". ` +
69
+ 'Supported: jpg, jpeg, png, gif, webp');
70
+ }
71
+ async function downloadRemoteImage(imageUrl) {
72
+ let parsed;
73
+ try {
74
+ parsed = new URL(imageUrl);
75
+ }
76
+ catch {
77
+ throw new Error(`Invalid image URL: ${imageUrl}`);
78
+ }
79
+ if (!/^https?:$/.test(parsed.protocol)) {
80
+ throw new Error(`Unsupported image URL protocol: ${parsed.protocol}`);
81
+ }
82
+ const response = await fetch(imageUrl);
83
+ if (!response.ok) {
84
+ throw new Error(`Image download failed: HTTP ${response.status}`);
85
+ }
86
+ const contentLength = Number(response.headers.get('content-length') || '0');
87
+ if (contentLength > MAX_IMAGE_SIZE_BYTES) {
88
+ throw new Error(`Image too large: ${(contentLength / 1024 / 1024).toFixed(1)} MB (max ${MAX_IMAGE_SIZE_BYTES / 1024 / 1024} MB)`);
89
+ }
90
+ const ext = resolveImageExtension(imageUrl, response.headers.get('content-type'));
91
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-twitter-reply-'));
92
+ const tmpPath = path.join(tmpDir, `image${ext}`);
93
+ const buffer = Buffer.from(await response.arrayBuffer());
94
+ if (buffer.byteLength > MAX_IMAGE_SIZE_BYTES) {
95
+ fs.rmSync(tmpDir, { recursive: true, force: true });
96
+ throw new Error(`Image too large: ${(buffer.byteLength / 1024 / 1024).toFixed(1)} MB (max ${MAX_IMAGE_SIZE_BYTES / 1024 / 1024} MB)`);
97
+ }
98
+ fs.writeFileSync(tmpPath, buffer);
99
+ return tmpPath;
100
+ }
101
+ async function attachReplyImage(page, absImagePath) {
102
+ let uploaded = false;
103
+ if (page.setFileInput) {
104
+ try {
105
+ await page.setFileInput([absImagePath], REPLY_FILE_INPUT_SELECTOR);
106
+ uploaded = true;
107
+ }
108
+ catch (err) {
109
+ const msg = err instanceof Error ? err.message : String(err);
110
+ if (!msg.includes('Unknown action') && !msg.includes('not supported')) {
111
+ throw new Error(`Image upload failed: ${msg}`);
112
+ }
113
+ // setFileInput not supported by extension — fall through to base64 fallback
114
+ }
115
+ }
116
+ if (!uploaded) {
117
+ const ext = path.extname(absImagePath).toLowerCase();
118
+ const mimeType = ext === '.png'
119
+ ? 'image/png'
120
+ : ext === '.gif'
121
+ ? 'image/gif'
122
+ : ext === '.webp'
123
+ ? 'image/webp'
124
+ : 'image/jpeg';
125
+ const base64 = fs.readFileSync(absImagePath).toString('base64');
126
+ if (base64.length > 500_000) {
127
+ console.warn(`[warn] Image base64 payload is ${(base64.length / 1024 / 1024).toFixed(1)}MB. ` +
128
+ 'This may fail with the browser bridge. Update the extension to v1.6+ for CDP-based upload, ' +
129
+ 'or compress the image before attaching.');
130
+ }
131
+ const upload = await page.evaluate(`
132
+ (() => {
133
+ const input = document.querySelector(${JSON.stringify(REPLY_FILE_INPUT_SELECTOR)});
134
+ if (!input) return { ok: false, error: 'No file input found on page' };
135
+
136
+ const binary = atob(${JSON.stringify(base64)});
137
+ const bytes = new Uint8Array(binary.length);
138
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
139
+
140
+ const dt = new DataTransfer();
141
+ const blob = new Blob([bytes], { type: ${JSON.stringify(mimeType)} });
142
+ dt.items.add(new File([blob], ${JSON.stringify(path.basename(absImagePath))}, { type: ${JSON.stringify(mimeType)} }));
143
+
144
+ Object.defineProperty(input, 'files', { value: dt.files, writable: false });
145
+ input.dispatchEvent(new Event('change', { bubbles: true }));
146
+ input.dispatchEvent(new Event('input', { bubbles: true }));
147
+ return { ok: true };
148
+ })()
149
+ `);
150
+ if (!upload?.ok) {
151
+ throw new Error(`Image upload failed: ${upload?.error ?? 'unknown error'}`);
152
+ }
153
+ }
154
+ await page.wait(2);
155
+ const uploadState = await page.evaluate(`
156
+ (() => {
157
+ const previewCount = document.querySelectorAll(
158
+ '[data-testid="attachments"] img, [data-testid="attachments"] video, [data-testid="tweetPhoto"]'
159
+ ).length;
160
+ const hasMedia = previewCount > 0
161
+ || !!document.querySelector('[data-testid="attachments"]')
162
+ || !!Array.from(document.querySelectorAll('button,[role="button"]')).find((el) =>
163
+ /remove media|remove image|remove/i.test((el.getAttribute('aria-label') || '') + ' ' + (el.textContent || ''))
164
+ );
165
+ return { ok: hasMedia, previewCount };
166
+ })()
167
+ `);
168
+ if (!uploadState?.ok) {
169
+ throw new Error('Image upload failed: preview did not appear.');
170
+ }
171
+ }
172
+ async function submitReply(page, text) {
173
+ return page.evaluate(`(async () => {
174
+ try {
175
+ const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0);
176
+ const boxes = Array.from(document.querySelectorAll('[data-testid="tweetTextarea_0"]'));
177
+ const box = boxes.find(visible) || boxes[0];
178
+ if (!box) {
179
+ return { ok: false, message: 'Could not find the reply text area. Are you logged in?' };
180
+ }
181
+
182
+ box.focus();
183
+ const textToInsert = ${JSON.stringify(text)};
184
+ // execCommand('insertText') is more reliable with Twitter's Draft.js editor
185
+ if (!document.execCommand('insertText', false, textToInsert)) {
186
+ // Fallback to paste event if execCommand fails
187
+ const dataTransfer = new DataTransfer();
188
+ dataTransfer.setData('text/plain', textToInsert);
189
+ box.dispatchEvent(new ClipboardEvent('paste', {
190
+ clipboardData: dataTransfer,
191
+ bubbles: true,
192
+ cancelable: true
193
+ }));
194
+ }
195
+
196
+ await new Promise(r => setTimeout(r, 1000));
197
+
198
+ const buttons = Array.from(
199
+ document.querySelectorAll('[data-testid="tweetButton"], [data-testid="tweetButtonInline"]')
200
+ );
201
+ const btn = buttons.find((el) => visible(el) && !el.disabled);
202
+ if (!btn) {
203
+ return { ok: false, message: 'Reply button is disabled or not found.' };
204
+ }
205
+
206
+ btn.click();
207
+ return { ok: true, message: 'Reply posted successfully.' };
208
+ } catch (e) {
209
+ return { ok: false, message: e.toString() };
210
+ }
211
+ })()`);
212
+ }
3
213
  cli({
4
214
  site: 'twitter',
5
215
  name: 'reply',
6
- description: 'Reply to a specific tweet',
216
+ description: 'Reply to a specific tweet, optionally with a local or remote image',
7
217
  domain: 'x.com',
8
218
  strategy: Strategy.UI, // Uses the UI directly to input and click post
9
219
  browser: true,
10
220
  args: [
11
221
  { name: 'url', type: 'string', required: true, positional: true, help: 'The URL of the tweet to reply to' },
12
222
  { name: 'text', type: 'string', required: true, positional: true, help: 'The text content of your reply' },
223
+ { name: 'image', help: 'Optional local image path to attach to the reply' },
224
+ { name: 'image-url', help: 'Optional remote image URL to download and attach to the reply' },
13
225
  ],
14
226
  columns: ['status', 'message', 'text'],
15
227
  func: async (page, kwargs) => {
16
228
  if (!page)
17
229
  throw new CommandExecutionError('Browser session required for twitter reply');
18
- // 1. Navigate to the tweet page
19
- await page.goto(kwargs.url);
20
- await page.wait({ selector: '[data-testid="primaryColumn"]' });
21
- // 2. Automate typing the reply and clicking reply
22
- const result = await page.evaluate(`(async () => {
230
+ if (kwargs.image && kwargs['image-url']) {
231
+ throw new Error('Use either --image or --image-url, not both.');
232
+ }
233
+ let localImagePath;
234
+ let cleanupDir;
23
235
  try {
24
- // Find the reply text area on the tweet page.
25
- // The placeholder is usually "Post your reply"
26
- const box = document.querySelector('[data-testid="tweetTextarea_0"]');
27
- if (box) {
28
- box.focus();
29
- document.execCommand('insertText', false, ${JSON.stringify(kwargs.text)});
30
- } else {
31
- return { ok: false, message: 'Could not find the reply text area. Are you logged in?' };
236
+ if (kwargs.image) {
237
+ localImagePath = resolveImagePath(kwargs.image);
238
+ }
239
+ else if (kwargs['image-url']) {
240
+ localImagePath = await downloadRemoteImage(kwargs['image-url']);
241
+ cleanupDir = path.dirname(localImagePath);
32
242
  }
33
-
34
- // Wait for React state to register the input and enable the button
35
- await new Promise(r => setTimeout(r, 1000));
36
-
37
- // Find the Reply button. It usually shares the same test id tweetButtonInline in this context
38
- const btn = document.querySelector('[data-testid="tweetButtonInline"]');
39
- if (btn && !btn.disabled) {
40
- btn.click();
41
- return { ok: true, message: 'Reply posted successfully.' };
42
- } else {
43
- return { ok: false, message: 'Reply button is disabled or not found.' };
243
+ // Dedicated composer is more reliable for image replies because the media
244
+ // toolbar and file input are consistently present there.
245
+ if (localImagePath) {
246
+ await page.goto(buildReplyComposerUrl(kwargs.url));
247
+ await page.wait({ selector: '[data-testid="tweetTextarea_0"]' });
248
+ await page.wait({ selector: REPLY_FILE_INPUT_SELECTOR, timeout: 20 });
249
+ await attachReplyImage(page, localImagePath);
44
250
  }
45
- } catch (e) {
46
- return { ok: false, message: e.toString() };
251
+ else {
252
+ await page.goto(kwargs.url);
253
+ await page.wait({ selector: '[data-testid="primaryColumn"]' });
254
+ }
255
+ const result = await submitReply(page, kwargs.text);
256
+ if (result.ok) {
257
+ await page.wait(3); // Wait for network submission to complete
258
+ }
259
+ return [{
260
+ status: result.ok ? 'success' : 'failed',
261
+ message: result.message,
262
+ text: kwargs.text,
263
+ ...(kwargs.image ? { image: kwargs.image } : {}),
264
+ ...(kwargs['image-url'] ? { 'image-url': kwargs['image-url'] } : {}),
265
+ }];
47
266
  }
48
- })()`);
49
- if (result.ok) {
50
- await page.wait(3); // Wait for network submission to complete
267
+ finally {
268
+ if (cleanupDir) {
269
+ fs.rmSync(cleanupDir, { recursive: true, force: true });
270
+ }
51
271
  }
52
- return [{
53
- status: result.ok ? 'success' : 'failed',
54
- message: result.message,
55
- text: kwargs.text
56
- }];
57
272
  }
58
273
  });
274
+ export const __test__ = {
275
+ buildReplyComposerUrl,
276
+ downloadRemoteImage,
277
+ extractTweetId,
278
+ resolveImageExtension,
279
+ resolveImagePath,
280
+ };
@@ -0,0 +1 @@
1
+ export {};