@jackwener/opencli 1.7.8 → 1.7.10

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 (281) hide show
  1. package/README.md +49 -14
  2. package/README.zh-CN.md +30 -10
  3. package/cli-manifest.json +646 -30
  4. package/clis/36kr/news.js +1 -1
  5. package/clis/apple-podcasts/commands.test.js +4 -4
  6. package/clis/apple-podcasts/episodes.js +1 -1
  7. package/clis/apple-podcasts/search.js +1 -1
  8. package/clis/apple-podcasts/top.js +1 -1
  9. package/clis/arxiv/paper.js +1 -1
  10. package/clis/arxiv/search.js +1 -1
  11. package/clis/band/mentions.js +3 -3
  12. package/clis/bbc/news.js +1 -1
  13. package/clis/bilibili/subtitle.js +2 -2
  14. package/clis/bloomberg/businessweek.js +1 -1
  15. package/clis/bloomberg/economics.js +1 -1
  16. package/clis/bloomberg/industries.js +1 -1
  17. package/clis/bloomberg/main.js +1 -1
  18. package/clis/bloomberg/markets.js +1 -1
  19. package/clis/bloomberg/opinions.js +1 -1
  20. package/clis/bloomberg/politics.js +1 -1
  21. package/clis/bloomberg/tech.js +1 -1
  22. package/clis/boss/search.js +49 -8
  23. package/clis/boss/search.test.js +78 -0
  24. package/clis/boss/send.js +3 -3
  25. package/clis/chatgpt/image.js +37 -8
  26. package/clis/chatgpt/image.test.js +92 -0
  27. package/clis/chatgpt/utils.js +39 -6
  28. package/clis/chatgpt/utils.test.js +63 -0
  29. package/clis/chatgpt-app/ask.js +1 -1
  30. package/clis/chatgpt-app/ax.js +4 -2
  31. package/clis/chatgpt-app/ax.test.js +12 -0
  32. package/clis/chatgpt-app/model.js +1 -1
  33. package/clis/chatgpt-app/new.js +1 -1
  34. package/clis/chatgpt-app/read.js +1 -1
  35. package/clis/chatgpt-app/send.js +1 -1
  36. package/clis/chatgpt-app/status.js +1 -1
  37. package/clis/chatwise/ask.js +2 -2
  38. package/clis/chatwise/model.js +2 -2
  39. package/clis/chatwise/send.js +2 -2
  40. package/clis/claude/ask.js +128 -0
  41. package/clis/claude/ask.test.js +338 -0
  42. package/clis/claude/commands.test.js +118 -0
  43. package/clis/claude/detail.js +29 -0
  44. package/clis/claude/history.js +31 -0
  45. package/clis/claude/new.js +21 -0
  46. package/clis/claude/read.js +24 -0
  47. package/clis/claude/send.js +41 -0
  48. package/clis/claude/status.js +24 -0
  49. package/clis/claude/utils.js +440 -0
  50. package/clis/claude/utils.test.js +148 -0
  51. package/clis/codex/ask.js +2 -2
  52. package/clis/codex/send.js +2 -2
  53. package/clis/ctrip/search.js +1 -1
  54. package/clis/ctrip/search.test.js +4 -4
  55. package/clis/cursor/ask.js +2 -2
  56. package/clis/cursor/composer.js +2 -2
  57. package/clis/cursor/send.js +2 -2
  58. package/clis/deepseek/ask.js +17 -4
  59. package/clis/deepseek/ask.test.js +46 -0
  60. package/clis/deepseek/utils.js +55 -16
  61. package/clis/deepseek/utils.test.js +124 -5
  62. package/clis/doubao/utils.js +53 -11
  63. package/clis/doubao/utils.test.js +22 -2
  64. package/clis/eastmoney/announcement.js +1 -1
  65. package/clis/eastmoney/convertible.js +1 -1
  66. package/clis/eastmoney/etf.js +1 -1
  67. package/clis/eastmoney/holders.js +1 -1
  68. package/clis/eastmoney/index-board.js +1 -1
  69. package/clis/eastmoney/kline.js +1 -1
  70. package/clis/eastmoney/kuaixun.js +1 -1
  71. package/clis/eastmoney/longhu.js +1 -1
  72. package/clis/eastmoney/money-flow.js +1 -1
  73. package/clis/eastmoney/northbound.js +1 -1
  74. package/clis/eastmoney/quote.js +1 -1
  75. package/clis/eastmoney/rank.js +1 -1
  76. package/clis/eastmoney/sectors.js +1 -1
  77. package/clis/facebook/marketplace-inbox.js +83 -0
  78. package/clis/facebook/marketplace-listings.js +83 -0
  79. package/clis/facebook/marketplace.test.js +91 -0
  80. package/clis/google/news.js +1 -1
  81. package/clis/google/suggest.js +1 -1
  82. package/clis/google/trends.js +1 -1
  83. package/clis/google-scholar/cite.js +74 -0
  84. package/clis/google-scholar/cite.test.js +47 -0
  85. package/clis/google-scholar/profile.js +92 -0
  86. package/clis/google-scholar/profile.test.js +49 -0
  87. package/clis/google-scholar/search.js +1 -1
  88. package/clis/google-scholar/search.test.js +15 -0
  89. package/clis/hf/top.js +1 -1
  90. package/clis/instagram/collection-create.js +57 -0
  91. package/clis/instagram/saved.js +21 -7
  92. package/clis/jd/item.js +679 -47
  93. package/clis/jd/item.test.js +318 -7
  94. package/clis/jd/item.test.ts +517 -0
  95. package/clis/lesswrong/comments.js +1 -1
  96. package/clis/lesswrong/curated.js +1 -1
  97. package/clis/lesswrong/frontpage.js +1 -1
  98. package/clis/lesswrong/new.js +1 -1
  99. package/clis/lesswrong/read.js +1 -1
  100. package/clis/lesswrong/sequences.js +1 -1
  101. package/clis/lesswrong/shortform.js +1 -1
  102. package/clis/lesswrong/tag.js +1 -1
  103. package/clis/lesswrong/tags.js +1 -1
  104. package/clis/lesswrong/top-month.js +1 -1
  105. package/clis/lesswrong/top-week.js +1 -1
  106. package/clis/lesswrong/top-year.js +1 -1
  107. package/clis/lesswrong/top.js +1 -1
  108. package/clis/lesswrong/user-posts.js +1 -1
  109. package/clis/lesswrong/user.js +1 -1
  110. package/clis/paperreview/commands.test.js +6 -6
  111. package/clis/paperreview/feedback.js +1 -1
  112. package/clis/paperreview/review.js +1 -1
  113. package/clis/paperreview/submit.js +1 -1
  114. package/clis/producthunt/posts.js +1 -1
  115. package/clis/producthunt/today.js +1 -1
  116. package/clis/sinablog/search.js +1 -1
  117. package/clis/sinafinance/news.js +1 -1
  118. package/clis/sinafinance/stock.js +1 -1
  119. package/clis/sinafinance/stock.test.js +2 -2
  120. package/clis/spotify/spotify.js +6 -6
  121. package/clis/substack/search.js +1 -1
  122. package/clis/toutiao/articles.js +5 -6
  123. package/clis/toutiao/articles.test.js +22 -15
  124. package/clis/twitter/followers.js +2 -2
  125. package/clis/twitter/following.js +224 -73
  126. package/clis/twitter/following.test.js +277 -0
  127. package/clis/twitter/post.js +184 -47
  128. package/clis/twitter/post.test.js +114 -34
  129. package/clis/uiverse/_shared.js +63 -4
  130. package/clis/uiverse/_shared.test.js +7 -0
  131. package/clis/uiverse/code.js +1 -0
  132. package/clis/uiverse/navigation.test.js +12 -0
  133. package/clis/uiverse/preview.js +1 -0
  134. package/clis/web/read.js +319 -81
  135. package/clis/web/read.test.js +221 -5
  136. package/clis/weibo/favorites.js +169 -0
  137. package/clis/weibo/favorites.test.js +114 -0
  138. package/clis/weibo/publish.js +282 -0
  139. package/clis/weibo/publish.test.js +183 -0
  140. package/clis/weread/ranking.js +1 -1
  141. package/clis/weread/search-regression.test.js +8 -8
  142. package/clis/weread/search.js +1 -1
  143. package/clis/wikipedia/random.js +1 -1
  144. package/clis/wikipedia/search.js +1 -1
  145. package/clis/wikipedia/summary.js +1 -1
  146. package/clis/wikipedia/trending.js +1 -1
  147. package/clis/xianyu/chat.js +3 -3
  148. package/clis/xianyu/item.js +2 -2
  149. package/clis/xianyu/item.test.js +3 -3
  150. package/clis/xiaohongshu/search.js +17 -2
  151. package/clis/xiaohongshu/search.test.js +37 -1
  152. package/clis/xiaoyuzhou/download.js +1 -1
  153. package/clis/xiaoyuzhou/download.test.js +3 -3
  154. package/clis/xiaoyuzhou/episode.js +1 -1
  155. package/clis/xiaoyuzhou/podcast-episodes.js +1 -1
  156. package/clis/xiaoyuzhou/podcast-episodes.test.js +2 -2
  157. package/clis/xiaoyuzhou/podcast.js +1 -1
  158. package/clis/xiaoyuzhou/transcript.js +1 -1
  159. package/clis/xiaoyuzhou/transcript.test.js +5 -5
  160. package/clis/yollomi/models.js +1 -1
  161. package/clis/youtube/channel.js +24 -1
  162. package/clis/youtube/channel.test.js +59 -0
  163. package/clis/zhihu/answer.js +21 -162
  164. package/clis/zhihu/answer.test.js +26 -53
  165. package/clis/zhihu/collection.js +197 -0
  166. package/clis/zhihu/collection.test.js +290 -0
  167. package/clis/zhihu/collections.js +127 -0
  168. package/clis/zhihu/collections.test.js +182 -0
  169. package/clis/zhihu/comment.js +24 -305
  170. package/clis/zhihu/comment.test.js +31 -35
  171. package/clis/zhihu/favorite.js +44 -182
  172. package/clis/zhihu/favorite.test.js +30 -167
  173. package/clis/zhihu/follow.js +25 -56
  174. package/clis/zhihu/follow.test.js +20 -23
  175. package/clis/zhihu/like.js +22 -67
  176. package/clis/zhihu/like.test.js +19 -42
  177. package/clis/zhihu/search.js +3 -2
  178. package/clis/zhihu/write-shared.js +8 -1
  179. package/clis/zhihu/write-shared.test.js +1 -0
  180. package/clis/zlibrary/commands.test.js +75 -0
  181. package/clis/zlibrary/info.js +47 -0
  182. package/clis/zlibrary/search.js +46 -0
  183. package/clis/zlibrary/utils.js +136 -0
  184. package/dist/src/adapter-source.d.ts +11 -0
  185. package/dist/src/adapter-source.js +24 -0
  186. package/dist/src/adapter-source.test.js +29 -0
  187. package/dist/src/browser/base-page.d.ts +3 -1
  188. package/dist/src/browser/base-page.js +76 -1
  189. package/dist/src/browser/base-page.test.d.ts +1 -0
  190. package/dist/src/browser/base-page.test.js +74 -0
  191. package/dist/src/browser/bridge.d.ts +1 -2
  192. package/dist/src/browser/bridge.js +40 -41
  193. package/dist/src/browser/cdp.d.ts +1 -0
  194. package/dist/src/browser/cdp.js +3 -3
  195. package/dist/src/browser/daemon-client.d.ts +38 -4
  196. package/dist/src/browser/daemon-client.js +24 -7
  197. package/dist/src/browser/daemon-client.test.js +49 -0
  198. package/dist/src/browser/daemon-lifecycle.d.ts +23 -0
  199. package/dist/src/browser/daemon-lifecycle.js +67 -0
  200. package/dist/src/browser/daemon-version.d.ts +4 -0
  201. package/dist/src/browser/daemon-version.js +12 -0
  202. package/dist/src/browser/errors.js +3 -0
  203. package/dist/src/browser/errors.test.js +3 -0
  204. package/dist/src/browser/network-cache.d.ts +1 -0
  205. package/dist/src/browser/page.d.ts +3 -1
  206. package/dist/src/browser/page.js +10 -2
  207. package/dist/src/browser/profile.d.ts +14 -0
  208. package/dist/src/browser/profile.js +85 -0
  209. package/dist/src/build-manifest.d.ts +2 -0
  210. package/dist/src/build-manifest.js +13 -3
  211. package/dist/src/build-manifest.test.js +20 -2
  212. package/dist/src/cli.d.ts +6 -0
  213. package/dist/src/cli.js +477 -35
  214. package/dist/src/cli.test.js +303 -2
  215. package/dist/src/commanderAdapter.js +17 -9
  216. package/dist/src/commanderAdapter.test.js +67 -2
  217. package/dist/src/commands/daemon.d.ts +2 -0
  218. package/dist/src/commands/daemon.js +42 -1
  219. package/dist/src/commands/daemon.test.js +103 -2
  220. package/dist/src/completion-shared.js +1 -2
  221. package/dist/src/completion.test.js +3 -2
  222. package/dist/src/daemon.js +125 -41
  223. package/dist/src/doctor.d.ts +5 -6
  224. package/dist/src/doctor.js +77 -19
  225. package/dist/src/doctor.test.js +117 -0
  226. package/dist/src/engine.test.js +6 -5
  227. package/dist/src/errors.d.ts +14 -8
  228. package/dist/src/errors.js +36 -30
  229. package/dist/src/errors.test.js +5 -5
  230. package/dist/src/execution.d.ts +4 -0
  231. package/dist/src/execution.js +173 -25
  232. package/dist/src/execution.test.js +171 -1
  233. package/dist/src/main.js +10 -0
  234. package/dist/src/observation/artifact.d.ts +16 -0
  235. package/dist/src/observation/artifact.js +260 -0
  236. package/dist/src/observation/artifact.test.d.ts +1 -0
  237. package/dist/src/observation/artifact.test.js +121 -0
  238. package/dist/src/observation/events.d.ts +89 -0
  239. package/dist/src/observation/events.js +1 -0
  240. package/dist/src/observation/index.d.ts +7 -0
  241. package/dist/src/observation/index.js +7 -0
  242. package/dist/src/observation/manager.d.ts +9 -0
  243. package/dist/src/observation/manager.js +27 -0
  244. package/dist/src/observation/manager.test.d.ts +1 -0
  245. package/dist/src/observation/manager.test.js +13 -0
  246. package/dist/src/observation/redaction.d.ts +11 -0
  247. package/dist/src/observation/redaction.js +81 -0
  248. package/dist/src/observation/redaction.test.d.ts +1 -0
  249. package/dist/src/observation/redaction.test.js +32 -0
  250. package/dist/src/observation/retention.d.ts +32 -0
  251. package/dist/src/observation/retention.js +160 -0
  252. package/dist/src/observation/retention.test.d.ts +1 -0
  253. package/dist/src/observation/retention.test.js +118 -0
  254. package/dist/src/observation/ring-buffer.d.ts +22 -0
  255. package/dist/src/observation/ring-buffer.js +45 -0
  256. package/dist/src/observation/ring-buffer.test.d.ts +1 -0
  257. package/dist/src/observation/ring-buffer.test.js +22 -0
  258. package/dist/src/observation/session.d.ts +25 -0
  259. package/dist/src/observation/session.js +50 -0
  260. package/dist/src/pipeline/executor.test.js +1 -0
  261. package/dist/src/pipeline/steps/download.test.js +1 -0
  262. package/dist/src/pipeline/steps/fetch.js +1 -21
  263. package/dist/src/pipeline/steps/fetch.test.js +6 -12
  264. package/dist/src/plugin-scaffold.js +1 -1
  265. package/dist/src/plugin-scaffold.test.js +1 -1
  266. package/dist/src/registry.d.ts +40 -9
  267. package/dist/src/registry.js +3 -1
  268. package/dist/src/runtime-detect.d.ts +10 -0
  269. package/dist/src/runtime-detect.js +19 -0
  270. package/dist/src/runtime-detect.test.js +12 -1
  271. package/dist/src/runtime.d.ts +2 -0
  272. package/dist/src/runtime.js +1 -0
  273. package/dist/src/types.d.ts +22 -0
  274. package/dist/src/update-check.d.ts +31 -1
  275. package/dist/src/update-check.js +62 -16
  276. package/dist/src/update-check.test.js +86 -1
  277. package/package.json +1 -1
  278. package/dist/src/diagnostic.d.ts +0 -63
  279. package/dist/src/diagnostic.js +0 -292
  280. package/dist/src/diagnostic.test.js +0 -302
  281. /package/dist/src/{diagnostic.test.d.ts → adapter-source.test.d.ts} +0 -0
