@jackwener/opencli 1.7.7 → 1.7.9

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 (280) hide show
  1. package/README.md +49 -14
  2. package/README.zh-CN.md +30 -10
  3. package/cli-manifest.json +782 -55
  4. package/clis/36kr/news.js +1 -1
  5. package/clis/amazon/discussion.js +37 -6
  6. package/clis/amazon/discussion.test.js +147 -32
  7. package/clis/apple-podcasts/commands.test.js +4 -4
  8. package/clis/apple-podcasts/episodes.js +1 -1
  9. package/clis/apple-podcasts/search.js +1 -1
  10. package/clis/apple-podcasts/top.js +1 -1
  11. package/clis/arxiv/paper.js +1 -1
  12. package/clis/arxiv/search.js +1 -1
  13. package/clis/band/mentions.js +3 -3
  14. package/clis/bbc/news.js +1 -1
  15. package/clis/bilibili/subtitle.js +2 -2
  16. package/clis/bloomberg/businessweek.js +1 -1
  17. package/clis/bloomberg/economics.js +1 -1
  18. package/clis/bloomberg/industries.js +1 -1
  19. package/clis/bloomberg/main.js +1 -1
  20. package/clis/bloomberg/markets.js +1 -1
  21. package/clis/bloomberg/opinions.js +1 -1
  22. package/clis/bloomberg/politics.js +1 -1
  23. package/clis/bloomberg/tech.js +1 -1
  24. package/clis/boss/search.js +49 -8
  25. package/clis/boss/search.test.js +78 -0
  26. package/clis/boss/send.js +3 -3
  27. package/clis/chatgpt/image.js +37 -8
  28. package/clis/chatgpt/image.test.js +92 -0
  29. package/clis/chatgpt/utils.js +39 -6
  30. package/clis/chatgpt/utils.test.js +63 -0
  31. package/clis/chatgpt-app/ask.js +4 -20
  32. package/clis/chatgpt-app/ax.js +135 -2
  33. package/clis/chatgpt-app/ax.test.js +35 -0
  34. package/clis/chatgpt-app/model.js +1 -1
  35. package/clis/chatgpt-app/new.js +1 -1
  36. package/clis/chatgpt-app/read.js +1 -1
  37. package/clis/chatgpt-app/send.js +3 -22
  38. package/clis/chatgpt-app/status.js +1 -1
  39. package/clis/chatwise/ask.js +2 -2
  40. package/clis/chatwise/model.js +2 -2
  41. package/clis/chatwise/send.js +2 -2
  42. package/clis/claude/ask.js +128 -0
  43. package/clis/claude/ask.test.js +338 -0
  44. package/clis/claude/commands.test.js +118 -0
  45. package/clis/claude/detail.js +29 -0
  46. package/clis/claude/history.js +31 -0
  47. package/clis/claude/new.js +21 -0
  48. package/clis/claude/read.js +24 -0
  49. package/clis/claude/send.js +41 -0
  50. package/clis/claude/status.js +24 -0
  51. package/clis/claude/utils.js +440 -0
  52. package/clis/claude/utils.test.js +148 -0
  53. package/clis/codex/ask.js +2 -2
  54. package/clis/codex/send.js +2 -2
  55. package/clis/ctrip/search.js +1 -1
  56. package/clis/ctrip/search.test.js +4 -4
  57. package/clis/cursor/ask.js +2 -2
  58. package/clis/cursor/composer.js +2 -2
  59. package/clis/cursor/send.js +2 -2
  60. package/clis/deepseek/ask.js +49 -10
  61. package/clis/deepseek/ask.test.js +150 -3
  62. package/clis/deepseek/utils.js +60 -22
  63. package/clis/deepseek/utils.test.js +124 -5
  64. package/clis/doubao/utils.js +53 -11
  65. package/clis/doubao/utils.test.js +22 -2
  66. package/clis/eastmoney/announcement.js +1 -1
  67. package/clis/eastmoney/convertible.js +1 -1
  68. package/clis/eastmoney/etf.js +1 -1
  69. package/clis/eastmoney/holders.js +1 -1
  70. package/clis/eastmoney/index-board.js +1 -1
  71. package/clis/eastmoney/kline.js +1 -1
  72. package/clis/eastmoney/kuaixun.js +1 -1
  73. package/clis/eastmoney/longhu.js +1 -1
  74. package/clis/eastmoney/money-flow.js +1 -1
  75. package/clis/eastmoney/northbound.js +1 -1
  76. package/clis/eastmoney/quote.js +1 -1
  77. package/clis/eastmoney/rank.js +1 -1
  78. package/clis/eastmoney/sectors.js +1 -1
  79. package/clis/facebook/marketplace-inbox.js +83 -0
  80. package/clis/facebook/marketplace-listings.js +83 -0
  81. package/clis/facebook/marketplace.test.js +91 -0
  82. package/clis/google/news.js +1 -1
  83. package/clis/google/suggest.js +1 -1
  84. package/clis/google/trends.js +1 -1
  85. package/clis/google-scholar/cite.js +74 -0
  86. package/clis/google-scholar/cite.test.js +47 -0
  87. package/clis/google-scholar/profile.js +92 -0
  88. package/clis/google-scholar/profile.test.js +49 -0
  89. package/clis/google-scholar/search.js +1 -1
  90. package/clis/google-scholar/search.test.js +15 -0
  91. package/clis/hf/top.js +1 -1
  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/powerchina/search.js +250 -0
  115. package/clis/powerchina/search.test.js +67 -0
  116. package/clis/producthunt/posts.js +1 -1
  117. package/clis/producthunt/today.js +1 -1
  118. package/clis/sinablog/search.js +1 -1
  119. package/clis/sinafinance/news.js +1 -1
  120. package/clis/sinafinance/stock.js +6 -3
  121. package/clis/sinafinance/stock.test.js +59 -0
  122. package/clis/spotify/spotify.js +6 -6
  123. package/clis/substack/search.js +1 -1
  124. package/clis/toutiao/articles.js +80 -0
  125. package/clis/toutiao/articles.test.js +30 -0
  126. package/clis/twitter/followers.js +2 -2
  127. package/clis/twitter/following.js +224 -73
  128. package/clis/twitter/following.test.js +277 -0
  129. package/clis/twitter/post.js +184 -47
  130. package/clis/twitter/post.test.js +114 -34
  131. package/clis/uiverse/_shared.js +63 -4
  132. package/clis/uiverse/_shared.test.js +7 -0
  133. package/clis/uiverse/code.js +1 -0
  134. package/clis/uiverse/navigation.test.js +12 -0
  135. package/clis/uiverse/preview.js +1 -0
  136. package/clis/web/read.js +319 -81
  137. package/clis/web/read.test.js +221 -5
  138. package/clis/weibo/favorites.js +169 -0
  139. package/clis/weibo/favorites.test.js +114 -0
  140. package/clis/weibo/publish.js +282 -0
  141. package/clis/weibo/publish.test.js +183 -0
  142. package/clis/weixin/create-draft.js +225 -0
  143. package/clis/weixin/drafts.js +65 -0
  144. package/clis/weixin/drafts.test.js +65 -0
  145. package/clis/weread/ranking.js +1 -1
  146. package/clis/weread/search-regression.test.js +8 -8
  147. package/clis/weread/search.js +1 -1
  148. package/clis/wikipedia/random.js +1 -1
  149. package/clis/wikipedia/search.js +1 -1
  150. package/clis/wikipedia/summary.js +1 -1
  151. package/clis/wikipedia/trending.js +1 -1
  152. package/clis/xianyu/chat.js +3 -3
  153. package/clis/xianyu/item.js +2 -2
  154. package/clis/xianyu/item.test.js +3 -3
  155. package/clis/xiaohongshu/search.js +17 -2
  156. package/clis/xiaohongshu/search.test.js +37 -1
  157. package/clis/xiaoyuzhou/download.js +1 -1
  158. package/clis/xiaoyuzhou/download.test.js +3 -3
  159. package/clis/xiaoyuzhou/episode.js +1 -1
  160. package/clis/xiaoyuzhou/podcast-episodes.js +1 -1
  161. package/clis/xiaoyuzhou/podcast-episodes.test.js +2 -2
  162. package/clis/xiaoyuzhou/podcast.js +1 -1
  163. package/clis/xiaoyuzhou/transcript.js +1 -1
  164. package/clis/xiaoyuzhou/transcript.test.js +5 -5
  165. package/clis/yollomi/models.js +1 -1
  166. package/clis/youtube/channel.js +24 -1
  167. package/clis/youtube/channel.test.js +59 -0
  168. package/clis/zhihu/answer.js +21 -162
  169. package/clis/zhihu/answer.test.js +26 -53
  170. package/clis/zhihu/collection.js +197 -0
  171. package/clis/zhihu/collection.test.js +290 -0
  172. package/clis/zhihu/collections.js +127 -0
  173. package/clis/zhihu/collections.test.js +182 -0
  174. package/clis/zhihu/comment.js +24 -305
  175. package/clis/zhihu/comment.test.js +31 -35
  176. package/clis/zhihu/favorite.js +44 -182
  177. package/clis/zhihu/favorite.test.js +30 -167
  178. package/clis/zhihu/follow.js +25 -56
  179. package/clis/zhihu/follow.test.js +20 -23
  180. package/clis/zhihu/like.js +22 -67
  181. package/clis/zhihu/like.test.js +19 -42
  182. package/clis/zhihu/search.js +3 -2
  183. package/clis/zhihu/write-shared.js +8 -1
  184. package/clis/zhihu/write-shared.test.js +1 -0
  185. package/clis/zlibrary/commands.test.js +75 -0
  186. package/clis/zlibrary/info.js +47 -0
  187. package/clis/zlibrary/search.js +46 -0
  188. package/clis/zlibrary/utils.js +136 -0
  189. package/dist/src/adapter-source.d.ts +11 -0
  190. package/dist/src/adapter-source.js +24 -0
  191. package/dist/src/adapter-source.test.js +29 -0
  192. package/dist/src/browser/base-page.d.ts +3 -1
  193. package/dist/src/browser/base-page.js +76 -1
  194. package/dist/src/browser/base-page.test.d.ts +1 -0
  195. package/dist/src/browser/base-page.test.js +74 -0
  196. package/dist/src/browser/bridge.d.ts +1 -0
  197. package/dist/src/browser/bridge.js +36 -9
  198. package/dist/src/browser/cdp.d.ts +1 -0
  199. package/dist/src/browser/cdp.js +3 -3
  200. package/dist/src/browser/daemon-client.d.ts +38 -4
  201. package/dist/src/browser/daemon-client.js +24 -7
  202. package/dist/src/browser/daemon-client.test.js +49 -0
  203. package/dist/src/browser/errors.js +3 -0
  204. package/dist/src/browser/errors.test.js +3 -0
  205. package/dist/src/browser/network-cache.d.ts +1 -0
  206. package/dist/src/browser/page.d.ts +3 -1
  207. package/dist/src/browser/page.js +10 -2
  208. package/dist/src/browser/profile.d.ts +14 -0
  209. package/dist/src/browser/profile.js +85 -0
  210. package/dist/src/build-manifest.d.ts +2 -0
  211. package/dist/src/build-manifest.js +13 -3
  212. package/dist/src/build-manifest.test.js +20 -2
  213. package/dist/src/cli.d.ts +6 -0
  214. package/dist/src/cli.js +462 -32
  215. package/dist/src/cli.test.js +209 -2
  216. package/dist/src/commanderAdapter.js +29 -9
  217. package/dist/src/commanderAdapter.test.js +78 -2
  218. package/dist/src/commands/daemon.js +6 -0
  219. package/dist/src/completion-shared.js +1 -2
  220. package/dist/src/completion.test.js +3 -2
  221. package/dist/src/daemon.js +125 -41
  222. package/dist/src/doctor.d.ts +4 -6
  223. package/dist/src/doctor.js +80 -22
  224. package/dist/src/doctor.test.js +82 -0
  225. package/dist/src/engine.test.js +6 -5
  226. package/dist/src/errors.d.ts +14 -8
  227. package/dist/src/errors.js +36 -30
  228. package/dist/src/errors.test.js +5 -5
  229. package/dist/src/execution.d.ts +4 -0
  230. package/dist/src/execution.js +173 -25
  231. package/dist/src/execution.test.js +171 -1
  232. package/dist/src/main.js +10 -0
  233. package/dist/src/observation/artifact.d.ts +16 -0
  234. package/dist/src/observation/artifact.js +260 -0
  235. package/dist/src/observation/artifact.test.d.ts +1 -0
  236. package/dist/src/observation/artifact.test.js +121 -0
  237. package/dist/src/observation/events.d.ts +89 -0
  238. package/dist/src/observation/events.js +1 -0
  239. package/dist/src/observation/index.d.ts +7 -0
  240. package/dist/src/observation/index.js +7 -0
  241. package/dist/src/observation/manager.d.ts +9 -0
  242. package/dist/src/observation/manager.js +27 -0
  243. package/dist/src/observation/manager.test.d.ts +1 -0
  244. package/dist/src/observation/manager.test.js +13 -0
  245. package/dist/src/observation/redaction.d.ts +11 -0
  246. package/dist/src/observation/redaction.js +81 -0
  247. package/dist/src/observation/redaction.test.d.ts +1 -0
  248. package/dist/src/observation/redaction.test.js +32 -0
  249. package/dist/src/observation/retention.d.ts +32 -0
  250. package/dist/src/observation/retention.js +160 -0
  251. package/dist/src/observation/retention.test.d.ts +1 -0
  252. package/dist/src/observation/retention.test.js +118 -0
  253. package/dist/src/observation/ring-buffer.d.ts +22 -0
  254. package/dist/src/observation/ring-buffer.js +45 -0
  255. package/dist/src/observation/ring-buffer.test.d.ts +1 -0
  256. package/dist/src/observation/ring-buffer.test.js +22 -0
  257. package/dist/src/observation/session.d.ts +25 -0
  258. package/dist/src/observation/session.js +50 -0
  259. package/dist/src/pipeline/executor.test.js +1 -0
  260. package/dist/src/pipeline/steps/download.test.js +1 -0
  261. package/dist/src/pipeline/steps/fetch.js +1 -21
  262. package/dist/src/pipeline/steps/fetch.test.js +6 -12
  263. package/dist/src/plugin-scaffold.js +1 -1
  264. package/dist/src/plugin-scaffold.test.js +1 -1
  265. package/dist/src/registry.d.ts +40 -9
  266. package/dist/src/registry.js +3 -1
  267. package/dist/src/runtime-detect.d.ts +10 -0
  268. package/dist/src/runtime-detect.js +19 -0
  269. package/dist/src/runtime-detect.test.js +12 -1
  270. package/dist/src/runtime.d.ts +2 -0
  271. package/dist/src/runtime.js +1 -0
  272. package/dist/src/types.d.ts +22 -0
  273. package/dist/src/update-check.d.ts +31 -1
  274. package/dist/src/update-check.js +62 -16
  275. package/dist/src/update-check.test.js +86 -1
  276. package/package.json +1 -1
  277. package/dist/src/diagnostic.d.ts +0 -63
  278. package/dist/src/diagnostic.js +0 -292
  279. package/dist/src/diagnostic.test.js +0 -302
  280. /package/dist/src/{diagnostic.test.d.ts → adapter-source.test.d.ts} +0 -0
