@jackwener/opencli 1.7.16 → 1.7.17
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 +8 -9
- package/README.zh-CN.md +8 -8
- package/cli-manifest.json +97 -271
- package/clis/chatgpt/ask.js +1 -1
- package/clis/chatgpt/commands.test.js +2 -2
- package/clis/chatgpt/detail.js +1 -1
- package/clis/chatgpt/history.js +1 -1
- package/clis/chatgpt/image.js +38 -4
- package/clis/chatgpt/image.test.js +68 -1
- package/clis/chatgpt/new.js +1 -1
- package/clis/chatgpt/read.js +1 -1
- package/clis/chatgpt/send.js +1 -1
- package/clis/chatgpt/status.js +1 -1
- package/clis/chatgpt/utils.js +208 -16
- package/clis/chatgpt/utils.test.js +131 -2
- package/clis/claude/ask.js +1 -1
- package/clis/claude/detail.js +1 -1
- package/clis/claude/history.js +1 -1
- package/clis/claude/new.js +1 -1
- package/clis/claude/read.js +1 -1
- package/clis/claude/send.js +1 -1
- package/clis/claude/status.js +1 -1
- package/clis/deepseek/ask.js +1 -1
- package/clis/deepseek/detail.js +1 -1
- package/clis/deepseek/history.js +1 -1
- package/clis/deepseek/new.js +1 -1
- package/clis/deepseek/read.js +1 -1
- package/clis/deepseek/send.js +1 -1
- package/clis/deepseek/status.js +1 -1
- package/clis/doubao/ask.js +1 -1
- package/clis/doubao/detail.js +1 -1
- package/clis/doubao/history.js +1 -1
- package/clis/doubao/meeting-summary.js +1 -1
- package/clis/doubao/meeting-transcript.js +1 -1
- package/clis/doubao/new.js +1 -1
- package/clis/doubao/read.js +1 -1
- package/clis/doubao/send.js +1 -1
- package/clis/doubao/status.js +1 -1
- package/clis/gemini/ask.js +1 -1
- package/clis/gemini/deep-research-result.js +1 -1
- package/clis/gemini/deep-research.js +1 -1
- package/clis/gemini/image.js +1 -1
- package/clis/gemini/new.js +1 -1
- package/clis/grok/ask.js +1 -1
- package/clis/grok/detail.js +1 -1
- package/clis/grok/history.js +1 -1
- package/clis/grok/image.js +1 -1
- package/clis/grok/new.js +1 -1
- package/clis/grok/read.js +1 -1
- package/clis/grok/send.js +1 -1
- package/clis/grok/status.js +1 -1
- package/clis/notebooklm/current.js +1 -1
- package/clis/notebooklm/get.js +1 -1
- package/clis/notebooklm/history.js +1 -1
- package/clis/notebooklm/note-list.js +1 -1
- package/clis/notebooklm/notes-get.js +1 -1
- package/clis/notebooklm/open.js +2 -2
- package/clis/notebooklm/open.test.js +1 -1
- package/clis/notebooklm/source-fulltext.js +1 -1
- package/clis/notebooklm/source-get.js +1 -1
- package/clis/notebooklm/source-guide.js +1 -1
- package/clis/notebooklm/source-list.js +1 -1
- package/clis/notebooklm/summary.js +1 -1
- package/clis/qwen/ask.js +1 -1
- package/clis/qwen/detail.js +1 -1
- package/clis/qwen/history.js +1 -1
- package/clis/qwen/image.js +1 -1
- package/clis/qwen/new.js +1 -1
- package/clis/qwen/read.js +1 -1
- package/clis/qwen/send.js +1 -1
- package/clis/qwen/status.js +1 -1
- package/clis/reddit/comment.js +1 -1
- package/clis/reddit/frontpage.js +1 -1
- package/clis/reddit/popular.js +1 -1
- package/clis/reddit/read.js +1 -1
- package/clis/reddit/read.test.js +2 -2
- package/clis/reddit/save.js +1 -1
- package/clis/reddit/saved.js +1 -1
- package/clis/reddit/search.js +1 -1
- package/clis/reddit/subreddit.js +1 -1
- package/clis/reddit/subscribe.js +1 -1
- package/clis/reddit/upvote.js +1 -1
- package/clis/reddit/upvoted.js +1 -1
- package/clis/reddit/user-comments.js +1 -1
- package/clis/reddit/user-posts.js +1 -1
- package/clis/reddit/user.js +1 -1
- package/clis/twitter/article.js +1 -1
- package/clis/twitter/bookmark-folder.js +1 -1
- package/clis/twitter/bookmark-folders.js +1 -1
- package/clis/twitter/bookmarks.js +1 -1
- package/clis/twitter/download.js +1 -1
- package/clis/twitter/followers.js +1 -1
- package/clis/twitter/following.js +1 -1
- package/clis/twitter/likes.js +1 -1
- package/clis/twitter/list-tweets.js +1 -1
- package/clis/twitter/lists.js +1 -1
- package/clis/twitter/notifications.js +1 -1
- package/clis/twitter/profile.js +1 -1
- package/clis/twitter/search.js +1 -1
- package/clis/twitter/thread.js +1 -1
- package/clis/twitter/timeline.js +1 -1
- package/clis/twitter/trending.js +1 -1
- package/clis/twitter/tweets.js +1 -1
- package/clis/yuanbao/ask.js +1 -1
- package/clis/yuanbao/detail.js +1 -1
- package/clis/yuanbao/history.js +1 -1
- package/clis/yuanbao/new.js +1 -1
- package/clis/yuanbao/read.js +1 -1
- package/clis/yuanbao/send.js +1 -1
- package/clis/yuanbao/status.js +1 -1
- package/dist/src/browser/bridge.d.ts +3 -1
- package/dist/src/browser/bridge.js +3 -1
- package/dist/src/browser/cdp.d.ts +3 -1
- package/dist/src/browser/daemon-client.d.ts +7 -14
- package/dist/src/browser/daemon-client.js +2 -6
- package/dist/src/browser/network-cache.d.ts +5 -5
- package/dist/src/browser/network-cache.js +8 -8
- package/dist/src/browser/network-cache.test.js +4 -4
- package/dist/src/browser/page.d.ts +8 -7
- package/dist/src/browser/page.js +23 -16
- package/dist/src/browser/page.test.js +60 -30
- package/dist/src/build-manifest.js +1 -1
- package/dist/src/cli.js +60 -162
- package/dist/src/cli.test.js +178 -197
- package/dist/src/commanderAdapter.js +2 -0
- package/dist/src/discovery.js +1 -1
- package/dist/src/doctor.d.ts +0 -4
- package/dist/src/doctor.js +8 -72
- package/dist/src/doctor.test.js +26 -97
- package/dist/src/execution.d.ts +1 -0
- package/dist/src/execution.js +20 -21
- package/dist/src/execution.test.js +27 -31
- package/dist/src/help.js +7 -1
- package/dist/src/main.js +0 -19
- package/dist/src/manifest-types.d.ts +2 -4
- package/dist/src/observation/artifact.js +1 -1
- package/dist/src/observation/artifact.test.js +3 -3
- package/dist/src/observation/events.d.ts +1 -1
- package/dist/src/observation/manager.js +1 -1
- package/dist/src/observation/manager.test.js +3 -3
- package/dist/src/registry-api.d.ts +1 -1
- package/dist/src/registry.d.ts +3 -12
- package/dist/src/registry.js +6 -10
- package/dist/src/runtime.d.ts +7 -2
- package/dist/src/runtime.js +3 -1
- package/dist/src/serialization.d.ts +1 -1
- package/dist/src/serialization.js +1 -1
- package/dist/src/types.d.ts +0 -15
- package/package.json +1 -1
package/clis/chatgpt/ask.js
CHANGED
|
@@ -22,7 +22,7 @@ export const askCommand = cli({
|
|
|
22
22
|
domain: CHATGPT_DOMAIN,
|
|
23
23
|
strategy: Strategy.COOKIE,
|
|
24
24
|
browser: true,
|
|
25
|
-
|
|
25
|
+
siteSession: 'persistent',
|
|
26
26
|
navigateBefore: false,
|
|
27
27
|
args: [
|
|
28
28
|
{ name: 'prompt', positional: true, required: true, help: 'Prompt to send' },
|
|
@@ -10,7 +10,7 @@ import './status.js';
|
|
|
10
10
|
import './image.js';
|
|
11
11
|
|
|
12
12
|
describe('chatgpt browser command registration', () => {
|
|
13
|
-
it('registers the baseline web chat commands with site
|
|
13
|
+
it('registers the baseline web chat commands with persistent site sessions', () => {
|
|
14
14
|
const expectedAccess = {
|
|
15
15
|
ask: 'write',
|
|
16
16
|
send: 'write',
|
|
@@ -29,7 +29,7 @@ describe('chatgpt browser command registration', () => {
|
|
|
29
29
|
expect(cmd.domain).toBe('chatgpt.com');
|
|
30
30
|
expect(cmd.strategy).toBe('cookie');
|
|
31
31
|
expect(cmd.browser).toBe(true);
|
|
32
|
-
expect(cmd.
|
|
32
|
+
expect(cmd.siteSession).toBe('persistent');
|
|
33
33
|
expect(cmd.navigateBefore).toBe(false);
|
|
34
34
|
expect(cmd.access).toBe(access);
|
|
35
35
|
}
|
package/clis/chatgpt/detail.js
CHANGED
|
@@ -19,7 +19,7 @@ export const detailCommand = cli({
|
|
|
19
19
|
domain: CHATGPT_DOMAIN,
|
|
20
20
|
strategy: Strategy.COOKIE,
|
|
21
21
|
browser: true,
|
|
22
|
-
|
|
22
|
+
siteSession: 'persistent',
|
|
23
23
|
navigateBefore: false,
|
|
24
24
|
args: [
|
|
25
25
|
{ name: 'id', positional: true, required: true, help: 'Conversation ID or full /c/<id> URL' },
|
package/clis/chatgpt/history.js
CHANGED
|
@@ -16,7 +16,7 @@ export const historyCommand = cli({
|
|
|
16
16
|
domain: CHATGPT_DOMAIN,
|
|
17
17
|
strategy: Strategy.COOKIE,
|
|
18
18
|
browser: true,
|
|
19
|
-
|
|
19
|
+
siteSession: 'persistent',
|
|
20
20
|
navigateBefore: false,
|
|
21
21
|
args: [
|
|
22
22
|
{ name: 'limit', type: 'int', default: 20, help: 'Max conversations to show' },
|
package/clis/chatgpt/image.js
CHANGED
|
@@ -4,7 +4,7 @@ import * as fs from 'node:fs';
|
|
|
4
4
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
5
5
|
import { saveBase64ToFile } from '@jackwener/opencli/utils';
|
|
6
6
|
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
7
|
-
import { getChatGPTVisibleImageUrls, normalizeBooleanFlag, sendChatGPTMessage, waitForChatGPTImages, getChatGPTImageAssets } from './utils.js';
|
|
7
|
+
import { clearChatGPTDraft, getChatGPTVisibleImageUrls, normalizeBooleanFlag, prepareChatGPTImagePaths, sendChatGPTMessage, waitForChatGPTImages, getChatGPTImageAssets, uploadChatGPTImages } from './utils.js';
|
|
8
8
|
|
|
9
9
|
const CHATGPT_DOMAIN = 'chatgpt.com';
|
|
10
10
|
|
|
@@ -36,6 +36,23 @@ export function nextAvailablePath(dir, baseName, ext, existsSync = fs.existsSync
|
|
|
36
36
|
return candidate;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
export function parseImagePaths(value) {
|
|
40
|
+
if (Array.isArray(value)) {
|
|
41
|
+
return value.flatMap(item => parseImagePaths(item));
|
|
42
|
+
}
|
|
43
|
+
return String(value ?? '')
|
|
44
|
+
.split(',')
|
|
45
|
+
.map(item => item.trim())
|
|
46
|
+
.filter(Boolean);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function buildPrompt(prompt, imageCount) {
|
|
50
|
+
if (imageCount > 0) {
|
|
51
|
+
return `Edit the attached image${imageCount === 1 ? '' : 's'}: ${prompt}`;
|
|
52
|
+
}
|
|
53
|
+
return `Generate an image of: ${prompt}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
39
56
|
async function currentChatGPTLink(page) {
|
|
40
57
|
const url = await page.evaluate('window.location.href').catch(() => '');
|
|
41
58
|
return typeof url === 'string' && url ? url : 'https://chatgpt.com';
|
|
@@ -49,11 +66,12 @@ export const imageCommand = cli({
|
|
|
49
66
|
domain: CHATGPT_DOMAIN,
|
|
50
67
|
strategy: Strategy.COOKIE,
|
|
51
68
|
browser: true,
|
|
52
|
-
|
|
69
|
+
siteSession: 'persistent',
|
|
53
70
|
navigateBefore: false,
|
|
54
71
|
defaultFormat: 'plain',
|
|
55
72
|
args: [
|
|
56
73
|
{ name: 'prompt', positional: true, required: true, help: 'Image prompt to send to ChatGPT' },
|
|
74
|
+
{ name: 'image', help: 'Local image path to attach before prompting; comma-separated paths are supported' },
|
|
57
75
|
{ name: 'op', help: 'Output directory (default: ~/Pictures/chatgpt)' },
|
|
58
76
|
{ name: 'sd', type: 'boolean', default: false, help: 'Skip download shorthand; only show ChatGPT link' },
|
|
59
77
|
{ name: 'timeout', type: 'int', required: false, default: 240, help: 'Max seconds for the overall command (default: 240)' },
|
|
@@ -61,6 +79,7 @@ export const imageCommand = cli({
|
|
|
61
79
|
columns: ['status', 'file', 'link'],
|
|
62
80
|
func: async (page, kwargs) => {
|
|
63
81
|
const prompt = kwargs.prompt;
|
|
82
|
+
const imagePaths = parseImagePaths(kwargs.image);
|
|
64
83
|
const outputDir = resolveOutputDir(kwargs.op);
|
|
65
84
|
const skipDownloadRaw = kwargs.sd;
|
|
66
85
|
const skipDownload = skipDownloadRaw === '' || skipDownloadRaw === true || normalizeBooleanFlag(skipDownloadRaw);
|
|
@@ -68,14 +87,29 @@ export const imageCommand = cli({
|
|
|
68
87
|
if (!Number.isInteger(timeout) || timeout < 1) {
|
|
69
88
|
throw new ArgumentError('--timeout must be a positive integer (seconds)');
|
|
70
89
|
}
|
|
90
|
+
const preparedImages = imagePaths.length ? await prepareChatGPTImagePaths(imagePaths) : { ok: true, paths: [] };
|
|
91
|
+
if (!preparedImages.ok) {
|
|
92
|
+
throw new ArgumentError(preparedImages.reason);
|
|
93
|
+
}
|
|
71
94
|
|
|
72
95
|
// Navigate to chatgpt.com/new with full reload to clear React sidebar state
|
|
73
96
|
await page.goto(`https://${CHATGPT_DOMAIN}/new`, { settleMs: 2000 });
|
|
97
|
+
await clearChatGPTDraft(page);
|
|
98
|
+
|
|
99
|
+
if (imagePaths.length) {
|
|
100
|
+
let upload;
|
|
101
|
+
try {
|
|
102
|
+
upload = await uploadChatGPTImages(page, preparedImages.paths);
|
|
103
|
+
} catch (err) {
|
|
104
|
+
throw new CommandExecutionError(`Failed to upload image to ChatGPT: ${err instanceof Error ? err.message : String(err)}`);
|
|
105
|
+
}
|
|
106
|
+
if (!upload?.ok) throw new CommandExecutionError(upload?.reason || 'Failed to upload image to ChatGPT');
|
|
107
|
+
}
|
|
74
108
|
|
|
75
109
|
const beforeUrls = await getChatGPTVisibleImageUrls(page);
|
|
76
110
|
|
|
77
|
-
// Send
|
|
78
|
-
const sent = await sendChatGPTMessage(page,
|
|
111
|
+
// Send an explicit generation/editing prompt so ChatGPT returns image assets.
|
|
112
|
+
const sent = await sendChatGPTMessage(page, buildPrompt(prompt, imagePaths.length));
|
|
79
113
|
if (!sent) {
|
|
80
114
|
throw new CommandExecutionError(
|
|
81
115
|
'Failed to send image prompt to ChatGPT',
|
|
@@ -4,13 +4,17 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
4
4
|
|
|
5
5
|
const mocks = vi.hoisted(() => ({
|
|
6
6
|
getChatGPTVisibleImageUrls: vi.fn(),
|
|
7
|
+
clearChatGPTDraft: vi.fn(),
|
|
8
|
+
prepareChatGPTImagePaths: vi.fn(),
|
|
7
9
|
sendChatGPTMessage: vi.fn(),
|
|
10
|
+
uploadChatGPTImages: vi.fn(),
|
|
8
11
|
waitForChatGPTImages: vi.fn(),
|
|
9
12
|
getChatGPTImageAssets: vi.fn(),
|
|
10
13
|
saveBase64ToFile: vi.fn(),
|
|
11
14
|
}));
|
|
12
15
|
|
|
13
16
|
vi.mock('./utils.js', () => ({
|
|
17
|
+
clearChatGPTDraft: mocks.clearChatGPTDraft,
|
|
14
18
|
getChatGPTVisibleImageUrls: mocks.getChatGPTVisibleImageUrls,
|
|
15
19
|
normalizeBooleanFlag: (value, fallback = false) => {
|
|
16
20
|
if (typeof value === 'boolean') return value;
|
|
@@ -18,7 +22,9 @@ vi.mock('./utils.js', () => ({
|
|
|
18
22
|
const normalized = String(value).trim().toLowerCase();
|
|
19
23
|
return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on';
|
|
20
24
|
},
|
|
25
|
+
prepareChatGPTImagePaths: mocks.prepareChatGPTImagePaths,
|
|
21
26
|
sendChatGPTMessage: mocks.sendChatGPTMessage,
|
|
27
|
+
uploadChatGPTImages: mocks.uploadChatGPTImages,
|
|
22
28
|
waitForChatGPTImages: mocks.waitForChatGPTImages,
|
|
23
29
|
getChatGPTImageAssets: mocks.getChatGPTImageAssets,
|
|
24
30
|
}));
|
|
@@ -27,7 +33,7 @@ vi.mock('@jackwener/opencli/utils', () => ({
|
|
|
27
33
|
saveBase64ToFile: mocks.saveBase64ToFile,
|
|
28
34
|
}));
|
|
29
35
|
|
|
30
|
-
const { imageCommand, nextAvailablePath, resolveOutputDir } = await import('./image.js');
|
|
36
|
+
const { imageCommand, nextAvailablePath, parseImagePaths, resolveOutputDir } = await import('./image.js');
|
|
31
37
|
|
|
32
38
|
function createPage() {
|
|
33
39
|
return {
|
|
@@ -39,8 +45,11 @@ function createPage() {
|
|
|
39
45
|
|
|
40
46
|
beforeEach(() => {
|
|
41
47
|
vi.restoreAllMocks();
|
|
48
|
+
mocks.clearChatGPTDraft.mockReset().mockResolvedValue(undefined);
|
|
49
|
+
mocks.prepareChatGPTImagePaths.mockReset().mockImplementation(async (paths) => ({ ok: true, paths }));
|
|
42
50
|
mocks.getChatGPTVisibleImageUrls.mockReset().mockResolvedValue([]);
|
|
43
51
|
mocks.sendChatGPTMessage.mockReset().mockResolvedValue(true);
|
|
52
|
+
mocks.uploadChatGPTImages.mockReset().mockResolvedValue({ ok: true });
|
|
44
53
|
mocks.waitForChatGPTImages.mockReset().mockResolvedValue(['https://images.example/generated.png']);
|
|
45
54
|
mocks.getChatGPTImageAssets.mockReset().mockResolvedValue([{
|
|
46
55
|
url: 'https://images.example/generated.png',
|
|
@@ -66,6 +75,64 @@ describe('chatgpt image output paths', () => {
|
|
|
66
75
|
|
|
67
76
|
expect(nextAvailablePath(dir, 'chatgpt_123', '.png', (file) => taken.has(file))).toBe(path.join(dir, 'chatgpt_123_2.png'));
|
|
68
77
|
});
|
|
78
|
+
|
|
79
|
+
it('parses comma-separated image paths', () => {
|
|
80
|
+
expect(parseImagePaths('/tmp/a.png, /tmp/b.jpg')).toEqual(['/tmp/a.png', '/tmp/b.jpg']);
|
|
81
|
+
expect(parseImagePaths([' /tmp/a.png ', '/tmp/b.jpg,/tmp/c.webp'])).toEqual(['/tmp/a.png', '/tmp/b.jpg', '/tmp/c.webp']);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('chatgpt image upload flow', () => {
|
|
86
|
+
it('uploads local images before sending an edit prompt', async () => {
|
|
87
|
+
mocks.prepareChatGPTImagePaths.mockResolvedValue({ ok: true, paths: ['/abs/cat.png', '/abs/dog.jpg'] });
|
|
88
|
+
await imageCommand.func(createPage(), {
|
|
89
|
+
prompt: 'make the background blue',
|
|
90
|
+
image: '/tmp/cat.png,/tmp/dog.jpg',
|
|
91
|
+
op: '',
|
|
92
|
+
sd: true,
|
|
93
|
+
timeout: 240,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
expect(mocks.clearChatGPTDraft).toHaveBeenCalled();
|
|
97
|
+
expect(mocks.uploadChatGPTImages).toHaveBeenCalledWith(expect.anything(), ['/abs/cat.png', '/abs/dog.jpg']);
|
|
98
|
+
expect(mocks.uploadChatGPTImages.mock.invocationCallOrder[0]).toBeLessThan(
|
|
99
|
+
mocks.getChatGPTVisibleImageUrls.mock.invocationCallOrder[0],
|
|
100
|
+
);
|
|
101
|
+
expect(mocks.sendChatGPTMessage).toHaveBeenCalledWith(expect.anything(), 'Edit the attached images: make the background blue');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('rejects invalid local image paths before browser navigation', async () => {
|
|
105
|
+
mocks.prepareChatGPTImagePaths.mockResolvedValue({ ok: false, reason: 'Image not found: /tmp/missing.png' });
|
|
106
|
+
const page = createPage();
|
|
107
|
+
|
|
108
|
+
await expect(imageCommand.func(page, {
|
|
109
|
+
prompt: 'make the background blue',
|
|
110
|
+
image: '/tmp/missing.png',
|
|
111
|
+
op: '',
|
|
112
|
+
sd: false,
|
|
113
|
+
timeout: 240,
|
|
114
|
+
})).rejects.toMatchObject({
|
|
115
|
+
code: 'ARGUMENT',
|
|
116
|
+
message: expect.stringContaining('Image not found'),
|
|
117
|
+
});
|
|
118
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
119
|
+
expect(mocks.uploadChatGPTImages).not.toHaveBeenCalled();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('surfaces upload failures as command execution errors', async () => {
|
|
123
|
+
mocks.uploadChatGPTImages.mockResolvedValue({ ok: false, reason: 'image upload preview did not appear' });
|
|
124
|
+
|
|
125
|
+
await expect(imageCommand.func(createPage(), {
|
|
126
|
+
prompt: 'make the background blue',
|
|
127
|
+
image: '/tmp/cat.png',
|
|
128
|
+
op: '',
|
|
129
|
+
sd: false,
|
|
130
|
+
timeout: 240,
|
|
131
|
+
})).rejects.toMatchObject({
|
|
132
|
+
code: 'COMMAND_EXEC',
|
|
133
|
+
message: expect.stringContaining('image upload preview did not appear'),
|
|
134
|
+
});
|
|
135
|
+
});
|
|
69
136
|
});
|
|
70
137
|
|
|
71
138
|
describe('chatgpt image failure contracts', () => {
|
package/clis/chatgpt/new.js
CHANGED
package/clis/chatgpt/read.js
CHANGED
|
@@ -17,7 +17,7 @@ export const readCommand = cli({
|
|
|
17
17
|
domain: CHATGPT_DOMAIN,
|
|
18
18
|
strategy: Strategy.COOKIE,
|
|
19
19
|
browser: true,
|
|
20
|
-
|
|
20
|
+
siteSession: 'persistent',
|
|
21
21
|
navigateBefore: false,
|
|
22
22
|
args: [
|
|
23
23
|
{ name: 'markdown', type: 'boolean', default: false, help: 'Emit assistant replies as markdown' },
|
package/clis/chatgpt/send.js
CHANGED
|
@@ -19,7 +19,7 @@ export const sendCommand = cli({
|
|
|
19
19
|
domain: CHATGPT_DOMAIN,
|
|
20
20
|
strategy: Strategy.COOKIE,
|
|
21
21
|
browser: true,
|
|
22
|
-
|
|
22
|
+
siteSession: 'persistent',
|
|
23
23
|
navigateBefore: false,
|
|
24
24
|
args: [
|
|
25
25
|
{ name: 'prompt', positional: true, required: true, help: 'Prompt to send' },
|
package/clis/chatgpt/status.js
CHANGED
package/clis/chatgpt/utils.js
CHANGED
|
@@ -20,6 +20,9 @@ const COMPOSER_SELECTORS = [
|
|
|
20
20
|
'[contenteditable="true"][role="textbox"]',
|
|
21
21
|
];
|
|
22
22
|
const SEND_BUTTON_SELECTOR = 'button[data-testid="send-button"]:not([disabled])';
|
|
23
|
+
const SEND_BUTTON_FALLBACK_SELECTORS = [
|
|
24
|
+
'#composer-submit-button:not([disabled])',
|
|
25
|
+
];
|
|
23
26
|
const SEND_BUTTON_LABELS = [
|
|
24
27
|
'Send prompt',
|
|
25
28
|
'Send message',
|
|
@@ -203,6 +206,40 @@ export async function ensureChatGPTComposer(page, message = 'ChatGPT composer is
|
|
|
203
206
|
return state;
|
|
204
207
|
}
|
|
205
208
|
|
|
209
|
+
export async function clearChatGPTDraft(page) {
|
|
210
|
+
await page.evaluate(`
|
|
211
|
+
(() => {
|
|
212
|
+
const removeLabels = [/^remove file/i, /^移除文件/];
|
|
213
|
+
for (let pass = 0; pass < 10; pass += 1) {
|
|
214
|
+
const button = Array.from(document.querySelectorAll('button')).find((node) => {
|
|
215
|
+
const label = node.getAttribute('aria-label') || '';
|
|
216
|
+
return removeLabels.some((pattern) => pattern.test(label));
|
|
217
|
+
});
|
|
218
|
+
if (!button) break;
|
|
219
|
+
button.click();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const selectors = ${JSON.stringify(COMPOSER_SELECTORS)};
|
|
223
|
+
for (const selector of selectors) {
|
|
224
|
+
for (const node of document.querySelectorAll(selector)) {
|
|
225
|
+
if (!(node instanceof HTMLElement)) continue;
|
|
226
|
+
if (node instanceof HTMLTextAreaElement || node instanceof HTMLInputElement) {
|
|
227
|
+
node.value = '';
|
|
228
|
+
} else if (node.isContentEditable) {
|
|
229
|
+
node.textContent = '';
|
|
230
|
+
node.innerHTML = '<p><br></p>';
|
|
231
|
+
} else {
|
|
232
|
+
node.textContent = '';
|
|
233
|
+
}
|
|
234
|
+
node.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward', data: null }));
|
|
235
|
+
node.dispatchEvent(new Event('change', { bubbles: true }));
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
})()
|
|
239
|
+
`);
|
|
240
|
+
await page.wait(0.5);
|
|
241
|
+
}
|
|
242
|
+
|
|
206
243
|
/**
|
|
207
244
|
* Send a message to the ChatGPT composer and submit it.
|
|
208
245
|
* Returns true if the message was sent successfully.
|
|
@@ -227,7 +264,16 @@ export async function sendChatGPTMessage(page, text) {
|
|
|
227
264
|
const composer = findComposer();
|
|
228
265
|
if (!composer) return false;
|
|
229
266
|
composer.focus();
|
|
230
|
-
composer
|
|
267
|
+
if (composer instanceof HTMLTextAreaElement || composer instanceof HTMLInputElement) {
|
|
268
|
+
composer.value = '';
|
|
269
|
+
} else if (composer.isContentEditable) {
|
|
270
|
+
composer.textContent = '';
|
|
271
|
+
composer.innerHTML = '<p><br></p>';
|
|
272
|
+
} else {
|
|
273
|
+
composer.textContent = '';
|
|
274
|
+
}
|
|
275
|
+
composer.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward', data: null }));
|
|
276
|
+
composer.dispatchEvent(new Event('change', { bubbles: true }));
|
|
231
277
|
return true;
|
|
232
278
|
})()
|
|
233
279
|
`);
|
|
@@ -255,27 +301,35 @@ export async function sendChatGPTMessage(page, text) {
|
|
|
255
301
|
`);
|
|
256
302
|
}
|
|
257
303
|
|
|
258
|
-
|
|
259
|
-
|
|
304
|
+
let sent = null;
|
|
305
|
+
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
306
|
+
await page.wait(0.5);
|
|
307
|
+
sent = await page.evaluate(`
|
|
308
|
+
(() => {
|
|
309
|
+
const isUsable = (button) => button
|
|
310
|
+
&& !button.disabled
|
|
311
|
+
&& button.getAttribute('aria-disabled') !== 'true';
|
|
312
|
+
const primary = document.querySelector(${JSON.stringify(SEND_BUTTON_SELECTOR)})
|
|
313
|
+
|| ${JSON.stringify(SEND_BUTTON_FALLBACK_SELECTORS)}.map(selector => document.querySelector(selector)).find(Boolean);
|
|
314
|
+
const btns = Array.from(document.querySelectorAll('button'));
|
|
315
|
+
const labels = ${JSON.stringify(SEND_BUTTON_LABELS)};
|
|
316
|
+
const sendBtn = isUsable(primary)
|
|
317
|
+
? primary
|
|
318
|
+
: btns.find(b => labels.includes(b.getAttribute('aria-label') || '') && isUsable(b));
|
|
319
|
+
return { sendBtnFound: !!sendBtn };
|
|
320
|
+
})()
|
|
321
|
+
`);
|
|
322
|
+
if (sent?.sendBtnFound) break;
|
|
323
|
+
}
|
|
260
324
|
|
|
261
|
-
|
|
262
|
-
const sent = await page.evaluate(`
|
|
263
|
-
(() => {
|
|
264
|
-
const primary = document.querySelector(${JSON.stringify(SEND_BUTTON_SELECTOR)});
|
|
265
|
-
const btns = Array.from(document.querySelectorAll('button'));
|
|
266
|
-
const labels = ${JSON.stringify(SEND_BUTTON_LABELS)};
|
|
267
|
-
const sendBtn = primary || btns.find(b => labels.includes(b.getAttribute('aria-label') || '') && !b.disabled);
|
|
268
|
-
return { sendBtnFound: !!sendBtn };
|
|
269
|
-
})()
|
|
270
|
-
`);
|
|
271
|
-
|
|
272
|
-
if (!sent || !sent.sendBtnFound) {
|
|
325
|
+
if (!sent?.sendBtnFound) {
|
|
273
326
|
return false;
|
|
274
327
|
}
|
|
275
328
|
|
|
276
329
|
await page.evaluate(`
|
|
277
330
|
(() => {
|
|
278
|
-
const primary = document.querySelector(${JSON.stringify(SEND_BUTTON_SELECTOR)})
|
|
331
|
+
const primary = document.querySelector(${JSON.stringify(SEND_BUTTON_SELECTOR)})
|
|
332
|
+
|| ${JSON.stringify(SEND_BUTTON_FALLBACK_SELECTORS)}.map(selector => document.querySelector(selector)).find(Boolean);
|
|
279
333
|
const labels = ${JSON.stringify(SEND_BUTTON_LABELS)};
|
|
280
334
|
const sendBtn = primary || Array.from(document.querySelectorAll('button')).find(b => labels.includes(b.getAttribute('aria-label') || '') && !b.disabled);
|
|
281
335
|
if (sendBtn) sendBtn.click();
|
|
@@ -462,6 +516,142 @@ async function extractConversationLinks(page) {
|
|
|
462
516
|
: [];
|
|
463
517
|
}
|
|
464
518
|
|
|
519
|
+
function imageMimeFromPath(filePath) {
|
|
520
|
+
const lower = String(filePath || '').toLowerCase();
|
|
521
|
+
if (lower.endsWith('.png')) return 'image/png';
|
|
522
|
+
if (lower.endsWith('.webp')) return 'image/webp';
|
|
523
|
+
if (lower.endsWith('.gif')) return 'image/gif';
|
|
524
|
+
if (lower.endsWith('.heic')) return 'image/heic';
|
|
525
|
+
if (lower.endsWith('.heif')) return 'image/heif';
|
|
526
|
+
return 'image/jpeg';
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
export async function prepareChatGPTImagePaths(imagePaths) {
|
|
530
|
+
const fs = await import('node:fs');
|
|
531
|
+
const path = await import('node:path');
|
|
532
|
+
const absPaths = imagePaths.map(filePath => path.default.resolve(filePath));
|
|
533
|
+
const allowedExts = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif', '.heic', '.heif']);
|
|
534
|
+
|
|
535
|
+
for (const absPath of absPaths) {
|
|
536
|
+
if (!fs.default.existsSync(absPath)) {
|
|
537
|
+
return { ok: false, reason: `Image not found: ${absPath}` };
|
|
538
|
+
}
|
|
539
|
+
const stat = fs.default.statSync(absPath);
|
|
540
|
+
if (!stat.isFile()) {
|
|
541
|
+
return { ok: false, reason: `Not a file: ${absPath}` };
|
|
542
|
+
}
|
|
543
|
+
if (stat.size > 25 * 1024 * 1024) {
|
|
544
|
+
return { ok: false, reason: `Image too large (${(stat.size / 1024 / 1024).toFixed(1)} MB). Max: 25 MB` };
|
|
545
|
+
}
|
|
546
|
+
const ext = path.default.extname(absPath).toLowerCase();
|
|
547
|
+
if (!allowedExts.has(ext)) {
|
|
548
|
+
return { ok: false, reason: `Unsupported image type: ${absPath}` };
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
return { ok: true, paths: absPaths };
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
async function waitForChatGPTUploadPreview(page, fileNames) {
|
|
556
|
+
const namesJson = JSON.stringify(fileNames);
|
|
557
|
+
for (let attempt = 0; attempt < 10; attempt += 1) {
|
|
558
|
+
await page.wait(1);
|
|
559
|
+
const ready = await page.evaluate(`
|
|
560
|
+
(() => {
|
|
561
|
+
const names = ${namesJson};
|
|
562
|
+
const text = document.body ? (document.body.innerText || '') : '';
|
|
563
|
+
const matchedNames = names.filter(name => text.includes(name)).length;
|
|
564
|
+
if (matchedNames >= names.length) return true;
|
|
565
|
+
|
|
566
|
+
const composer = document.querySelector('[aria-label="Chat with ChatGPT"], [placeholder="Ask anything"], #prompt-textarea');
|
|
567
|
+
let root = composer;
|
|
568
|
+
for (let i = 0; i < 6 && root && root.parentElement; i += 1) root = root.parentElement;
|
|
569
|
+
const scope = root || document.body;
|
|
570
|
+
if (!scope) return false;
|
|
571
|
+
|
|
572
|
+
const previewNodes = scope.querySelectorAll('img[src], canvas, video, [style*="background-image"], [data-testid*="attachment"], [data-testid*="upload"], [class*="attachment"], [class*="upload"]');
|
|
573
|
+
return previewNodes.length >= names.length;
|
|
574
|
+
})()
|
|
575
|
+
`);
|
|
576
|
+
if (ready) return true;
|
|
577
|
+
}
|
|
578
|
+
return false;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
export async function uploadChatGPTImages(page, imagePaths) {
|
|
582
|
+
const fs = await import('node:fs');
|
|
583
|
+
const path = await import('node:path');
|
|
584
|
+
const prepared = await prepareChatGPTImagePaths(imagePaths);
|
|
585
|
+
if (!prepared.ok) return prepared;
|
|
586
|
+
const absPaths = prepared.paths;
|
|
587
|
+
|
|
588
|
+
const fileNames = absPaths.map(filePath => path.default.basename(filePath));
|
|
589
|
+
|
|
590
|
+
let uploaded = false;
|
|
591
|
+
if (page.setFileInput) {
|
|
592
|
+
try {
|
|
593
|
+
await page.setFileInput(absPaths, 'input[type="file"]');
|
|
594
|
+
uploaded = true;
|
|
595
|
+
} catch (err) {
|
|
596
|
+
const msg = String(err?.message || err);
|
|
597
|
+
if (!msg.includes('Unknown action') && !msg.includes('not supported') && !msg.includes('Not allowed') && !msg.includes('No element found')) {
|
|
598
|
+
throw err;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if (!uploaded) {
|
|
604
|
+
const files = absPaths.map(absPath => ({
|
|
605
|
+
name: path.default.basename(absPath),
|
|
606
|
+
mime: imageMimeFromPath(absPath),
|
|
607
|
+
base64: fs.default.readFileSync(absPath).toString('base64'),
|
|
608
|
+
}));
|
|
609
|
+
const fallbackResult = await page.evaluate(`
|
|
610
|
+
(() => {
|
|
611
|
+
const files = ${JSON.stringify(files)};
|
|
612
|
+
const input = document.querySelector('input[type="file"]');
|
|
613
|
+
if (!(input instanceof HTMLInputElement)) {
|
|
614
|
+
return { ok: false, reason: 'file input not found' };
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const dt = new DataTransfer();
|
|
618
|
+
for (const item of files) {
|
|
619
|
+
const binary = atob(item.base64);
|
|
620
|
+
const bytes = new Uint8Array(binary.length);
|
|
621
|
+
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
|
622
|
+
dt.items.add(new File([bytes], item.name, { type: item.mime }));
|
|
623
|
+
}
|
|
624
|
+
input.files = dt.files;
|
|
625
|
+
|
|
626
|
+
const propsKey = Object.keys(input).find(key => key.startsWith('__reactProps$'));
|
|
627
|
+
if (propsKey && input[propsKey] && typeof input[propsKey].onChange === 'function') {
|
|
628
|
+
const nativeEvent = new Event('change', { bubbles: true });
|
|
629
|
+
input[propsKey].onChange({
|
|
630
|
+
target: input,
|
|
631
|
+
currentTarget: input,
|
|
632
|
+
nativeEvent,
|
|
633
|
+
preventDefault() {},
|
|
634
|
+
stopPropagation() {},
|
|
635
|
+
isDefaultPrevented() { return false; },
|
|
636
|
+
isPropagationStopped() { return false; },
|
|
637
|
+
persist() {},
|
|
638
|
+
});
|
|
639
|
+
} else {
|
|
640
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
641
|
+
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
642
|
+
}
|
|
643
|
+
return { ok: true };
|
|
644
|
+
})()
|
|
645
|
+
`);
|
|
646
|
+
if (fallbackResult && !fallbackResult.ok) return fallbackResult;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const ready = await waitForChatGPTUploadPreview(page, fileNames);
|
|
650
|
+
if (!ready) return { ok: false, reason: 'image upload preview did not appear' };
|
|
651
|
+
|
|
652
|
+
return { ok: true, files: absPaths };
|
|
653
|
+
}
|
|
654
|
+
|
|
465
655
|
/**
|
|
466
656
|
* Check if ChatGPT is still generating a response.
|
|
467
657
|
*/
|
|
@@ -573,10 +763,12 @@ export async function waitForChatGPTImages(page, beforeUrls, timeoutSeconds, con
|
|
|
573
763
|
export const __test__ = {
|
|
574
764
|
COMPOSER_SELECTORS,
|
|
575
765
|
SEND_BUTTON_SELECTOR,
|
|
766
|
+
SEND_BUTTON_FALLBACK_SELECTORS,
|
|
576
767
|
SEND_BUTTON_LABELS,
|
|
577
768
|
CLOSE_SIDEBAR_LABELS,
|
|
578
769
|
isSameChatGPTConversation,
|
|
579
770
|
parseChatGPTConversationId,
|
|
771
|
+
imageMimeFromPath,
|
|
580
772
|
};
|
|
581
773
|
|
|
582
774
|
/**
|