@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.
- package/README.md +49 -14
- package/README.zh-CN.md +30 -10
- package/cli-manifest.json +782 -55
- package/clis/36kr/news.js +1 -1
- package/clis/amazon/discussion.js +37 -6
- package/clis/amazon/discussion.test.js +147 -32
- package/clis/apple-podcasts/commands.test.js +4 -4
- package/clis/apple-podcasts/episodes.js +1 -1
- package/clis/apple-podcasts/search.js +1 -1
- package/clis/apple-podcasts/top.js +1 -1
- package/clis/arxiv/paper.js +1 -1
- package/clis/arxiv/search.js +1 -1
- package/clis/band/mentions.js +3 -3
- package/clis/bbc/news.js +1 -1
- package/clis/bilibili/subtitle.js +2 -2
- package/clis/bloomberg/businessweek.js +1 -1
- package/clis/bloomberg/economics.js +1 -1
- package/clis/bloomberg/industries.js +1 -1
- package/clis/bloomberg/main.js +1 -1
- package/clis/bloomberg/markets.js +1 -1
- package/clis/bloomberg/opinions.js +1 -1
- package/clis/bloomberg/politics.js +1 -1
- package/clis/bloomberg/tech.js +1 -1
- package/clis/boss/search.js +49 -8
- package/clis/boss/search.test.js +78 -0
- package/clis/boss/send.js +3 -3
- package/clis/chatgpt/image.js +37 -8
- package/clis/chatgpt/image.test.js +92 -0
- package/clis/chatgpt/utils.js +39 -6
- package/clis/chatgpt/utils.test.js +63 -0
- package/clis/chatgpt-app/ask.js +4 -20
- package/clis/chatgpt-app/ax.js +135 -2
- package/clis/chatgpt-app/ax.test.js +35 -0
- package/clis/chatgpt-app/model.js +1 -1
- package/clis/chatgpt-app/new.js +1 -1
- package/clis/chatgpt-app/read.js +1 -1
- package/clis/chatgpt-app/send.js +3 -22
- package/clis/chatgpt-app/status.js +1 -1
- package/clis/chatwise/ask.js +2 -2
- package/clis/chatwise/model.js +2 -2
- package/clis/chatwise/send.js +2 -2
- package/clis/claude/ask.js +128 -0
- package/clis/claude/ask.test.js +338 -0
- package/clis/claude/commands.test.js +118 -0
- package/clis/claude/detail.js +29 -0
- package/clis/claude/history.js +31 -0
- package/clis/claude/new.js +21 -0
- package/clis/claude/read.js +24 -0
- package/clis/claude/send.js +41 -0
- package/clis/claude/status.js +24 -0
- package/clis/claude/utils.js +440 -0
- package/clis/claude/utils.test.js +148 -0
- package/clis/codex/ask.js +2 -2
- package/clis/codex/send.js +2 -2
- package/clis/ctrip/search.js +1 -1
- package/clis/ctrip/search.test.js +4 -4
- package/clis/cursor/ask.js +2 -2
- package/clis/cursor/composer.js +2 -2
- package/clis/cursor/send.js +2 -2
- package/clis/deepseek/ask.js +49 -10
- package/clis/deepseek/ask.test.js +150 -3
- package/clis/deepseek/utils.js +60 -22
- package/clis/deepseek/utils.test.js +124 -5
- package/clis/doubao/utils.js +53 -11
- package/clis/doubao/utils.test.js +22 -2
- package/clis/eastmoney/announcement.js +1 -1
- package/clis/eastmoney/convertible.js +1 -1
- package/clis/eastmoney/etf.js +1 -1
- package/clis/eastmoney/holders.js +1 -1
- package/clis/eastmoney/index-board.js +1 -1
- package/clis/eastmoney/kline.js +1 -1
- package/clis/eastmoney/kuaixun.js +1 -1
- package/clis/eastmoney/longhu.js +1 -1
- package/clis/eastmoney/money-flow.js +1 -1
- package/clis/eastmoney/northbound.js +1 -1
- package/clis/eastmoney/quote.js +1 -1
- package/clis/eastmoney/rank.js +1 -1
- package/clis/eastmoney/sectors.js +1 -1
- package/clis/facebook/marketplace-inbox.js +83 -0
- package/clis/facebook/marketplace-listings.js +83 -0
- package/clis/facebook/marketplace.test.js +91 -0
- package/clis/google/news.js +1 -1
- package/clis/google/suggest.js +1 -1
- package/clis/google/trends.js +1 -1
- package/clis/google-scholar/cite.js +74 -0
- package/clis/google-scholar/cite.test.js +47 -0
- package/clis/google-scholar/profile.js +92 -0
- package/clis/google-scholar/profile.test.js +49 -0
- package/clis/google-scholar/search.js +1 -1
- package/clis/google-scholar/search.test.js +15 -0
- package/clis/hf/top.js +1 -1
- package/clis/jd/item.js +679 -47
- package/clis/jd/item.test.js +318 -7
- package/clis/jd/item.test.ts +517 -0
- package/clis/lesswrong/comments.js +1 -1
- package/clis/lesswrong/curated.js +1 -1
- package/clis/lesswrong/frontpage.js +1 -1
- package/clis/lesswrong/new.js +1 -1
- package/clis/lesswrong/read.js +1 -1
- package/clis/lesswrong/sequences.js +1 -1
- package/clis/lesswrong/shortform.js +1 -1
- package/clis/lesswrong/tag.js +1 -1
- package/clis/lesswrong/tags.js +1 -1
- package/clis/lesswrong/top-month.js +1 -1
- package/clis/lesswrong/top-week.js +1 -1
- package/clis/lesswrong/top-year.js +1 -1
- package/clis/lesswrong/top.js +1 -1
- package/clis/lesswrong/user-posts.js +1 -1
- package/clis/lesswrong/user.js +1 -1
- package/clis/paperreview/commands.test.js +6 -6
- package/clis/paperreview/feedback.js +1 -1
- package/clis/paperreview/review.js +1 -1
- package/clis/paperreview/submit.js +1 -1
- package/clis/powerchina/search.js +250 -0
- package/clis/powerchina/search.test.js +67 -0
- package/clis/producthunt/posts.js +1 -1
- package/clis/producthunt/today.js +1 -1
- package/clis/sinablog/search.js +1 -1
- package/clis/sinafinance/news.js +1 -1
- package/clis/sinafinance/stock.js +6 -3
- package/clis/sinafinance/stock.test.js +59 -0
- package/clis/spotify/spotify.js +6 -6
- package/clis/substack/search.js +1 -1
- package/clis/toutiao/articles.js +80 -0
- package/clis/toutiao/articles.test.js +30 -0
- package/clis/twitter/followers.js +2 -2
- package/clis/twitter/following.js +224 -73
- package/clis/twitter/following.test.js +277 -0
- package/clis/twitter/post.js +184 -47
- package/clis/twitter/post.test.js +114 -34
- package/clis/uiverse/_shared.js +63 -4
- package/clis/uiverse/_shared.test.js +7 -0
- package/clis/uiverse/code.js +1 -0
- package/clis/uiverse/navigation.test.js +12 -0
- package/clis/uiverse/preview.js +1 -0
- package/clis/web/read.js +319 -81
- package/clis/web/read.test.js +221 -5
- package/clis/weibo/favorites.js +169 -0
- package/clis/weibo/favorites.test.js +114 -0
- package/clis/weibo/publish.js +282 -0
- package/clis/weibo/publish.test.js +183 -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/ranking.js +1 -1
- package/clis/weread/search-regression.test.js +8 -8
- package/clis/weread/search.js +1 -1
- package/clis/wikipedia/random.js +1 -1
- package/clis/wikipedia/search.js +1 -1
- package/clis/wikipedia/summary.js +1 -1
- package/clis/wikipedia/trending.js +1 -1
- package/clis/xianyu/chat.js +3 -3
- package/clis/xianyu/item.js +2 -2
- package/clis/xianyu/item.test.js +3 -3
- package/clis/xiaohongshu/search.js +17 -2
- package/clis/xiaohongshu/search.test.js +37 -1
- package/clis/xiaoyuzhou/download.js +1 -1
- package/clis/xiaoyuzhou/download.test.js +3 -3
- package/clis/xiaoyuzhou/episode.js +1 -1
- package/clis/xiaoyuzhou/podcast-episodes.js +1 -1
- package/clis/xiaoyuzhou/podcast-episodes.test.js +2 -2
- package/clis/xiaoyuzhou/podcast.js +1 -1
- package/clis/xiaoyuzhou/transcript.js +1 -1
- package/clis/xiaoyuzhou/transcript.test.js +5 -5
- package/clis/yollomi/models.js +1 -1
- package/clis/youtube/channel.js +24 -1
- package/clis/youtube/channel.test.js +59 -0
- package/clis/zhihu/answer.js +21 -162
- package/clis/zhihu/answer.test.js +26 -53
- package/clis/zhihu/collection.js +197 -0
- package/clis/zhihu/collection.test.js +290 -0
- package/clis/zhihu/collections.js +127 -0
- package/clis/zhihu/collections.test.js +182 -0
- package/clis/zhihu/comment.js +24 -305
- package/clis/zhihu/comment.test.js +31 -35
- package/clis/zhihu/favorite.js +44 -182
- package/clis/zhihu/favorite.test.js +30 -167
- package/clis/zhihu/follow.js +25 -56
- package/clis/zhihu/follow.test.js +20 -23
- package/clis/zhihu/like.js +22 -67
- package/clis/zhihu/like.test.js +19 -42
- package/clis/zhihu/search.js +3 -2
- package/clis/zhihu/write-shared.js +8 -1
- package/clis/zhihu/write-shared.test.js +1 -0
- package/clis/zlibrary/commands.test.js +75 -0
- package/clis/zlibrary/info.js +47 -0
- package/clis/zlibrary/search.js +46 -0
- package/clis/zlibrary/utils.js +136 -0
- package/dist/src/adapter-source.d.ts +11 -0
- package/dist/src/adapter-source.js +24 -0
- package/dist/src/adapter-source.test.js +29 -0
- package/dist/src/browser/base-page.d.ts +3 -1
- package/dist/src/browser/base-page.js +76 -1
- package/dist/src/browser/base-page.test.d.ts +1 -0
- package/dist/src/browser/base-page.test.js +74 -0
- package/dist/src/browser/bridge.d.ts +1 -0
- package/dist/src/browser/bridge.js +36 -9
- package/dist/src/browser/cdp.d.ts +1 -0
- package/dist/src/browser/cdp.js +3 -3
- package/dist/src/browser/daemon-client.d.ts +38 -4
- package/dist/src/browser/daemon-client.js +24 -7
- package/dist/src/browser/daemon-client.test.js +49 -0
- package/dist/src/browser/errors.js +3 -0
- package/dist/src/browser/errors.test.js +3 -0
- package/dist/src/browser/network-cache.d.ts +1 -0
- package/dist/src/browser/page.d.ts +3 -1
- package/dist/src/browser/page.js +10 -2
- package/dist/src/browser/profile.d.ts +14 -0
- package/dist/src/browser/profile.js +85 -0
- package/dist/src/build-manifest.d.ts +2 -0
- package/dist/src/build-manifest.js +13 -3
- package/dist/src/build-manifest.test.js +20 -2
- package/dist/src/cli.d.ts +6 -0
- package/dist/src/cli.js +462 -32
- package/dist/src/cli.test.js +209 -2
- package/dist/src/commanderAdapter.js +29 -9
- package/dist/src/commanderAdapter.test.js +78 -2
- package/dist/src/commands/daemon.js +6 -0
- package/dist/src/completion-shared.js +1 -2
- package/dist/src/completion.test.js +3 -2
- package/dist/src/daemon.js +125 -41
- package/dist/src/doctor.d.ts +4 -6
- package/dist/src/doctor.js +80 -22
- package/dist/src/doctor.test.js +82 -0
- package/dist/src/engine.test.js +6 -5
- package/dist/src/errors.d.ts +14 -8
- package/dist/src/errors.js +36 -30
- package/dist/src/errors.test.js +5 -5
- package/dist/src/execution.d.ts +4 -0
- package/dist/src/execution.js +173 -25
- package/dist/src/execution.test.js +171 -1
- package/dist/src/main.js +10 -0
- package/dist/src/observation/artifact.d.ts +16 -0
- package/dist/src/observation/artifact.js +260 -0
- package/dist/src/observation/artifact.test.d.ts +1 -0
- package/dist/src/observation/artifact.test.js +121 -0
- package/dist/src/observation/events.d.ts +89 -0
- package/dist/src/observation/events.js +1 -0
- package/dist/src/observation/index.d.ts +7 -0
- package/dist/src/observation/index.js +7 -0
- package/dist/src/observation/manager.d.ts +9 -0
- package/dist/src/observation/manager.js +27 -0
- package/dist/src/observation/manager.test.d.ts +1 -0
- package/dist/src/observation/manager.test.js +13 -0
- package/dist/src/observation/redaction.d.ts +11 -0
- package/dist/src/observation/redaction.js +81 -0
- package/dist/src/observation/redaction.test.d.ts +1 -0
- package/dist/src/observation/redaction.test.js +32 -0
- package/dist/src/observation/retention.d.ts +32 -0
- package/dist/src/observation/retention.js +160 -0
- package/dist/src/observation/retention.test.d.ts +1 -0
- package/dist/src/observation/retention.test.js +118 -0
- package/dist/src/observation/ring-buffer.d.ts +22 -0
- package/dist/src/observation/ring-buffer.js +45 -0
- package/dist/src/observation/ring-buffer.test.d.ts +1 -0
- package/dist/src/observation/ring-buffer.test.js +22 -0
- package/dist/src/observation/session.d.ts +25 -0
- package/dist/src/observation/session.js +50 -0
- package/dist/src/pipeline/executor.test.js +1 -0
- package/dist/src/pipeline/steps/download.test.js +1 -0
- package/dist/src/pipeline/steps/fetch.js +1 -21
- package/dist/src/pipeline/steps/fetch.test.js +6 -12
- package/dist/src/plugin-scaffold.js +1 -1
- package/dist/src/plugin-scaffold.test.js +1 -1
- package/dist/src/registry.d.ts +40 -9
- package/dist/src/registry.js +3 -1
- package/dist/src/runtime-detect.d.ts +10 -0
- package/dist/src/runtime-detect.js +19 -0
- package/dist/src/runtime-detect.test.js +12 -1
- package/dist/src/runtime.d.ts +2 -0
- package/dist/src/runtime.js +1 -0
- package/dist/src/types.d.ts +22 -0
- package/dist/src/update-check.d.ts +31 -1
- package/dist/src/update-check.js +62 -16
- package/dist/src/update-check.test.js +86 -1
- package/package.json +1 -1
- package/dist/src/diagnostic.d.ts +0 -63
- package/dist/src/diagnostic.js +0 -292
- package/dist/src/diagnostic.test.js +0 -302
- /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,
|
|
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
|
|
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
|
|
38
|
+
throw selectorError('消息输入框', '聊天页面 UI 可能已改变');
|
|
39
39
|
}
|
|
40
40
|
await page.wait({ time: 1 });
|
|
41
41
|
return [{ status: '✅ 发送成功', detail: `已向 ${friendName} 发送: ${kwargs.text}` }];
|
package/clis/chatgpt/image.js
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
+
});
|
package/clis/chatgpt/utils.js
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|
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',
|
|
@@ -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 (
|
|
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
|
-
|
|
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
|
|
@@ -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
|
|
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
|
-
|
|
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
|
+
};
|