@@ -0,0 +1,78 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { getRegistry } from '@jackwener/opencli/registry';
3
+ import { ArgumentError } from '@jackwener/opencli/errors';
4
+ import { __test__ } from './search.js';
5
+ import './search.js';
6
+
7
+ function createPageMock(response) {
8
+ return {
9
+ goto: vi.fn().mockResolvedValue(undefined),
10
+ wait: vi.fn().mockResolvedValue(undefined),
11
+ evaluate: vi.fn().mockResolvedValue(response),
12
+ };
13
+ }
14
+
15
+ describe('boss search', () => {
16
+ const command = getRegistry().get('boss/search');
17
+
18
+ it('keeps legacy 在校/应届 experience input compatible', () => {
19
+ expect(__test__.resolveMap('在校/应届', __test__.EXP_MAP)).toBe('108');
20
+ expect(__test__.resolveMap('应届', __test__.EXP_MAP)).toBe('102');
21
+ });
22
+
23
+ it('fails fast on invalid jobType values', async () => {
24
+ expect(() => __test__.resolveJobType('外包')).toThrow(ArgumentError);
25
+ });
26
+
27
+ it('accepts supported jobType labels and raw codes', () => {
28
+ expect(__test__.resolveJobType('全职')).toBe('1901');
29
+ expect(__test__.resolveJobType('实习')).toBe('1902');
30
+ expect(__test__.resolveJobType('兼职')).toBe('1903');
31
+ expect(__test__.resolveJobType('1902')).toBe('1902');
32
+ });
33
+
34
+ it('keeps empty query empty and sends jobType filter to the API', async () => {
35
+ const page = createPageMock({
36
+ code: 0,
37
+ zpData: {
38
+ hasMore: false,
39
+ jobList: [
40
+ {
41
+ encryptJobId: 'abc',
42
+ securityId: 'sec',
43
+ jobName: '前端开发实习生',
44
+ salaryDesc: '150-200/天',
45
+ brandName: 'OpenCLI',
46
+ cityName: '北京',
47
+ areaDistrict: '海淀区',
48
+ businessDistrict: '',
49
+ jobExperience: '在校/应届',
50
+ jobDegree: '本科',
51
+ skills: ['JavaScript'],
52
+ bossName: '张三',
53
+ bossTitle: '技术负责人',
54
+ bossOnline: false,
55
+ },
56
+ ],
57
+ },
58
+ });
59
+
60
+ const rows = await command.func(page, {
61
+ query: undefined,
62
+ city: '北京',
63
+ jobType: '实习',
64
+ limit: 1,
65
+ page: 1,
66
+ });
67
+
68
+ expect(page.goto).toHaveBeenCalledWith('https://www.zhipin.com/web/geek/job?query=&city=101010100');
69
+ const fetchScript = page.evaluate.mock.calls.at(-1)[0];
70
+ expect(fetchScript).toContain('query=');
71
+ expect(fetchScript).not.toContain('query=undefined');
72
+ expect(fetchScript).toContain('jobType=1902');
73
+ expect(rows[0]).toMatchObject({
74
+ name: '前端开发实习生',
75
+ bossOnline: 'N',
76
+ });
77
+ });
78
+ });
package/clis/boss/send.js CHANGED
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import { cli, Strategy } from '@jackwener/opencli/registry';
8
8
  import { requirePage, navigateToChat, findFriendByUid, clickCandidateInList, typeAndSendMessage, } from './utils.js';
