@jackwener/opencli 1.7.15 → 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.
Files changed (172) hide show
  1. package/README.md +15 -13
  2. package/README.zh-CN.md +15 -12
  3. package/cli-manifest.json +165 -209
  4. package/clis/chatgpt/ask.js +3 -2
  5. package/clis/chatgpt/commands.test.js +2 -2
  6. package/clis/chatgpt/detail.js +7 -2
  7. package/clis/chatgpt/history.js +1 -1
  8. package/clis/chatgpt/image.js +38 -4
  9. package/clis/chatgpt/image.test.js +68 -1
  10. package/clis/chatgpt/new.js +1 -1
  11. package/clis/chatgpt/read.js +3 -2
  12. package/clis/chatgpt/send.js +3 -2
  13. package/clis/chatgpt/status.js +1 -1
  14. package/clis/chatgpt/utils.js +259 -25
  15. package/clis/chatgpt/utils.test.js +166 -2
  16. package/clis/claude/ask.js +23 -8
  17. package/clis/claude/detail.js +10 -3
  18. package/clis/claude/history.js +1 -1
  19. package/clis/claude/new.js +9 -3
  20. package/clis/claude/read.js +3 -2
  21. package/clis/claude/send.js +9 -4
  22. package/clis/claude/status.js +1 -1
  23. package/clis/claude/utils.js +27 -4
  24. package/clis/deepseek/ask.js +22 -9
  25. package/clis/deepseek/detail.js +10 -2
  26. package/clis/deepseek/history.js +1 -1
  27. package/clis/deepseek/new.js +14 -3
  28. package/clis/deepseek/read.js +3 -2
  29. package/clis/deepseek/send.js +1 -1
  30. package/clis/deepseek/status.js +1 -1
  31. package/clis/deepseek/utils.js +8 -1
  32. package/clis/doubao/ask.js +1 -1
  33. package/clis/doubao/detail.js +1 -1
  34. package/clis/doubao/history.js +1 -1
  35. package/clis/doubao/meeting-summary.js +1 -1
  36. package/clis/doubao/meeting-transcript.js +1 -1
  37. package/clis/doubao/new.js +1 -1
  38. package/clis/doubao/read.js +1 -1
  39. package/clis/doubao/send.js +1 -1
  40. package/clis/doubao/status.js +1 -1
  41. package/clis/gemini/ask.js +1 -1
  42. package/clis/gemini/deep-research-result.js +1 -1
  43. package/clis/gemini/deep-research.js +1 -1
  44. package/clis/gemini/image.js +1 -1
  45. package/clis/gemini/new.js +1 -1
  46. package/clis/grok/ask.js +1 -1
  47. package/clis/grok/detail.js +1 -1
  48. package/clis/grok/history.js +1 -1
  49. package/clis/grok/image.js +1 -1
  50. package/clis/grok/new.js +1 -1
  51. package/clis/grok/read.js +1 -1
  52. package/clis/grok/send.js +1 -1
  53. package/clis/grok/status.js +1 -1
  54. package/clis/linkedin/search.js +8 -11
  55. package/clis/maimai/search-talents.js +10 -6
  56. package/clis/notebooklm/current.js +1 -1
  57. package/clis/notebooklm/get.js +1 -1
  58. package/clis/notebooklm/history.js +1 -1
  59. package/clis/notebooklm/note-list.js +1 -1
  60. package/clis/notebooklm/notes-get.js +1 -1
  61. package/clis/notebooklm/open.js +2 -2
  62. package/clis/notebooklm/open.test.js +1 -1
  63. package/clis/notebooklm/source-fulltext.js +1 -1
  64. package/clis/notebooklm/source-get.js +1 -1
  65. package/clis/notebooklm/source-guide.js +1 -1
  66. package/clis/notebooklm/source-list.js +1 -1
  67. package/clis/notebooklm/summary.js +1 -1
  68. package/clis/openreview/author.js +58 -0
  69. package/clis/openreview/openreview.test.js +83 -1
  70. package/clis/openreview/utils.js +14 -0
  71. package/clis/qwen/ask.js +1 -1
  72. package/clis/qwen/detail.js +1 -1
  73. package/clis/qwen/history.js +1 -1
  74. package/clis/qwen/image.js +1 -1
  75. package/clis/qwen/new.js +1 -1
  76. package/clis/qwen/read.js +1 -1
  77. package/clis/qwen/send.js +1 -1
  78. package/clis/qwen/status.js +1 -1
  79. package/clis/reddit/comment.js +1 -0
  80. package/clis/reddit/frontpage.js +1 -0
  81. package/clis/reddit/popular.js +1 -0
  82. package/clis/reddit/read.js +2 -0
  83. package/clis/reddit/read.test.js +4 -0
  84. package/clis/reddit/save.js +1 -0
  85. package/clis/reddit/saved.js +1 -0
  86. package/clis/reddit/search.js +1 -0
  87. package/clis/reddit/subreddit.js +1 -0
  88. package/clis/reddit/subscribe.js +1 -0
  89. package/clis/reddit/upvote.js +1 -0
  90. package/clis/reddit/upvoted.js +1 -0
  91. package/clis/reddit/user-comments.js +1 -0
  92. package/clis/reddit/user-posts.js +1 -0
  93. package/clis/reddit/user.js +1 -0
  94. package/clis/twitter/article.js +7 -4
  95. package/clis/twitter/bookmark-folder.js +3 -5
  96. package/clis/twitter/bookmark-folder.test.js +5 -2
  97. package/clis/twitter/bookmark-folders.js +3 -5
  98. package/clis/twitter/bookmark-folders.test.js +3 -1
  99. package/clis/twitter/bookmarks.js +3 -5
  100. package/clis/twitter/download.js +1 -0
  101. package/clis/twitter/followers.js +1 -0
  102. package/clis/twitter/following.js +3 -6
  103. package/clis/twitter/following.test.js +2 -1
  104. package/clis/twitter/likes.js +3 -5
  105. package/clis/twitter/list-add.js +4 -3
  106. package/clis/twitter/list-add.test.js +23 -1
  107. package/clis/twitter/list-remove.js +4 -3
  108. package/clis/twitter/list-remove.test.js +23 -1
  109. package/clis/twitter/list-tweets.js +3 -5
  110. package/clis/twitter/lists.js +3 -5
  111. package/clis/twitter/notifications.js +1 -0
  112. package/clis/twitter/profile.js +7 -4
  113. package/clis/twitter/search.js +1 -0
  114. package/clis/twitter/thread.js +5 -7
  115. package/clis/twitter/timeline.js +5 -7
  116. package/clis/twitter/trending.js +4 -4
  117. package/clis/twitter/tweets.js +3 -6
  118. package/clis/youtube/like.js +6 -2
  119. package/clis/youtube/subscribe.js +6 -2
  120. package/clis/youtube/unlike.js +6 -2
  121. package/clis/youtube/unsubscribe.js +6 -2
  122. package/clis/youtube/utils.js +19 -13
  123. package/clis/youtube/utils.test.js +17 -1
  124. package/clis/yuanbao/ask.js +1 -1
  125. package/clis/yuanbao/detail.js +1 -1
  126. package/clis/yuanbao/history.js +1 -1
  127. package/clis/yuanbao/new.js +1 -1
  128. package/clis/yuanbao/read.js +1 -1
  129. package/clis/yuanbao/send.js +1 -1
  130. package/clis/yuanbao/status.js +1 -1
  131. package/dist/src/browser/bridge.d.ts +4 -1
  132. package/dist/src/browser/bridge.js +3 -1
  133. package/dist/src/browser/cdp.d.ts +4 -1
  134. package/dist/src/browser/daemon-client.d.ts +9 -16
  135. package/dist/src/browser/daemon-client.js +8 -9
  136. package/dist/src/browser/daemon-client.test.js +10 -0
  137. package/dist/src/browser/network-cache.d.ts +5 -5
  138. package/dist/src/browser/network-cache.js +8 -8
  139. package/dist/src/browser/network-cache.test.js +4 -4
  140. package/dist/src/browser/page.d.ts +9 -7
  141. package/dist/src/browser/page.js +27 -16
  142. package/dist/src/browser/page.test.js +60 -30
  143. package/dist/src/build-manifest.js +1 -1
  144. package/dist/src/cli.js +91 -125
  145. package/dist/src/cli.test.js +293 -180
  146. package/dist/src/commanderAdapter.js +9 -0
  147. package/dist/src/discovery.js +1 -1
  148. package/dist/src/doctor.d.ts +0 -4
  149. package/dist/src/doctor.js +8 -72
  150. package/dist/src/doctor.test.js +26 -97
  151. package/dist/src/execution.d.ts +3 -0
  152. package/dist/src/execution.js +47 -23
  153. package/dist/src/execution.test.js +68 -45
  154. package/dist/src/external-clis.yaml +24 -0
  155. package/dist/src/help.d.ts +1 -0
  156. package/dist/src/help.js +36 -1
  157. package/dist/src/main.js +0 -29
  158. package/dist/src/manifest-types.d.ts +2 -4
  159. package/dist/src/observation/artifact.js +1 -1
  160. package/dist/src/observation/artifact.test.js +3 -3
  161. package/dist/src/observation/events.d.ts +1 -1
  162. package/dist/src/observation/manager.js +1 -1
  163. package/dist/src/observation/manager.test.js +3 -3
  164. package/dist/src/registry-api.d.ts +1 -1
  165. package/dist/src/registry.d.ts +3 -12
  166. package/dist/src/registry.js +6 -10
  167. package/dist/src/runtime.d.ts +10 -2
  168. package/dist/src/runtime.js +4 -1
  169. package/dist/src/serialization.d.ts +1 -1
  170. package/dist/src/serialization.js +1 -1
  171. package/dist/src/types.d.ts +0 -15
  172. package/package.json +1 -1
