@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.
Files changed (94) hide show
  1. package/README.md +9 -6
  2. package/README.zh-CN.md +9 -6
  3. package/cli-manifest.json +161 -31
  4. package/clis/chatgpt/ask.js +2 -1
  5. package/clis/chatgpt/detail.js +6 -1
  6. package/clis/chatgpt/read.js +2 -1
  7. package/clis/chatgpt/send.js +2 -1
  8. package/clis/chatgpt/utils.js +54 -12
  9. package/clis/chatgpt/utils.test.js +36 -1
  10. package/clis/claude/ask.js +22 -7
  11. package/clis/claude/detail.js +9 -2
  12. package/clis/claude/new.js +8 -2
  13. package/clis/claude/read.js +2 -1
  14. package/clis/claude/send.js +8 -3
  15. package/clis/claude/utils.js +27 -4
  16. package/clis/deepseek/ask.js +21 -8
  17. package/clis/deepseek/detail.js +9 -1
  18. package/clis/deepseek/new.js +13 -2
  19. package/clis/deepseek/read.js +2 -1
  20. package/clis/deepseek/utils.js +8 -1
  21. package/clis/linkedin/search.js +8 -11
  22. package/clis/maimai/search-talents.js +10 -6
  23. package/clis/openreview/author.js +58 -0
  24. package/clis/openreview/openreview.test.js +83 -1
  25. package/clis/openreview/utils.js +14 -0
  26. package/clis/reddit/comment.js +1 -0
  27. package/clis/reddit/frontpage.js +1 -0
  28. package/clis/reddit/popular.js +1 -0
  29. package/clis/reddit/read.js +2 -0
  30. package/clis/reddit/read.test.js +4 -0
  31. package/clis/reddit/save.js +1 -0
  32. package/clis/reddit/saved.js +1 -0
  33. package/clis/reddit/search.js +1 -0
  34. package/clis/reddit/subreddit.js +1 -0
  35. package/clis/reddit/subscribe.js +1 -0
  36. package/clis/reddit/upvote.js +1 -0
  37. package/clis/reddit/upvoted.js +1 -0
  38. package/clis/reddit/user-comments.js +1 -0
  39. package/clis/reddit/user-posts.js +1 -0
  40. package/clis/reddit/user.js +1 -0
  41. package/clis/twitter/article.js +7 -4
  42. package/clis/twitter/bookmark-folder.js +3 -5
  43. package/clis/twitter/bookmark-folder.test.js +5 -2
  44. package/clis/twitter/bookmark-folders.js +3 -5
  45. package/clis/twitter/bookmark-folders.test.js +3 -1
  46. package/clis/twitter/bookmarks.js +3 -5
  47. package/clis/twitter/download.js +1 -0
  48. package/clis/twitter/followers.js +1 -0
  49. package/clis/twitter/following.js +3 -6
  50. package/clis/twitter/following.test.js +2 -1
  51. package/clis/twitter/likes.js +3 -5
  52. package/clis/twitter/list-add.js +4 -3
  53. package/clis/twitter/list-add.test.js +23 -1
  54. package/clis/twitter/list-remove.js +4 -3
  55. package/clis/twitter/list-remove.test.js +23 -1
  56. package/clis/twitter/list-tweets.js +3 -5
  57. package/clis/twitter/lists.js +3 -5
  58. package/clis/twitter/notifications.js +1 -0
  59. package/clis/twitter/profile.js +7 -4
  60. package/clis/twitter/search.js +1 -0
  61. package/clis/twitter/thread.js +5 -7
  62. package/clis/twitter/timeline.js +5 -7
  63. package/clis/twitter/trending.js +4 -4
  64. package/clis/twitter/tweets.js +3 -6
  65. package/clis/youtube/like.js +6 -2
  66. package/clis/youtube/subscribe.js +6 -2
  67. package/clis/youtube/unlike.js +6 -2
  68. package/clis/youtube/unsubscribe.js +6 -2
  69. package/clis/youtube/utils.js +19 -13
  70. package/clis/youtube/utils.test.js +17 -1
  71. package/dist/src/browser/bridge.d.ts +1 -0
  72. package/dist/src/browser/bridge.js +1 -1
  73. package/dist/src/browser/cdp.d.ts +1 -0
  74. package/dist/src/browser/daemon-client.d.ts +2 -2
  75. package/dist/src/browser/daemon-client.js +6 -3
  76. package/dist/src/browser/daemon-client.test.js +10 -0
  77. package/dist/src/browser/page.d.ts +2 -1
  78. package/dist/src/browser/page.js +5 -1
  79. package/dist/src/cli.js +70 -2
  80. package/dist/src/cli.test.js +139 -7
  81. package/dist/src/commanderAdapter.js +7 -0
  82. package/dist/src/doctor.js +2 -2
  83. package/dist/src/doctor.test.js +4 -4
  84. package/dist/src/execution.d.ts +2 -0
  85. package/dist/src/execution.js +31 -6
  86. package/dist/src/execution.test.js +43 -16
  87. package/dist/src/external-clis.yaml +24 -0
  88. package/dist/src/help.d.ts +1 -0
  89. package/dist/src/help.js +29 -0
  90. package/dist/src/main.js +4 -14
  91. package/dist/src/runtime.d.ts +3 -0
  92. package/dist/src/runtime.js +1 -0
  93. package/dist/src/types.d.ts +1 -1
  94. package/package.json +1 -1
@@ -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
- await page.wait(2);
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
- await page.wait(2);
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 closeBtn = Array.from(document.querySelectorAll('button')).find(b => b.getAttribute('aria-label') === 'Close sidebar');
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
- await page.wait(0.5);
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) await page.wait(1);
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
- await page.wait(2);
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
+ });
@@ -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, ensureOnClaude, selectModel, setAdaptiveThinking,
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
- await page.wait(3);
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
- await page.wait(2);
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
- await page.wait(2);
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
- if (modelResult?.toggled) await page.wait(0.5);
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
- if (thinkResult?.toggled) await page.wait(0.5);
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
- await page.wait(3);
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(
@@ -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
- await page.wait(4);
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);
@@ -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
- await page.wait(2);
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
  },
@@ -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;
@@ -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
- await page.wait(3);
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
 
@@ -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
- await page.wait(3);
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
- await page.wait(3);
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
- await page.wait(0.6);
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
- await page.wait(0.6);
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"]'));
@@ -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
- await page.wait(3);
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
- await page.wait(2);
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
- if (modelResult?.toggled) await page.wait(0.5);
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
- if (thinkResult?.toggled || searchResult?.toggled) await page.wait(0.5);
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
- await page.wait(3);
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.` }];
@@ -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
- await page.wait(5);
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(
@@ -1,5 +1,6 @@
1
1
  import { cli, Strategy } from '@jackwener/opencli/registry';
2
- import { DEEPSEEK_DOMAIN, DEEPSEEK_URL } from './utils.js';
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
- await page.wait(2);
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
  });
@@ -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.' }];
@@ -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
- await page.wait(3);
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
 
@@ -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 (body) => {
85
- // Get CSRF token from cookie or meta tag
86
- let csrftoken = document.cookie.split('; ')
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
- }, requestBody);
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 || [];