473 lines
14 KiB
JavaScript
473 lines
14 KiB
JavaScript
#!/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);
|
|
});
|