@@ -12,15 +12,26 @@ 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])';
23
+ const SEND_BUTTON_FALLBACK_SELECTORS = [
24
+ '#composer-submit-button:not([disabled])',
25
+ ];
20
26
  const SEND_BUTTON_LABELS = [
21
27
  'Send prompt',
22
28
  'Send message',
23
29
  'Send',
30
+ '发送提示',
31
+ ];
32
+ const CLOSE_SIDEBAR_LABELS = [
33
+ 'Close sidebar',
34
+ '关闭边栏',
24
35
  ];
25
36
 
26
37
  function isSameChatGPTConversation(currentUrl, expectedUrl) {
@@ -119,16 +130,34 @@ export async function isOnChatGPT(page) {
119
130
  }
120
131
  }
121
132
 
133
+ // Comma-joined CSS selector list passed to page.wait({ selector }) so the
134
+ // wait succeeds as soon as any composer flavour mounts (querySelectorAll
135
+ // matches all of them). Tracks the most stable subset of COMPOSER_SELECTORS;
136
+ // we only need to know "the composer is ready", not which variant rendered.
137
+ const COMPOSER_WAIT_SELECTOR = '#prompt-textarea, [data-testid="prompt-textarea"]';
138
+ const CONVERSATION_LINK_SELECTOR = 'a[href*="/c/"]';
139
+ // Selector used by detail.js to wait for at least one rendered message bubble
140
+ // after navigating to /c/<id>; mirrors the markup queried by getVisibleMessages.
141
+ export const CONVERSATION_MESSAGE_SELECTOR = '[data-message-author-role], article[data-testid*="conversation-turn"]';
142
+
122
143
  export async function ensureOnChatGPT(page) {
123
144
  if (await isOnChatGPT(page)) return false;
124
145
  await page.goto(CHATGPT_URL, { settleMs: 2000 });
125
- await page.wait(2);
146
+ try {
147
+ await page.wait({ selector: COMPOSER_WAIT_SELECTOR, timeout: 8 });
148
+ } catch {
149
+ // Composer didn't mount; downstream ensureChatGPTLogin / ensureChatGPTComposer surfaces a typed error.
150
+ }
126
151
  return true;
127
152
  }
