add interactive browser

This commit is contained in:
2026-08-23 22:11:20 -04:00
parent b78ddbf256
commit 973df33c62
6 changed files with 856 additions and 5 deletions
@@ -0,0 +1,472 @@
#!/usr/bin/env node
/**
* Interactive Browser Server
*
* A long-running HTTP server that keeps a visible Playwright browser open
* and accepts commands via HTTP. Designed for human-in-the-loop interaction.
*
* Usage:
* node browser-server.js [port]
*
* Default port: 9876
*
* API Endpoints:
* POST /launch?url=<url> - Launch browser and navigate
* POST /go?url=<url> - Navigate to URL
* POST /reload - Reload current page
* POST /screenshot - Take screenshot
* POST /click?selector=<sel> - Click element
* POST /dblclick?selector=<sel> - Double-click element
* POST /fill?selector=<sel>&value=<val> - Fill form field
* POST /select?selector=<sel>&value=<val> - Select dropdown option
* POST /hover?selector=<sel> - Hover over element
* POST /evaluate?expr=<js> - Evaluate JS expression
* POST /get_text?selector=<sel> - Get element text
* POST /close - Close browser
*
* All responses are JSON with screenshot paths.
* Screenshots are saved to the specified output directory.
*/
const { chromium } = require('playwright');
const http = require('http');
const { URL } = require('url');
const fs = require('fs');
const path = require('path');
const os = require('os');
const PORT = parseInt(process.argv[2]) || 9876;
const OUTPUT_DIR = process.argv[3] || path.join(os.tmpdir(), 'pi-browser-screenshots');
const STATE_FILE = path.join(os.tmpdir(), 'pi-browser-state.json');
let browser = null;
let context = null;
let page = null;
let wasClosed = false;
// Ensure output directory exists
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
// Never let an unhandled async error kill the server — log it instead.
process.on('unhandledRejection', (err) => {
console.error('[unhandledRejection]', err);
});
process.on('uncaughtException', (err) => {
console.error('[uncaughtException]', err);
});
async function ensureBrowser() {
if (!browser) {
browser = await chromium.launch({
headless: false,
slowMo: 100,
args: [
'--start-maximized',
'--disable-blink-features=AutomationControlled',
],
});
context = await browser.newContext({
viewport: { width: 1280, height: 900 },
locale: 'en-US',
timezoneId: 'America/New_York',
});
page = await context.newPage();
page.on('console', (msg) => {
console.error(`[Browser Console] ${msg.type()}: ${msg.text()}`);
});
page.on('pageerror', (err) => {
console.error(`[Browser Page Error] ${err.message}`);
});
wasClosed = false;
}
}
async function saveScreenshot(page, suffix = 'screenshot') {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = `${suffix}-${timestamp}.png`;
const filepath = path.join(OUTPUT_DIR, filename);
await page.screenshot({ path: filepath, fullPage: false });
return filepath;
}
async function getPageInfo(page) {
try {
return {
url: page.url(),
title: await page.title(),
};
} catch {
return { url: 'about:blank', title: '' };
}
}
function jsonResponse(res, statusCode, data) {
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(data, null, 2));
}
function errorResponse(res, statusCode, message, details) {
console.error(`[Error] ${message}`);
if (details) console.error(details);
jsonResponse(res, statusCode, { status: 'error', message, details });
}
function parseBody(bodyStr) {
const params = new URLSearchParams();
if (bodyStr) {
try {
// Try JSON first
const obj = JSON.parse(bodyStr);
for (const [k, v] of Object.entries(obj)) {
params.set(k, v);
}
} catch {
// Fall back to URL-encoded
const sp = new URLSearchParams(bodyStr);
for (const [k, v] of sp) params.set(k, v);
}
}
return params;
}
async function handleRequest(req, res) {
const parsedUrl = new URL(req.url, `http://localhost:${PORT}`);
const pathname = parsedUrl.pathname;
const urlParams = parsedUrl.searchParams;
// CORS support
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') {
res.writeHead(204);
res.end();
return;
}
if (req.method !== 'POST') {
errorResponse(res, 405, 'Method not allowed. Use POST.');
return;
}
let body = '';
for await (const chunk of req) {
body += chunk;
}
// Merge URL params and body params (body takes precedence)
const bodyParams = parseBody(body);
const params = new URLSearchParams();
for (const [k, v] of urlParams) params.set(k, v);
for (const [k, v] of bodyParams) params.set(k, v);
try {
switch (pathname) {
case '/launch': {
const url = params.get('url');
if (!url) {
errorResponse(res, 400, 'Missing required parameter: url');
return;
}
await ensureBrowser();
console.error(`[Server] Navigating to: ${url}`);
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(1000);
const info = await getPageInfo(page);
const screenshotPath = await saveScreenshot(page, 'launch');
jsonResponse(res, 200, {
status: 'ok',
command: 'launch',
url: info.url,
title: info.title,
screenshot: screenshotPath,
});
break;
}
case '/go': {
const url = params.get('url');
if (!url) {
errorResponse(res, 400, 'Missing required parameter: url');
return;
}
await ensureBrowser();
console.error(`[Server] Navigating to: ${url}`);
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(1000);
const info = await getPageInfo(page);
const screenshotPath = await saveScreenshot(page, 'go');
jsonResponse(res, 200, {
status: 'ok',
command: 'go',
url: info.url,
title: info.title,
screenshot: screenshotPath,
});
break;
}
case '/reload': {
await ensureBrowser();
console.error('[Server] Reloading page...');
await page.reload({ waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(1000);
const info = await getPageInfo(page);
const screenshotPath = await saveScreenshot(page, 'reload');
jsonResponse(res, 200, {
status: 'ok',
command: 'reload',
url: info.url,
title: info.title,
screenshot: screenshotPath,
});
break;
}
case '/screenshot': {
await ensureBrowser();
const screenshotPath = await saveScreenshot(page, 'screenshot');
const info = await getPageInfo(page);
jsonResponse(res, 200, {
status: 'ok',
command: 'screenshot',
url: info.url,
title: info.title,
screenshot: screenshotPath,
});
break;
}
case '/click': {
const selector = params.get('selector');
if (!selector) {
errorResponse(res, 400, 'Missing required parameter: selector');
return;
}
await ensureBrowser();
const force = params.get('force') === 'true';
console.error(`[Server] Clicking: ${selector}${force ? ' (force)' : ''}`);
await page.click(selector, { timeout: 8000, force });
await page.waitForTimeout(500);
const screenshotPath = await saveScreenshot(page, 'click');
const info = await getPageInfo(page);
jsonResponse(res, 200, {
status: 'ok',
command: 'click',
selector,
url: info.url,
title: info.title,
screenshot: screenshotPath,
});
break;
}
case '/dblclick': {
const selector = params.get('selector');
if (!selector) {
errorResponse(res, 400, 'Missing required parameter: selector');
return;
}
await ensureBrowser();
console.error(`[Server] Double-clicking: ${selector}`);
await page.dblclick(selector, { timeout: 8000 });
await page.waitForTimeout(500);
const screenshotPath = await saveScreenshot(page, 'dblclick');
const info = await getPageInfo(page);
jsonResponse(res, 200, {
status: 'ok',
command: 'dblclick',
selector,
url: info.url,
title: info.title,
screenshot: screenshotPath,
});
break;
}
case '/fill': {
const selector = params.get('selector');
const value = params.get('value');
if (!selector || value === null) {
errorResponse(res, 400, 'Missing required parameters: selector, value');
return;
}
await ensureBrowser();
console.error(`[Server] Filling "${value}" into: ${selector}`);
await page.fill(selector, value, { timeout: 8000 });
await page.waitForTimeout(300);
const screenshotPath = await saveScreenshot(page, 'fill');
const info = await getPageInfo(page);
jsonResponse(res, 200, {
status: 'ok',
command: 'fill',
selector,
value,
url: info.url,
title: info.title,
screenshot: screenshotPath,
});
break;
}
case '/select': {
const selector = params.get('selector');
const value = params.get('value');
if (!selector || !value) {
errorResponse(res, 400, 'Missing required parameters: selector, value');
return;
}
await ensureBrowser();
console.error(`[Server] Selecting "${value}" in: ${selector}`);
await page.selectOption(selector, value, { timeout: 8000 });
await page.waitForTimeout(300);
const screenshotPath = await saveScreenshot(page, 'select');
const info = await getPageInfo(page);
jsonResponse(res, 200, {
status: 'ok',
command: 'select',
selector,
value,
url: info.url,
title: info.title,
screenshot: screenshotPath,
});
break;
}
case '/hover': {
const selector = params.get('selector');
if (!selector) {
errorResponse(res, 400, 'Missing required parameter: selector');
return;
}
await ensureBrowser();
console.error(`[Server] Hovering: ${selector}`);
await page.hover(selector, { timeout: 8000 });
await page.waitForTimeout(300);
const screenshotPath = await saveScreenshot(page, 'hover');
const info = await getPageInfo(page);
jsonResponse(res, 200, {
status: 'ok',
command: 'hover',
selector,
url: info.url,
title: info.title,
screenshot: screenshotPath,
});
break;
}
case '/evaluate': {
const expr = params.get('expr');
if (!expr) {
errorResponse(res, 400, 'Missing required parameter: expr');
return;
}
await ensureBrowser();
console.error(`[Server] Evaluating: ${expr}`);
const result = await page.evaluate(expr);
const screenshotPath = await saveScreenshot(page, 'evaluate');
const info = await getPageInfo(page);
jsonResponse(res, 200, {
status: 'ok',
command: 'evaluate',
expression: expr,
result: String(result),
url: info.url,
title: info.title,
screenshot: screenshotPath,
});
break;
}
case '/get_text': {
const selector = params.get('selector');
if (!selector) {
errorResponse(res, 400, 'Missing required parameter: selector');
return;
}
await ensureBrowser();
console.error(`[Server] Getting text from: ${selector}`);
const text = await page.textContent(selector);
const screenshotPath = await saveScreenshot(page, 'get_text');
const info = await getPageInfo(page);
jsonResponse(res, 200, {
status: 'ok',
command: 'get_text',
selector,
text: text || '',
url: info.url,
title: info.title,
screenshot: screenshotPath,
});
break;
}
case '/close': {
console.error('[Server] Closing browser...');
if (browser) {
await browser.close();
}
wasClosed = true;
browser = null;
context = null;
page = null;
jsonResponse(res, 200, {
status: 'ok',
command: 'close',
message: 'Browser closed',
});
break;
}
case '/status': {
if (wasClosed || !browser) {
jsonResponse(res, 200, {
status: 'ok',
state: 'idle',
message: 'Browser is not running',
});
} else {
const info = await getPageInfo(page);
jsonResponse(res, 200, {
status: 'ok',
state: 'running',
url: info.url,
title: info.title,
});
}
break;
}
default:
errorResponse(res, 404, `Unknown endpoint: ${pathname}`);
}
} catch (err) {
errorResponse(res, 500, `Server error`, err.message);
}
}
const server = http.createServer(handleRequest);
server.listen(PORT, () => {
console.error(`Interactive Browser Server running on http://localhost:${PORT}`);
console.error(`Screenshots saved to: ${OUTPUT_DIR}`);
console.error('Press Ctrl+C to stop');
});
// Graceful shutdown
process.on('SIGINT', async () => {
console.error('\n[Server] Shutting down...');
if (browser) await browser.close();
server.close();
process.exit(0);
});
process.on('SIGTERM', async () => {
console.error('\n[Server] Shutting down...');
if (browser) await browser.close();
server.close();
process.exit(0);
});