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