@jackwener/opencli 1.7.15 → 1.7.16
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 +9 -6
- package/README.zh-CN.md +9 -6
- package/cli-manifest.json +161 -31
- package/clis/chatgpt/ask.js +2 -1
- package/clis/chatgpt/detail.js +6 -1
- package/clis/chatgpt/read.js +2 -1
- package/clis/chatgpt/send.js +2 -1
- package/clis/chatgpt/utils.js +54 -12
- package/clis/chatgpt/utils.test.js +36 -1
- package/clis/claude/ask.js +22 -7
- package/clis/claude/detail.js +9 -2
- package/clis/claude/new.js +8 -2
- package/clis/claude/read.js +2 -1
- package/clis/claude/send.js +8 -3
- package/clis/claude/utils.js +27 -4
- package/clis/deepseek/ask.js +21 -8
- package/clis/deepseek/detail.js +9 -1
- package/clis/deepseek/new.js +13 -2
- package/clis/deepseek/read.js +2 -1
- package/clis/deepseek/utils.js +8 -1
- package/clis/linkedin/search.js +8 -11
- package/clis/maimai/search-talents.js +10 -6
- package/clis/openreview/author.js +58 -0
- package/clis/openreview/openreview.test.js +83 -1
- package/clis/openreview/utils.js +14 -0
- package/clis/reddit/comment.js +1 -0
- package/clis/reddit/frontpage.js +1 -0
- package/clis/reddit/popular.js +1 -0
- package/clis/reddit/read.js +2 -0
- package/clis/reddit/read.test.js +4 -0
- package/clis/reddit/save.js +1 -0
- package/clis/reddit/saved.js +1 -0
- package/clis/reddit/search.js +1 -0
- package/clis/reddit/subreddit.js +1 -0
- package/clis/reddit/subscribe.js +1 -0
- package/clis/reddit/upvote.js +1 -0
- package/clis/reddit/upvoted.js +1 -0
- package/clis/reddit/user-comments.js +1 -0
- package/clis/reddit/user-posts.js +1 -0
- package/clis/reddit/user.js +1 -0
- package/clis/twitter/article.js +7 -4
- package/clis/twitter/bookmark-folder.js +3 -5
- package/clis/twitter/bookmark-folder.test.js +5 -2
- package/clis/twitter/bookmark-folders.js +3 -5
- package/clis/twitter/bookmark-folders.test.js +3 -1
- package/clis/twitter/bookmarks.js +3 -5
- package/clis/twitter/download.js +1 -0
- package/clis/twitter/followers.js +1 -0
- package/clis/twitter/following.js +3 -6
- package/clis/twitter/following.test.js +2 -1
- package/clis/twitter/likes.js +3 -5
- package/clis/twitter/list-add.js +4 -3
- package/clis/twitter/list-add.test.js +23 -1
- package/clis/twitter/list-remove.js +4 -3
- package/clis/twitter/list-remove.test.js +23 -1
- package/clis/twitter/list-tweets.js +3 -5
- package/clis/twitter/lists.js +3 -5
- package/clis/twitter/notifications.js +1 -0
- package/clis/twitter/profile.js +7 -4
- package/clis/twitter/search.js +1 -0
- package/clis/twitter/thread.js +5 -7
- package/clis/twitter/timeline.js +5 -7
- package/clis/twitter/trending.js +4 -4
- package/clis/twitter/tweets.js +3 -6
- package/clis/youtube/like.js +6 -2
- package/clis/youtube/subscribe.js +6 -2
- package/clis/youtube/unlike.js +6 -2
- package/clis/youtube/unsubscribe.js +6 -2
- package/clis/youtube/utils.js +19 -13
- package/clis/youtube/utils.test.js +17 -1
- package/dist/src/browser/bridge.d.ts +1 -0
- package/dist/src/browser/bridge.js +1 -1
- package/dist/src/browser/cdp.d.ts +1 -0
- package/dist/src/browser/daemon-client.d.ts +2 -2
- package/dist/src/browser/daemon-client.js +6 -3
- package/dist/src/browser/daemon-client.test.js +10 -0
- package/dist/src/browser/page.d.ts +2 -1
- package/dist/src/browser/page.js +5 -1
- package/dist/src/cli.js +70 -2
- package/dist/src/cli.test.js +139 -7
- package/dist/src/commanderAdapter.js +7 -0
- package/dist/src/doctor.js +2 -2
- package/dist/src/doctor.test.js +4 -4
- package/dist/src/execution.d.ts +2 -0
- package/dist/src/execution.js +31 -6
- package/dist/src/execution.test.js +43 -16
- package/dist/src/external-clis.yaml +24 -0
- package/dist/src/help.d.ts +1 -0
- package/dist/src/help.js +29 -0
- package/dist/src/main.js +4 -14
- package/dist/src/runtime.d.ts +3 -0
- package/dist/src/runtime.js +1 -0
- package/dist/src/types.d.ts +1 -1
- package/package.json +1 -1
package/clis/chatgpt/utils.js
CHANGED
|
@@ -12,15 +12,23 @@ export const CHATGPT_URL = 'https://chatgpt.com';
|
|
|
12
12
|
// Selectors
|
|
13
13
|
const COMPOSER_SELECTORS = [
|
|
14
14
|
'[aria-label="Chat with ChatGPT"]',
|
|
15
|
+
'[aria-label="与 ChatGPT 聊天"]',
|
|
15
16
|
'[placeholder="Ask anything"]',
|
|
17
|
+
'[placeholder="有问题,尽管问"]',
|
|
16
18
|
'#prompt-textarea',
|
|
17
19
|
'[data-testid="prompt-textarea"]',
|
|
18
20
|
'[contenteditable="true"][role="textbox"]',
|
|
19
21
|
];
|
|
22
|
+
const SEND_BUTTON_SELECTOR = 'button[data-testid="send-button"]:not([disabled])';
|
|
20
23
|
const SEND_BUTTON_LABELS = [
|
|
21
24
|
'Send prompt',
|
|
22
25
|
'Send message',
|
|
23
26
|
'Send',
|
|
27
|
+
'发送提示',
|
|
28
|
+
];
|
|
29
|
+
const CLOSE_SIDEBAR_LABELS = [
|
|
30
|
+
'Close sidebar',
|
|
31
|
+
'关闭边栏',
|
|
24
32
|
];
|
|
25
33
|
|
|
26
34
|
function isSameChatGPTConversation(currentUrl, expectedUrl) {
|
|
@@ -119,16 +127,34 @@ export async function isOnChatGPT(page) {
|
|
|
119
127
|
}
|
|
120
128
|
}
|
|
121
129
|
|
|
130
|
+
// Comma-joined CSS selector list passed to page.wait({ selector }) so the
|
|
131
|
+
// wait succeeds as soon as any composer flavour mounts (querySelectorAll
|
|
132
|
+
// matches all of them). Tracks the most stable subset of COMPOSER_SELECTORS;
|
|
133
|
+
// we only need to know "the composer is ready", not which variant rendered.
|
|
134
|
+
const COMPOSER_WAIT_SELECTOR = '#prompt-textarea, [data-testid="prompt-textarea"]';
|
|
135
|
+
const CONVERSATION_LINK_SELECTOR = 'a[href*="/c/"]';
|
|
136
|
+
// Selector used by detail.js to wait for at least one rendered message bubble
|
|
137
|
+
// after navigating to /c/<id>; mirrors the markup queried by getVisibleMessages.
|
|
138
|
+
export const CONVERSATION_MESSAGE_SELECTOR = '[data-message-author-role], article[data-testid*="conversation-turn"]';
|
|
139
|
+
|
|
122
140
|
export async function ensureOnChatGPT(page) {
|
|
123
141
|
if (await isOnChatGPT(page)) return false;
|
|
124
142
|
await page.goto(CHATGPT_URL, { settleMs: 2000 });
|
|
125
|
-
|
|
143
|
+
try {
|
|
144
|
+
await page.wait({ selector: COMPOSER_WAIT_SELECTOR, timeout: 8 });
|
|
145
|
+
} catch {
|
|
146
|
+
// Composer didn't mount; downstream ensureChatGPTLogin / ensureChatGPTComposer surfaces a typed error.
|
|
147
|
+
}
|
|
126
148
|
return true;
|
|
127
149
|
}
|
|
128
150
|
|
|
129
151
|
export async function startNewChat(page) {
|
|
130
152
|
await page.goto(`${CHATGPT_URL}/new`, { settleMs: 2000 });
|
|
131
|
-
|
|
153
|
+
try {
|
|
154
|
+
await page.wait({ selector: COMPOSER_WAIT_SELECTOR, timeout: 8 });
|
|
155
|
+
} catch {
|
|
156
|
+
// Composer didn't mount; downstream ensureChatGPTComposer surfaces a typed error.
|
|
157
|
+
}
|
|
132
158
|
}
|
|
133
159
|
|
|
134
160
|
export async function getPageState(page) {
|
|
@@ -185,15 +211,16 @@ export async function sendChatGPTMessage(page, text) {
|
|
|
185
211
|
// Close sidebar if open (it can cover the chat composer)
|
|
186
212
|
await page.evaluate(`
|
|
187
213
|
(() => {
|
|
188
|
-
const
|
|
214
|
+
const labels = ${JSON.stringify(CLOSE_SIDEBAR_LABELS)};
|
|
215
|
+
const closeBtn = Array.from(document.querySelectorAll('button')).find(b => labels.includes(b.getAttribute('aria-label') || ''));
|
|
189
216
|
if (closeBtn) closeBtn.click();
|
|
190
217
|
})()
|
|
191
218
|
`);
|
|
192
|
-
|
|
219
|
+
// The previous 0.5 s + 1.5 s pre-composer settles are dropped: the next
|
|
220
|
+
// page.evaluate roundtrip flushes the close-sidebar React update and
|
|
221
|
+
// findComposer() retries inside a single CDP call, so no fixed sleep is
|
|
222
|
+
// needed before reading the composer.
|
|
193
223
|
|
|
194
|
-
// Wait for composer to be ready and use Playwright's type()
|
|
195
|
-
await page.wait(1.5);
|
|
196
|
-
|
|
197
224
|
const typeResult = await page.evaluate(`
|
|
198
225
|
(() => {
|
|
199
226
|
${buildComposerLocatorScript()}
|
|
@@ -234,9 +261,10 @@ export async function sendChatGPTMessage(page, text) {
|
|
|
234
261
|
// Click send button
|
|
235
262
|
const sent = await page.evaluate(`
|
|
236
263
|
(() => {
|
|
264
|
+
const primary = document.querySelector(${JSON.stringify(SEND_BUTTON_SELECTOR)});
|
|
237
265
|
const btns = Array.from(document.querySelectorAll('button'));
|
|
238
266
|
const labels = ${JSON.stringify(SEND_BUTTON_LABELS)};
|
|
239
|
-
const sendBtn = btns.find(b => labels.includes(b.getAttribute('aria-label') || '') && !b.disabled);
|
|
267
|
+
const sendBtn = primary || btns.find(b => labels.includes(b.getAttribute('aria-label') || '') && !b.disabled);
|
|
240
268
|
return { sendBtnFound: !!sendBtn };
|
|
241
269
|
})()
|
|
242
270
|
`);
|
|
@@ -247,8 +275,9 @@ export async function sendChatGPTMessage(page, text) {
|
|
|
247
275
|
|
|
248
276
|
await page.evaluate(`
|
|
249
277
|
(() => {
|
|
278
|
+
const primary = document.querySelector(${JSON.stringify(SEND_BUTTON_SELECTOR)});
|
|
250
279
|
const labels = ${JSON.stringify(SEND_BUTTON_LABELS)};
|
|
251
|
-
const sendBtn = Array.from(document.querySelectorAll('button')).find(b => labels.includes(b.getAttribute('aria-label') || '') && !b.disabled);
|
|
280
|
+
const sendBtn = primary || Array.from(document.querySelectorAll('button')).find(b => labels.includes(b.getAttribute('aria-label') || '') && !b.disabled);
|
|
252
281
|
if (sendBtn) sendBtn.click();
|
|
253
282
|
})()
|
|
254
283
|
`);
|
|
@@ -361,8 +390,9 @@ export async function waitForChatGPTResponse(page, baselineCount, prompt, timeou
|
|
|
361
390
|
}
|
|
362
391
|
|
|
363
392
|
export async function getConversationList(page) {
|
|
393
|
+
// ensureOnChatGPT already waits for the composer selector after navigation,
|
|
394
|
+
// so the previous standalone 2 s settle is redundant.
|
|
364
395
|
await ensureOnChatGPT(page);
|
|
365
|
-
await page.wait(2);
|
|
366
396
|
|
|
367
397
|
const openSidebar = await page.evaluate(`(() => {
|
|
368
398
|
const button = Array.from(document.querySelectorAll('button'))
|
|
@@ -373,12 +403,22 @@ export async function getConversationList(page) {
|
|
|
373
403
|
}
|
|
374
404
|
return false;
|
|
375
405
|
})()`);
|
|
376
|
-
if (openSidebar)
|
|
406
|
+
if (openSidebar) {
|
|
407
|
+
try {
|
|
408
|
+
await page.wait({ selector: CONVERSATION_LINK_SELECTOR, timeout: 3 });
|
|
409
|
+
} catch {
|
|
410
|
+
// Sidebar slide-in didn't surface conversation links; extractConversationLinks below tolerates empty and falls back to home goto.
|
|
411
|
+
}
|
|
412
|
+
}
|
|
377
413
|
|
|
378
414
|
let items = await extractConversationLinks(page);
|
|
379
415
|
if (!items.length) {
|
|
380
416
|
await page.goto(CHATGPT_URL, { settleMs: 2000 });
|
|
381
|
-
|
|
417
|
+
try {
|
|
418
|
+
await page.wait({ selector: CONVERSATION_LINK_SELECTOR, timeout: 8 });
|
|
419
|
+
} catch {
|
|
420
|
+
// No conversation links visible after fallback goto; extractConversationLinks returns empty.
|
|
421
|
+
}
|
|
382
422
|
items = await extractConversationLinks(page);
|
|
383
423
|
}
|
|
384
424
|
|
|
@@ -532,7 +572,9 @@ export async function waitForChatGPTImages(page, beforeUrls, timeoutSeconds, con
|
|
|
532
572
|
|
|
533
573
|
export const __test__ = {
|
|
534
574
|
COMPOSER_SELECTORS,
|
|
575
|
+
SEND_BUTTON_SELECTOR,
|
|
535
576
|
SEND_BUTTON_LABELS,
|
|
577
|
+
CLOSE_SIDEBAR_LABELS,
|
|
536
578
|
isSameChatGPTConversation,
|
|
537
579
|
parseChatGPTConversationId,
|
|
538
580
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
-
import { __test__, waitForChatGPTImages } from './utils.js';
|
|
2
|
+
import { __test__, sendChatGPTMessage, waitForChatGPTImages } from './utils.js';
|
|
3
3
|
|
|
4
4
|
function createPageMock({ location = '', generating = [], imageUrls = [] } = {}) {
|
|
5
5
|
let generatingIndex = 0;
|
|
@@ -74,3 +74,38 @@ describe('chatgpt conversation id parsing', () => {
|
|
|
74
74
|
expect(() => __test__.parseChatGPTConversationId('https://chatgpt.com/')).toThrow(/conversation id/);
|
|
75
75
|
});
|
|
76
76
|
});
|
|
77
|
+
|
|
78
|
+
describe('chatgpt send selectors', () => {
|
|
79
|
+
it('keeps locale-independent send-button selector before aria-label fallbacks', async () => {
|
|
80
|
+
const page = {
|
|
81
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
82
|
+
nativeType: vi.fn().mockResolvedValue(undefined),
|
|
83
|
+
evaluate: vi.fn((script) => {
|
|
84
|
+
if (script.includes('findComposer')) return Promise.resolve(true);
|
|
85
|
+
if (script.includes('sendBtnFound')) {
|
|
86
|
+
expect(script).toContain('data-testid=\\\"send-button\\\"');
|
|
87
|
+
return Promise.resolve({ sendBtnFound: true });
|
|
88
|
+
}
|
|
89
|
+
if (script.includes('if (sendBtn) sendBtn.click')) {
|
|
90
|
+
expect(script).toContain('data-testid=\\\"send-button\\\"');
|
|
91
|
+
}
|
|
92
|
+
return Promise.resolve(undefined);
|
|
93
|
+
}),
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
await expect(sendChatGPTMessage(page, 'hello')).resolves.toBe(true);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('keeps zh-CN aria and placeholder fallbacks without replacing English selectors', () => {
|
|
100
|
+
expect(__test__.COMPOSER_SELECTORS).toEqual(expect.arrayContaining([
|
|
101
|
+
'[aria-label="Chat with ChatGPT"]',
|
|
102
|
+
'[aria-label="与 ChatGPT 聊天"]',
|
|
103
|
+
'[placeholder="Ask anything"]',
|
|
104
|
+
'[placeholder="有问题,尽管问"]',
|
|
105
|
+
'[data-testid="prompt-textarea"]',
|
|
106
|
+
]));
|
|
107
|
+
expect(__test__.SEND_BUTTON_SELECTOR).toBe('button[data-testid="send-button"]:not([disabled])');
|
|
108
|
+
expect(__test__.SEND_BUTTON_LABELS).toEqual(expect.arrayContaining(['Send prompt', 'Send message', 'Send', '发送提示']));
|
|
109
|
+
expect(__test__.CLOSE_SIDEBAR_LABELS).toEqual(expect.arrayContaining(['Close sidebar', '关闭边栏']));
|
|
110
|
+
});
|
|
111
|
+
});
|
package/clis/claude/ask.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
2
|
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
3
3
|
import {
|
|
4
|
-
CLAUDE_DOMAIN, CLAUDE_URL,
|
|
4
|
+
CLAUDE_DOMAIN, CLAUDE_URL, COMPOSER_SELECTOR, MESSAGE_SELECTOR,
|
|
5
|
+
ensureOnClaude, selectModel, setAdaptiveThinking,
|
|
5
6
|
sendMessage, sendWithFile, getBubbleCount, waitForResponse, parseBoolFlag, withRetry,
|
|
6
7
|
ensureClaudeComposer, requireNonEmptyPrompt, requirePositiveInt,
|
|
7
8
|
} from './utils.js';
|
|
@@ -38,7 +39,11 @@ export const askCommand = cli({
|
|
|
38
39
|
|
|
39
40
|
if (parseBoolFlag(kwargs.new)) {
|
|
40
41
|
await page.goto(CLAUDE_URL);
|
|
41
|
-
|
|
42
|
+
try {
|
|
43
|
+
await page.wait({ selector: COMPOSER_SELECTOR, timeout: 8 });
|
|
44
|
+
} catch {
|
|
45
|
+
// Composer didn't mount; ensureClaudeComposer below surfaces a typed error.
|
|
46
|
+
}
|
|
42
47
|
} else {
|
|
43
48
|
const navigated = await ensureOnClaude(page);
|
|
44
49
|
if (navigated) {
|
|
@@ -48,11 +53,18 @@ export const askCommand = cli({
|
|
|
48
53
|
var link = document.querySelector('a[href*="/chat/"]');
|
|
49
54
|
if (link) link.click();
|
|
50
55
|
})()`);
|
|
51
|
-
|
|
56
|
+
// Wait for the resumed conversation to render messages, or
|
|
57
|
+
// fall through if the link click had no effect (no recents).
|
|
58
|
+
try {
|
|
59
|
+
await page.wait({ selector: MESSAGE_SELECTOR, timeout: 5 });
|
|
60
|
+
} catch {
|
|
61
|
+
// No prior conversation; ensureClaudeComposer still requires composer below.
|
|
62
|
+
}
|
|
52
63
|
}
|
|
53
64
|
}
|
|
54
65
|
|
|
55
|
-
|
|
66
|
+
// ensureClaudeComposer reads composer presence directly via getPageState,
|
|
67
|
+
// so the previous standalone 2 s settle is redundant.
|
|
56
68
|
await withRetry(() => ensureClaudeComposer(page, 'Claude ask requires a visible composer on the current page.'));
|
|
57
69
|
|
|
58
70
|
// Model selector is only available on the new-chat page, not inside
|
|
@@ -80,14 +92,16 @@ export const askCommand = cli({
|
|
|
80
92
|
}
|
|
81
93
|
throw new CommandExecutionError(`Could not switch to ${wantModel} model`);
|
|
82
94
|
}
|
|
83
|
-
|
|
95
|
+
// Post-toggle settle dropped — the next CDP eval (setAdaptiveThinking) gives
|
|
96
|
+
// React enough time to flush aria-checked updates between rountrips.
|
|
84
97
|
}
|
|
85
98
|
|
|
86
99
|
const thinkResult = await withRetry(() => setAdaptiveThinking(page, wantThink));
|
|
87
100
|
if (!thinkResult?.ok && wantThink) {
|
|
88
101
|
throw new CommandExecutionError('Could not enable Adaptive thinking');
|
|
89
102
|
}
|
|
90
|
-
|
|
103
|
+
// Post-toggle settle dropped — the next CDP eval (sendMessage / sendWithFile)
|
|
104
|
+
// gives React enough time to flush aria-checked updates.
|
|
91
105
|
|
|
92
106
|
if (kwargs.file) {
|
|
93
107
|
const baseline = await withRetry(() => getBubbleCount(page));
|
|
@@ -100,7 +114,8 @@ export const askCommand = cli({
|
|
|
100
114
|
// SPA navigates after send; "Promise was collected" means send succeeded
|
|
101
115
|
if (!String(err?.message || err).includes('Promise was collected')) throw err;
|
|
102
116
|
}
|
|
103
|
-
|
|
117
|
+
// Pre-waitForResponse settle dropped — waitForResponse's first 3 s polling
|
|
118
|
+
// tick covers the same window without an unconditional sleep.
|
|
104
119
|
const result = await waitForResponse(page, baseline, prompt, timeoutMs);
|
|
105
120
|
if (!result) {
|
|
106
121
|
throw new EmptyResultError(
|
package/clis/claude/detail.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
2
|
import { EmptyResultError } from '@jackwener/opencli/errors';
|
|
3
|
-
import { CLAUDE_DOMAIN, getVisibleMessages, ensureClaudeLogin, requireConversationId } from './utils.js';
|
|
3
|
+
import { CLAUDE_DOMAIN, MESSAGE_SELECTOR, getVisibleMessages, ensureClaudeLogin, requireConversationId } from './utils.js';
|
|
4
4
|
|
|
5
5
|
export const detailCommand = cli({
|
|
6
6
|
site: 'claude',
|
|
@@ -21,7 +21,14 @@ export const detailCommand = cli({
|
|
|
21
21
|
const id = requireConversationId(kwargs.id);
|
|
22
22
|
|
|
23
23
|
await page.goto(`https://claude.ai/chat/${id}`);
|
|
24
|
-
|
|
24
|
+
// Wait for the first assistant message bubble to render instead of a
|
|
25
|
+
// fixed 4 s sleep. Swallow the timeout so empty conversations and
|
|
26
|
+
// login redirects fall through to ensureClaudeLogin / EmptyResultError.
|
|
27
|
+
try {
|
|
28
|
+
await page.wait({ selector: MESSAGE_SELECTOR, timeout: 10 });
|
|
29
|
+
} catch {
|
|
30
|
+
// Empty conversation, missing access, or login redirect — handled below.
|
|
31
|
+
}
|
|
25
32
|
await ensureClaudeLogin(page, 'Claude detail requires a logged-in Claude session.');
|
|
26
33
|
|
|
27
34
|
const messages = await getVisibleMessages(page);
|
package/clis/claude/new.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
-
import { CLAUDE_DOMAIN, CLAUDE_URL, ensureClaudeComposer } from './utils.js';
|
|
2
|
+
import { CLAUDE_DOMAIN, CLAUDE_URL, COMPOSER_SELECTOR, ensureClaudeComposer } from './utils.js';
|
|
3
3
|
|
|
4
4
|
export const newCommand = cli({
|
|
5
5
|
site: 'claude',
|
|
@@ -16,7 +16,13 @@ export const newCommand = cli({
|
|
|
16
16
|
|
|
17
17
|
func: async (page) => {
|
|
18
18
|
await page.goto(CLAUDE_URL);
|
|
19
|
-
|
|
19
|
+
// Wait for the composer to mount instead of a fixed 2 s sleep. If it
|
|
20
|
+
// never mounts, swallow and let ensureClaudeComposer surface a typed error.
|
|
21
|
+
try {
|
|
22
|
+
await page.wait({ selector: COMPOSER_SELECTOR, timeout: 8 });
|
|
23
|
+
} catch {
|
|
24
|
+
// Login or error page — ensureClaudeComposer below throws AuthRequiredError / CommandExecutionError.
|
|
25
|
+
}
|
|
20
26
|
await ensureClaudeComposer(page, 'Claude new requires a logged-in Claude session with a visible composer.');
|
|
21
27
|
return [{ Status: 'New chat started' }];
|
|
22
28
|
},
|
package/clis/claude/read.js
CHANGED
|
@@ -16,8 +16,9 @@ export const readCommand = cli({
|
|
|
16
16
|
columns: ['Index', 'Role', 'Text'],
|
|
17
17
|
|
|
18
18
|
func: async (page) => {
|
|
19
|
+
// ensureOnClaude now waits for the composer selector; the previous post-nav
|
|
20
|
+
// 3 s settle is covered by that event-based wait.
|
|
19
21
|
await ensureOnClaude(page);
|
|
20
|
-
await page.wait(3);
|
|
21
22
|
await ensureClaudeLogin(page, 'Claude read requires a logged-in Claude session.');
|
|
22
23
|
const messages = await getVisibleMessages(page);
|
|
23
24
|
if (messages.length > 0) return messages;
|
package/clis/claude/send.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
2
|
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
|
3
|
-
import { CLAUDE_DOMAIN, CLAUDE_URL, ensureOnClaude, sendMessage, parseBoolFlag, withRetry, ensureClaudeComposer, requireNonEmptyPrompt } from './utils.js';
|
|
3
|
+
import { CLAUDE_DOMAIN, CLAUDE_URL, COMPOSER_SELECTOR, ensureOnClaude, sendMessage, parseBoolFlag, withRetry, ensureClaudeComposer, requireNonEmptyPrompt } from './utils.js';
|
|
4
4
|
|
|
5
5
|
export const sendCommand = cli({
|
|
6
6
|
site: 'claude',
|
|
@@ -23,10 +23,15 @@ export const sendCommand = cli({
|
|
|
23
23
|
|
|
24
24
|
if (parseBoolFlag(kwargs.new)) {
|
|
25
25
|
await page.goto(CLAUDE_URL);
|
|
26
|
-
|
|
26
|
+
try {
|
|
27
|
+
await page.wait({ selector: COMPOSER_SELECTOR, timeout: 8 });
|
|
28
|
+
} catch {
|
|
29
|
+
// Composer didn't mount; ensureClaudeComposer below surfaces a typed error.
|
|
30
|
+
}
|
|
27
31
|
} else {
|
|
32
|
+
// ensureOnClaude now waits for the composer selector; the previous
|
|
33
|
+
// post-nav 2 s settle is covered by that event-based wait.
|
|
28
34
|
await ensureOnClaude(page);
|
|
29
|
-
await page.wait(2);
|
|
30
35
|
}
|
|
31
36
|
await withRetry(() => ensureClaudeComposer(page, 'Claude send requires a visible composer on the current page.'));
|
|
32
37
|
|
package/clis/claude/utils.js
CHANGED
|
@@ -26,7 +26,14 @@ export async function isOnClaude(page) {
|
|
|
26
26
|
export async function ensureOnClaude(page) {
|
|
27
27
|
if (await isOnClaude(page)) return false;
|
|
28
28
|
await page.goto(CLAUDE_URL);
|
|
29
|
-
|
|
29
|
+
// Wait for the composer textarea instead of a fixed 3 s sleep. On the login
|
|
30
|
+
// page it never mounts; swallow the timeout so callers (read / detail /
|
|
31
|
+
// send) can still inspect page state and produce typed errors.
|
|
32
|
+
try {
|
|
33
|
+
await page.wait({ selector: COMPOSER_SELECTOR, timeout: 8 });
|
|
34
|
+
} catch {
|
|
35
|
+
// Login or error page — downstream ensureClaudeLogin / ensureClaudeComposer surfaces a typed error.
|
|
36
|
+
}
|
|
30
37
|
return true;
|
|
31
38
|
}
|
|
32
39
|
|
|
@@ -111,7 +118,13 @@ export async function getVisibleMessages(page) {
|
|
|
111
118
|
export async function getConversationList(page) {
|
|
112
119
|
if (!(await isOnClaude(page)) || !(await page.evaluate('window.location.href') || '').includes('/recents')) {
|
|
113
120
|
await page.goto('https://claude.ai/recents');
|
|
114
|
-
|
|
121
|
+
// Recents list mounts <a href="/chat/...">; an empty history is also
|
|
122
|
+
// valid (returns []), so swallow the timeout instead of raising.
|
|
123
|
+
try {
|
|
124
|
+
await page.wait({ selector: 'a[href*="/chat/"]', timeout: 8 });
|
|
125
|
+
} catch {
|
|
126
|
+
// Empty history or login page — downstream evaluate returns [].
|
|
127
|
+
}
|
|
115
128
|
}
|
|
116
129
|
const items = await page.evaluate(`(() => {
|
|
117
130
|
var links = Array.from(document.querySelectorAll('a[href*="/chat/"]'));
|
|
@@ -147,7 +160,12 @@ export async function selectModel(page, modelName) {
|
|
|
147
160
|
if (!opened?.ok) return opened;
|
|
148
161
|
if (!opened.opened) return opened;
|
|
149
162
|
|
|
150
|
-
|
|
163
|
+
// Wait for the dropdown menu items to mount instead of a fixed 0.6 s sleep.
|
|
164
|
+
try {
|
|
165
|
+
await page.wait({ selector: 'div[role="menuitemradio"]', timeout: 3 });
|
|
166
|
+
} catch {
|
|
167
|
+
// Dropdown didn't open — next evaluate finds no target and returns { ok: false }.
|
|
168
|
+
}
|
|
151
169
|
|
|
152
170
|
return page.evaluate(`(() => {
|
|
153
171
|
var items = Array.from(document.querySelectorAll('div[role="menuitemradio"]'));
|
|
@@ -175,7 +193,12 @@ export async function setAdaptiveThinking(page, enabled) {
|
|
|
175
193
|
})()`);
|
|
176
194
|
if (!opened?.ok) return { ok: false };
|
|
177
195
|
|
|
178
|
-
|
|
196
|
+
// Wait for the dropdown menu items to mount instead of a fixed 0.6 s sleep.
|
|
197
|
+
try {
|
|
198
|
+
await page.wait({ selector: 'div[role="menuitem"]', timeout: 3 });
|
|
199
|
+
} catch {
|
|
200
|
+
// Dropdown didn't open — next evaluate finds no target and returns { ok: false }.
|
|
201
|
+
}
|
|
179
202
|
|
|
180
203
|
return page.evaluate(`(() => {
|
|
181
204
|
var items = Array.from(document.querySelectorAll('div[role="menuitem"]'));
|
package/clis/deepseek/ask.js
CHANGED
|
@@ -3,7 +3,7 @@ import { CliError, CommandExecutionError, EXIT_CODES } from '@jackwener/opencli/
|
|
|
3
3
|
import {
|
|
4
4
|
DEEPSEEK_DOMAIN, DEEPSEEK_URL, ensureOnDeepSeek, selectModel, setFeature,
|
|
5
5
|
sendMessage, sendWithFile, getBubbleCount, waitForResponse, parseBoolFlag, withRetry,
|
|
6
|
-
pickResumeUrl,
|
|
6
|
+
pickResumeUrl, TEXTAREA_SELECTOR,
|
|
7
7
|
} from './utils.js';
|
|
8
8
|
|
|
9
9
|
export const askCommand = cli({
|
|
@@ -35,7 +35,13 @@ export const askCommand = cli({
|
|
|
35
35
|
|
|
36
36
|
if (parseBoolFlag(kwargs.new)) {
|
|
37
37
|
await page.goto(DEEPSEEK_URL);
|
|
38
|
-
|
|
38
|
+
// Wait for the composer to mount instead of a fixed 3 s sleep.
|
|
39
|
+
try {
|
|
40
|
+
await page.wait({ selector: TEXTAREA_SELECTOR, timeout: 8 });
|
|
41
|
+
} catch {
|
|
42
|
+
// Selector still missing → downstream selectModel/sendMessage
|
|
43
|
+
// will surface the failure with a typed error.
|
|
44
|
+
}
|
|
39
45
|
} else {
|
|
40
46
|
const navigated = await ensureOnDeepSeek(page);
|
|
41
47
|
if (navigated) {
|
|
@@ -49,12 +55,15 @@ export const askCommand = cli({
|
|
|
49
55
|
);
|
|
50
56
|
}
|
|
51
57
|
await page.goto(resumeUrl);
|
|
52
|
-
|
|
58
|
+
try {
|
|
59
|
+
await page.wait({ selector: TEXTAREA_SELECTOR, timeout: 5 });
|
|
60
|
+
} catch {
|
|
61
|
+
// Conversation page may still be loading; subsequent steps
|
|
62
|
+
// will retry or report.
|
|
63
|
+
}
|
|
53
64
|
}
|
|
54
65
|
}
|
|
55
66
|
|
|
56
|
-
await page.wait(2);
|
|
57
|
-
|
|
58
67
|
// Model selector is only available on the new-chat page, not inside
|
|
59
68
|
// an existing conversation. Skip it when we resumed a prior thread.
|
|
60
69
|
const currentUrl = await page.evaluate('window.location.href') || '';
|
|
@@ -76,7 +85,9 @@ export const askCommand = cli({
|
|
|
76
85
|
if (!modelResult?.ok) {
|
|
77
86
|
throw new CommandExecutionError(`Could not switch to ${wantModel} model`);
|
|
78
87
|
}
|
|
79
|
-
|
|
88
|
+
// The 0.5 s settle previously here was redundant: each subsequent
|
|
89
|
+
// step (setFeature, sendMessage) issues a fresh CDP eval, giving
|
|
90
|
+
// React more than enough time to flush the toggle's state update.
|
|
80
91
|
}
|
|
81
92
|
|
|
82
93
|
const thinkResult = await withRetry(() => setFeature(page, 'DeepThink', wantThink));
|
|
@@ -102,7 +113,8 @@ export const askCommand = cli({
|
|
|
102
113
|
}
|
|
103
114
|
}
|
|
104
115
|
|
|
105
|
-
|
|
116
|
+
// No settle wait after toggles: the next CDP eval below already gives
|
|
117
|
+
// React time to flush the aria-checked state.
|
|
106
118
|
|
|
107
119
|
if (kwargs.file) {
|
|
108
120
|
const baseline = await withRetry(() => getBubbleCount(page));
|
|
@@ -115,7 +127,8 @@ export const askCommand = cli({
|
|
|
115
127
|
// SPA navigates after send; "Promise was collected" means send succeeded
|
|
116
128
|
if (!String(err?.message || err).includes('Promise was collected')) throw err;
|
|
117
129
|
}
|
|
118
|
-
|
|
130
|
+
// waitForResponse polls every 3 s for new bubbles, so the previous
|
|
131
|
+
// 3 s settle here was a redundant sleep on top of the first poll.
|
|
119
132
|
const result = await waitForResponse(page, baseline, prompt, timeoutMs, wantThink);
|
|
120
133
|
if (!result) {
|
|
121
134
|
return [{ response: `[NO RESPONSE] No reply within ${kwargs.timeout}s.` }];
|
package/clis/deepseek/detail.js
CHANGED
|
@@ -2,6 +2,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
|
2
2
|
import { EmptyResultError } from '@jackwener/opencli/errors';
|
|
3
3
|
import {
|
|
4
4
|
DEEPSEEK_DOMAIN,
|
|
5
|
+
MESSAGE_SELECTOR,
|
|
5
6
|
ensureOnDeepSeek,
|
|
6
7
|
getVisibleMessages,
|
|
7
8
|
parseDeepSeekConversationId,
|
|
@@ -25,7 +26,14 @@ export const detailCommand = cli({
|
|
|
25
26
|
const id = parseDeepSeekConversationId(kwargs.id);
|
|
26
27
|
await ensureOnDeepSeek(page);
|
|
27
28
|
await page.goto(`https://chat.deepseek.com/a/chat/s/${id}`);
|
|
28
|
-
|
|
29
|
+
// Wait for at least one rendered bubble instead of a fixed 5 s sleep.
|
|
30
|
+
// Empty / invalid conversations fall through to the EmptyResultError
|
|
31
|
+
// below.
|
|
32
|
+
try {
|
|
33
|
+
await page.wait({ selector: MESSAGE_SELECTOR, timeout: 10 });
|
|
34
|
+
} catch {
|
|
35
|
+
// No bubble mounted within 10 s; treated as empty by the check below.
|
|
36
|
+
}
|
|
29
37
|
const messages = await getVisibleMessages(page);
|
|
30
38
|
if (messages.length === 0) {
|
|
31
39
|
throw new EmptyResultError(
|
package/clis/deepseek/new.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
-
import {
|
|
2
|
+
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
|
3
|
+
import { DEEPSEEK_DOMAIN, DEEPSEEK_URL, TEXTAREA_SELECTOR } from './utils.js';
|
|
3
4
|
|
|
4
5
|
export const newCommand = cli({
|
|
5
6
|
site: 'deepseek',
|
|
@@ -16,7 +17,17 @@ export const newCommand = cli({
|
|
|
16
17
|
|
|
17
18
|
func: async (page) => {
|
|
18
19
|
await page.goto(DEEPSEEK_URL);
|
|
19
|
-
|
|
20
|
+
// Confirm the composer mounted before reporting success. The previous
|
|
21
|
+
// 2 s blind sleep would return "New chat started" even when the page
|
|
22
|
+
// was still loading or the user was logged out.
|
|
23
|
+
try {
|
|
24
|
+
await page.wait({ selector: TEXTAREA_SELECTOR, timeout: 8 });
|
|
25
|
+
} catch {
|
|
26
|
+
throw new CommandExecutionError(
|
|
27
|
+
'DeepSeek composer did not mount within 8 s',
|
|
28
|
+
'Verify you are logged into chat.deepseek.com.',
|
|
29
|
+
);
|
|
30
|
+
}
|
|
20
31
|
return [{ Status: 'New chat started' }];
|
|
21
32
|
},
|
|
22
33
|
});
|
package/clis/deepseek/read.js
CHANGED
|
@@ -15,8 +15,9 @@ export const readCommand = cli({
|
|
|
15
15
|
columns: ['Role', 'Text'],
|
|
16
16
|
|
|
17
17
|
func: async (page) => {
|
|
18
|
+
// ensureOnDeepSeek already waits for the composer to mount; the
|
|
19
|
+
// follow-up 5 s sleep was redundant.
|
|
18
20
|
await ensureOnDeepSeek(page);
|
|
19
|
-
await page.wait(5);
|
|
20
21
|
const messages = await getVisibleMessages(page);
|
|
21
22
|
if (messages.length > 0) return messages;
|
|
22
23
|
return [{ Role: 'system', Text: 'No visible messages found.' }];
|
package/clis/deepseek/utils.js
CHANGED
|
@@ -43,7 +43,14 @@ export async function isOnDeepSeek(page) {
|
|
|
43
43
|
export async function ensureOnDeepSeek(page) {
|
|
44
44
|
if (await isOnDeepSeek(page)) return false;
|
|
45
45
|
await page.goto(DEEPSEEK_URL);
|
|
46
|
-
|
|
46
|
+
// Wait for the composer textarea instead of a fixed 3 s sleep. On the login
|
|
47
|
+
// page it never mounts; swallow the timeout so callers (status / read /
|
|
48
|
+
// history) can still inspect page state.
|
|
49
|
+
try {
|
|
50
|
+
await page.wait({ selector: TEXTAREA_SELECTOR, timeout: 8 });
|
|
51
|
+
} catch {
|
|
52
|
+
// Login or error page — downstream will see hasTextarea=false / empty results.
|
|
53
|
+
}
|
|
47
54
|
return true;
|
|
48
55
|
}
|
|
49
56
|
|
package/clis/linkedin/search.js
CHANGED
|
@@ -245,23 +245,20 @@ async function fetchJobCards(page, input) {
|
|
|
245
245
|
const MAX_BATCH = 25;
|
|
246
246
|
const allJobs = [];
|
|
247
247
|
let offset = input.start;
|
|
248
|
+
// Read JSESSIONID directly from the cookie store via CDP — zero page.evaluate round-trip
|
|
249
|
+
const cookies = await page.getCookies({ url: 'https://www.linkedin.com' });
|
|
250
|
+
const jsession = cookies.find((c) => c.name === 'JSESSIONID')?.value;
|
|
251
|
+
if (!jsession) {
|
|
252
|
+
throw new AuthRequiredError(LINKEDIN_DOMAIN, 'LinkedIn JSESSIONID cookie not found. Please sign in to LinkedIn in the browser.');
|
|
253
|
+
}
|
|
254
|
+
const csrf = jsession.replace(/^"|"$/g, '');
|
|
248
255
|
while (allJobs.length < input.limit) {
|
|
249
256
|
const count = Math.min(MAX_BATCH, input.limit - allJobs.length);
|
|
250
257
|
const apiPath = buildVoyagerUrl(input, offset, count);
|
|
251
258
|
const batch = await page.evaluate(`(async () => {
|
|
252
|
-
const jsession = document.cookie.split(';').map(p => p.trim())
|
|
253
|
-
.find(p => p.startsWith('JSESSIONID='))?.slice('JSESSIONID='.length);
|
|
254
|
-
if (!jsession) {
|
|
255
|
-
return {
|
|
256
|
-
authRequired: true,
|
|
257
|
-
error: 'LinkedIn JSESSIONID cookie not found. Please sign in to LinkedIn in the browser.'
|
|
258
|
-
};
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
const csrf = jsession.replace(/^"|"$/g, '');
|
|
262
259
|
const res = await fetch(${JSON.stringify(apiPath)}, {
|
|
263
260
|
credentials: 'include',
|
|
264
|
-
headers: { 'csrf-token': csrf, 'x-restli-protocol-version': '2.0.0' },
|
|
261
|
+
headers: { 'csrf-token': ${JSON.stringify(csrf)}, 'x-restli-protocol-version': '2.0.0' },
|
|
265
262
|
});
|
|
266
263
|
if (res.status === 401 || res.status === 403) {
|
|
267
264
|
const text = await res.text();
|
|
@@ -80,18 +80,22 @@ cli({
|
|
|
80
80
|
},
|
|
81
81
|
};
|
|
82
82
|
|
|
83
|
+
// Read csrftoken directly from the cookie store via CDP — zero page.evaluate round-trip
|
|
84
|
+
const cookies = await page.getCookies({ url: 'https://maimai.cn' });
|
|
85
|
+
const csrftokenFromCookie = cookies.find((c) => c.name === 'csrftoken')?.value || '';
|
|
86
|
+
|
|
83
87
|
// Execute the search API call in browser context
|
|
84
|
-
const data = await page.evaluate(async (
|
|
85
|
-
//
|
|
86
|
-
let csrftoken =
|
|
87
|
-
.find(row => row.startsWith('csrftoken='))
|
|
88
|
-
?.split('=')[1] || '';
|
|
88
|
+
const data = await page.evaluate(`async () => {
|
|
89
|
+
// Prefer cookie-derived csrftoken (hoisted from CDP); fall back to meta tag
|
|
90
|
+
let csrftoken = ${JSON.stringify(csrftokenFromCookie)};
|
|
89
91
|
|
|
90
92
|
if (!csrftoken) {
|
|
91
93
|
const meta = document.querySelector('meta[name="csrf-token"]');
|
|
92
94
|
if (meta) csrftoken = meta.getAttribute('content') || '';
|
|
93
95
|
}
|
|
94
96
|
|
|
97
|
+
const body = ${JSON.stringify(requestBody)};
|
|
98
|
+
|
|
95
99
|
const res = await fetch('https://maimai.cn/api/ent/discover/search?channel=www&data_version=3.0&version=1.0.0', {
|
|
96
100
|
method: 'POST',
|
|
97
101
|
headers: {
|
|
@@ -117,7 +121,7 @@ cli({
|
|
|
117
121
|
}
|
|
118
122
|
|
|
119
123
|
return result;
|
|
120
|
-
}
|
|
124
|
+
}`);
|
|
121
125
|
|
|
122
126
|
// Extract talent list from response
|
|
123
127
|
const talentList = data.data?.list || data.data?.talent_list || data.list || data.talent_list || [];
|