add interactive browser
This commit is contained in:
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user