@jackwener/opencli 1.7.6 → 1.7.8

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 (85) hide show
  1. package/README.md +17 -8
  2. package/README.zh-CN.md +14 -8
  3. package/cli-manifest.json +469 -11
  4. package/clis/51job/company.js +125 -0
  5. package/clis/51job/detail.js +108 -0
  6. package/clis/51job/hot.js +55 -0
  7. package/clis/51job/search.js +79 -0
  8. package/clis/51job/utils.js +302 -0
  9. package/clis/51job/utils.test.js +69 -0
  10. package/clis/amazon/discussion.js +37 -6
  11. package/clis/amazon/discussion.test.js +147 -32
  12. package/clis/bilibili/video.js +11 -4
  13. package/clis/bilibili/video.test.js +51 -0
  14. package/clis/chatgpt/image.js +1 -1
  15. package/clis/chatgpt-app/ask.js +3 -19
  16. package/clis/chatgpt-app/ax.js +132 -1
  17. package/clis/chatgpt-app/ax.test.js +23 -0
  18. package/clis/chatgpt-app/send.js +2 -21
  19. package/clis/deepseek/ask.js +50 -18
  20. package/clis/deepseek/ask.test.js +195 -2
  21. package/clis/deepseek/utils.js +113 -29
  22. package/clis/deepseek/utils.test.js +109 -1
  23. package/clis/gemini/image.js +1 -1
  24. package/clis/instagram/download.js +1 -1
  25. package/clis/powerchina/search.js +250 -0
  26. package/clis/powerchina/search.test.js +67 -0
  27. package/clis/sinafinance/stock.js +5 -2
  28. package/clis/sinafinance/stock.test.js +59 -0
  29. package/clis/toutiao/articles.js +81 -0
  30. package/clis/toutiao/articles.test.js +23 -0
  31. package/clis/twitter/likes.js +3 -2
  32. package/clis/twitter/search.js +4 -2
  33. package/clis/twitter/search.test.js +4 -0
  34. package/clis/twitter/shared.js +28 -0
  35. package/clis/twitter/shared.test.js +96 -0
  36. package/clis/twitter/thread.js +3 -1
  37. package/clis/twitter/timeline.js +3 -2
  38. package/clis/twitter/tweets.js +3 -2
  39. package/clis/twitter/tweets.test.js +1 -1
  40. package/clis/web/read.js +25 -5
  41. package/clis/web/read.test.js +76 -0
  42. package/clis/weixin/create-draft.js +225 -0
  43. package/clis/weixin/drafts.js +65 -0
  44. package/clis/weixin/drafts.test.js +65 -0
  45. package/clis/weread/ai-outline.js +170 -0
  46. package/clis/weread/ai-outline.test.js +83 -0
  47. package/clis/weread/book.js +57 -44
  48. package/clis/weread/commands.test.js +24 -0
  49. package/clis/xiaoyuzhou/podcast-episodes.js +2 -2
  50. package/clis/xiaoyuzhou/podcast-episodes.test.js +78 -0
  51. package/dist/src/browser/analyze.d.ts +103 -0
  52. package/dist/src/browser/analyze.js +230 -0
  53. package/dist/src/browser/analyze.test.d.ts +1 -0
  54. package/dist/src/browser/analyze.test.js +164 -0
  55. package/dist/src/browser/article-extract.d.ts +57 -0
  56. package/dist/src/browser/article-extract.e2e.test.d.ts +1 -0
  57. package/dist/src/browser/article-extract.e2e.test.js +105 -0
  58. package/dist/src/browser/article-extract.js +169 -0
  59. package/dist/src/browser/article-extract.test.d.ts +1 -0
  60. package/dist/src/browser/article-extract.test.js +94 -0
  61. package/dist/src/browser/cdp.js +11 -2
  62. package/dist/src/browser/verify-fixture.d.ts +59 -0
  63. package/dist/src/browser/verify-fixture.js +213 -0
  64. package/dist/src/browser/verify-fixture.test.d.ts +1 -0
  65. package/dist/src/browser/verify-fixture.test.js +161 -0
  66. package/dist/src/cli.d.ts +32 -0
  67. package/dist/src/cli.js +333 -43
  68. package/dist/src/cli.test.js +257 -1
  69. package/dist/src/commanderAdapter.js +12 -0
  70. package/dist/src/commanderAdapter.test.js +11 -0
  71. package/dist/src/daemon.d.ts +3 -2
  72. package/dist/src/daemon.js +16 -4
  73. package/dist/src/daemon.test.d.ts +1 -0
  74. package/dist/src/daemon.test.js +19 -0
  75. package/dist/src/download/article-download.d.ts +12 -0
  76. package/dist/src/download/article-download.js +141 -17
  77. package/dist/src/download/article-download.test.js +196 -0
  78. package/dist/src/download/index.js +73 -86
  79. package/dist/src/errors.js +4 -2
  80. package/dist/src/errors.test.js +13 -0
  81. package/dist/src/launcher.d.ts +1 -1
  82. package/dist/src/launcher.js +3 -3
  83. package/dist/src/output.js +1 -1
  84. package/dist/src/output.test.js +6 -0
  85. package/package.json +5 -1