9
- import { EmptyResultError, SelectorError } from '@jackwener/opencli/errors';
9
+ import { EmptyResultError, selectorError } from '@jackwener/opencli/errors';
10
10
  cli({
11
11
  site: 'boss',
12
12
  name: 'send',
@@ -30,12 +30,12 @@ cli({
30
30
  const friendName = friend.name || '候选人';
31
31
  const clicked = await clickCandidateInList(page, numericUid);
32
32
  if (!clicked) {
33
- throw new SelectorError('聊天列表中的用户', '请确认聊天列表中有此人');
33
+ throw selectorError('聊天列表中的用户', '请确认聊天列表中有此人');
34
34
  }
35
35
  await page.wait({ time: 2 });
36
36
  const sent = await typeAndSendMessage(page, kwargs.text);
37
37
  if (!sent) {
38
- throw new SelectorError('消息输入框', '聊天页面 UI 可能已改变');
38
+ throw selectorError('消息输入框', '聊天页面 UI 可能已改变');
39
39
  }
40
40
  await page.wait({ time: 1 });
41
41
  return [{ status: '✅ 发送成功', detail: `已向 ${friendName} 发送: ${kwargs.text}` }];
@@ -1,7 +1,9 @@
1
1
  import * as os from 'node:os';
2
2
  import * as path from 'node:path';
3
+ import * as fs from 'node:fs';
3
4
  import { cli, Strategy } from '@jackwener/opencli/registry';
4
5
  import { saveBase64ToFile } from '@jackwener/opencli/utils';
6
+ import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
5
7
  import { getChatGPTVisibleImageUrls, sendChatGPTMessage, waitForChatGPTImages, getChatGPTImageAssets } from './utils.js';
6
8
 
7
9
  const CHATGPT_DOMAIN = 'chatgpt.com';
@@ -24,6 +26,22 @@ function displayPath(filePath) {
24
26
  return filePath.startsWith(home) ? `~${filePath.slice(home.length)}` : filePath;
25
27
  }
26
28
 
29
+ export function resolveOutputDir(value) {
30
+ const raw = String(value || '').trim();
31
+ if (!raw) return path.join(os.homedir(), 'Pictures', 'chatgpt');
32
+ if (raw === '~') return os.homedir();
33
+ if (raw.startsWith('~/')) return path.join(os.homedir(), raw.slice(2));
34
+ return path.resolve(raw);
35
+ }
36
+
37
+ export function nextAvailablePath(dir, baseName, ext, existsSync = fs.existsSync) {
38
+ let candidate = path.join(dir, `${baseName}${ext}`);
39
+ for (let index = 1; existsSync(candidate); index += 1) {
40
+ candidate = path.join(dir, `${baseName}_${index}${ext}`);
41
+ }
42
+ return candidate;
43
+ }
44
+
27
45
  async function currentChatGPTLink(page) {
28
46
  const url = await page.evaluate('window.location.href').catch(() => '');
29
47
  return typeof url === 'string' && url ? url : 'https://chatgpt.com';
@@ -41,13 +59,13 @@ export const imageCommand = cli({
41
59
  timeoutSeconds: 240,
42
60
  args: [
43
61
  { name: 'prompt', positional: true, required: true, help: 'Image prompt to send to ChatGPT' },
44
- { name: 'op', default: '~/Pictures/chatgpt', help: 'Output directory' },
62
+ { name: 'op', help: 'Output directory (default: ~/Pictures/chatgpt)' },
45
63
  { name: 'sd', type: 'boolean', default: false, help: 'Skip download shorthand; only show ChatGPT link' },
46
64
  ],
47
65
  columns: ['status', 'file', 'link'],
48
66
  func: async (page, kwargs) => {
49
67
  const prompt = kwargs.prompt;
50
- const outputDir = kwargs.op || path.join(os.homedir(), 'Pictures', 'chatgpt');
68
+ const outputDir = resolveOutputDir(kwargs.op);
51
69
  const skipDownloadRaw = kwargs.sd;
52
70
  const skipDownload = skipDownloadRaw === '' || skipDownloadRaw === true || normalizeBooleanFlag(skipDownloadRaw);
53
71
  const timeout = 120;
@@ -63,12 +81,23 @@ export const imageCommand = cli({
63
81
  return [{ status: '⚠️ send-failed', file: '📁 -', link: `🔗 ${await currentChatGPTLink(page)}` }];
64
82
  }
65
83
 
66
- // Wait for response and images
67
- const urls = await waitForChatGPTImages(page, beforeUrls, timeout);
68
- const link = await currentChatGPTLink(page);
84
+ // ChatGPT briefly navigates to /c/{id} after sending, then may
85
+ // redirect back to the home page. Poll until we capture the /c/ URL.
86
+ let convUrl = '';
87
+ for (let ci = 0; ci < 10; ci++) {
88
+ const url = await currentChatGPTLink(page);
89
+ if (url.includes('/c/')) { convUrl = url; break; }
90
+ await page.wait(2);
91
+ }
92
+ if (!convUrl) {
93
+ convUrl = await currentChatGPTLink(page);
94
+ }
95
+
96
+ const urls = await waitForChatGPTImages(page, beforeUrls, timeout, convUrl);
97
+ const link = convUrl;
69
98
 
70
99
  if (!urls.length) {
71
- return [{ status: '⚠️ no-images', file: '📁 -', link: `🔗 ${link}` }];
100
+ throw new EmptyResultError('chatgpt image', `No generated images were detected before timeout. Open ${link} and verify whether ChatGPT finished generating the image.`);
72
101
  }
73
102
 
74
103
  if (skipDownload) {
@@ -78,7 +107,7 @@ export const imageCommand = cli({
78
107
  // Export and save images
79
108
  const assets = await getChatGPTImageAssets(page, urls);
80
109
  if (!assets.length) {
81
- return [{ status: '⚠️ export-failed', file: '📁 -', link: `🔗 ${link}` }];
110
+ throw new CommandExecutionError('Failed to export generated ChatGPT image assets', `Open ${link} and verify the generated images are visible, then retry.`);
82
111
  }
83
112
 
84
113
  const stamp = Date.now();
@@ -88,7 +117,7 @@ export const imageCommand = cli({
88
117
  const base64 = asset.dataUrl.replace(/^data:[^;]+;base64,/, '');
89
118
  const suffix = assets.length > 1 ? `_${index + 1}` : '';
90
119
  const ext = extFromMime(asset.mimeType);
91
- const filePath = path.join(outputDir, `chatgpt_${stamp}${suffix}${ext}`);
120
+ const filePath = nextAvailablePath(outputDir, `chatgpt_${stamp}${suffix}`, ext);
92
121
  await saveBase64ToFile(base64, filePath);
93
122
  results.push({ status: '✅ saved', file: `📁 ${displayPath(filePath)}`, link: `🔗 ${link}` });
94
123
  }
@@ -0,0 +1,92 @@
1
+ import * as os from 'node:os';
2
+ import * as path from 'node:path';
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
4
+
5
+ const mocks = vi.hoisted(() => ({
6
+ getChatGPTVisibleImageUrls: vi.fn(),
7
+ sendChatGPTMessage: vi.fn(),
8
+ waitForChatGPTImages: vi.fn(),
9
+ getChatGPTImageAssets: vi.fn(),
10
+ saveBase64ToFile: vi.fn(),
11
+ }));
12
+
13
+ vi.mock('./utils.js', () => ({
14
+ getChatGPTVisibleImageUrls: mocks.getChatGPTVisibleImageUrls,
15
+ sendChatGPTMessage: mocks.sendChatGPTMessage,
16
+ waitForChatGPTImages: mocks.waitForChatGPTImages,
17
+ getChatGPTImageAssets: mocks.getChatGPTImageAssets,
18
+ }));
19
+
20
+ vi.mock('@jackwener/opencli/utils', () => ({
21
+ saveBase64ToFile: mocks.saveBase64ToFile,
22
+ }));
23
+
24
+ const { imageCommand, nextAvailablePath, resolveOutputDir } = await import('./image.js');
25
+
26
+ function createPage() {
27
+ return {
28
+ goto: vi.fn().mockResolvedValue(undefined),
29
+ wait: vi.fn().mockResolvedValue(undefined),
30
+ evaluate: vi.fn().mockResolvedValue('https://chatgpt.com/c/test-conversation'),
31
+ };
32
+ }
33
+
34
+ beforeEach(() => {
35
+ vi.restoreAllMocks();
36
+ mocks.getChatGPTVisibleImageUrls.mockReset().mockResolvedValue([]);
37
+ mocks.sendChatGPTMessage.mockReset().mockResolvedValue(true);
38
+ mocks.waitForChatGPTImages.mockReset().mockResolvedValue(['https://images.example/generated.png']);
39
+ mocks.getChatGPTImageAssets.mockReset().mockResolvedValue([{
40
+ url: 'https://images.example/generated.png',
41
+ dataUrl: 'data:image/png;base64,aGVsbG8=',
42
+ mimeType: 'image/png',
43
+ }]);
44
+ mocks.saveBase64ToFile.mockReset().mockResolvedValue(undefined);
45
+ });
46
+
47
+ describe('chatgpt image output paths', () => {
48
+ it('expands the default and explicit home-relative output directories', () => {
49
+ expect(resolveOutputDir()).toBe(path.join(os.homedir(), 'Pictures', 'chatgpt'));
50
+ expect(resolveOutputDir('~/tmp/chatgpt-images')).toBe(path.join(os.homedir(), 'tmp', 'chatgpt-images'));
51
+ expect(resolveOutputDir('~')).toBe(os.homedir());
52
+ });
53
+
54
+ it('generates a non-overwriting file path when a timestamp collision exists', () => {
55
+ const dir = '/tmp/chatgpt';
56
+ const taken = new Set([
57
+ path.join(dir, 'chatgpt_123.png'),
58
+ path.join(dir, 'chatgpt_123_1.png'),
59
+ ]);
60
+
61
+ expect(nextAvailablePath(dir, 'chatgpt_123', '.png', (file) => taken.has(file))).toBe(path.join(dir, 'chatgpt_123_2.png'));
62
+ });
63
+ });
64
+
65
+ describe('chatgpt image failure contracts', () => {
66
+ it('fails fast when image generation detection finds no new images', async () => {
67
+ mocks.waitForChatGPTImages.mockResolvedValue([]);
68
+
69
+ await expect(imageCommand.func(createPage(), {
70
+ prompt: 'cat',
71
+ op: '',
72
+ sd: false,
73
+ })).rejects.toMatchObject({
74
+ code: 'EMPTY_RESULT',
75
+ message: expect.stringContaining('chatgpt image returned no data'),
76
+ hint: expect.stringContaining('No generated images were detected'),
77
+ });
78
+ });
79
+
80
+ it('fails fast when generated image assets cannot be exported', async () => {
81
+ mocks.getChatGPTImageAssets.mockResolvedValue([]);
82
+
83
+ await expect(imageCommand.func(createPage(), {
84
+ prompt: 'cat',
85
+ op: '',
86
+ sd: false,
87
+ })).rejects.toMatchObject({
88
+ code: 'COMMAND_EXEC',
89
+ message: expect.stringContaining('Failed to export generated ChatGPT image assets'),
90
+ });
91
+ });
92
+ });
@@ -7,11 +7,21 @@ export const CHATGPT_DOMAIN = 'chatgpt.com';
7
7
  export const CHATGPT_URL = 'https://chatgpt.com';
8
8
 
9
9
  // Selectors
10
- const COMPOSER_SELECTOR = '[aria-label="Chat with ChatGPT"]';
10
+ const COMPOSER_SELECTORS = [
11
+ '[aria-label="Chat with ChatGPT"]',
12
+ '[placeholder="Ask anything"]',
13
+ '#prompt-textarea',
14
+ ];
11
15
  const SEND_BTN_SELECTOR = 'button[aria-label="Send prompt"]';
12
16
 
17
+ function isSameChatGPTConversation(currentUrl, expectedUrl) {
18
+ if (!currentUrl || !expectedUrl) return false;
19
+ return currentUrl === expectedUrl
20
+ || currentUrl.startsWith(`${expectedUrl}?`)
21
+ || currentUrl.startsWith(`${expectedUrl}#`);
22
+ }
23
+
13
24
  function buildComposerLocatorScript() {
14
- const selectorsJson = JSON.stringify([COMPOSER_SELECTOR]);
15
25
  const markerAttr = 'data-opencli-chatgpt-composer';
16
26
  return `
17
27
  const isVisible = (el) => {
@@ -33,7 +43,7 @@ function buildComposerLocatorScript() {
33
43
  const marked = document.querySelector('[' + markerAttr + '="1"]');
34
44
  if (marked instanceof HTMLElement && isVisible(marked)) return marked;
35
45
 
36
- for (const selector of ${JSON.stringify([COMPOSER_SELECTOR])}) {
46
+ for (const selector of ${JSON.stringify(COMPOSER_SELECTORS)}) {
37
47
  const node = Array.from(document.querySelectorAll(selector)).find(c => c instanceof HTMLElement && isVisible(c));
38
48
  if (node instanceof HTMLElement) {
39
49
  node.setAttribute(markerAttr, '1');
@@ -89,7 +99,9 @@ export async function sendChatGPTMessage(page, text) {
89
99
  // Fallback: use execCommand
90
100
  await page.evaluate(`
91
101
  (() => {
92
- const composer = document.querySelector('[aria-label="Chat with ChatGPT"]');
102
+ var composer = null;
103
+ var sels = ${JSON.stringify(COMPOSER_SELECTORS)};
104
+ for (var si = 0; si < sels.length; si++) { composer = document.querySelector(sels[si]); if (composer) break; }
93
105
  if (!composer) return;
94
106
  composer.focus();
95
107
  document.execCommand('insertText', false, ${JSON.stringify(text)});
@@ -181,7 +193,7 @@ export async function getChatGPTVisibleImageUrls(page) {
181
193
  /**
182
194
  * Wait for new images to appear after sending a prompt.
183
195
  */
184
- export async function waitForChatGPTImages(page, beforeUrls, timeoutSeconds) {
196
+ export async function waitForChatGPTImages(page, beforeUrls, timeoutSeconds, convUrl) {
185
197
  const beforeSet = new Set(beforeUrls);
186
198
  const pollIntervalSeconds = 3;
187
199
  const maxPolls = Math.max(1, Math.ceil(timeoutSeconds / pollIntervalSeconds));
@@ -191,10 +203,26 @@ export async function waitForChatGPTImages(page, beforeUrls, timeoutSeconds) {
191
203
  for (let i = 0; i < maxPolls; i++) {
192
204
  await page.wait(i === 0 ? 3 : pollIntervalSeconds);
193
205
 
194
- // Check if still generating
206
+ let currentUrl = '';
207
+ if (convUrl && convUrl.includes('/c/')) {
208
+ currentUrl = await page.evaluate('window.location.href').catch(() => '');
209
+ if (currentUrl && !isSameChatGPTConversation(currentUrl, convUrl)) {
210
+ await page.goto(convUrl);
211
+ await page.wait(3);
212
+ }
213
+ }
214
+
195
215
  const generating = await isGenerating(page);
196
216
  if (generating) continue;
197
217
 
218
+ if (convUrl && convUrl.includes('/c/') && i > 0 && i % 5 === 0) {
219
+ const onConversation = !currentUrl || isSameChatGPTConversation(currentUrl, convUrl);
220
+ if (onConversation) {
221
+ await page.goto(convUrl);
222
+ await page.wait(3);
223
+ }
224
+ }
225
+
198
226
  const urls = (await getChatGPTVisibleImageUrls(page)).filter(url => !beforeSet.has(url));
199
227
  if (urls.length === 0) continue;
200
228
 
@@ -214,6 +242,11 @@ export async function waitForChatGPTImages(page, beforeUrls, timeoutSeconds) {
214
242
  return lastUrls;
215
243
  }
216
244
 
245
+ export const __test__ = {
246
+ COMPOSER_SELECTORS,
247
+ isSameChatGPTConversation,
248
+ };
249
+
217
250
  /**
218
251
  * Export images by URL: fetch from ChatGPT backend API and convert to base64 data URLs.
219
252
  */
@@ -0,0 +1,63 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { __test__, waitForChatGPTImages } from './utils.js';
3
+
4
+ function createPageMock({ location = '', generating = [], imageUrls = [] } = {}) {
5
+ let generatingIndex = 0;
6
+ let imageIndex = 0;
7
+ return {
8
+ wait: vi.fn().mockResolvedValue(undefined),
9
+ goto: vi.fn().mockResolvedValue(undefined),
10
+ evaluate: vi.fn((script) => {
11
+ if (script === 'window.location.href') return Promise.resolve(location);
12
+ if (script.includes('Stop generating') || script.includes('Thinking')) {
13
+ const value = generating[Math.min(generatingIndex, generating.length - 1)] ?? false;
14
+ generatingIndex += 1;
15
+ return Promise.resolve(value);
16
+ }
17
+ if (script.includes("document.querySelectorAll('img')")) {
18
+ const value = imageUrls[Math.min(imageIndex, imageUrls.length - 1)] ?? [];
19
+ imageIndex += 1;
20
+ return Promise.resolve(value);
21
+ }
22
+ return Promise.resolve(undefined);
23
+ }),
24
+ };
25
+ }
26
+
27
+ describe('chatgpt image wait contract', () => {
28
+ it('does not periodically reload the conversation while generation is still active', async () => {
29
+ const convUrl = 'https://chatgpt.com/c/demo';
30
+ const page = createPageMock({
31
+ location: convUrl,
32
+ generating: [true, true, true, true, true, true],
33
+ });
34
+
35
+ await expect(waitForChatGPTImages(page, [], 18, convUrl)).resolves.toEqual([]);
36
+ expect(page.goto).not.toHaveBeenCalled();
37
+ });
38
+
39
+ it('jumps back to the captured conversation when the page drifts away', async () => {
40
+ const convUrl = 'https://chatgpt.com/c/demo';
41
+ const page = createPageMock({
42
+ location: 'https://chatgpt.com/',
43
+ generating: [false],
44
+ imageUrls: [['https://cdn.openai.com/generated/demo.png']],
45
+ });
46
+
47
+ await expect(waitForChatGPTImages(page, [], 3, convUrl)).resolves.toEqual([
48
+ 'https://cdn.openai.com/generated/demo.png',
49
+ ]);
50
+ expect(page.goto).toHaveBeenCalledWith(convUrl);
51
+ });
52
+
53
+ it('treats query and hash variants as the same conversation', () => {
54
+ expect(__test__.isSameChatGPTConversation(
55
+ 'https://chatgpt.com/c/demo?model=gpt-image-1',
56
+ 'https://chatgpt.com/c/demo',
57
+ )).toBe(true);
58
+ expect(__test__.isSameChatGPTConversation(
59
+ 'https://chatgpt.com/c/other',
60
+ 'https://chatgpt.com/c/demo',
61
+ )).toBe(false);
62
+ });
63
+ });
@@ -1,7 +1,7 @@
1
- import { execSync, spawnSync } from 'node:child_process';
1
+ import { execSync } from 'node:child_process';
2
2
  import { cli, Strategy } from '@jackwener/opencli/registry';
3
3
  import { ConfigError } from '@jackwener/opencli/errors';
4
- import { activateChatGPT, getVisibleChatMessages, selectModel, MODEL_CHOICES, isGenerating } from './ax.js';
4
+ import { activateChatGPT, getVisibleChatMessages, selectModel, MODEL_CHOICES, isGenerating, sendPrompt } from './ax.js';
5
5
  export const askCommand = cli({
6
6
  site: 'chatgpt-app',
7
7
  name: 'ask',
@@ -15,7 +15,7 @@ export const askCommand = cli({
15
15
  { name: 'timeout', required: false, help: 'Max seconds to wait for response (default: 30)', default: '30' },
16
16
  ],
17
17
  columns: ['Role', 'Text'],
18
- func: async (page, kwargs) => {
18
+ func: async (kwargs) => {
19
19
  if (process.platform !== 'darwin') {
20
20
  throw new ConfigError('ChatGPT Desktop integration requires macOS (osascript is not available on this platform)');
21
21
  }
@@ -27,26 +27,10 @@ export const askCommand = cli({
27
27
  activateChatGPT();
28
28
  selectModel(model);
29
29
  }
30
- // Backup clipboard
31
- let clipBackup = '';
32
- try {
33
- clipBackup = execSync('pbpaste', { encoding: 'utf-8' });
34
- }
35
- catch { }
36
30
  const messagesBefore = getVisibleChatMessages();
37
31
  // Send the message
38
- spawnSync('pbcopy', { input: text });
39
32
  activateChatGPT();
40
- const cmd = "osascript " +
41
- "-e 'tell application \"System Events\"' " +
42
- "-e 'keystroke \"v\" using command down' " +
43
- "-e 'delay 0.2' " +
44
- "-e 'keystroke return' " +
45
- "-e 'end tell'";
46
- execSync(cmd);
47
- // Restore clipboard after the prompt is sent.
48
- if (clipBackup)
49
- spawnSync('pbcopy', { input: clipBackup });
33
+ sendPrompt(text);
50
34
  // Wait for response: poll until ChatGPT stops generating ("Stop generating" button disappears),
51
35
  // then read the final response text.
52
36
  const pollInterval = 2;
@@ -60,6 +60,125 @@ for list in lists {
60
60
  let data = try! JSONSerialization.data(withJSONObject: best, options: [])
61
61
  print(String(data: data, encoding: .utf8)!)
62
62
  `;
63
+ const AX_SEND_SCRIPT = `
64
+ import Cocoa
65
+ import ApplicationServices
66
+
67
+ func attr(_ el: AXUIElement, _ name: String) -> AnyObject? {
68
+ var value: CFTypeRef?
69
+ guard AXUIElementCopyAttributeValue(el, name as CFString, &value) == .success else { return nil }
70
+ return value as AnyObject?
71
+ }
72
+
73
+ func s(_ el: AXUIElement, _ name: String) -> String? {
74
+ if let v = attr(el, name) as? String { return v }
75
+ return nil
76
+ }
77
+
78
+ func isEnabled(_ el: AXUIElement) -> Bool {
79
+ (attr(el, kAXEnabledAttribute as String) as? Bool) ?? true
80
+ }
81
+
82
+ func children(_ el: AXUIElement) -> [AXUIElement] {
83
+ (attr(el, kAXChildrenAttribute as String) as? [AnyObject] ?? []).map { $0 as! AXUIElement }
84
+ }
85
+
86
+ func collectEditableInputs(_ el: AXUIElement, into out: inout [AXUIElement], depth: Int = 0) {
87
+ guard depth < 25 else { return }
88
+ let role = s(el, kAXRoleAttribute as String) ?? ""
89
+ if (role == kAXTextAreaRole as String || role == kAXTextFieldRole as String) && isEnabled(el) {
90
+ out.append(el)
91
+ }
92
+ for c in children(el) { collectEditableInputs(c, into: &out, depth: depth + 1) }
93
+ }
94
+
95
+ func isInput(_ el: AXUIElement) -> Bool {
96
+ let role = s(el, kAXRoleAttribute as String) ?? ""
97
+ return role == kAXTextAreaRole as String || role == kAXTextFieldRole as String
98
+ }
99
+
100
+ func focusedInput(_ axApp: AXUIElement) -> AXUIElement? {
101
+ guard let focused = attr(axApp, kAXFocusedUIElementAttribute as String) as! AXUIElement? else {
102
+ return nil
103
+ }
104
+ return isInput(focused) && isEnabled(focused) ? focused : nil
105
+ }
106
+
107
+ func findByDescriptions(_ el: AXUIElement, _ targets: [String], depth: Int = 0) -> AXUIElement? {
108
+ guard depth < 25 else { return nil }
109
+ let role = s(el, kAXRoleAttribute as String) ?? ""
110
+ let desc = s(el, kAXDescriptionAttribute as String) ?? ""
111
+ if role == "AXButton" && targets.contains(desc) && isEnabled(el) { return el }
112
+ for c in children(el) {
113
+ if let found = findByDescriptions(c, targets, depth: depth + 1) { return found }
114
+ }
115
+ return nil
116
+ }
117
+
118
+ func press(_ el: AXUIElement) {
119
+ AXUIElementPerformAction(el, kAXPressAction as CFString)
120
+ }
121
+
122
+ let args = CommandLine.arguments
123
+ guard args.count > 1 else {
124
+ fputs("Missing prompt text\\n", stderr)
125
+ exit(1)
126
+ }
127
+ let text = args[1]
128
+
129
+ guard let app = NSRunningApplication.runningApplications(withBundleIdentifier: "com.openai.chat").first else {
130
+ fputs("ChatGPT not running\\n", stderr)
131
+ exit(1)
132
+ }
133
+
134
+ let axApp = AXUIElementCreateApplication(app.processIdentifier)
135
+ guard let win = attr(axApp, kAXFocusedWindowAttribute as String) as! AXUIElement? else {
136
+ fputs("No focused ChatGPT window\\n", stderr)
137
+ exit(1)
138
+ }
139
+
140
+ var inputs: [AXUIElement] = []
141
+ collectEditableInputs(win, into: &inputs)
142
+ guard let input = focusedInput(axApp) ?? inputs.last else {
143
+ fputs("Could not find editable input area\\n", stderr)
144
+ exit(1)
145
+ }
146
+
147
+ guard AXUIElementSetAttributeValue(input, kAXValueAttribute as CFString, text as CFTypeRef) == .success else {
148
+ fputs("Failed to set input value\\n", stderr)
149
+ exit(1)
150
+ }
151
+
152
+ Thread.sleep(forTimeInterval: 0.2)
153
+
154
+ guard s(input, kAXValueAttribute as String) == text else {
155
+ fputs("Failed to verify input value after AX set\\n", stderr)
156
+ exit(1)
157
+ }
158
+
159
+ guard let sendButton = findByDescriptions(win, ["发送", "傳送", "Send"]) else {
160
+ fputs("Could not find send button\\n", stderr)
161
+ exit(1)
162
+ }
163
+
164
+ press(sendButton)
165
+
166
+ var submitted = false
167
+ for _ in 0..<15 {
168
+ Thread.sleep(forTimeInterval: 0.1)
169
+ if s(input, kAXValueAttribute as String) != text {
170
+ submitted = true
171
+ break
172
+ }
173
+ }
174
+
175
+ guard submitted else {
176
+ fputs("Prompt did not leave input after pressing send\\n", stderr)
177
+ exit(1)
178
+ }
179
+
180
+ print("Sent")
181
+ `;
63
182
  const AX_MODEL_SCRIPT = `
64
183
  import Cocoa
65
184
  import ApplicationServices
@@ -121,10 +240,11 @@ let args = CommandLine.arguments
121
240
  let target = args.count > 1 ? args[1] : ""
122
241
  let needsLegacy = args.count > 2 && args[2] == "legacy"
123
242
 
124
- // Step 1: Click the "Options" button to open the popover (support both English and Chinese UI)
243
+ // Step 1: Click the "Options" button to open the popover (support English, Simplified and Traditional Chinese UI)
125
244
  var optionsBtn: AXUIElement? = nil
126
245
  if let btn = findByDesc(win, "Options") { optionsBtn = btn }
127
246
  else if let btn = findByDesc(win, "选项") { optionsBtn = btn }
247
+ else if let btn = findByDesc(win, "選項") { optionsBtn = btn }
128
248
  guard let options = optionsBtn else {
129
249
  fputs("Could not find Options button\\n", stderr); exit(1)
130
250
  }
@@ -192,7 +312,8 @@ let axApp = AXUIElementCreateApplication(app.processIdentifier)
192
312
  guard let win = attr(axApp, kAXFocusedWindowAttribute as String) as! AXUIElement? else {
193
313
  print("false"); exit(0)
194
314
  }
195
- print(hasButton(win, desc: "Stop generating") ? "true" : "false")
315
+ let targets = ["Stop generating", "停止生成"]
316
+ print(targets.contains(where: { hasButton(win, desc: $0) }) ? "true" : "false")
196
317
  `;
197
318
  const MODEL_MAP = {
198
319
  'auto': { desc: 'Auto' },
@@ -221,6 +342,13 @@ export function selectModel(model) {
221
342
  }).trim();
222
343
  return output;
223
344
  }
345
+ export function sendPrompt(text) {
346
+ return execFileSync('swift', ['-', text], {
347
+ input: AX_SEND_SCRIPT,
348
+ encoding: 'utf-8',
349
+ maxBuffer: 10 * 1024 * 1024,
350
+ }).trim();
351
+ }
224
352
  export function isGenerating() {
225
353
  try {
226
354
  const output = execFileSync('swift', ['-'], {
@@ -250,3 +378,8 @@ export function getVisibleChatMessages() {
250
378
  .map((item) => item.replace(/[\uFFFC\u200B-\u200D\uFEFF]/g, '').trim())
251
379
  .filter((item) => item.length > 0);
252
380
  }
381
+ export const __test__ = {
382
+ AX_SEND_SCRIPT,
383
+ AX_MODEL_SCRIPT,
384
+ AX_GENERATING_SCRIPT,
385
+ };