@@ -0,0 +1,282 @@
1
+ /**
2
+ * Weibo publish — post a new Weibo update via browser UI automation.
3
+ *
4
+ * Flow:
5
+ * 1. Navigate to weibo.com and wait for the feed
6
+ * 2. Check login state (getSelfUid)
7
+ * 3. Click "发微博" button to open the inline compose editor
8
+ * 4. Wait for textarea editor to appear
9
+ * 5. Fill text content via CDP type
10
+ * 6. Optionally upload images via CDP setFileInput
11
+ * 7. Click the publish button
12
+ * 8. Poll for success/failure feedback
13
+ *
14
+ * Usage:
15
+ * opencli weibo publish "Hello from OpenCLI! #opencli" # publishes immediately
16
+ * opencli weibo publish "Check this out" --images /path/a.jpg,/path/b.jpg
17
+ */
18
+ import * as fs from 'node:fs';
19
+ import * as path from 'node:path';
20
+ import { cli, Strategy } from '@jackwener/opencli/registry';
21
+ import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
22
+ import { getSelfUid } from './utils.js';
23
+
24
+ const MAX_IMAGES = 9;
25
+ const UPLOAD_POLL_MS = 1500;
26
+ const UPLOAD_TIMEOUT_MS = 30_000;
27
+ const COMPOSE_POLL_MS = 300;
28
+ const COMPOSE_TIMEOUT_MS = 10_000;
29
+ const SUBMIT_POLL_MS = 500;
30
+ const SUBMIT_TIMEOUT_MS = 20_000;
31
+ const SUPPORTED_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp']);
32
+
33
+ // Weibo PC UI selectors
34
+ const TEXTAREA_SELECTOR = 'textarea._input_13iqr_8';
35
+ const FILE_INPUT_SELECTOR = 'input[type="file"][class*="_file_"]';
36
+
37
+ function validateText(text) {
38
+ const t = String(text ?? '').trim();
39
+ if (!t) throw new ArgumentError('weibo publish text cannot be empty');
40
+ if (t.length > 2000) throw new ArgumentError('weibo publish text exceeds 2000 characters');
41
+ return t;
42
+ }
43
+
44
+ function validateImagePaths(raw) {
45
+ if (!raw) return [];
46
+ const paths = raw.split(',').map(s => s.trim()).filter(Boolean);
47
+ if (paths.length > MAX_IMAGES) {
48
+ throw new ArgumentError(`Too many images: ${paths.length} (max ${MAX_IMAGES})`);
49
+ }
50
+ return paths.map(p => {
51
+ const absPath = path.resolve(p);
52
+ const ext = path.extname(absPath).toLowerCase();
53
+ if (!SUPPORTED_EXTENSIONS.has(ext)) {
54
+ throw new ArgumentError(`Unsupported image format "${ext}". Supported: jpg, png, gif, webp`);
55
+ }
56
+ const stat = fs.statSync(absPath, { throwIfNoEntry: false });
57
+ if (!stat || !stat.isFile()) {
58
+ throw new ArgumentError(`Not a valid file: ${absPath}`);
59
+ }
60
+ return absPath;
61
+ });
62
+ }
63
+
64
+ cli({
65
+ site: 'weibo',
66
+ name: 'publish',
67
+ description: 'Publish a new Weibo post immediately',
68
+ domain: 'weibo.com',
69
+ strategy: Strategy.UI,
70
+ browser: true,
71
+ args: [
72
+ {
73
+ name: 'text',
74
+ type: 'string',
75
+ required: true,
76
+ positional: true,
77
+ help: 'Weibo text content (max 2000 chars)',
78
+ },
79
+ {
80
+ name: 'images',
81
+ type: 'string',
82
+ required: false,
83
+ help: `Image paths, comma-separated, max ${MAX_IMAGES} (jpg/png/gif/webp)`,
84
+ },
85
+ ],
86
+ columns: ['status', 'message', 'text'],
87
+ func: async (page, kwargs) => {
88
+ if (!page) throw new CommandExecutionError('Browser session required for weibo publish');
89
+
90
+ const text = validateText(kwargs.text);
91
+ const absPaths = validateImagePaths(kwargs.images);
92
+
93
+ // Step 1: Navigate to weibo.com and wait for feed to load
94
+ await page.goto('https://weibo.com', { waitUntil: 'load', settleMs: 2000 });
95
+ await page.wait({ time: 2 });
96
+
97
+ // Step 2: Check login
98
+ try {
99
+ await getSelfUid(page);
100
+ } catch (err) {
101
+ if (err instanceof AuthRequiredError) throw err;
102
+ throw new CommandExecutionError('Not logged into Weibo. Please login at weibo.com in your Chrome browser.');
103
+ }
104
+
105
+ // Step 3: Click "发微博" button to open inline compose editor
106
+ const clickResult = await page.evaluate(`
107
+ () => {
108
+ const visible = el => !!el && el.offsetParent !== null && !el.disabled;
109
+ const buttons = document.querySelectorAll('button[title="发微博"], button[title="写微博"]');
110
+ for (const btn of buttons) {
111
+ if (visible(btn)) {
112
+ btn.click();
113
+ return { ok: true };
114
+ }
115
+ }
116
+ return { ok: false, message: 'Could not find 发微博 button' };
117
+ }
118
+ `);
119
+ if (!clickResult?.ok) {
120
+ throw new CommandExecutionError(clickResult?.message ?? 'Could not open compose editor.');
121
+ }
122
+
123
+ // Step 4: Wait for the textarea editor to appear (visible, not just in DOM)
124
+ let editorFound = false;
125
+ for (let i = 0; i < Math.ceil(COMPOSE_TIMEOUT_MS / COMPOSE_POLL_MS); i++) {
126
+ const result = await page.evaluate(`
127
+ () => {
128
+ const ta = document.querySelector('textarea._input_13iqr_8');
129
+ if (!ta) return { found: false };
130
+ const visible = ta.offsetParent !== null;
131
+ return { found: true, visible, rectTop: visible ? ta.getBoundingClientRect().top : -1 };
132
+ }
133
+ `);
134
+ if (result?.found && result.visible && result.rectTop >= 0) {
135
+ editorFound = true;
136
+ break;
137
+ }
138
+ await page.wait({ time: COMPOSE_POLL_MS / 1000 });
139
+ }
140
+ if (!editorFound) {
141
+ throw new CommandExecutionError('Weibo compose editor did not appear');
142
+ }
143
+
144
+ // Step 5: Upload images first (before text to avoid editor reset)
145
+ if (absPaths.length > 0) {
146
+ if (!page.setFileInput) {
147
+ throw new CommandExecutionError('Browser extension does not support file upload. Please update the extension.');
148
+ }
149
+
150
+ // Find the file input
151
+ const fileInputFound = await page.evaluate(`
152
+ () => {
153
+ const input = document.querySelector('input[type="file"][class*="_file_"]');
154
+ return !!input;
155
+ }
156
+ `);
157
+ if (!fileInputFound) {
158
+ throw new CommandExecutionError('Could not find image file input on Weibo compose page. UI may have changed.');
159
+ }
160
+
161
+ await page.setFileInput(absPaths, FILE_INPUT_SELECTOR);
162
+
163
+ // Wait for upload to complete
164
+ let uploadResult = null;
165
+ for (let i = 0; i < Math.ceil(UPLOAD_TIMEOUT_MS / UPLOAD_POLL_MS); i++) {
166
+ await page.wait({ time: UPLOAD_POLL_MS / 1000 });
167
+ uploadResult = await page.evaluateWithArgs(`
168
+ (() => {
169
+ const expectedCount = expected;
170
+ const uploading = document.querySelector('[class*="upload"], [class*="progress"]');
171
+ if (uploading && uploading.offsetParent !== null) return null;
172
+ const pics = document.querySelectorAll('img[class*="pic"], [class*="imgItem"], [class*="picture"] img');
173
+ if (pics.length >= expectedCount) return { ok: true, count: pics.length };
174
+ return null;
175
+ })()
176
+ `, { expected: absPaths.length });
177
+ if (uploadResult !== null) break;
178
+ }
179
+
180
+ if (!uploadResult?.ok) {
181
+ throw new CommandExecutionError(uploadResult?.message ?? 'Image upload did not complete before timeout');
182
+ }
183
+ }
184
+
185
+ // Step 6: Insert text using native DOM setter (preserves Weibo internal state)
186
+ // IMPORTANT: Using nativeSetter preserves the textarea's reactive/internal state.
187
+ // Direct ta.value= assignment bypasses Weibo's Vue reactivity and causes "undefined" content.
188
+ const insertResult = await page.evaluateWithArgs(`
189
+ (() => {
190
+ const ta = document.querySelector('textarea._input_13iqr_8');
191
+ if (!ta || ta.offsetParent === null) return { ok: false, message: 'textarea not visible' };
192
+ ta.focus();
193
+ const nativeSetter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set;
194
+ if (nativeSetter) {
195
+ nativeSetter.call(ta, textContent);
196
+ } else {
197
+ ta.value = textContent;
198
+ }
199
+ ta.dispatchEvent(new Event('input', { bubbles: true }));
200
+ ta.dispatchEvent(new Event('change', { bubbles: true }));
201
+ return { ok: true, valueLength: ta.value.length };
202
+ })()
203
+ `, { textContent: text });
204
+
205
+ if (!insertResult?.ok) {
206
+ throw new CommandExecutionError(insertResult?.message ?? 'Could not insert text.');
207
+ }
208
+
209
+
210
+ // Step 7: Click the send button inside the compose editor
211
+ // Try 发送 first (compose editor's submit), then 发布 (fallback)
212
+ await page.wait({ time: 0.5 });
213
+ const publishResult = await page.evaluate(`
214
+ () => {
215
+ const visible = el => !!el && el.offsetParent !== null && !el.disabled;
216
+ const labels = ['发送', '发布'];
217
+ for (const label of labels) {
218
+ const allBtns = document.querySelectorAll('button, [role="button"]');
219
+ for (const btn of allBtns) {
220
+ const t = (btn.innerText || btn.textContent || '').trim();
221
+ if (t === label && visible(btn)) {
222
+ btn.click();
223
+ return { ok: true, label };
224
+ }
225
+ }
226
+ }
227
+ return { ok: false, message: 'Could not find send button' };
228
+ }
229
+ `);
230
+ if (!publishResult?.ok) {
231
+ throw new CommandExecutionError(publishResult?.message ?? 'Could not click publish.');
232
+ }
233
+
234
+ // Step 8: Wait for success/failure result
235
+ let finalResult = null;
236
+ for (let i = 0; i < Math.ceil(SUBMIT_TIMEOUT_MS / SUBMIT_POLL_MS); i++) {
237
+ await page.wait({ time: SUBMIT_POLL_MS / 1000 });
238
+ finalResult = await page.evaluateWithArgs(`
239
+ (() => {
240
+ const successMarkers = ['发布成功', '已发布', '发送成功'];
241
+ const errorMarkers = ['发布失败', '发送失败', '内容违规', '请稍后再试', '频繁'];
242
+ for (const el of document.querySelectorAll('*')) {
243
+ if (el.children.length > 3) continue;
244
+ const txt = (el.innerText || '').trim();
245
+ if (!txt || txt.length > 100) continue;
246
+ for (const m of successMarkers) {
247
+ if (txt.includes(m) && (txt.includes('成功') || txt.includes('微博'))) {
248
+ return { ok: true, message: txt };
249
+ }
250
+ }
251
+ for (const m of errorMarkers) {
252
+ if (txt.includes(m)) {
253
+ return { ok: false, message: txt };
254
+ }
255
+ }
256
+ }
257
+ return null;
258
+ })()
259
+ `, { maxIterations: Math.ceil(SUBMIT_TIMEOUT_MS / SUBMIT_POLL_MS), currentIndex: i });
260
+ if (finalResult !== null) break;
261
+ }
262
+
263
+ if (!finalResult) {
264
+ throw new CommandExecutionError('Publish button clicked but result was unclear. Check Weibo manually.');
265
+ }
266
+
267
+ if (!finalResult.ok) {
268
+ throw new CommandExecutionError(finalResult.message || 'Weibo publish failed');
269
+ }
270
+
271
+ return [{
272
+ status: 'success',
273
+ message: finalResult.message || 'Published successfully',
274
+ text,
275
+ }];
276
+ },
277
+ });
278
+
279
+ export const __test__ = {
280
+ validateText,
281
+ validateImagePaths,
282
+ };
@@ -0,0 +1,183 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { getRegistry } from '@jackwener/opencli/registry';
3
+ import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
4
+
5
+ vi.mock('node:fs', async (importOriginal) => {
6
+ const actual = await importOriginal();
7
+ return {
8
+ ...actual,
9
+ statSync: vi.fn((p) => {
10
+ if (String(p).includes('missing')) return undefined;
11
+ return { isFile: () => !String(p).includes('directory') };
12
+ }),
13
+ };
14
+ });
15
+
16
+ vi.mock('node:path', async (importOriginal) => {
17
+ const actual = await importOriginal();
18
+ return {
19
+ ...actual,
20
+ resolve: vi.fn((p) => `/abs/${p}`),
21
+ extname: vi.fn((p) => {
22
+ const m = String(p).match(/\.[^.]+$/);
23
+ return m ? m[0] : '';
24
+ }),
25
+ };
26
+ });
27
+
28
+ import './publish.js';
29
+
30
+ function makePage({ evaluateResults = [], evaluateWithArgsResults = [], overrides = {} } = {}) {
31
+ const evaluate = vi.fn();
32
+ for (const result of evaluateResults) {
33
+ evaluate.mockResolvedValueOnce(result);
34
+ }
35
+ evaluate.mockResolvedValue({ ok: true });
36
+
37
+ const evaluateWithArgs = vi.fn();
38
+ for (const result of evaluateWithArgsResults) {
39
+ evaluateWithArgs.mockResolvedValueOnce(result);
40
+ }
41
+ evaluateWithArgs.mockResolvedValue(null);
42
+
43
+ return {
44
+ goto: vi.fn().mockResolvedValue(undefined),
45
+ wait: vi.fn().mockResolvedValue(undefined),
46
+ evaluate,
47
+ evaluateWithArgs,
48
+ setFileInput: vi.fn().mockResolvedValue(undefined),
49
+ ...overrides,
50
+ };
51
+ }
52
+
53
+ describe('weibo publish command', () => {
54
+ const getCommand = () => getRegistry().get('weibo/publish');
55
+
56
+ it('publishes a text-only post when the UI reports success', async () => {
57
+ const command = getCommand();
58
+ const page = makePage({
59
+ evaluateResults: [
60
+ '123456',
61
+ { ok: true },
62
+ { found: true, visible: true, rectTop: 100 },
63
+ { ok: true, label: '发送' },
64
+ ],
65
+ evaluateWithArgsResults: [
66
+ { ok: true, valueLength: 5 },
67
+ { ok: true, message: '发送成功' },
68
+ ],
69
+ });
70
+
71
+ const result = await command.func(page, { text: 'hello' });
72
+
73
+ expect(result).toEqual([{ status: 'success', message: '发送成功', text: 'hello' }]);
74
+ expect(page.goto).toHaveBeenCalledWith('https://weibo.com', { waitUntil: 'load', settleMs: 2000 });
75
+ });
76
+
77
+ it('uploads up to nine images before publishing', async () => {
78
+ const command = getCommand();
79
+ const page = makePage({
80
+ evaluateResults: [
81
+ '123456',
82
+ { ok: true },
83
+ { found: true, visible: true, rectTop: 100 },
84
+ true,
85
+ { ok: true, label: '发送' },
86
+ ],
87
+ evaluateWithArgsResults: [
88
+ { ok: true, count: 2 },
89
+ { ok: true, valueLength: 11 },
90
+ { ok: true, message: '发送成功' },
91
+ ],
92
+ });
93
+
94
+ await command.func(page, { text: 'with images', images: 'a.png,b.webp' });
95
+
96
+ expect(page.setFileInput).toHaveBeenCalledWith(
97
+ ['/abs/a.png', '/abs/b.webp'],
98
+ 'input[type="file"][class*="_file_"]',
99
+ );
100
+ });
101
+
102
+ it('maps auth failures to AuthRequiredError', async () => {
103
+ const command = getCommand();
104
+ const page = makePage({ evaluateResults: [null, null] });
105
+
106
+ await expect(command.func(page, { text: 'hello' })).rejects.toBeInstanceOf(AuthRequiredError);
107
+ });
108
+
109
+ it('validates text and image arguments before navigation', async () => {
110
+ const command = getCommand();
111
+ const page = makePage();
112
+
113
+ await expect(command.func(page, { text: ' ' })).rejects.toBeInstanceOf(ArgumentError);
114
+ await expect(command.func(page, { text: 'hi', images: 'a.bmp' })).rejects.toBeInstanceOf(ArgumentError);
115
+ await expect(command.func(page, { text: 'hi', images: 'missing.png' })).rejects.toBeInstanceOf(ArgumentError);
116
+ await expect(command.func(page, { text: 'hi', images: '1.png,2.png,3.png,4.png,5.png,6.png,7.png,8.png,9.png,10.png' })).rejects.toBeInstanceOf(ArgumentError);
117
+ expect(page.goto).not.toHaveBeenCalled();
118
+ });
119
+
120
+ it('throws CommandExecutionError when compose cannot be opened', async () => {
121
+ const command = getCommand();
122
+ const page = makePage({
123
+ evaluateResults: ['123456', { ok: false, message: 'Could not find 发微博 button' }],
124
+ });
125
+
126
+ await expect(command.func(page, { text: 'hello' })).rejects.toBeInstanceOf(CommandExecutionError);
127
+ });
128
+
129
+ it('throws CommandExecutionError when upload readiness is not proven', async () => {
130
+ const command = getCommand();
131
+ const page = makePage({
132
+ evaluateResults: [
133
+ '123456',
134
+ { ok: true },
135
+ { found: true, visible: true, rectTop: 100 },
136
+ true,
137
+ ],
138
+ evaluateWithArgsResults: [null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null],
139
+ });
140
+
141
+ await expect(command.func(page, { text: 'hello', images: 'a.png' })).rejects.toBeInstanceOf(CommandExecutionError);
142
+ });
143
+
144
+ it('throws CommandExecutionError when publish result is unclear or failed', async () => {
145
+ const command = getCommand();
146
+ const page = makePage({
147
+ evaluateResults: [
148
+ '123456',
149
+ { ok: true },
150
+ { found: true, visible: true, rectTop: 100 },
151
+ { ok: true, label: '发送' },
152
+ ],
153
+ evaluateWithArgsResults: [
154
+ { ok: true, valueLength: 5 },
155
+ { ok: false, message: '内容违规' },
156
+ ],
157
+ });
158
+
159
+ await expect(command.func(page, { text: 'hello' })).rejects.toBeInstanceOf(CommandExecutionError);
160
+ });
161
+
162
+ it('does not treat editor close as positive publish proof', async () => {
163
+ const command = getCommand();
164
+ const page = makePage({
165
+ evaluateResults: [
166
+ '123456',
167
+ { ok: true },
168
+ { found: true, visible: true, rectTop: 100 },
169
+ { ok: true, label: '发送' },
170
+ ],
171
+ evaluateWithArgsResults: [
172
+ { ok: true, valueLength: 5 },
173
+ null,
174
+ ],
175
+ });
176
+
177
+ await expect(command.func(page, { text: 'hello' })).rejects.toBeInstanceOf(CommandExecutionError);
178
+
179
+ const submitScript = page.evaluateWithArgs.mock.calls.at(-1)[0];
180
+ expect(submitScript).not.toContain('Editor closed after publish');
181
+ expect(submitScript).toContain('发布成功');
182
+ });
183
+ });
@@ -12,7 +12,7 @@ cli({
12
12
  { name: 'limit', type: 'int', default: 20, help: 'Max results' },
13
13
  ],
14
14
  columns: ['rank', 'title', 'author', 'category', 'readingCount', 'bookId'],
15
- func: async (_page, args) => {
15
+ func: async (args) => {
16
16
  const cat = encodeURIComponent(args.category ?? 'all');
17
17
  const data = await fetchWebApi(`/bookListInCategory/${cat}`, { rank: '1' });
18
18
  const books = data?.books ?? [];
@@ -35,7 +35,7 @@ describe('weread/search regression', () => {
35
35
  `),
36
36
  });
37
37
  vi.stubGlobal('fetch', fetchMock);
38
- const result = await command.func(null, { query: 'deep work', limit: 5 });
38
+ const result = await command.func({ query: 'deep work', limit: 5 });
39
39
  expect(fetchMock).toHaveBeenCalledTimes(2);
40
40
  expect(String(fetchMock.mock.calls[0][0])).toContain('keyword=deep+work');
41
41
  expect(String(fetchMock.mock.calls[1][0])).toContain('/web/search/books?keyword=deep+work');
@@ -72,7 +72,7 @@ describe('weread/search regression', () => {
72
72
  text: () => Promise.resolve('<html><body><p>no search cards</p></body></html>'),
73
73
  });
74
74
  vi.stubGlobal('fetch', fetchMock);
75
- const result = await command.func(null, { query: 'deep work', limit: 5 });
75
+ const result = await command.func({ query: 'deep work', limit: 5 });
76
76
  expect(result).toEqual([
77
77
  {
78
78
  rank: 1,
@@ -128,7 +128,7 @@ describe('weread/search regression', () => {
128
128
  `),
129
129
  });
130
130
  vi.stubGlobal('fetch', fetchMock);
131
- const result = await command.func(null, { query: 'cal newport', limit: 5 });
131
+ const result = await command.func({ query: 'cal newport', limit: 5 });
132
132
  expect(result).toEqual([
133
133
  {
134
134
  rank: 1,
@@ -166,7 +166,7 @@ describe('weread/search regression', () => {
166
166
  })
167
167
  .mockRejectedValueOnce(new Error('network timeout'));
168
168
  vi.stubGlobal('fetch', fetchMock);
169
- const result = await command.func(null, { query: 'deep work', limit: 5 });
169
+ const result = await command.func({ query: 'deep work', limit: 5 });
170
170
  expect(result).toEqual([
171
171
  {
172
172
  rank: 1,
@@ -220,7 +220,7 @@ describe('weread/search regression', () => {
220
220
  `),
221
221
  });
222
222
  vi.stubGlobal('fetch', fetchMock);
223
- const result = await command.func(null, { query: '文明', limit: 5 });
223
+ const result = await command.func({ query: '文明', limit: 5 });
224
224
  expect(result).toEqual([
225
225
  {
226
226
  rank: 1,
@@ -279,7 +279,7 @@ describe('weread/search regression', () => {
279
279
  `),
280
280
  });
281
281
  vi.stubGlobal('fetch', fetchMock);
282
- const result = await command.func(null, { query: '文明', limit: 5 });
282
+ const result = await command.func({ query: '文明', limit: 5 });
283
283
  expect(result).toEqual([
284
284
  {
285
285
  rank: 1,
@@ -332,7 +332,7 @@ describe('weread/search regression', () => {
332
332
  `),
333
333
  });
334
334
  vi.stubGlobal('fetch', fetchMock);
335
- const result = await command.func(null, { query: '文明', limit: 5 });
335
+ const result = await command.func({ query: '文明', limit: 5 });
336
336
  expect(result).toEqual([
337
337
  {
338
338
  rank: 1,
@@ -386,7 +386,7 @@ describe('weread/search regression', () => {
386
386
  `),
387
387
  });
388
388
  vi.stubGlobal('fetch', fetchMock);
389
- const result = await command.func(null, { query: '文明', limit: 5 });
389
+ const result = await command.func({ query: '文明', limit: 5 });
390
390
  expect(result).toEqual([
391
391
  {
392
392
  rank: 1,
@@ -124,7 +124,7 @@ cli({
124
124
  { name: 'limit', type: 'int', default: 10, help: 'Max results' },
125
125
  ],
126
126
  columns: ['rank', 'title', 'author', 'bookId', 'url'],
127
- func: async (_page, args) => {
127
+ func: async (args) => {
128
128
  const [data, htmlEntries] = await Promise.all([
129
129
  fetchWebApi('/search/global', { keyword: args.query }),
130
130
  loadSearchHtmlEntries(String(args.query ?? '')),
@@ -9,7 +9,7 @@ cli({
9
9
  browser: false,
10
10
  args: [{ name: 'lang', default: 'en', help: 'Language code (e.g. en, zh, ja)' }],
11
11
  columns: ['title', 'description', 'extract', 'url'],
12
- func: async (_page, args) => {
12
+ func: async (args) => {
13
13
  const lang = args.lang || 'en';
14
14
  const data = (await wikiFetch(lang, '/api/rest_v1/page/random/summary'));
15
15
  if (!data?.title)
@@ -13,7 +13,7 @@ cli({
13
13
  { name: 'lang', default: 'en', help: 'Language code (e.g. en, zh, ja)' },
14
14
  ],
15
15
  columns: ['title', 'snippet', 'url'],
16
- func: async (_page, args) => {
16
+ func: async (args) => {
17
17
  const limit = Math.max(1, Math.min(Number(args.limit), 50));
18
18
  const lang = args.lang || 'en';
19
19
  const q = encodeURIComponent(args.query);
@@ -12,7 +12,7 @@ cli({
12
12
  { name: 'lang', default: 'en', help: 'Language code (e.g. en, zh, ja)' },
13
13
  ],
14
14
  columns: ['title', 'description', 'extract', 'url'],
15
- func: async (_page, args) => {
15
+ func: async (args) => {
16
16
  const lang = args.lang || 'en';
17
17
  const title = encodeURIComponent(args.title.replace(/ /g, '_'));
18
18
  const data = (await wikiFetch(lang, `/api/rest_v1/page/summary/${title}`));
@@ -12,7 +12,7 @@ cli({
12
12
  { name: 'lang', default: 'en', help: 'Language code (e.g. en, zh, ja)' },
13
13
  ],
14
14
  columns: ['rank', 'title', 'description', 'views'],
15
- func: async (_page, args) => {
15
+ func: async (args) => {
16
16
  const lang = args.lang || 'en';
17
17
  const limit = Math.max(1, Math.min(Number(args.limit), 50));
18
18
  // Use yesterday's UTC date — Wikipedia API expects UTC and yesterday
@@ -1,4 +1,4 @@
1
- import { AuthRequiredError, SelectorError } from '@jackwener/opencli/errors';
1
+ import { AuthRequiredError, selectorError } from '@jackwener/opencli/errors';
2
2
  import { cli, Strategy } from '@jackwener/opencli/registry';
3
3
  import { normalizeNumericId } from './utils.js';
4
4
  function buildChatUrl(itemId, peerUserId) {
@@ -105,7 +105,7 @@ cli({
105
105
  throw new AuthRequiredError('www.goofish.com', 'Xianyu chat requires a logged-in browser session');
106
106
  }
107
107
  if (!state?.can_input) {
108
- throw new SelectorError('闲鱼聊天输入框', '未找到可用的聊天输入框,请确认该会话页已正确加载');
108
+ throw selectorError('闲鱼聊天输入框', '未找到可用的聊天输入框,请确认该会话页已正确加载');
109
109
  }
110
110
  if (!text) {
111
111
  return [{
@@ -123,7 +123,7 @@ cli({
123
123
  }
124
124
  const sent = await page.evaluate(buildSendMessageEvaluate(text));
125
125
  if (!sent?.ok) {
126
- throw new SelectorError('闲鱼发送按钮', `消息发送失败:${sent?.reason || 'unknown-reason'}`);
126
+ throw selectorError('闲鱼发送按钮', `消息发送失败:${sent?.reason || 'unknown-reason'}`);
127
127
  }
128
128
  await page.wait(1);
129
129
  return [{
@@ -1,4 +1,4 @@
1
- import { AuthRequiredError, EmptyResultError, SelectorError } from '@jackwener/opencli/errors';
1
+ import { AuthRequiredError, EmptyResultError, selectorError } from '@jackwener/opencli/errors';
2
2
  import { cli, Strategy } from '@jackwener/opencli/registry';
3
3
  import { normalizeNumericId } from './utils.js';
4
4
  function buildItemUrl(itemId) {
@@ -127,7 +127,7 @@ cli({
127
127
  throw new EmptyResultError('xianyu item', 'Xianyu item detail is blocked by verification or risk control');
128
128
  }
129
129
  if (result?.error === 'mtop-not-ready') {
130
- throw new SelectorError('window.lib.mtop', '闲鱼页面未完成初始化,无法调用商品详情接口');
130
+ throw selectorError('window.lib.mtop', '闲鱼页面未完成初始化,无法调用商品详情接口');
131
131
  }
132
132
  if (!result || typeof result !== 'object') {
133
133
  throw new EmptyResultError('xianyu item', '闲鱼商品详情接口未返回有效数据');
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
- import { AuthRequiredError, EmptyResultError, SelectorError } from '@jackwener/opencli/errors';
2
+ import { AuthRequiredError, EmptyResultError } from '@jackwener/opencli/errors';
3
3
  import { getRegistry } from '@jackwener/opencli/registry';
4
4
  import { __test__ } from './item.js';
5
5
  import './item.js';
@@ -49,8 +49,8 @@ describe('xianyu item command', () => {
49
49
  const page = createPageMock({ error: 'blocked' });
50
50
  await expect(command.func(page, { item_id: '1040754408976' })).rejects.toBeInstanceOf(EmptyResultError);
51
51
  });
52
- it('keeps SelectorError for true mtop initialization failures', async () => {
52
+ it('keeps SELECTOR code for true mtop initialization failures', async () => {
53
53
  const page = createPageMock({ error: 'mtop-not-ready' });
54
- await expect(command.func(page, { item_id: '1040754408976' })).rejects.toBeInstanceOf(SelectorError);
54
+ await expect(command.func(page, { item_id: '1040754408976' })).rejects.toMatchObject({ code: 'SELECTOR' });
55
55
  });
56
56
  });