@jackwener/opencli 1.5.9 → 1.6.1

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 (61) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +18 -0
  3. package/SKILL.md +59 -0
  4. package/autoresearch/baseline-browse.txt +1 -0
  5. package/autoresearch/baseline-skill.txt +1 -0
  6. package/autoresearch/browse-tasks.json +688 -0
  7. package/autoresearch/eval-browse.ts +185 -0
  8. package/autoresearch/eval-skill.ts +248 -0
  9. package/autoresearch/run-browse.sh +9 -0
  10. package/autoresearch/run-skill.sh +9 -0
  11. package/bun.lock +615 -0
  12. package/dist/browser/daemon-client.d.ts +20 -1
  13. package/dist/browser/daemon-client.js +37 -30
  14. package/dist/browser/daemon-client.test.d.ts +1 -0
  15. package/dist/browser/daemon-client.test.js +77 -0
  16. package/dist/browser/discover.js +8 -19
  17. package/dist/browser/page.d.ts +4 -0
  18. package/dist/browser/page.js +48 -1
  19. package/dist/cli-manifest.json +2 -2
  20. package/dist/cli.js +392 -0
  21. package/dist/clis/twitter/article.js +28 -1
  22. package/dist/clis/twitter/search.js +67 -5
  23. package/dist/clis/twitter/search.test.js +83 -5
  24. package/dist/clis/xiaohongshu/note.js +11 -0
  25. package/dist/clis/xiaohongshu/note.test.js +49 -0
  26. package/dist/commanderAdapter.js +1 -1
  27. package/dist/commanderAdapter.test.js +43 -0
  28. package/dist/commands/daemon.js +7 -46
  29. package/dist/commands/daemon.test.js +44 -69
  30. package/dist/discovery.js +27 -0
  31. package/dist/types.d.ts +8 -0
  32. package/docs/guide/getting-started.md +21 -0
  33. package/docs/superpowers/specs/2026-04-02-browse-skill-testing-design.md +144 -0
  34. package/docs/zh/guide/getting-started.md +21 -0
  35. package/extension/package-lock.json +2 -2
  36. package/extension/src/background.ts +51 -4
  37. package/extension/src/cdp.ts +77 -124
  38. package/extension/src/protocol.ts +5 -1
  39. package/package.json +1 -1
  40. package/skills/opencli-explorer/SKILL.md +6 -0
  41. package/skills/opencli-oneshot/SKILL.md +6 -0
  42. package/skills/opencli-operate/SKILL.md +213 -0
  43. package/skills/opencli-usage/SKILL.md +113 -32
  44. package/src/browser/daemon-client.test.ts +103 -0
  45. package/src/browser/daemon-client.ts +53 -30
  46. package/src/browser/discover.ts +8 -17
  47. package/src/browser/page.ts +48 -1
  48. package/src/cli.ts +392 -0
  49. package/src/clis/twitter/article.ts +31 -1
  50. package/src/clis/twitter/search.test.ts +88 -5
  51. package/src/clis/twitter/search.ts +68 -5
  52. package/src/clis/xiaohongshu/note.test.ts +51 -0
  53. package/src/clis/xiaohongshu/note.ts +18 -0
  54. package/src/commanderAdapter.test.ts +62 -0
  55. package/src/commanderAdapter.ts +1 -1
  56. package/src/commands/daemon.test.ts +49 -83
  57. package/src/commands/daemon.ts +7 -55
  58. package/src/discovery.ts +22 -0
  59. package/src/doctor.ts +1 -1
  60. package/src/types.ts +8 -0
  61. package/extension/dist/background.js +0 -681
package/dist/cli.js CHANGED
@@ -16,6 +16,12 @@ import { loadExternalClis, executeExternalCli, installExternalCli, registerExter
16
16
  import { registerAllCommands } from './commanderAdapter.js';
17
17
  import { EXIT_CODES, getErrorMessage } from './errors.js';
18
18
  import { daemonStatus, daemonStop, daemonRestart } from './commands/daemon.js';
19
+ /** Create a browser page for operate commands. Uses 'operate' workspace for session persistence. */
20
+ async function getOperatePage() {
21
+ const { BrowserBridge } = await import('./browser/index.js');
22
+ const bridge = new BrowserBridge();
23
+ return bridge.connect({ timeout: 30, workspace: 'operate:default' });
24
+ }
19
25
  export function runCli(BUILTIN_CLIS, USER_CLIS) {
20
26
  const program = new Command();
21
27
  // enablePositionalOptions: prevents parent from consuming flags meant for subcommands;
@@ -210,6 +216,392 @@ export function runCli(BUILTIN_CLIS, USER_CLIS) {
210
216
  }, { workspace });
211
217
  console.log(renderCascadeResult(result));
212
218
  });