@@ -1,6 +1,6 @@
1
- import { CommandExecutionError } from '@jackwener/opencli/errors';
1
+ import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
2
2
  import { cli, Strategy } from '@jackwener/opencli/registry';
3
- import { buildDiscussionUrl, buildProvenance, cleanText, extractAsin, normalizeProductUrl, parseRatingValue, parseReviewCount, trimRatingPrefix, uniqueNonEmpty, assertUsableState, gotoAndReadState, } from './shared.js';
3
+ import { buildProductUrl, buildDiscussionUrl, buildProvenance, cleanText, extractAsin, normalizeProductUrl, parseRatingValue, parseReviewCount, trimRatingPrefix, uniqueNonEmpty, assertUsableState, gotoAndReadState, } from './shared.js';
4
4
  function normalizeDiscussionPayload(payload) {
5
5
  const sourceUrl = cleanText(payload.href) || buildDiscussionUrl(payload.href ?? '');
6
6
  const asin = extractAsin(payload.href ?? '') ?? null;
@@ -28,10 +28,16 @@ function normalizeDiscussionPayload(payload) {
28
28
  })),
29
29
  };
30
30
  }
31
- async function readDiscussionPayload(page, input, limit) {
32
- const url = buildDiscussionUrl(input);
33
- const state = await gotoAndReadState(page, url, 2500, 'discussion');
34
- assertUsableState(state, 'discussion');
31
+ function hasDiscussionSummary(payload) {
32
+ return Boolean(cleanText(payload.average_rating_text) || cleanText(payload.total_review_count_text));
33
+ }
34
+ function isSignInState(state) {
35
+ const href = cleanText(state.href).toLowerCase();
36
+ const title = cleanText(state.title).toLowerCase();
37
+ return href.includes('/ap/signin')
38
+ || title.includes('amazon sign-in');
39
+ }
40
+ async function readCurrentDiscussionPayload(page, limit) {
35
41
  return await page.evaluate(`
36
42
  (() => ({
37
43
  href: window.location.href,
@@ -53,6 +59,29 @@ async function readDiscussionPayload(page, input, limit) {
53
59
  }))()
54
60
  `);
55
61
  }
62
+ async function readDiscussionPayload(page, input, limit) {
63
+ const reviewUrl = buildDiscussionUrl(input);
64
+ const reviewState = await gotoAndReadState(page, reviewUrl, 2500, 'discussion');
65
+ assertUsableState(reviewState, 'discussion');
66
+ const reviewPayload = await readCurrentDiscussionPayload(page, limit);
67
+ if (hasDiscussionSummary(reviewPayload)) {
68
+ return reviewPayload;
69
+ }
70
+ const productUrl = buildProductUrl(input);
71
+ const productState = await gotoAndReadState(page, productUrl, 2500, 'discussion');
72
+ assertUsableState(productState, 'discussion');
73
+ if (isSignInState(reviewState) && isSignInState(productState)) {
74
+ throw new AuthRequiredError('amazon.com', 'Amazon review discussion requires an active signed-in Amazon session in the shared Chrome profile.');
75
+ }
76
+ const productPayload = await readCurrentDiscussionPayload(page, limit);
77
+ if (hasDiscussionSummary(productPayload)) {
78
+ return productPayload;
79
+ }
80
+ if (isSignInState(reviewState)) {
81
+ throw new CommandExecutionError('amazon review page redirected to sign-in and product page fallback did not expose review summary', 'Open the product page in Chrome, verify reviews are visible, and retry.');
82
+ }
83
+ return reviewPayload;
84
+ }
56
85
  cli({
57
86
  site: 'amazon',
58
87
  name: 'discussion',
@@ -88,4 +117,6 @@ cli({
88
117
  });
89
118
  export const __test__ = {
90
119
  normalizeDiscussionPayload,
120
+ hasDiscussionSummary,
121
+ isSignInState,
91
122
  };
@@ -1,36 +1,151 @@
1
- import { describe, expect, it } from 'vitest';
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { AuthRequiredError } from '@jackwener/opencli/errors';
3
+ import { getRegistry } from '@jackwener/opencli/registry';
2
4
  import { __test__ } from './discussion.js';
5
+ import './discussion.js';
6
+
7
+ function createPageMock(evaluateResults) {
8
+ const evaluate = vi.fn();
9
+ for (const result of evaluateResults) {
10
+ evaluate.mockResolvedValueOnce(result);
11
+ }
12
+ return {
13
+ goto: vi.fn().mockResolvedValue(undefined),
14
+ wait: vi.fn().mockResolvedValue(undefined),
15
+ evaluate,
16
+ snapshot: vi.fn().mockResolvedValue(undefined),
17
+ click: vi.fn().mockResolvedValue(undefined),
18
+ typeText: vi.fn().mockResolvedValue(undefined),
19
+ pressKey: vi.fn().mockResolvedValue(undefined),
20
+ scrollTo: vi.fn().mockResolvedValue(undefined),
21
+ getFormState: vi.fn().mockResolvedValue({ forms: [], orphanFields: [] }),
22
+ tabs: vi.fn().mockResolvedValue([]),
23
+ selectTab: vi.fn().mockResolvedValue(undefined),
24
+ networkRequests: vi.fn().mockResolvedValue([]),
25
+ consoleMessages: vi.fn().mockResolvedValue([]),
26
+ scroll: vi.fn().mockResolvedValue(undefined),
27
+ autoScroll: vi.fn().mockResolvedValue(undefined),
28
+ installInterceptor: vi.fn().mockResolvedValue(undefined),
29
+ getInterceptedRequests: vi.fn().mockResolvedValue([]),
30
+ getCookies: vi.fn().mockResolvedValue([]),
31
+ screenshot: vi.fn().mockResolvedValue(''),
32
+ waitForCapture: vi.fn().mockResolvedValue(undefined),
33
+ };
34
+ }
35
+
3
36
  describe('amazon discussion normalization', () => {
4
- it('normalizes review summary and sample reviews', () => {
5
- const result = __test__.normalizeDiscussionPayload({
6
- href: 'https://www.amazon.com/product-reviews/B0FJS72893',
7
- average_rating_text: '3.9 out of 5',
8
- total_review_count_text: '27 global ratings',
9
- qa_links: [],
10
- review_samples: [
11
- {
12
- title: '5.0 out of 5 stars Great value and quality',
13
- rating_text: '5.0 out of 5 stars',
14
- author: 'GTreader2',
15
- date_text: 'Reviewed in the United States on February 21, 2026',
16
- body: 'Small but mighty.',
17
- verified: true,
18
- },
19
- ],
20
- });
21
- expect(result.asin).toBe('B0FJS72893');
22
- expect(result.average_rating_value).toBe(3.9);
23
- expect(result.total_review_count).toBe(27);
24
- expect(result.review_samples).toEqual([
25
- {
26
- title: 'Great value and quality',
27
- rating_text: '5.0 out of 5 stars',
28
- rating_value: 5,
29
- author: 'GTreader2',
30
- date_text: 'Reviewed in the United States on February 21, 2026',
31
- body: 'Small but mighty.',
32
- verified_purchase: true,
33
- },
34
- ]);
37
+ it('normalizes review summary and sample reviews', () => {
38
+ const result = __test__.normalizeDiscussionPayload({
39
+ href: 'https://www.amazon.com/product-reviews/B0FJS72893',
40
+ average_rating_text: '3.9 out of 5',
41
+ total_review_count_text: '27 global ratings',
42
+ qa_links: [],
43
+ review_samples: [
44
+ {
45
+ title: '5.0 out of 5 stars Great value and quality',
46
+ rating_text: '5.0 out of 5 stars',
47
+ author: 'GTreader2',
48
+ date_text: 'Reviewed in the United States on February 21, 2026',
49
+ body: 'Small but mighty.',
50
+ verified: true,
51
+ },
52
+ ],
35
53
  });
54
+
55
+ expect(result.asin).toBe('B0FJS72893');
56
+ expect(result.average_rating_value).toBe(3.9);
57
+ expect(result.total_review_count).toBe(27);
58
+ expect(result.review_samples).toEqual([
59
+ {
60
+ title: 'Great value and quality',
61
+ rating_text: '5.0 out of 5 stars',
62
+ rating_value: 5,
63
+ author: 'GTreader2',
64
+ date_text: 'Reviewed in the United States on February 21, 2026',
65
+ body: 'Small but mighty.',
66
+ verified_purchase: true,
67
+ },
68
+ ]);
69
+ });
70
+
71
+ it('falls back to the product page when the review page redirects to sign-in', async () => {
72
+ const command = getRegistry().get('amazon/discussion');
73
+ const page = createPageMock([
74
+ {
75
+ href: 'https://www.amazon.com/ap/signin?openid.return_to=https%3A%2F%2Fwww.amazon.com%2Fproduct-reviews%2FB09HKN2ZRT',
76
+ title: 'Amazon Sign-In',
77
+ body_text: 'Sign in Create account',
78
+ },
79
+ {
80
+ href: 'https://www.amazon.com/ap/signin?openid.return_to=https%3A%2F%2Fwww.amazon.com%2Fproduct-reviews%2FB09HKN2ZRT',
81
+ average_rating_text: '',
82
+ total_review_count_text: '',
83
+ review_samples: [],
84
+ },
85
+ {
86
+ href: 'https://www.amazon.com/dp/B09HKN2ZRT',
87
+ title: 'Amazon.com: Example product',
88
+ body_text: 'Hello, zejia-wu Reviews',
89
+ },
90
+ {
91
+ href: 'https://www.amazon.com/dp/B09HKN2ZRT',
92
+ average_rating_text: '4.4 out of 5',
93
+ total_review_count_text: '349 global ratings',
94
+ review_samples: [
95
+ {
96
+ title: '5.0 out of 5 stars Perfect for the office',
97
+ rating_text: '5.0 out of 5 stars',
98
+ author: 'Ken',
99
+ date_text: 'Reviewed in the United States on March 19, 2026',
100
+ body: 'Good for the office, no complaints.',
101
+ verified: true,
102
+ },
103
+ ],
104
+ },
105
+ ]);
106
+
107
+ const result = await command.func(page, { input: 'B09HKN2ZRT', limit: 1 });
108
+
109
+ expect(page.goto.mock.calls.map((call) => call[0])).toEqual([
110
+ 'https://www.amazon.com/product-reviews/B09HKN2ZRT',
111
+ 'https://www.amazon.com/dp/B09HKN2ZRT',
112
+ ]);
113
+ expect(result).toEqual([
114
+ expect.objectContaining({
115
+ asin: 'B09HKN2ZRT',
116
+ discussion_url: 'https://www.amazon.com/dp/B09HKN2ZRT',
117
+ average_rating_value: 4.4,
118
+ total_review_count: 349,
119
+ }),
120
+ ]);
121
+ });
122
+
123
+ it('throws AuthRequiredError when both review and product pages are gated', async () => {
124
+ const command = getRegistry().get('amazon/discussion');
125
+ const authState = {
126
+ href: 'https://www.amazon.com/ap/signin?openid.return_to=https%3A%2F%2Fwww.amazon.com%2Fproduct-reviews%2FB09HKN2ZRT',
127
+ title: 'Amazon Sign-In',
128
+ body_text: 'Sign in Create account',
129
+ };
130
+ const page = createPageMock([
131
+ authState,
132
+ {
133
+ href: authState.href,
134
+ average_rating_text: '',
135
+ total_review_count_text: '',
136
+ review_samples: [],
137
+ },
138
+ authState,
139
+ ]);
140
+
141
+ await expect(command.func(page, { input: 'B09HKN2ZRT', limit: 1 })).rejects.toBeInstanceOf(AuthRequiredError);
142
+ });
143
+
144
+ it('does not treat a public product page with sign-in copy as a gated page', () => {
145
+ expect(__test__.isSignInState({
146
+ href: 'https://www.amazon.com/dp/B09HKN2ZRT',
147
+ title: 'Amazon.com: Example product',
148
+ body_text: 'Hello, sign in Account & Lists Create account',
149
+ })).toBe(false);
150
+ });
36
151
  });
@@ -15,7 +15,16 @@ cli({
15
15
  if (!page) {
16
16
  throw new CommandExecutionError('Browser session required for bilibili video');
17
17
  }
18
- const bvid = await resolveBvid(kwargs.bvid);
18
+
19
+ // Resolve BV ID from three advertised input forms:
20
+ // 1. Bare "BV..." id
21
+ // 2. Full bilibili.com/video/<BV>... URL (with or without query string / www / m.)
22
+ // 3. b23.tv short link (delegated to resolveBvid)
23
+ // resolveBvid() alone handles (1) and (3) but not (2), so we pre-extract
24
+ // from bilibili URLs before falling through.
25
+ const input = String(kwargs.bvid ?? '').trim();
26
+ const bilibiliUrlMatch = input.match(/bilibili\.com\/(?:video|bangumi\/play)\/(BV[A-Za-z0-9]+)/i);
27
+ const bvid = bilibiliUrlMatch ? bilibiliUrlMatch[1] : await resolveBvid(input);
19
28
 
20
29
  // Navigate to video page first so subsequent api call shares a primed session.
21
30
  await page.goto(`https://www.bilibili.com/video/${bvid}/`);
@@ -35,8 +44,6 @@ cli({
35
44
  const dur = d.duration || 0;
36
45
  const mm = Math.floor(dur / 60);
37
46
  const ss = dur % 60;
38
- const desc = (d.desc || '').replace(/\s+/g, ' ').trim();
39
- const descTrunc = desc.length > 200 ? desc.slice(0, 200) + '…' : desc;
40
47
 
41
48
  return [
42
49
  { field: 'bvid', value: d.bvid ?? '' },
@@ -55,7 +62,7 @@ cli({
55
62
  { field: 'share', value: String(stat.share ?? '') },
56
63
  { field: 'parts', value: String(d.videos ?? 1) },
57
64
  { field: 'thumbnail', value: d.pic ?? '' },
58
- { field: 'description', value: descTrunc },
65
+ { field: 'description', value: d.desc ?? '' },
59
66
  ];
60
67
  },
61
68
  });
@@ -78,4 +78,55 @@ describe('bilibili video', () => {
78
78
  (err) => err instanceof CommandExecutionError && /啥都木有|-404/.test(err.message),
79
79
  );
80
80
  });
81
+
82
+ it('extracts BV ID from full bilibili.com URL input', async () => {
83
+ mockApiGet.mockResolvedValueOnce({
84
+ code: 0,
85
+ data: { bvid: 'BV1xx411c7mD', stat: {}, owner: {}, desc: '' },
86
+ });
87
+
88
+ await command.func(page, { bvid: 'https://www.bilibili.com/video/BV1xx411c7mD/' });
89
+
90
+ expect(page.goto).toHaveBeenCalledWith('https://www.bilibili.com/video/BV1xx411c7mD/');
91
+ expect(mockApiGet).toHaveBeenCalledWith(page, '/x/web-interface/view', { params: { bvid: 'BV1xx411c7mD' } });
92
+ });
93
+
94
+ it('extracts BV ID from bilibili URL with trailing query string', async () => {
95
+ mockApiGet.mockResolvedValueOnce({
96
+ code: 0,
97
+ data: { bvid: 'BV1Je9EBnEha', stat: {}, owner: {}, desc: '' },
98
+ });
99
+
100
+ await command.func(page, {
101
+ bvid: 'https://www.bilibili.com/video/BV1Je9EBnEha/?spm_id_from=333.1007&vd_source=abc',
102
+ });
103
+
104
+ expect(mockApiGet).toHaveBeenCalledWith(page, '/x/web-interface/view', { params: { bvid: 'BV1Je9EBnEha' } });
105
+ });
106
+
107
+ it('extracts BV ID from m.bilibili.com mobile URL', async () => {
108
+ mockApiGet.mockResolvedValueOnce({
109
+ code: 0,
110
+ data: { bvid: 'BV1xx411c7mD', stat: {}, owner: {}, desc: '' },
111
+ });
112
+
113
+ await command.func(page, { bvid: 'https://m.bilibili.com/video/BV1xx411c7mD' });
114
+
115
+ expect(mockApiGet).toHaveBeenCalledWith(page, '/x/web-interface/view', { params: { bvid: 'BV1xx411c7mD' } });
116
+ });
117
+
118
+ it('returns full description without truncation or whitespace collapse', async () => {
119
+ const longDesc = '第一行描述\n\n第二段,有多个空格 和换行\n\n' + 'x'.repeat(500);
120
+ mockApiGet.mockResolvedValueOnce({
121
+ code: 0,
122
+ data: { bvid: 'BV1xx411c7mD', stat: {}, owner: {}, desc: longDesc },
123
+ });
124
+
125
+ const rows = await command.func(page, { bvid: 'BV1xx411c7mD' });
126
+ const byField = Object.fromEntries(rows.map((r) => [r.field, r.value]));
127
+ // JSON/YAML consumers must receive the complete description verbatim,
128
+ // including original whitespace and length > 200 chars.
129
+ expect(byField.description).toBe(longDesc);
130
+ expect(byField.description.length).toBeGreaterThan(200);
131
+ });
81
132
  });
@@ -41,7 +41,7 @@ export const imageCommand = cli({
41
41
  timeoutSeconds: 240,
42
42
  args: [
43
43
  { name: 'prompt', positional: true, required: true, help: 'Image prompt to send to ChatGPT' },
44
- { name: 'op', default: path.join(os.homedir(), 'Pictures', 'chatgpt'), help: 'Output directory' },
44
+ { name: 'op', default: '~/Pictures/chatgpt', help: 'Output directory' },
45
45
  { name: 'sd', type: 'boolean', default: false, help: 'Skip download shorthand; only show ChatGPT link' },
46
46
  ],
47
47
  columns: ['status', 'file', 'link'],
@@ -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',
@@ -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
@@ -192,7 +311,8 @@ let axApp = AXUIElementCreateApplication(app.processIdentifier)
192
311
  guard let win = attr(axApp, kAXFocusedWindowAttribute as String) as! AXUIElement? else {
193
312
  print("false"); exit(0)
194
313
  }
195
- print(hasButton(win, desc: "Stop generating") ? "true" : "false")
314
+ let targets = ["Stop generating", "停止生成"]
315
+ print(targets.contains(where: { hasButton(win, desc: $0) }) ? "true" : "false")
196
316
  `;
197
317
  const MODEL_MAP = {
198
318
  'auto': { desc: 'Auto' },
@@ -221,6 +341,13 @@ export function selectModel(model) {
221
341
  }).trim();
222
342
  return output;
223
343
  }
344
+ export function sendPrompt(text) {
345
+ return execFileSync('swift', ['-', text], {
346
+ input: AX_SEND_SCRIPT,
347
+ encoding: 'utf-8',
348
+ maxBuffer: 10 * 1024 * 1024,
349
+ }).trim();
350
+ }
224
351
  export function isGenerating() {
225
352
  try {
226
353
  const output = execFileSync('swift', ['-'], {
@@ -250,3 +377,7 @@ export function getVisibleChatMessages() {
250
377
  .map((item) => item.replace(/[\uFFFC\u200B-\u200D\uFEFF]/g, '').trim())
251
378
  .filter((item) => item.length > 0);
252
379
  }
380
+ export const __test__ = {
381
+ AX_SEND_SCRIPT,
382
+ AX_GENERATING_SCRIPT,
383
+ };
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { __test__ } from './ax.js';
3
+
4
+ describe('chatgpt-app AX send script', () => {
5
+ it('prefers the focused composer before falling back to the last editable input', () => {
6
+ expect(__test__.AX_SEND_SCRIPT).toContain('kAXFocusedUIElementAttribute');
7
+ });
8
+
9
+ it('fails fast when the AX set does not round-trip into the composer value', () => {
10
+ expect(__test__.AX_SEND_SCRIPT).toContain('Failed to verify input value after AX set');
11
+ });
12
+
13
+ it('does not report success until the prompt leaves the composer after send', () => {
14
+ expect(__test__.AX_SEND_SCRIPT).toContain('Prompt did not leave input after pressing send');
15
+ });
16
+ });
17
+
18
+ describe('chatgpt-app generating detection', () => {
19
+ it('supports both english and zh-CN stop-generating labels', () => {
20
+ expect(__test__.AX_GENERATING_SCRIPT).toContain('Stop generating');
21
+ expect(__test__.AX_GENERATING_SCRIPT).toContain('停止生成');
22
+ });
23
+ });
@@ -1,7 +1,6 @@
1
- import { execSync, spawnSync } from 'node:child_process';
2
1
  import { cli, Strategy } from '@jackwener/opencli/registry';
3
2
  import { getErrorMessage } from '@jackwener/opencli/errors';
4
- import { activateChatGPT, selectModel, MODEL_CHOICES } from './ax.js';
3
+ import { activateChatGPT, selectModel, MODEL_CHOICES, sendPrompt } from './ax.js';
5
4
  export const sendCommand = cli({
6
5
  site: 'chatgpt-app',
7
6
  name: 'send',
@@ -23,26 +22,8 @@ export const sendCommand = cli({
23
22
  activateChatGPT();
24
23
  selectModel(model);
25
24
  }
26
- // Backup current clipboard content
27
- let clipBackup = '';
28
- try {
29
- clipBackup = execSync('pbpaste', { encoding: 'utf-8' });
30
- }
31
- catch { /* clipboard may be empty */ }
32
- // Copy text to clipboard
33
- spawnSync('pbcopy', { input: text });
34
25
  activateChatGPT();
35
- const cmd = "osascript " +
36
- "-e 'tell application \"System Events\"' " +
37
- "-e 'keystroke \"v\" using command down' " +
38
- "-e 'delay 0.2' " +
39
- "-e 'keystroke return' " +
40
- "-e 'end tell'";
41
- execSync(cmd);
42
- // Restore original clipboard content
43
- if (clipBackup) {
44
- spawnSync('pbcopy', { input: clipBackup });
45
- }
26
+ sendPrompt(text);
46
27
  return [{ Status: 'Success' }];
47
28
  }
48
29
  catch (err) {