mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-18 10:31:30 +08:00
feat: 40+ browser commands (read, write, meta)
Read: text, html, links, forms, accessibility, js, eval, css, attrs, console, network, cookies, storage, perf Write: goto, back, forward, reload, click, fill, select, hover, type, press, scroll, wait, viewport, cookie, header, useragent Meta: tabs, tab, newtab, closetab, status, url, stop, restart, screenshot, pdf, responsive, chain, diff Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
198
src/read-commands.ts
Normal file
198
src/read-commands.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Read commands — extract data from pages without side effects
|
||||
*
|
||||
* text, html, links, forms, accessibility, js, eval, css, attrs,
|
||||
* console, network, cookies, storage, perf
|
||||
*/
|
||||
|
||||
import type { BrowserManager } from './browser-manager';
|
||||
import { consoleBuffer, networkBuffer } from './buffers';
|
||||
import * as fs from 'fs';
|
||||
|
||||
export async function handleReadCommand(
|
||||
command: string,
|
||||
args: string[],
|
||||
bm: BrowserManager
|
||||
): Promise<string> {
|
||||
const page = bm.getPage();
|
||||
|
||||
switch (command) {
|
||||
case 'text': {
|
||||
return await page.evaluate(() => {
|
||||
const body = document.body;
|
||||
if (!body) return '';
|
||||
const clone = body.cloneNode(true) as HTMLElement;
|
||||
clone.querySelectorAll('script, style, noscript, svg').forEach(el => el.remove());
|
||||
return clone.innerText
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0)
|
||||
.join('\n');
|
||||
});
|
||||
}
|
||||
|
||||
case 'html': {
|
||||
const selector = args[0];
|
||||
if (selector) {
|
||||
return await page.innerHTML(selector);
|
||||
}
|
||||
return await page.content();
|
||||
}
|
||||
|
||||
case 'links': {
|
||||
const links = await page.evaluate(() =>
|
||||
[...document.querySelectorAll('a[href]')].map(a => ({
|
||||
text: a.textContent?.trim().slice(0, 120) || '',
|
||||
href: (a as HTMLAnchorElement).href,
|
||||
})).filter(l => l.text && l.href)
|
||||
);
|
||||
return links.map(l => `${l.text} → ${l.href}`).join('\n');
|
||||
}
|
||||
|
||||
case 'forms': {
|
||||
const forms = await page.evaluate(() => {
|
||||
return [...document.querySelectorAll('form')].map((form, i) => {
|
||||
const fields = [...form.querySelectorAll('input, select, textarea')].map(el => {
|
||||
const input = el as HTMLInputElement;
|
||||
return {
|
||||
tag: el.tagName.toLowerCase(),
|
||||
type: input.type || undefined,
|
||||
name: input.name || undefined,
|
||||
id: input.id || undefined,
|
||||
placeholder: input.placeholder || undefined,
|
||||
required: input.required || undefined,
|
||||
value: input.value || undefined,
|
||||
options: el.tagName === 'SELECT'
|
||||
? [...(el as HTMLSelectElement).options].map(o => ({ value: o.value, text: o.text }))
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
return {
|
||||
index: i,
|
||||
action: form.action || undefined,
|
||||
method: form.method || 'get',
|
||||
id: form.id || undefined,
|
||||
fields,
|
||||
};
|
||||
});
|
||||
});
|
||||
return JSON.stringify(forms, null, 2);
|
||||
}
|
||||
|
||||
case 'accessibility': {
|
||||
const snapshot = await page.locator("body").ariaSnapshot();
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
case 'js': {
|
||||
const expr = args[0];
|
||||
if (!expr) throw new Error('Usage: browse js <expression>');
|
||||
const result = await page.evaluate(expr);
|
||||
return typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result ?? '');
|
||||
}
|
||||
|
||||
case 'eval': {
|
||||
const filePath = args[0];
|
||||
if (!filePath) throw new Error('Usage: browse eval <js-file>');
|
||||
if (!fs.existsSync(filePath)) throw new Error(`File not found: ${filePath}`);
|
||||
const code = fs.readFileSync(filePath, 'utf-8');
|
||||
const result = await page.evaluate(code);
|
||||
return typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result ?? '');
|
||||
}
|
||||
|
||||
case 'css': {
|
||||
const [selector, property] = args;
|
||||
if (!selector || !property) throw new Error('Usage: browse css <selector> <property>');
|
||||
const value = await page.evaluate(
|
||||
([sel, prop]) => {
|
||||
const el = document.querySelector(sel);
|
||||
if (!el) return `Element not found: ${sel}`;
|
||||
return getComputedStyle(el).getPropertyValue(prop);
|
||||
},
|
||||
[selector, property]
|
||||
);
|
||||
return value;
|
||||
}
|
||||
|
||||
case 'attrs': {
|
||||
const selector = args[0];
|
||||
if (!selector) throw new Error('Usage: browse attrs <selector>');
|
||||
const attrs = await page.evaluate((sel) => {
|
||||
const el = document.querySelector(sel);
|
||||
if (!el) return `Element not found: ${sel}`;
|
||||
const result: Record<string, string> = {};
|
||||
for (const attr of el.attributes) {
|
||||
result[attr.name] = attr.value;
|
||||
}
|
||||
return result;
|
||||
}, selector);
|
||||
return typeof attrs === 'string' ? attrs : JSON.stringify(attrs, null, 2);
|
||||
}
|
||||
|
||||
case 'console': {
|
||||
if (args[0] === '--clear') {
|
||||
consoleBuffer.length = 0;
|
||||
return 'Console buffer cleared.';
|
||||
}
|
||||
if (consoleBuffer.length === 0) return '(no console messages)';
|
||||
return consoleBuffer.map(e =>
|
||||
`[${new Date(e.timestamp).toISOString()}] [${e.level}] ${e.text}`
|
||||
).join('\n');
|
||||
}
|
||||
|
||||
case 'network': {
|
||||
if (args[0] === '--clear') {
|
||||
networkBuffer.length = 0;
|
||||
return 'Network buffer cleared.';
|
||||
}
|
||||
if (networkBuffer.length === 0) return '(no network requests)';
|
||||
return networkBuffer.map(e =>
|
||||
`${e.method} ${e.url} → ${e.status || 'pending'} (${e.duration || '?'}ms, ${e.size || '?'}B)`
|
||||
).join('\n');
|
||||
}
|
||||
|
||||
case 'cookies': {
|
||||
const cookies = await page.context().cookies();
|
||||
return JSON.stringify(cookies, null, 2);
|
||||
}
|
||||
|
||||
case 'storage': {
|
||||
if (args[0] === 'set' && args[1]) {
|
||||
const key = args[1];
|
||||
const value = args[2] || '';
|
||||
await page.evaluate(([k, v]) => localStorage.setItem(k, v), [key, value]);
|
||||
return `Set localStorage["${key}"] = "${value}"`;
|
||||
}
|
||||
const storage = await page.evaluate(() => ({
|
||||
localStorage: { ...localStorage },
|
||||
sessionStorage: { ...sessionStorage },
|
||||
}));
|
||||
return JSON.stringify(storage, null, 2);
|
||||
}
|
||||
|
||||
case 'perf': {
|
||||
const timings = await page.evaluate(() => {
|
||||
const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
|
||||
if (!nav) return 'No navigation timing data available.';
|
||||
return {
|
||||
dns: Math.round(nav.domainLookupEnd - nav.domainLookupStart),
|
||||
tcp: Math.round(nav.connectEnd - nav.connectStart),
|
||||
ssl: Math.round(nav.secureConnectionStart > 0 ? nav.connectEnd - nav.secureConnectionStart : 0),
|
||||
ttfb: Math.round(nav.responseStart - nav.requestStart),
|
||||
download: Math.round(nav.responseEnd - nav.responseStart),
|
||||
domParse: Math.round(nav.domInteractive - nav.responseEnd),
|
||||
domReady: Math.round(nav.domContentLoadedEventEnd - nav.startTime),
|
||||
load: Math.round(nav.loadEventEnd - nav.startTime),
|
||||
total: Math.round(nav.loadEventEnd - nav.startTime),
|
||||
};
|
||||
});
|
||||
if (typeof timings === 'string') return timings;
|
||||
return Object.entries(timings)
|
||||
.map(([k, v]) => `${k.padEnd(12)} ${v}ms`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown read command: ${command}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user