@jackwener/opencli 1.7.7 → 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.
- package/cli-manifest.json +144 -0
- package/clis/amazon/discussion.js +37 -6
- package/clis/amazon/discussion.test.js +147 -32
- package/clis/chatgpt-app/ask.js +3 -19
- package/clis/chatgpt-app/ax.js +132 -1
- package/clis/chatgpt-app/ax.test.js +23 -0
- package/clis/chatgpt-app/send.js +2 -21
- package/clis/deepseek/ask.js +32 -6
- package/clis/deepseek/ask.test.js +104 -3
- package/clis/deepseek/utils.js +5 -6
- package/clis/powerchina/search.js +250 -0
- package/clis/powerchina/search.test.js +67 -0
- package/clis/sinafinance/stock.js +5 -2
- package/clis/sinafinance/stock.test.js +59 -0
- package/clis/toutiao/articles.js +81 -0
- package/clis/toutiao/articles.test.js +23 -0
- package/clis/weixin/create-draft.js +225 -0
- package/clis/weixin/drafts.js +65 -0
- package/clis/weixin/drafts.test.js +65 -0
- package/dist/src/commanderAdapter.js +12 -0
- package/dist/src/commanderAdapter.test.js +11 -0
- package/package.json +1 -1
package/cli-manifest.json
CHANGED
|
@@ -13039,6 +13039,43 @@
|
|
|
13039
13039
|
"sourceFile": "pixiv/user.js",
|
|
13040
13040
|
"navigateBefore": "https://www.pixiv.net"
|
|
13041
13041
|
},
|
|
13042
|
+
{
|
|
13043
|
+
"site": "powerchina",
|
|
13044
|
+
"name": "search",
|
|
13045
|
+
"description": "搜索中国电建阳光采购公告",
|
|
13046
|
+
"domain": "bid.powerchina.cn",
|
|
13047
|
+
"strategy": "cookie",
|
|
13048
|
+
"browser": true,
|
|
13049
|
+
"args": [
|
|
13050
|
+
{
|
|
13051
|
+
"name": "query",
|
|
13052
|
+
"type": "str",
|
|
13053
|
+
"required": true,
|
|
13054
|
+
"positional": true,
|
|
13055
|
+
"help": "Search keyword, e.g. \"procurement\""
|
|
13056
|
+
},
|
|
13057
|
+
{
|
|
13058
|
+
"name": "limit",
|
|
13059
|
+
"type": "int",
|
|
13060
|
+
"default": 20,
|
|
13061
|
+
"required": false,
|
|
13062
|
+
"help": "Number of results (max 50)"
|
|
13063
|
+
}
|
|
13064
|
+
],
|
|
13065
|
+
"columns": [
|
|
13066
|
+
"rank",
|
|
13067
|
+
"content_type",
|
|
13068
|
+
"title",
|
|
13069
|
+
"publish_time",
|
|
13070
|
+
"project_code",
|
|
13071
|
+
"budget_or_limit",
|
|
13072
|
+
"url"
|
|
13073
|
+
],
|
|
13074
|
+
"type": "js",
|
|
13075
|
+
"modulePath": "powerchina/search.js",
|
|
13076
|
+
"sourceFile": "powerchina/search.js",
|
|
13077
|
+
"navigateBefore": "https://bid.powerchina.cn"
|
|
13078
|
+
},
|
|
13042
13079
|
{
|
|
13043
13080
|
"site": "producthunt",
|
|
13044
13081
|
"name": "browse",
|
|
@@ -15593,6 +15630,36 @@
|
|
|
15593
15630
|
"sourceFile": "tiktok/user.js",
|
|
15594
15631
|
"navigateBefore": "https://www.tiktok.com"
|
|
15595
15632
|
},
|
|
15633
|
+
{
|
|
15634
|
+
"site": "toutiao",
|
|
15635
|
+
"name": "articles",
|
|
15636
|
+
"description": "获取头条号创作者后台文章列表及数据",
|
|
15637
|
+
"domain": "mp.toutiao.com",
|
|
15638
|
+
"strategy": "cookie",
|
|
15639
|
+
"browser": true,
|
|
15640
|
+
"args": [
|
|
15641
|
+
{
|
|
15642
|
+
"name": "page",
|
|
15643
|
+
"type": "int",
|
|
15644
|
+
"default": 1,
|
|
15645
|
+
"required": false,
|
|
15646
|
+
"help": "页码 (1-4)"
|
|
15647
|
+
}
|
|
15648
|
+
],
|
|
15649
|
+
"columns": [
|
|
15650
|
+
"title",
|
|
15651
|
+
"date",
|
|
15652
|
+
"status",
|
|
15653
|
+
"展现",
|
|
15654
|
+
"阅读",
|
|
15655
|
+
"点赞",
|
|
15656
|
+
"评论"
|
|
15657
|
+
],
|
|
15658
|
+
"type": "js",
|
|
15659
|
+
"modulePath": "toutiao/articles.js",
|
|
15660
|
+
"sourceFile": "toutiao/articles.js",
|
|
15661
|
+
"navigateBefore": "https://mp.toutiao.com"
|
|
15662
|
+
},
|
|
15596
15663
|
{
|
|
15597
15664
|
"site": "twitter",
|
|
15598
15665
|
"name": "accept",
|
|
@@ -17274,6 +17341,56 @@
|
|
|
17274
17341
|
"sourceFile": "weibo/user.js",
|
|
17275
17342
|
"navigateBefore": "https://weibo.com"
|
|
17276
17343
|
},
|
|
17344
|
+
{
|
|
17345
|
+
"site": "weixin",
|
|
17346
|
+
"name": "create-draft",
|
|
17347
|
+
"description": "创建微信公众号图文草稿",
|
|
17348
|
+
"domain": "mp.weixin.qq.com",
|
|
17349
|
+
"strategy": "cookie",
|
|
17350
|
+
"browser": true,
|
|
17351
|
+
"args": [
|
|
17352
|
+
{
|
|
17353
|
+
"name": "title",
|
|
17354
|
+
"type": "str",
|
|
17355
|
+
"required": true,
|
|
17356
|
+
"help": "文章标题 (最长64字)"
|
|
17357
|
+
},
|
|
17358
|
+
{
|
|
17359
|
+
"name": "content",
|
|
17360
|
+
"type": "str",
|
|
17361
|
+
"required": true,
|
|
17362
|
+
"positional": true,
|
|
17363
|
+
"help": "文章正文"
|
|
17364
|
+
},
|
|
17365
|
+
{
|
|
17366
|
+
"name": "author",
|
|
17367
|
+
"type": "str",
|
|
17368
|
+
"required": false,
|
|
17369
|
+
"help": "作者名 (最长8字)"
|
|
17370
|
+
},
|
|
17371
|
+
{
|
|
17372
|
+
"name": "cover-image",
|
|
17373
|
+
"type": "str",
|
|
17374
|
+
"required": false,
|
|
17375
|
+
"help": "封面图片路径 (会先上传到正文再设为封面)"
|
|
17376
|
+
},
|
|
17377
|
+
{
|
|
17378
|
+
"name": "summary",
|
|
17379
|
+
"type": "str",
|
|
17380
|
+
"required": false,
|
|
17381
|
+
"help": "文章摘要"
|
|
17382
|
+
}
|
|
17383
|
+
],
|
|
17384
|
+
"columns": [
|
|
17385
|
+
"status",
|
|
17386
|
+
"detail"
|
|
17387
|
+
],
|
|
17388
|
+
"timeout": 180,
|
|
17389
|
+
"type": "js",
|
|
17390
|
+
"modulePath": "weixin/create-draft.js",
|
|
17391
|
+
"sourceFile": "weixin/create-draft.js",
|
|
17392
|
+
"navigateBefore": false
|
|
17393
|
+
},
|
|
17277
17394
|
{
|
|
17278
17395
|
"site": "weixin",
|
|
17279
17396
|
"name": "download",
|
|
@@ -17316,6 +17433,33 @@
|
|
|
17316
17433
|
"sourceFile": "weixin/download.js",
|
|
17317
17434
|
"navigateBefore": "https://mp.weixin.qq.com"
|
|
17318
17435
|
},
|
|
17436
|
+
{
|
|
17437
|
+
"site": "weixin",
|
|
17438
|
+
"name": "drafts",
|
|
17439
|
+
"description": "列出微信公众号草稿箱",
|
|
17440
|
+
"domain": "mp.weixin.qq.com",
|
|
17441
|
+
"strategy": "cookie",
|
|
17442
|
+
"browser": true,
|
|
17443
|
+
"args": [
|
|
17444
|
+
{
|
|
17445
|
+
"name": "limit",
|
|
17446
|
+
"type": "int",
|
|
17447
|
+
"default": 10,
|
|
17448
|
+
"required": false,
|
|
17449
|
+
"help": "最多显示条数"
|
|
17450
|
+
}
|
|
17451
|
+
],
|
|
17452
|
+
"columns": [
|
|
17453
|
+
"Index",
|
|
17454
|
+
"Title",
|
|
17455
|
+
"Time"
|
|
17456
|
+
],
|
|
17457
|
+
"timeout": 60,
|
|
17458
|
+
"type": "js",
|
|
17459
|
+
"modulePath": "weixin/drafts.js",
|
|
17460
|
+
"sourceFile": "weixin/drafts.js",
|
|
17461
|
+
"navigateBefore": false
|
|
17462
|
+
},
|
|
17319
17463
|
{
|
|
17320
17464
|
"site": "weread",
|
|
17321
17465
|
"name": "ai-outline",
|
|
@@ -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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
});
|
package/clis/chatgpt-app/ask.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { execSync
|
|
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
|
-
|
|
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;
|
package/clis/chatgpt-app/ax.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
});
|
package/clis/chatgpt-app/send.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|