128
153
 
129
154
  export async function startNewChat(page) {
130
155
  await page.goto(`${CHATGPT_URL}/new`, { settleMs: 2000 });
131
- await page.wait(2);
156
+ try {
157
+ await page.wait({ selector: COMPOSER_WAIT_SELECTOR, timeout: 8 });
158
+ } catch {
159
+ // Composer didn't mount; downstream ensureChatGPTComposer surfaces a typed error.
160
+ }
132
161
  }
133
162
 
134
163
  export async function getPageState(page) {
@@ -177,6 +206,40 @@ export async function ensureChatGPTComposer(page, message = 'ChatGPT composer is
177
206
  return state;
178
207
  }
179
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
+
180
243
  /**
181
244
  * Send a message to the ChatGPT composer and submit it.
182
245
  * Returns true if the message was sent successfully.
@@ -185,22 +248,32 @@ export async function sendChatGPTMessage(page, text) {
185
248
  // Close sidebar if open (it can cover the chat composer)
186
249
  await page.evaluate(`
187
250
  (() => {
188
- const closeBtn = Array.from(document.querySelectorAll('button')).find(b => b.getAttribute('aria-label') === 'Close sidebar');
251
+ const labels = ${JSON.stringify(CLOSE_SIDEBAR_LABELS)};
252
+ const closeBtn = Array.from(document.querySelectorAll('button')).find(b => labels.includes(b.getAttribute('aria-label') || ''));
189
253
  if (closeBtn) closeBtn.click();
190
254
  })()
191
255
  `);
192
- await page.wait(0.5);
256
+ // The previous 0.5 s + 1.5 s pre-composer settles are dropped: the next
257
+ // page.evaluate roundtrip flushes the close-sidebar React update and
258
+ // findComposer() retries inside a single CDP call, so no fixed sleep is
259
+ // needed before reading the composer.
193
260
 
194
- // Wait for composer to be ready and use Playwright's type()
195
- await page.wait(1.5);
196
-
197
261
  const typeResult = await page.evaluate(`
198
262
  (() => {
199
263
  ${buildComposerLocatorScript()}
200
264
  const composer = findComposer();
201
265
  if (!composer) return false;
202
266
  composer.focus();
203
- composer.textContent = '';
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 }));
204
277
  return true;
205
278
  })()
206
279
  `);
@@ -228,27 +301,37 @@ export async function sendChatGPTMessage(page, text) {
228
301
  `);
229
302
  }
230
303
 
231
- // Wait for send button to appear (it only shows when there's text)
232
- await page.wait(1.5);
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
+ }
233
324
 
234
- // Click send button
235
- const sent = await page.evaluate(`
236
- (() => {
237
- const btns = Array.from(document.querySelectorAll('button'));
238
- const labels = ${JSON.stringify(SEND_BUTTON_LABELS)};
239
- const sendBtn = btns.find(b => labels.includes(b.getAttribute('aria-label') || '') && !b.disabled);
240
- return { sendBtnFound: !!sendBtn };
241
- })()
242
- `);
243
-
244
- if (!sent || !sent.sendBtnFound) {
325
+ if (!sent?.sendBtnFound) {
245
326
  return false;
246
327
  }
247
328
 
248
329
  await page.evaluate(`
249
330
  (() => {
331
+ const primary = document.querySelector(${JSON.stringify(SEND_BUTTON_SELECTOR)})
332
+ || ${JSON.stringify(SEND_BUTTON_FALLBACK_SELECTORS)}.map(selector => document.querySelector(selector)).find(Boolean);
250
333
  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);
334
+ const sendBtn = primary || Array.from(document.querySelectorAll('button')).find(b => labels.includes(b.getAttribute('aria-label') || '') && !b.disabled);
252
335
  if (sendBtn) sendBtn.click();
253
336
  })()
254
337
  `);
@@ -361,8 +444,9 @@ export async function waitForChatGPTResponse(page, baselineCount, prompt, timeou
361
444
  }
362
445
 
363
446
  export async function getConversationList(page) {
447
+ // ensureOnChatGPT already waits for the composer selector after navigation,
448
+ // so the previous standalone 2 s settle is redundant.
364
449
  await ensureOnChatGPT(page);
365
- await page.wait(2);
366
450
 
367
451
  const openSidebar = await page.evaluate(`(() => {
368
452
  const button = Array.from(document.querySelectorAll('button'))
@@ -373,12 +457,22 @@ export async function getConversationList(page) {
373
457
  }
374
458
  return false;
375
459
  })()`);
376
- if (openSidebar) await page.wait(1);
460
+ if (openSidebar) {
461
+ try {
462
+ await page.wait({ selector: CONVERSATION_LINK_SELECTOR, timeout: 3 });
463
+ } catch {
464
+ // Sidebar slide-in didn't surface conversation links; extractConversationLinks below tolerates empty and falls back to home goto.
465
+ }
466
+ }
377
467
 
378
468
  let items = await extractConversationLinks(page);
379
469
  if (!items.length) {
380
470
  await page.goto(CHATGPT_URL, { settleMs: 2000 });
381
- await page.wait(2);
471
+ try {
472
+ await page.wait({ selector: CONVERSATION_LINK_SELECTOR, timeout: 8 });
473
+ } catch {
474
+ // No conversation links visible after fallback goto; extractConversationLinks returns empty.
475
+ }
382
476
  items = await extractConversationLinks(page);
383
477
  }
384
478
 
@@ -422,6 +516,142 @@ async function extractConversationLinks(page) {
422
516
  : [];
423
517
  }
424
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
+
425
655
  /**
426
656
  * Check if ChatGPT is still generating a response.
427
657
  */
@@ -532,9 +762,13 @@ export async function waitForChatGPTImages(page, beforeUrls, timeoutSeconds, con
532
762
 
533
763
  export const __test__ = {
534
764
  COMPOSER_SELECTORS,
765
+ SEND_BUTTON_SELECTOR,
766
+ SEND_BUTTON_FALLBACK_SELECTORS,
535
767
  SEND_BUTTON_LABELS,
768
+ CLOSE_SIDEBAR_LABELS,
536
769
  isSameChatGPTConversation,
537
770
  parseChatGPTConversationId,
771
+ imageMimeFromPath,
538
772
  };
539
773
 
540
774
  /**
@@ -1,5 +1,17 @@
1
- import { describe, expect, it, vi } from 'vitest';
2
- import { __test__, waitForChatGPTImages } from './utils.js';
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { afterEach, describe, expect, it, vi } from 'vitest';
5
+ import { __test__, prepareChatGPTImagePaths, sendChatGPTMessage, uploadChatGPTImages, waitForChatGPTImages } from './utils.js';
6
+
7
+ const tempDirs = [];
8
+
9
+ afterEach(() => {
10
+ vi.restoreAllMocks();
11
+ while (tempDirs.length) {
12
+ fs.rmSync(tempDirs.pop(), { recursive: true, force: true });
13
+ }
14
+ });
3
15
 
4
16
  function createPageMock({ location = '', generating = [], imageUrls = [] } = {}) {
5
17
  let generatingIndex = 0;
@@ -74,3 +86,155 @@ describe('chatgpt conversation id parsing', () => {
74
86
  expect(() => __test__.parseChatGPTConversationId('https://chatgpt.com/')).toThrow(/conversation id/);
75
87
  });
76
88
  });
89
+
90
+ describe('chatgpt send selectors', () => {
91
+ it('keeps locale-independent send-button selector before aria-label fallbacks', async () => {
92
+ const page = {
93
+ wait: vi.fn().mockResolvedValue(undefined),
94
+ nativeType: vi.fn().mockResolvedValue(undefined),
95
+ evaluate: vi.fn((script) => {
96
+ if (script.includes('findComposer')) return Promise.resolve(true);
97
+ if (script.includes('sendBtnFound')) {
98
+ expect(script).toContain('data-testid=\\\"send-button\\\"');
99
+ return Promise.resolve({ sendBtnFound: true });
100
+ }
101
+ if (script.includes('if (sendBtn) sendBtn.click')) {
102
+ expect(script).toContain('data-testid=\\\"send-button\\\"');
103
+ }
104
+ return Promise.resolve(undefined);
105
+ }),
106
+ };
107
+
108
+ await expect(sendChatGPTMessage(page, 'hello')).resolves.toBe(true);
109
+ });
110
+
111
+ it('uses the composer submit fallback consistently for readiness and click', async () => {
112
+ const page = {
113
+ wait: vi.fn().mockResolvedValue(undefined),
114
+ nativeType: vi.fn().mockResolvedValue(undefined),
115
+ evaluate: vi.fn((script) => {
116
+ if (script.includes('findComposer')) return Promise.resolve(true);
117
+ if (script.includes('sendBtnFound')) {
118
+ expect(script).toContain('#composer-submit-button:not([disabled])');
119
+ return Promise.resolve({ sendBtnFound: true });
120
+ }
121
+ if (script.includes('if (sendBtn) sendBtn.click')) {
122
+ expect(script).toContain('#composer-submit-button:not([disabled])');
123
+ }
124
+ return Promise.resolve(undefined);
125
+ }),
126
+ };
127
+
128
+ await expect(sendChatGPTMessage(page, 'hello')).resolves.toBe(true);
129
+ });
130
+
131
+ it('keeps zh-CN aria and placeholder fallbacks without replacing English selectors', () => {
132
+ expect(__test__.COMPOSER_SELECTORS).toEqual(expect.arrayContaining([
133
+ '[aria-label="Chat with ChatGPT"]',
134
+ '[aria-label="与 ChatGPT 聊天"]',
135
+ '[placeholder="Ask anything"]',
136
+ '[placeholder="有问题,尽管问"]',
137
+ '[data-testid="prompt-textarea"]',
138
+ ]));
139
+ expect(__test__.SEND_BUTTON_SELECTOR).toBe('button[data-testid="send-button"]:not([disabled])');
140
+ expect(__test__.SEND_BUTTON_FALLBACK_SELECTORS).toContain('#composer-submit-button:not([disabled])');
141
+ expect(__test__.SEND_BUTTON_LABELS).toEqual(expect.arrayContaining(['Send prompt', 'Send message', 'Send', '发送提示']));
142
+ expect(__test__.CLOSE_SIDEBAR_LABELS).toEqual(expect.arrayContaining(['Close sidebar', '关闭边栏']));
143
+ });
144
+ });
145
+
146
+ describe('chatgpt image upload helper', () => {
147
+ it('validates local images without a browser page', async () => {
148
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-chatgpt-'));
149
+ tempDirs.push(dir);
150
+ const filePath = path.join(dir, 'cat.png');
151
+ fs.writeFileSync(filePath, 'fake-png');
152
+
153
+ await expect(prepareChatGPTImagePaths([filePath])).resolves.toEqual({ ok: true, paths: [filePath] });
154
+ await expect(prepareChatGPTImagePaths([path.join(dir, 'missing.png')])).resolves.toMatchObject({
155
+ ok: false,
156
+ reason: expect.stringContaining('Image not found'),
157
+ });
158
+ });
159
+
160
+ it('prefers Browser Bridge file input upload and waits for a preview', async () => {
161
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-chatgpt-'));
162
+ tempDirs.push(dir);
163
+ const filePath = path.join(dir, 'cat.png');
164
+ fs.writeFileSync(filePath, 'fake-png');
165
+
166
+ const page = {
167
+ setFileInput: vi.fn().mockResolvedValue(undefined),
168
+ wait: vi.fn().mockResolvedValue(undefined),
169
+ evaluate: vi.fn().mockResolvedValue(true),
170
+ };
171
+
172
+ const result = await uploadChatGPTImages(page, [filePath]);
173
+
174
+ expect(result).toEqual({ ok: true, files: [filePath] });
175
+ expect(page.setFileInput).toHaveBeenCalledWith([filePath], 'input[type="file"]');
176
+ });
177
+
178
+ it('rejects missing files before touching the page', async () => {
179
+ const page = {
180
+ setFileInput: vi.fn(),
181
+ wait: vi.fn(),
182
+ evaluate: vi.fn(),
183
+ };
184
+
185
+ const result = await uploadChatGPTImages(page, ['/no/such/cat.png']);
186
+
187
+ expect(result.ok).toBe(false);
188
+ expect(result.reason).toContain('Image not found');
189
+ expect(page.setFileInput).not.toHaveBeenCalled();
190
+ });
191
+
192
+ it('rejects non-image extensions', async () => {
193
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-chatgpt-'));
194
+ tempDirs.push(dir);
195
+ const filePath = path.join(dir, 'report.pdf');
196
+ fs.writeFileSync(filePath, 'fake');
197
+
198
+ const page = {
199
+ setFileInput: vi.fn(),
200
+ wait: vi.fn(),
201
+ evaluate: vi.fn(),
202
+ };
203
+
204
+ const result = await uploadChatGPTImages(page, [filePath]);
205
+
206
+ expect(result.ok).toBe(false);
207
+ expect(result.reason).toContain('Unsupported image type');
208
+ expect(page.setFileInput).not.toHaveBeenCalled();
209
+ });
210
+
211
+ it('passes a React-compatible change event in fallback upload', async () => {
212
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-chatgpt-'));
213
+ tempDirs.push(dir);
214
+ const filePath = path.join(dir, 'cat.png');
215
+ fs.writeFileSync(filePath, 'fake-png');
216
+
217
+ const page = {
218
+ setFileInput: vi.fn().mockRejectedValue(new Error('No element found')),
219
+ wait: vi.fn().mockResolvedValue(undefined),
220
+ evaluate: vi.fn((script) => {
221
+ return Promise.resolve({ ok: true });
222
+ }),
223
+ };
224
+
225
+ const result = await uploadChatGPTImages(page, [filePath]);
226
+
227
+ expect(result).toEqual({ ok: true, files: [filePath] });
228
+ const fallbackScript = page.evaluate.mock.calls
229
+ .map(([script]) => String(script))
230
+ .find(script => script.includes('new DataTransfer()'));
231
+ expect(fallbackScript).toContain('preventDefault()');
232
+ expect(fallbackScript).toContain('stopPropagation()');
233
+ });
234
+
235
+ it('exposes image MIME inference for fallback upload', () => {
236
+ expect(__test__.imageMimeFromPath('/tmp/a.png')).toBe('image/png');
237
+ expect(__test__.imageMimeFromPath('/tmp/a.webp')).toBe('image/webp');
238
+ expect(__test__.imageMimeFromPath('/tmp/a.jpg')).toBe('image/jpeg');
239
+ });
240
+ });
@@ -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';
@@ -14,7 +15,7 @@ export const askCommand = cli({
14
15
  domain: CLAUDE_DOMAIN,
15
16
  strategy: Strategy.COOKIE,
16
17
  browser: true,
17
- browserSession: { reuse: 'site' },
18
+ siteSession: 'persistent',
18
19
  navigateBefore: false,
19
20
  args: [
20
21
  { name: 'prompt', positional: true, required: true, help: 'Prompt to send' },
@@ -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',
@@ -10,7 +10,7 @@ export const detailCommand = cli({
10
10
  domain: CLAUDE_DOMAIN,
11
11
  strategy: Strategy.COOKIE,
12
12
  browser: true,
13
- browserSession: { reuse: 'site' },
13
+ siteSession: 'persistent',
14
14
  navigateBefore: false,
15
15
  args: [
16
16
  { name: 'id', positional: true, required: true, help: 'Conversation ID (UUID from /chat/<id>)' },
@@ -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);