219
+ // ── Built-in: operate (browser control for Claude Code skill) ───────────────
220
+ //
221
+ // Make websites accessible for AI agents.
222
+ // All commands wrapped in operateAction() for consistent error handling.
223
+ const operate = program
224
+ .command('operate')
225
+ .description('Browser control — navigate, click, type, extract, wait (no LLM needed)');
226
+ /** Wrap operate actions with error handling and optional --json output */
227
+ function operateAction(fn) {
228
+ return async (...args) => {
229
+ try {
230
+ const page = await getOperatePage();
231
+ await fn(page, ...args);
232
+ }
233
+ catch (err) {
234
+ const msg = err instanceof Error ? err.message : String(err);
235
+ if (msg.includes('Extension not connected') || msg.includes('Daemon')) {
236
+ console.error(`Browser not connected. Run 'opencli doctor' to diagnose.`);
237
+ }
238
+ else if (msg.includes('attach failed') || msg.includes('chrome-extension://')) {
239
+ console.error(`Browser attach failed — another extension may be interfering. Try disabling 1Password.`);
240
+ }
241
+ else {
242
+ console.error(`Error: ${msg}`);
243
+ }
244
+ process.exitCode = EXIT_CODES.GENERIC_ERROR;
245
+ }
246
+ };
247
+ }
248
+ // ── Navigation ──
249
+ /** Network interceptor JS — injected on every open/navigate to capture fetch/XHR */
250
+ const NETWORK_INTERCEPTOR_JS = `(function(){if(window.__opencli_net)return;window.__opencli_net=[];var M=200,B=50000,F=window.fetch;window.fetch=async function(){var r=await F.apply(this,arguments);try{var ct=r.headers.get('content-type')||'';if(ct.includes('json')||ct.includes('text')){var c=r.clone(),t=await c.text();if(window.__opencli_net.length<M){var b=null;if(t.length<=B)try{b=JSON.parse(t)}catch(e){b=t}window.__opencli_net.push({url:r.url||(arguments[0]&&arguments[0].url)||String(arguments[0]),method:(arguments[1]&&arguments[1].method)||'GET',status:r.status,size:t.length,ct:ct,body:b})}}}catch(e){}return r};var X=XMLHttpRequest.prototype,O=X.open,S=X.send;X.open=function(m,u){this._om=m;this._ou=u;return O.apply(this,arguments)};X.send=function(){var x=this;x.addEventListener('load',function(){try{var ct=x.getResponseHeader('content-type')||'';if((ct.includes('json')||ct.includes('text'))&&window.__opencli_net.length<M){var t=x.responseText,b=null;if(t&&t.length<=B)try{b=JSON.parse(t)}catch(e){b=t}window.__opencli_net.push({url:x._ou,method:x._om||'GET',status:x.status,size:t?t.length:0,ct:ct,body:b})}}catch(e){}});return S.apply(this,arguments)}})()`;
251
+ operate.command('open').argument('<url>').description('Open URL in automation window')
252
+ .action(operateAction(async (page, url) => {
253
+ await page.goto(url);
254
+ await page.wait(2);
255
+ // Auto-inject network interceptor for API discovery
256
+ try {
257
+ await page.evaluate(NETWORK_INTERCEPTOR_JS);
258
+ }
259
+ catch { /* non-fatal */ }
260
+ console.log(`Navigated to: ${await page.getCurrentUrl?.() ?? url}`);
261
+ }));
262
+ operate.command('back').description('Go back in browser history')
263
+ .action(operateAction(async (page) => {
264
+ await page.evaluate('history.back()');
265
+ await page.wait(2);
266
+ console.log('Navigated back');
267
+ }));
268
+ operate.command('scroll').argument('<direction>', 'up or down').option('--amount <pixels>', 'Pixels to scroll', '500')
269
+ .description('Scroll page')
270
+ .action(operateAction(async (page, direction, opts) => {
271
+ if (direction !== 'up' && direction !== 'down') {
272
+ console.error(`Invalid direction "${direction}". Use "up" or "down".`);
273
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
274
+ return;
275
+ }
276
+ await page.scroll(direction, parseInt(opts.amount, 10));
277
+ console.log(`Scrolled ${direction}`);
278
+ }));
279
+ // ── Inspect ──
280
+ operate.command('state').description('Page state: URL, title, interactive elements with [N] indices')
281
+ .action(operateAction(async (page) => {
282
+ const snapshot = await page.snapshot({ viewportExpand: 800 });
283
+ const url = await page.getCurrentUrl?.() ?? '';
284
+ console.log(`URL: ${url}\n`);
285
+ console.log(typeof snapshot === 'string' ? snapshot : JSON.stringify(snapshot, null, 2));
286
+ }));
287
+ operate.command('screenshot').argument('[path]', 'Save to file (base64 if omitted)')
288
+ .description('Take screenshot')
289
+ .action(operateAction(async (page, path) => {
290
+ if (path) {
291
+ await page.screenshot({ path });
292
+ console.log(`Screenshot saved to: ${path}`);
293
+ }
294
+ else {
295
+ console.log(await page.screenshot({ format: 'png' }));
296
+ }
297
+ }));
298
+ // ── Get commands (structured data extraction) ──
299
+ const get = operate.command('get').description('Get page properties');
300
+ get.command('title').description('Page title')
301
+ .action(operateAction(async (page) => {
302
+ console.log(await page.evaluate('document.title'));
303
+ }));
304
+ get.command('url').description('Current page URL')
305
+ .action(operateAction(async (page) => {
306
+ console.log(await page.getCurrentUrl?.() ?? await page.evaluate('location.href'));
307
+ }));
308
+ get.command('text').argument('<index>', 'Element index').description('Element text content')
309
+ .action(operateAction(async (page, index) => {
310
+ const text = await page.evaluate(`document.querySelector('[data-opencli-ref="${index}"]')?.textContent?.trim()`);
311
+ console.log(text ?? '(empty)');
312
+ }));
313
+ get.command('value').argument('<index>', 'Element index').description('Input/textarea value')
314
+ .action(operateAction(async (page, index) => {
315
+ const val = await page.evaluate(`document.querySelector('[data-opencli-ref="${index}"]')?.value`);
316
+ console.log(val ?? '(empty)');
317
+ }));
318
+ get.command('html').option('--selector <css>', 'CSS selector scope').description('Page HTML (or scoped)')
319
+ .action(operateAction(async (page, opts) => {
320
+ const sel = opts.selector ? JSON.stringify(opts.selector) : 'null';
321
+ const html = await page.evaluate(`(${sel} ? document.querySelector(${sel})?.outerHTML : document.documentElement.outerHTML)?.slice(0, 50000)`);
322
+ console.log(html ?? '(empty)');
323
+ }));
324
+ get.command('attributes').argument('<index>', 'Element index').description('Element attributes')
325
+ .action(operateAction(async (page, index) => {
326
+ const attrs = await page.evaluate(`JSON.stringify(Object.fromEntries([...document.querySelector('[data-opencli-ref="${index}"]')?.attributes].map(a=>[a.name,a.value])))`);
327
+ console.log(attrs ?? '{}');
328
+ }));
329
+ // ── Interact ──
330
+ operate.command('click').argument('<index>', 'Element index from state').description('Click element by index')
331
+ .action(operateAction(async (page, index) => {
332
+ await page.click(index);
333
+ console.log(`Clicked element [${index}]`);
334
+ }));
335
+ operate.command('type').argument('<index>', 'Element index').argument('<text>', 'Text to type')
336
+ .description('Click element, then type text')
337
+ .action(operateAction(async (page, index, text) => {
338
+ await page.click(index);
339
+ await page.wait(0.3);
340
+ await page.typeText(index, text);
341
+ console.log(`Typed "${text}" into element [${index}]`);
342
+ }));
343
+ operate.command('select').argument('<index>', 'Element index of <select>').argument('<option>', 'Option text')
344
+ .description('Select dropdown option')
345
+ .action(operateAction(async (page, index, option) => {
346
+ const result = await page.evaluate(`
347
+ (function() {
348
+ var sel = document.querySelector('[data-opencli-ref="${index}"]');
349
+ if (!sel || sel.tagName !== 'SELECT') return { error: 'Not a <select>' };
350
+ var match = Array.from(sel.options).find(o => o.text.trim() === ${JSON.stringify(option)} || o.value === ${JSON.stringify(option)});
351
+ if (!match) return { error: 'Option not found', available: Array.from(sel.options).map(o => o.text.trim()) };
352
+ var setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')?.set;
353
+ if (setter) setter.call(sel, match.value); else sel.value = match.value;
354
+ sel.dispatchEvent(new Event('input', {bubbles:true}));
355
+ sel.dispatchEvent(new Event('change', {bubbles:true}));
356
+ return { selected: match.text };
357
+ })()
358
+ `);
359
+ if (result?.error) {
360
+ console.error(`Error: ${result.error}${result.available ? ` — Available: ${result.available.join(', ')}` : ''}`);
361
+ process.exitCode = EXIT_CODES.GENERIC_ERROR;
362
+ }
363
+ else {
364
+ console.log(`Selected "${result?.selected}" in element [${index}]`);
365
+ }
366
+ }));
367
+ operate.command('keys').argument('<key>', 'Key to press (Enter, Escape, Tab, Control+a)')
368
+ .description('Press keyboard key')
369
+ .action(operateAction(async (page, key) => {
370
+ await page.pressKey(key);
371
+ console.log(`Pressed: ${key}`);
372
+ }));
373
+ // ── Wait commands ──
374
+ operate.command('wait')
375
+ .argument('<type>', 'selector, text, or time')
376
+ .argument('[value]', 'CSS selector, text string, or seconds')
377
+ .option('--timeout <ms>', 'Timeout in milliseconds', '10000')
378
+ .description('Wait for selector, text, or time (e.g. wait selector ".loaded", wait text "Success", wait time 3)')
379
+ .action(operateAction(async (page, type, value, opts) => {
380
+ const timeout = parseInt(opts.timeout, 10);
381
+ if (type === 'time') {
382
+ const seconds = parseFloat(value ?? '2');
383
+ await page.wait(seconds);
384
+ console.log(`Waited ${seconds}s`);
385
+ }
386
+ else if (type === 'selector') {
387
+ if (!value) {
388
+ console.error('Missing CSS selector');
389
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
390
+ return;
391
+ }
392
+ await page.wait({ selector: value, timeout: timeout / 1000 });
393
+ console.log(`Element "${value}" appeared`);
394
+ }
395
+ else if (type === 'text') {
396
+ if (!value) {
397
+ console.error('Missing text');
398
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
399
+ return;
400
+ }
401
+ await page.wait({ text: value, timeout: timeout / 1000 });
402
+ console.log(`Text "${value}" appeared`);
403
+ }
404
+ else {
405
+ console.error(`Unknown wait type "${type}". Use: selector, text, or time`);
406
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
407
+ }
408
+ }));
409
+ // ── Extract ──
410
+ operate.command('eval').argument('<js>', 'JavaScript code').description('Execute JS in page context, return result')
411
+ .action(operateAction(async (page, js) => {
412
+ const result = await page.evaluate(js);
413
+ if (typeof result === 'string')
414
+ console.log(result);
415
+ else
416
+ console.log(JSON.stringify(result, null, 2));
417
+ }));
418
+ // ── Network (API discovery) ──
419
+ operate.command('network')
420
+ .option('--detail <index>', 'Show full response body of request at index')
421
+ .option('--all', 'Show all requests including static resources')
422
+ .description('Show captured network requests (auto-captured since last open)')
423
+ .action(operateAction(async (page, opts) => {
424
+ const requests = await page.evaluate(`(function(){
425
+ var reqs = window.__opencli_net || [];
426
+ return JSON.stringify(reqs);
427
+ })()`);
428
+ let items = [];
429
+ try {
430
+ items = JSON.parse(requests);
431
+ }
432
+ catch {
433
+ console.log('No network data captured. Run "operate open <url>" first.');
434
+ return;
435
+ }
436
+ if (items.length === 0) {
437
+ console.log('No requests captured.');
438
+ return;
439
+ }
440
+ // Filter out static resources unless --all
441
+ if (!opts.all) {
442
+ items = items.filter(r => (r.ct?.includes('json') || r.ct?.includes('xml') || r.ct?.includes('text/plain')) &&
443
+ !/\.(js|css|png|jpg|gif|svg|woff|ico|map)(\?|$)/i.test(r.url) &&
444
+ !/analytics|tracking|telemetry|beacon|pixel|gtag|fbevents/i.test(r.url));
445
+ }
446
+ if (opts.detail !== undefined) {
447
+ const idx = parseInt(opts.detail, 10);
448
+ const req = items[idx];
449
+ if (!req) {
450
+ console.error(`Request #${idx} not found. ${items.length} requests available.`);
451
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
452
+ return;
453
+ }
454
+ console.log(`${req.method} ${req.url}`);
455
+ console.log(`Status: ${req.status} | Size: ${req.size} | Type: ${req.ct}`);
456
+ console.log('---');
457
+ console.log(typeof req.body === 'string' ? req.body : JSON.stringify(req.body, null, 2));
458
+ }
459
+ else {
460
+ console.log(`Captured ${items.length} API requests:\n`);
461
+ items.forEach((r, i) => {
462
+ const bodyPreview = r.body ? (typeof r.body === 'string' ? r.body.slice(0, 60) : JSON.stringify(r.body).slice(0, 60)) : '';
463
+ console.log(` [${i}] ${r.method} ${r.status} ${r.url.slice(0, 80)}`);
464
+ if (bodyPreview)
465
+ console.log(` ${bodyPreview}...`);
466
+ });
467
+ console.log(`\nUse --detail <index> to see full response body.`);
468
+ }
469
+ }));
470
+ // ── Init (adapter scaffolding) ──
471
+ operate.command('init')
472
+ .argument('<name>', 'Adapter name in site/command format (e.g. hn/top)')
473
+ .description('Generate adapter scaffold in ~/.opencli/clis/')
474
+ .action(async (name) => {
475
+ try {
476
+ const parts = name.split('/');
477
+ if (parts.length !== 2 || !parts[0] || !parts[1]) {
478
+ console.error('Name must be site/command format (e.g. hn/top)');
479
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
480
+ return;
481
+ }
482
+ const [site, command] = parts;
483
+ if (!/^[a-zA-Z0-9_-]+$/.test(site) || !/^[a-zA-Z0-9_-]+$/.test(command)) {
484
+ console.error('Name parts must be alphanumeric/dash/underscore only');
485
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
486
+ return;
487
+ }
488
+ const os = await import('node:os');
489
+ const fs = await import('node:fs');
490
+ const path = await import('node:path');
491
+ const dir = path.join(os.homedir(), '.opencli', 'clis', site);
492
+ const filePath = path.join(dir, `${command}.ts`);
493
+ if (fs.existsSync(filePath)) {
494
+ console.log(`Adapter already exists: ${filePath}`);
495
+ return;
496
+ }
497
+ // Try to detect domain from last operate session
498
+ let domain = site;
499
+ try {
500
+ const page = await getOperatePage();
501
+ const url = await page.getCurrentUrl?.();
502
+ if (url) {
503
+ try {
504
+ domain = new URL(url).hostname;
505
+ }
506
+ catch { }
507
+ }
508
+ }
509
+ catch { /* no active session */ }
510
+ const template = `import { cli, Strategy } from '@jackwener/opencli/registry';
511
+
512
+ cli({
513
+ site: '${site}',
514
+ name: '${command}',
515
+ description: '', // TODO: describe what this command does
516
+ domain: '${domain}',
517
+ strategy: Strategy.PUBLIC, // TODO: PUBLIC (no auth), COOKIE (needs login), UI (DOM interaction)
518
+ browser: false, // TODO: set true if needs browser
519
+ args: [
520
+ { name: 'limit', type: 'int', default: 10, help: 'Number of items' },
521
+ ],
522
+ columns: [], // TODO: field names for table output (e.g. ['title', 'score', 'url'])
523
+ func: async (page, kwargs) => {
524
+ // TODO: implement data fetching
525
+ // Prefer API calls (fetch) over browser automation
526
+ // page is available if browser: true
527
+ return [];
528
+ },
529
+ });
530
+ `;
531
+ fs.mkdirSync(dir, { recursive: true });
532
+ fs.writeFileSync(filePath, template, 'utf-8');
533
+ console.log(`Created: ${filePath}`);
534
+ console.log(`Edit the file to implement your adapter, then run: opencli operate verify ${name}`);
535
+ }
536
+ catch (err) {
537
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
538
+ process.exitCode = EXIT_CODES.GENERIC_ERROR;
539
+ }
540
+ });
541
+ // ── Verify (test adapter) ──
542
+ operate.command('verify')
543
+ .argument('<name>', 'Adapter name in site/command format (e.g. hn/top)')
544
+ .description('Execute an adapter and show results')
545
+ .action(async (name) => {
546
+ try {
547
+ const parts = name.split('/');
548
+ if (parts.length !== 2) {
549
+ console.error('Name must be site/command format');
550
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
551
+ return;
552
+ }
553
+ const [site, command] = parts;
554
+ if (!/^[a-zA-Z0-9_-]+$/.test(site) || !/^[a-zA-Z0-9_-]+$/.test(command)) {
555
+ console.error('Name parts must be alphanumeric/dash/underscore only');
556
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
557
+ return;
558
+ }
559
+ const { execSync } = await import('node:child_process');
560
+ const os = await import('node:os');
561
+ const path = await import('node:path');
562
+ const filePath = path.join(os.homedir(), '.opencli', 'clis', site, `${command}.ts`);
563
+ const fs = await import('node:fs');
564
+ if (!fs.existsSync(filePath)) {
565
+ console.error(`Adapter not found: ${filePath}`);
566
+ console.error(`Run "opencli operate init ${name}" to create it.`);
567
+ process.exitCode = EXIT_CODES.GENERIC_ERROR;
568
+ return;
569
+ }
570
+ console.log(`🔍 Verifying ${name}...\n`);
571
+ console.log(` Loading: ${filePath}`);
572
+ try {
573
+ const output = execSync(`node dist/main.js ${site} ${command} --limit 3`, {
574
+ cwd: path.join(path.dirname(import.meta.url.replace('file://', '')), '..'),
575
+ timeout: 30000,
576
+ encoding: 'utf-8',
577
+ env: process.env,
578
+ stdio: ['pipe', 'pipe', 'pipe'],
579
+ });
580
+ console.log(` Executing: opencli ${site} ${command} --limit 3\n`);
581
+ console.log(output);
582
+ console.log(`\n ✓ Adapter works!`);
583
+ }
584
+ catch (err) {
585
+ console.log(` Executing: opencli ${site} ${command} --limit 3\n`);
586
+ if (err.stdout)
587
+ console.log(err.stdout);
588
+ if (err.stderr)
589
+ console.error(err.stderr.slice(0, 500));
590
+ console.log(`\n ✗ Adapter failed. Fix the code and try again.`);
591
+ process.exitCode = EXIT_CODES.GENERIC_ERROR;
592
+ }
593
+ }
594
+ catch (err) {
595
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
596
+ process.exitCode = EXIT_CODES.GENERIC_ERROR;
597
+ }
598
+ });
599
+ // ── Session ──
600
+ operate.command('close').description('Close the automation window')
601
+ .action(operateAction(async (page) => {
602
+ await page.closeWindow?.();
603
+ console.log('Automation window closed');
604
+ }));
213
605
  // ── Built-in: doctor / completion ──────────────────────────────────────────
