@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.
- package/README.md +17 -8
- package/README.zh-CN.md +14 -8
- package/cli-manifest.json +469 -11
- package/clis/51job/company.js +125 -0
- package/clis/51job/detail.js +108 -0
- package/clis/51job/hot.js +55 -0
- package/clis/51job/search.js +79 -0
- package/clis/51job/utils.js +302 -0
- package/clis/51job/utils.test.js +69 -0
- package/clis/amazon/discussion.js +37 -6
- package/clis/amazon/discussion.test.js +147 -32
- package/clis/bilibili/video.js +11 -4
- package/clis/bilibili/video.test.js +51 -0
- package/clis/chatgpt/image.js +1 -1
- 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 +50 -18
- package/clis/deepseek/ask.test.js +195 -2
- package/clis/deepseek/utils.js +113 -29
- package/clis/deepseek/utils.test.js +109 -1
- package/clis/gemini/image.js +1 -1
- package/clis/instagram/download.js +1 -1
- 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/twitter/likes.js +3 -2
- package/clis/twitter/search.js +4 -2
- package/clis/twitter/search.test.js +4 -0
- package/clis/twitter/shared.js +28 -0
- package/clis/twitter/shared.test.js +96 -0
- package/clis/twitter/thread.js +3 -1
- package/clis/twitter/timeline.js +3 -2
- package/clis/twitter/tweets.js +3 -2
- package/clis/twitter/tweets.test.js +1 -1
- package/clis/web/read.js +25 -5
- package/clis/web/read.test.js +76 -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/clis/weread/ai-outline.js +170 -0
- package/clis/weread/ai-outline.test.js +83 -0
- package/clis/weread/book.js +57 -44
- package/clis/weread/commands.test.js +24 -0
- package/clis/xiaoyuzhou/podcast-episodes.js +2 -2
- package/clis/xiaoyuzhou/podcast-episodes.test.js +78 -0
- package/dist/src/browser/analyze.d.ts +103 -0
- package/dist/src/browser/analyze.js +230 -0
- package/dist/src/browser/analyze.test.d.ts +1 -0
- package/dist/src/browser/analyze.test.js +164 -0
- package/dist/src/browser/article-extract.d.ts +57 -0
- package/dist/src/browser/article-extract.e2e.test.d.ts +1 -0
- package/dist/src/browser/article-extract.e2e.test.js +105 -0
- package/dist/src/browser/article-extract.js +169 -0
- package/dist/src/browser/article-extract.test.d.ts +1 -0
- package/dist/src/browser/article-extract.test.js +94 -0
- package/dist/src/browser/cdp.js +11 -2
- package/dist/src/browser/verify-fixture.d.ts +59 -0
- package/dist/src/browser/verify-fixture.js +213 -0
- package/dist/src/browser/verify-fixture.test.d.ts +1 -0
- package/dist/src/browser/verify-fixture.test.js +161 -0
- package/dist/src/cli.d.ts +32 -0
- package/dist/src/cli.js +333 -43
- package/dist/src/cli.test.js +257 -1
- package/dist/src/commanderAdapter.js +12 -0
- package/dist/src/commanderAdapter.test.js +11 -0
- package/dist/src/daemon.d.ts +3 -2
- package/dist/src/daemon.js +16 -4
- package/dist/src/daemon.test.d.ts +1 -0
- package/dist/src/daemon.test.js +19 -0
- package/dist/src/download/article-download.d.ts +12 -0
- package/dist/src/download/article-download.js +141 -17
- package/dist/src/download/article-download.test.js +196 -0
- package/dist/src/download/index.js +73 -86
- package/dist/src/errors.js +4 -2
- package/dist/src/errors.test.js +13 -0
- package/dist/src/launcher.d.ts +1 -1
- package/dist/src/launcher.js +3 -3
- package/dist/src/output.js +1 -1
- package/dist/src/output.test.js +6 -0
- 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
|
-
|
|
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/bilibili/video.js
CHANGED
|
@@ -15,7 +15,16 @@ cli({
|
|
|
15
15
|
if (!page) {
|
|
16
16
|
throw new CommandExecutionError('Browser session required for bilibili video');
|
|
17
17
|
}
|
|
18
|
-
|
|
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:
|
|
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
|
});
|
package/clis/chatgpt/image.js
CHANGED
|
@@ -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:
|
|
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'],
|
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) {
|