214
606
  program
215
607
  .command('doctor')
@@ -14,11 +14,38 @@ cli({
14
14
  ],
15
15
  columns: ['title', 'author', 'content', 'url'],
16
16
  func: async (page, kwargs) => {
17
- // Extract tweet ID from URL if needed
17
+ // Extract tweet ID from URL if needed.
18
+ // Article URLs (x.com/i/article/{articleId}) use a different ID than
19
+ // tweet status URLs — the GraphQL endpoint needs the parent tweet ID.
18
20
  let tweetId = kwargs['tweet-id'];
21
+ const isArticleUrl = /\/article\/\d+/.test(tweetId);
19
22
  const urlMatch = tweetId.match(/\/(?:status|article)\/(\d+)/);
20
23
  if (urlMatch)
21
24
  tweetId = urlMatch[1];
25
+ if (isArticleUrl) {
26
+ // Navigate to the article page and resolve the parent tweet ID from DOM
27
+ await page.goto(`https://x.com/i/article/${tweetId}`);
28
+ await page.wait(3);
29
+ const resolvedId = await page.evaluate(`
30
+ (function() {
31
+ var links = document.querySelectorAll('a[href*="/status/"]');
32
+ for (var i = 0; i < links.length; i++) {
33
+ var m = links[i].href.match(/\\/status\\/(\\d+)/);
34
+ if (m) return m[1];
35
+ }
36
+ var og = document.querySelector('meta[property="og:url"]');
37
+ if (og && og.content) {
38
+ var m2 = og.content.match(/\\/status\\/(\\d+)/);
39
+ if (m2) return m2[1];
40
+ }
41
+ return null;
42
+ })()
43
+ `);
44
+ if (!resolvedId || typeof resolvedId !== 'string') {
45
+ throw new CommandExecutionError(`Could not resolve article ${tweetId} to a tweet ID. The article page may not contain a linked tweet.`);
46
+ }
47
+ tweetId = resolvedId;
48
+ }
22
49
  // Navigate to the tweet page for cookie context
23
50
  await page.goto(`https://x.com/i/status/${tweetId}`);
24
51
  await page.wait(3);
@@ -1,15 +1,18 @@
1
1
  import { CommandExecutionError } from '../../errors.js';
2
2
  import { cli, Strategy } from '../../registry.js';
3
3
  /**
4
- * Trigger Twitter search SPA navigation and retry once on transient failures.
4
+ * Trigger Twitter search SPA navigation with fallback strategies.
5
5
  *
6
- * Twitter/X sometimes keeps the page on /explore for a short period even after
7
- * pushState + popstate. A second attempt is enough for the intermittent cases
8
- * reported in issue #353 while keeping the flow narrowly scoped.
6
+ * Primary: pushState + popstate (works in most environments).
7
+ * Fallback: Type into the search input and press Enter when pushState fails
8
+ * intermittently (e.g. due to Twitter A/B tests or timing races — see #690).
9
+ *
10
+ * Both strategies preserve the JS context so the fetch interceptor stays alive.
9
11
  */
10
12
  async function navigateToSearch(page, query, filter) {
11
13
  const searchUrl = JSON.stringify(`/search?q=${encodeURIComponent(query)}&f=${filter}`);
12
14
  let lastPath = '';
15
+ // Strategy 1 (primary): pushState + popstate with retry
13
16
  for (let attempt = 1; attempt <= 2; attempt++) {
14
17
  await page.evaluate(`
15
18
  (() => {
@@ -17,7 +20,12 @@ async function navigateToSearch(page, query, filter) {
17
20
  window.dispatchEvent(new PopStateEvent('popstate', { state: {} }));
18
21
  })()
19
22
  `);
20
- await page.wait({ selector: '[data-testid="primaryColumn"]' });
23
+ try {
24
+ await page.wait({ selector: '[data-testid="primaryColumn"]' });
25
+ }
26
+ catch {
27
+ // selector timeout — fall through to path check or next attempt
28
+ }
21
29
  lastPath = String(await page.evaluate('() => window.location.pathname') || '');
22
30
  if (lastPath.startsWith('/search')) {
23
31
  return;
@@ -26,6 +34,60 @@ async function navigateToSearch(page, query, filter) {
26
34
  await page.wait(1);
27
35
  }
28
36
  }
37
+ // Strategy 2 (fallback): Use the search input on /explore.
38
+ // The nativeSetter + Enter approach triggers Twitter's own form handler,
39
+ // performing SPA navigation without a full page reload.
40
+ const queryStr = JSON.stringify(query);
41
+ const navResult = await page.evaluate(`(async () => {
42
+ try {
43
+ const input = document.querySelector('[data-testid="SearchBox_Search_Input"]');
44
+ if (!input) return { ok: false };
45
+
46
+ input.focus();
47
+ await new Promise(r => setTimeout(r, 300));
48
+
49
+ const nativeSetter = Object.getOwnPropertyDescriptor(
50
+ window.HTMLInputElement.prototype, 'value'
51
+ )?.set;
52
+ if (!nativeSetter) return { ok: false };
53
+ nativeSetter.call(input, ${queryStr});
54
+ input.dispatchEvent(new Event('input', { bubbles: true }));
55
+ input.dispatchEvent(new Event('change', { bubbles: true }));
56
+ await new Promise(r => setTimeout(r, 500));
57
+
58
+ input.dispatchEvent(new KeyboardEvent('keydown', {
59
+ key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true
60
+ }));
61
+
62
+ return { ok: true };
63
+ } catch {
64
+ return { ok: false };
65
+ }
66
+ })()`);
67
+ if (navResult?.ok) {
68
+ try {
69
+ await page.wait({ selector: '[data-testid="primaryColumn"]' });
70
+ }
71
+ catch {
72
+ // fall through to path check
73
+ }
74
+ lastPath = String(await page.evaluate('() => window.location.pathname') || '');
75
+ if (lastPath.startsWith('/search')) {
76
+ if (filter === 'live') {
77
+ await page.evaluate(`(() => {
78
+ const tabs = document.querySelectorAll('[role="tab"]');
79
+ for (const tab of tabs) {
80
+ if (tab.textContent.includes('Latest') || tab.textContent.includes('最新')) {
81
+ tab.click();
82
+ return;
83
+ }
84
+ }
85
+ })()`);
86
+ await page.wait(2);
87
+ }
88
+ return;
89
+ }
90
+ }
29
91
  throw new CommandExecutionError(`SPA navigation to /search failed. Final path: ${lastPath || '(empty)'}. Twitter may have changed its routing.`);
30
92
  }
31
93
  cli({
@@ -132,14 +132,92 @@ describe('twitter search command', () => {
132
132
  const pushStateCall = evaluate.mock.calls[0][0];
133
133
  expect(pushStateCall).toContain('f=top');
134
134
  });
135
+ it('falls back to search input when pushState fails twice', async () => {
136
+ const command = getRegistry().get('twitter/search');
137
+ expect(command?.func).toBeTypeOf('function');
138
+ const evaluate = vi.fn()
139
+ .mockResolvedValueOnce(undefined) // pushState attempt 1
140
+ .mockResolvedValueOnce('/explore') // pathname check 1 — not /search
141
+ .mockResolvedValueOnce(undefined) // pushState attempt 2
142
+ .mockResolvedValueOnce('/explore') // pathname check 2 — still not /search
143
+ .mockResolvedValueOnce({ ok: true }) // search input fallback succeeds
144
+ .mockResolvedValueOnce('/search'); // pathname check after fallback
145
+ const page = {
146
+ goto: vi.fn().mockResolvedValue(undefined),
147
+ wait: vi.fn().mockResolvedValue(undefined),
148
+ installInterceptor: vi.fn().mockResolvedValue(undefined),
149
+ evaluate,
150
+ autoScroll: vi.fn().mockResolvedValue(undefined),
151
+ getInterceptedRequests: vi.fn().mockResolvedValue([
152
+ {
153
+ data: {
154
+ search_by_raw_query: {
155
+ search_timeline: {
156
+ timeline: {
157
+ instructions: [
158
+ {
159
+ type: 'TimelineAddEntries',
160
+ entries: [
161
+ {
162
+ entryId: 'tweet-99',
163
+ content: {
164
+ itemContent: {
165
+ tweet_results: {
166
+ result: {
167
+ rest_id: '99',
168
+ legacy: {
169
+ full_text: 'fallback works',
170
+ favorite_count: 3,
171
+ created_at: 'Wed Apr 02 12:00:00 +0000 2026',
172
+ },
173
+ core: {
174
+ user_results: {
175
+ result: {
176
+ core: { screen_name: 'bob' },
177
+ },
178
+ },
179
+ },
180
+ views: { count: '5' },
181
+ },
182
+ },
183
+ },
184
+ },
185
+ },
186
+ ],
187
+ },
188
+ ],
189
+ },
190
+ },
191
+ },
192
+ },
193
+ },
194
+ ]),
195
+ };
196
+ const result = await command.func(page, { query: 'test fallback', filter: 'top', limit: 5 });
197
+ expect(result).toEqual([
198
+ {
199
+ id: '99',
200
+ author: 'bob',
201
+ text: 'fallback works',
202
+ created_at: 'Wed Apr 02 12:00:00 +0000 2026',
203
+ likes: 3,
204
+ views: '5',
205
+ url: 'https://x.com/i/status/99',
206
+ },
207
+ ]);
208
+ // 6 evaluate calls: 2x pushState + 2x pathname check + 1x fallback + 1x pathname check
209
+ expect(evaluate).toHaveBeenCalledTimes(6);
210
+ expect(page.autoScroll).toHaveBeenCalled();
211
+ });
135
212
  it('throws with the final path after both attempts fail', async () => {
136
213
  const command = getRegistry().get('twitter/search');
137
214
  expect(command?.func).toBeTypeOf('function');
138
215
  const evaluate = vi.fn()
139
- .mockResolvedValueOnce(undefined)
140
- .mockResolvedValueOnce('/explore')
141
- .mockResolvedValueOnce(undefined)
142
- .mockResolvedValueOnce('/login');
216
+ .mockResolvedValueOnce(undefined) // pushState attempt 1
217
+ .mockResolvedValueOnce('/explore') // pathname check 1
218
+ .mockResolvedValueOnce(undefined) // pushState attempt 2
219
+ .mockResolvedValueOnce('/login') // pathname check 2
220
+ .mockResolvedValueOnce({ ok: false }); // search input fallback
143
221
  const page = {
144
222
  goto: vi.fn().mockResolvedValue(undefined),
145
223
  wait: vi.fn().mockResolvedValue(undefined),
@@ -153,6 +231,6 @@ describe('twitter search command', () => {
153
231
  .toThrow('Final path: /login');
154
232
  expect(page.autoScroll).not.toHaveBeenCalled();
155
233
  expect(page.getInterceptedRequests).not.toHaveBeenCalled();
156
- expect(evaluate).toHaveBeenCalledTimes(4);
234
+ expect(evaluate).toHaveBeenCalledTimes(5);
157
235
  });
158
236
  });