AI-Powered Browser Automation: Building Intelligent Web Agents with n8n and Playwright
AI-Powered Browser Automation: Building Intelligent Web Agents with n8n and Playwright
A comprehensive guide to building autonomous AI agents that can navigate the web, make intelligent decisions, and perform complex browser automation tasks using n8n, Playwright, and modern LLM-powered architectures.
1. Introduction: The Evolution from Web Scraping to AI Browser Agents
The landscape of web automation has undergone a paradigm shift. What began as simple HTTP requests and regex-based parsing has evolved into sophisticated AI-powered browser agents capable of understanding context, making decisions, and adapting to dynamic websites in real-time. Traditional web scraping tools are becoming obsolete as websites grow increasingly complex, JavaScript-heavy, and protected by sophisticated anti-bot measures.
The Problem with Traditional Web Scraping
Legacy web scraping approaches face insurmountable challenges in 2025:
- JavaScript-Heavy Applications: Modern SPAs (Single Page Applications) render content dynamically, making static HTML parsing ineffective
- Anti-Bot Sophistication: Cloudflare Turnstile, DataDome, and PerimeterX employ behavioral fingerprinting, CAPTCHA challenges, and ML-based detection
- Fragile Selectors: CSS selectors and XPath expressions break with every UI update, requiring constant maintenance
- Limited Interactivity: Traditional scrapers cannot interact with forms, handle authentication flows, or execute multi-step processes
- Context Blindness: Extracted data lacks semantic understanding, requiring extensive post-processing
Enter the AI Browser Agent
AI browser agents represent a fundamental leap forward. Instead of brittle selectors and rigid scripts, these agents use:
- Vision-Based Understanding: LLMs analyze visual screenshots to understand page structure and content
- Natural Language Instructions: Define tasks in plain English rather than complex XPath expressions
- Adaptive Navigation: Agents learn from failures and adjust their approach dynamically
- Human-Like Interaction: Mouse movements, scrolling, and typing patterns mimic human behavior
- Contextual Reasoning: Understanding of intent and semantics drives intelligent data extraction
┌─────────────────────────────────────────────────────────────────────────┐
│ TRADITIONAL SCRAPING vs AI BROWSER AGENTS │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ Traditional Scraping: AI Browser Agent: │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ fetch(URL) │ │ "Find and book │ │
│ │ parse(HTML) │ │ the cheapest │ │
│ │ extract(CSS) │ │ flight to Paris │ │
│ │ → Fragile │ │ for next Friday" │ │
│ └──────────────────┘ └──────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Breaks on │ │ Understands │ │
│ │ layout changes │ │ context, adapts │ │
│ │ Needs constant │ │ to changes, │ │
│ │ maintenance │ │ handles auth │ │
│ └──────────────────┘ └──────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Why n8n + Playwright?
The combination of n8n's visual workflow platform and Playwright's powerful browser automation creates an unmatched development experience:
- Visual Orchestration: n8n's drag-and-drop interface makes complex agent workflows accessible
- Scalable Infrastructure: Self-hosted n8n with Playwright in containers handles enterprise workloads
- Integration Ecosystem: 400+ native integrations including OpenAI, Anthropic, vector databases
- MCP Compatibility: Native support for Model Context Protocol enables Claude Desktop and Cursor integration
- Cost Efficiency: Open-source stack reduces dependency on expensive proprietary browser APIs
Real-World Applications
AI browser agents powered by n8n and Playwright are transforming industries:
- E-commerce Intelligence: Monitor competitor pricing, inventory levels, and promotional strategies in real-time
- Lead Generation: Autonomously navigate LinkedIn, extract profiles, and enrich data without API limits
- Travel Automation: Book flights, hotels, and rental cars by understanding natural language requirements
- Financial Data Aggregation: Navigate banking portals, download statements, and reconcile transactions
- Content Moderation: Review user-generated content across platforms with contextual understanding
- Market Research: Analyze sentiment, track trends, and compile reports from thousands of sources
2. Understanding the Browser Agent Architecture
Building effective browser agents requires understanding the architectural components that enable intelligent web navigation. This section explores the foundational patterns and technologies.
Core Architecture Components
┌─────────────────────────────────────────────────────────────────────────┐
│ AI BROWSER AGENT ARCHITECTURE │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ Instructions ┌──────────────┐ │
│ │ Human │──────────────────────►│ Agent │ │
│ │ Operator │ │ Controller │ │
│ └──────────────┘ └──────┬───────┘ │
│ │ │
│ ┌────────────────────────────┼────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────┐ │
│ │ Planning │ │ Browser │ │ Memory │ │
│ │ Module │◄────────────►│ Engine │ │ System │ │
│ │ (LLM) │ State │ (Playwright) │ │ │ │
│ └──────────────┘ └──────┬───────┘ └──────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ Website │ │
│ │ Target │ │
│ └──────────────┘ │
│ │
│ Key Components: │
│ • Planning Module: LLM-powered decision making │
│ • Browser Engine: Playwright for interaction │
│ • Memory System: Context retention across steps │
│ • Tool Registry: Available actions (click, type, scroll) │
│ │
└─────────────────────────────────────────────────────────────────────────┘
The Planning Module
At the heart of every browser agent lies a planning module powered by large language models. This component translates high-level goals into actionable steps:
Input: Natural language goal (e.g., "Find the cheapest flight from Bangkok to Tokyo next Friday")
Process:
- Goal Decomposition: Break complex tasks into subtasks
- Context Analysis: Review current page state and available actions
- Action Selection: Choose the optimal next step
- Reflection: Evaluate results and adjust strategy
Example Planning Prompt:
const PLANNING_PROMPT = `
You are an intelligent web navigation agent. Your task is to interact with websites to accomplish user goals.
Current State:
- URL: {{ currentUrl }}
- Page Title: {{ pageTitle }}
- Screenshot: [Base64 encoded image]
- Available Actions: {{ availableActions }}
- Previous Actions: {{ actionHistory }}
Goal: {{ userGoal }}
Instructions:
1. Analyze the current page state from the screenshot
2. Review your progress toward the goal
3. Select ONE action from the available options
4. If the goal is complete, respond with "DONE" and the result
5. If stuck, explain why and request human assistance
Respond in JSON format:
{
"reasoning": "Your step-by-step thought process",
"action": "The action to take",
"parameters": { /* action-specific parameters */ },
"confidence": 0.95
}
`;
The Browser Engine (Playwright)
Playwright serves as the execution layer, translating high-level commands into browser interactions:
Key Capabilities:
- Multi-Browser Support: Chromium, Firefox, WebKit with unified API
- Headless & Headed: Run invisible or with visible browser windows
- Mobile Emulation: Test mobile-specific behaviors and viewports
- Network Interception: Modify requests/responses, mock APIs
- Authentication State: Persistent cookies, localStorage, sessionStorage
- Screenshots & Videos: Capture full-page renders for LLM analysis
- Geolocation & Permissions: Simulate location, camera, microphone access
Playwright Context Configuration:
const { chromium } = require('playwright');
async function createBrowserContext() {
const browser = await chromium.launch({
headless: true,
args: [
'--disable-blink-features=AutomationControlled',
'--disable-web-security',
'--disable-features=IsolateOrigins,site-per-process',
]
});
const context = await browser.newContext({
viewport: { width: 1920, height: 1080 },
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
locale: 'en-US',
timezoneId: 'America/New_York',
geolocation: { latitude: 40.7128, longitude: -74.0060 },
permissions: ['geolocation'],
// Anti-detection measures
bypassCSP: true,
ignoreHTTPSErrors: true,
// Recording options
recordVideo: {
dir: './recordings/',
size: { width: 1920, height: 1080 }
}
});
// Inject anti-detection scripts
await context.addInitScript(() => {
Object.defineProperty(navigator, 'webdriver', {
get: () => undefined
});
window.chrome = { runtime: {} };
Object.defineProperty(navigator, 'plugins', {
get: () => [1, 2, 3, 4, 5]
});
});
return { browser, context };
}
The Memory System
Browser agents require sophisticated memory to maintain context across navigation steps:
Types of Memory:
- Short-Term Memory: Current page state, recent actions, immediate goals
- Working Memory: Active task parameters, extracted data, navigation history
- Long-Term Memory: Learned patterns, successful strategies, website-specific knowledge
Memory Implementation:
class AgentMemory {
constructor() {
this.shortTerm = new Map();
this.working = {
extractedData: [],
navigationHistory: [],
formData: {},
currentGoal: null
};
this.longTerm = {
sitePatterns: new Map(),
successfulStrategies: [],
failedApproaches: []
};
}
addNavigationStep(url, action, result) {
this.working.navigationHistory.push({
timestamp: new Date().toISOString(),
url,
action,
result,
screenshot: result.screenshot
});
// Keep only last 10 steps in short-term
if (this.working.navigationHistory.length > 10) {
this.archiveToLongTerm(this.working.navigationHistory.shift());
}
}
extractData(key, value) {
this.working.extractedData.push({
key,
value,
timestamp: new Date().toISOString(),
source: this.getCurrentUrl()
});
}
getContextForLLM() {
return {
currentGoal: this.working.currentGoal,
stepsTaken: this.working.navigationHistory.length,
extractedData: this.working.extractedData,
recentActions: this.working.navigationHistory.slice(-5),
pageHistory: this.working.navigationHistory.map(h => h.url)
};
}
learnPattern(site, pattern, success) {
if (!this.longTerm.sitePatterns.has(site)) {
this.longTerm.sitePatterns.set(site, []);
}
this.longTerm.sitePatterns.get(site).push({
pattern,
success,
timestamp: new Date().toISOString()
});
}
}
The Tool Registry
Actions available to the agent are defined as a structured tool registry:
const BROWSER_TOOLS = {
navigate: {
description: 'Navigate to a URL',
parameters: {
url: { type: 'string', required: true },
waitUntil: {
type: 'string',
enum: ['load', 'domcontentloaded', 'networkidle'],
default: 'networkidle'
}
},
handler: async (page, params) => {
await page.goto(params.url, { waitUntil: params.waitUntil });
return { success: true, url: page.url() };
}
},
click: {
description: 'Click an element on the page',
parameters: {
selector: { type: 'string', required: true },
force: { type: 'boolean', default: false },
timeout: { type: 'number', default: 5000 }
},
handler: async (page, params) => {
await page.click(params.selector, {
force: params.force,
timeout: params.timeout
});
await page.waitForTimeout(500);
return { success: true };
}
},
type: {
description: 'Type text into an input field',
parameters: {
selector: { type: 'string', required: true },
text: { type: 'string', required: true },
clear: { type: 'boolean', default: true },
delay: { type: 'number', default: 50 }
},
handler: async (page, params) => {
if (params.clear) {
await page.fill(params.selector, '');
}
await page.type(params.selector, params.text, { delay: params.delay });
return { success: true };
}
},
screenshot: {
description: 'Capture a screenshot of the current page',
parameters: {
fullPage: { type: 'boolean', default: false },
element: { type: 'string', required: false }
},
handler: async (page, params) => {
let screenshot;
if (params.element) {
const element = await page.locator(params.element);
screenshot = await element.screenshot({ encoding: 'base64' });
} else {
screenshot = await page.screenshot({
fullPage: params.fullPage,
encoding: 'base64'
});
}
return { success: true, screenshot };
}
},
extract: {
description: 'Extract text content from elements',
parameters: {
selector: { type: 'string', required: true },
attribute: { type: 'string', required: false }
},
handler: async (page, params) => {
const elements = await page.locator(params.selector).all();
const data = [];
for (const element of elements) {
if (params.attribute) {
const attr = await element.getAttribute(params.attribute);
data.push(attr);
} else {
const text = await element.textContent();
data.push(text.trim());
}
}
return { success: true, data };
}
},
scroll: {
description: 'Scroll the page',
parameters: {
direction: { type: 'string', enum: ['up', 'down', 'to'], default: 'down' },
amount: { type: 'number', default: 500 },
toElement: { type: 'string', required: false }
},
handler: async (page, params) => {
if (params.toElement) {
await page.locator(params.toElement).scrollIntoViewIfNeeded();
} else {
const direction = params.direction === 'up' ? -1 : 1;
await page.evaluate((amount, dir) => {
window.scrollBy(0, amount * dir);
}, params.amount, direction);
}
await page.waitForTimeout(300);
return { success: true };
}
},
wait: {
description: 'Wait for a condition',
parameters: {
selector: { type: 'string', required: false },
timeout: { type: 'number', default: 5000 },
state: {
type: 'string',
enum: ['visible', 'hidden', 'attached', 'detached'],
default: 'visible'
}
},
handler: async (page, params) => {
if (params.selector) {
await page.locator(params.selector).waitFor({
state: params.state,
timeout: params.timeout
});
} else {
await page.waitForTimeout(params.timeout);
}
return { success: true };
}
},
solveCaptcha: {
description: 'Attempt to solve a CAPTCHA challenge',
parameters: {
service: { type: 'string', enum: ['2captcha', 'anticaptcha'], default: '2captcha' },
type: { type: 'string', enum: ['image', 'recaptcha', 'hcaptcha'] }
},
handler: async (page, params) => {
// Implementation depends on CAPTCHA service
const captchaResult = await solveCaptchaWithService(page, params);
return { success: captchaResult.solved, result: captchaResult };
}
}
};
3. Setting Up Playwright with n8n
Integration between Playwright and n8n requires careful setup to enable browser automation within workflow nodes. This section provides complete implementation guidance.
Prerequisites
Before beginning, ensure you have:
- n8n instance: Self-hosted with execution permissions
- Node.js: Version 18+ with npm
- System dependencies: Browser rendering libraries (varies by OS)
- Memory: Minimum 2GB RAM for browser operations
- Storage: Sufficient space for screenshots and recordings
Installation and Configuration
Step 1: Install Playwright in n8n Environment
# SSH into your n8n server or container
ssh user@your-n8n-server
# Navigate to n8n installation directory
cd /opt/n8n
# Install Playwright and browsers
npm install playwright
# Install browser binaries
npx playwright install chromium
npx playwright install firefox
# Install system dependencies (Ubuntu/Debian)
npx playwright install-deps chromium
Step 2: Create a Playwright Service Module
Create a reusable module for browser operations:
// browser-service.js - Reusable Playwright wrapper
const { chromium, firefox, webkit } = require('playwright');
class BrowserService {
constructor(config = {}) {
this.browserType = config.browser || 'chromium';
this.headless = config.headless !== false;
this.slowMo = config.slowMo || 0;
this.timeout = config.timeout || 30000;
this.browsers = new Map();
}
async launch(sessionId) {
const browserType = { chromium, firefox, webkit }[this.browserType];
const browser = await browserType.launch({
headless: this.headless,
slowMo: this.slowMo,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-accelerated-2d-canvas',
'--no-first-run',
'--no-zygote',
'--disable-gpu',
'--disable-blink-features=AutomationControlled'
]
});
const context = await browser.newContext({
viewport: { width: 1920, height: 1080 },
userAgent: this.getRandomUserAgent(),
locale: 'en-US',
timezoneId: 'America/New_York',
bypassCSP: true,
ignoreHTTPSErrors: true
});
// Anti-detection
await context.addInitScript(() => {
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });
Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] });
});
const page = await context.newPage();
page.setDefaultTimeout(this.timeout);
this.browsers.set(sessionId, { browser, context, page });
return { sessionId, status: 'launched' };
}
async navigate(sessionId, url, options = {}) {
const { page } = this.browsers.get(sessionId);
await page.goto(url, {
waitUntil: options.waitUntil || 'networkidle',
timeout: options.timeout || this.timeout
});
const title = await page.title();
const currentUrl = page.url();
return {
success: true,
url: currentUrl,
title,
timestamp: new Date().toISOString()
};
}
async screenshot(sessionId, options = {}) {
const { page } = this.browsers.get(sessionId);
const screenshot = await page.screenshot({
fullPage: options.fullPage || false,
encoding: 'base64',
type: 'jpeg',
quality: 80
});
return {
success: true,
screenshot: `data:image/jpeg;base64,${screenshot}`,
timestamp: new Date().toISOString()
};
}
async click(sessionId, selector, options = {}) {
const { page } = this.browsers.get(sessionId);
await page.locator(selector).click({
timeout: options.timeout || 5000,
force: options.force || false
});
await page.waitForTimeout(options.waitAfter || 1000);
return { success: true, selector, timestamp: new Date().toISOString() };
}
async type(sessionId, selector, text, options = {}) {
const { page } = this.browsers.get(sessionId);
if (options.clear !== false) {
await page.locator(selector).fill('');
}
await page.locator(selector).type(text, {
delay: options.delay || 50
});
return { success: true, selector, text, timestamp: new Date().toISOString() };
}
async extract(sessionId, selector, options = {}) {
const { page } = this.browsers.get(sessionId);
const elements = await page.locator(selector).all();
const data = [];
for (const element of elements) {
const item = {};
if (options.text !== false) {
item.text = await element.textContent();
}
if (options.html) {
item.html = await element.innerHTML();
}
if (options.attributes) {
for (const attr of options.attributes) {
item[attr] = await element.getAttribute(attr);
}
}
data.push(item);
}
return { success: true, data, count: data.length };
}
async scroll(sessionId, options = {}) {
const { page } = this.browsers.get(sessionId);
if (options.toElement) {
await page.locator(options.toElement).scrollIntoViewIfNeeded();
} else {
const direction = options.direction === 'up' ? -1 : 1;
const amount = options.amount || 500;
await page.evaluate((amt, dir) => {
window.scrollBy(0, amt * dir);
}, amount, direction);
}
await page.waitForTimeout(options.waitAfter || 500);
return { success: true, timestamp: new Date().toISOString() };
}
async evaluate(sessionId, script, args = []) {
const { page } = this.browsers.get(sessionId);
const result = await page.evaluate(script, ...args);
return { success: true, result };
}
async getPageSource(sessionId) {
const { page } = this.browsers.get(sessionId);
const html = await page.content();
return { success: true, html };
}
async close(sessionId) {
const { browser } = this.browsers.get(sessionId);
await browser.close();
this.browsers.delete(sessionId);
return { success: true, sessionId };
}
getRandomUserAgent() {
const userAgents = [
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0'
];
return userAgents[Math.floor(Math.random() * userAgents.length)];
}
}
module.exports = BrowserService;
Step 3: n8n Function Node Implementation
Create n8n workflows that utilize the browser service:
// n8n Function Node - Browser Agent Controller
const BrowserService = require('/opt/n8n/custom/browser-service.js');
const browserService = new BrowserService({
browser: $input.first().json.browser || 'chromium',
headless: $input.first().json.headless !== false,
timeout: $input.first().json.timeout || 30000
});
// Generate unique session ID
const sessionId = 'session_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
try {
// Launch browser
await browserService.launch(sessionId);
// Navigate to target URL
const navigateResult = await browserService.navigate(
sessionId,
$input.first().json.url,
{ waitUntil: 'networkidle' }
);
// Take initial screenshot
const screenshotResult = await browserService.screenshot(sessionId, { fullPage: false });
// Extract page data
const extractResult = await browserService.extract(sessionId, 'h1, h2, .product-title', {
text: true,
attributes: ['href', 'data-id']
});
// Close browser
await browserService.close(sessionId);
return [{
json: {
success: true,
sessionId,
navigation: navigateResult,
screenshot: screenshotResult.screenshot,
extractedData: extractResult.data,
timestamp: new Date().toISOString()
}
}];
} catch (error) {
// Ensure browser closes on error
try {
await browserService.close(sessionId);
} catch (e) {}
return [{
json: {
success: false,
error: error.message,
stack: error.stack,
sessionId,
timestamp: new Date().toISOString()
}
}];
}
Docker Configuration for Production
For production deployments, containerize Playwright with n8n:
# Dockerfile.n8n-playwright
FROM n8nio/n8n:latest
USER root
# Install dependencies
RUN apt-get update && apt-get install -y \
libnss3 \
libatk-bridge2.0-0 \
libdrm-dev \
libxkbcommon-dev \
libgbm-dev \
libasound-dev \
&& rm -rf /var/lib/apt/lists/*
# Install Playwright
RUN npm install -g playwright
# Install browsers
RUN npx playwright install chromium firefox
# Create custom modules directory
RUN mkdir -p /opt/n8n/custom
COPY browser-service.js /opt/n8n/custom/
USER node
EXPOSE 5678
# docker-compose.yml
version: '3.8'
services:
n8n:
build:
context: .
dockerfile: Dockerfile.n8n-playwright
ports:
- "5678:5678"
environment:
- N8N_BASIC_AUTH_ACTIVE=true
- N8N_BASIC_AUTH_USER=admin
- N8N_BASIC_AUTH_PASSWORD=your-secure-password
- NODE_FUNCTION_ALLOW_EXTERNAL=playwright
volumes:
- n8n_data:/home/node/.n8n
- ./custom:/opt/n8n/custom
deploy:
resources:
limits:
memory: 4G
reservations:
memory: 2G
volumes:
n8n_data:
4. Building the LLM-Powered Agent Controller
The agent controller orchestrates the interaction between the planning module and browser engine. This section demonstrates how to integrate OpenAI, Anthropic, or local LLMs with n8n workflows.
OpenAI Vision API Integration
OpenAI's GPT-4 Vision enables screenshot-based web navigation:
// n8n Function Node - LLM Agent Controller with Vision
const { OpenAI } = require('openai');
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY
});
async function planNextAction(goal, screenshot, history, pageInfo) {
const messages = [
{
role: 'system',
content: `You are an expert web automation agent. Your task is to navigate websites and extract information.
Available Actions:
1. navigate({ url }) - Go to a specific URL
2. click({ selector }) - Click an element
3. type({ selector, text }) - Type text into an input
4. scroll({ direction, amount }) - Scroll the page
5. extract({ selector }) - Extract text from elements
6. screenshot({ fullPage }) - Take a screenshot
7. done({ result }) - Task complete, return results
Rules:
- Analyze the screenshot carefully to understand the page structure
- Use specific, reliable CSS selectors
- If an action fails, try an alternative approach
- Provide reasoning for each action
- Return ONLY valid JSON`
},
{
role: 'user',
content: [
{
type: 'text',
text: `Goal: ${goal}
Page Information:
- URL: ${pageInfo.url}
- Title: ${pageInfo.title}
Previous Actions:
${history.map((h, i) => `${i + 1}. ${h.action}: ${h.reasoning}`).join('\n')}
Current Screenshot:`
},
{
type: 'image_url',
image_url: {
url: screenshot,
detail: 'high'
}
}
]
}
];
const response = await openai.chat.completions.create({
model: 'gpt-4o',
messages,
max_tokens: 1000,
temperature: 0.2,
response_format: { type: 'json_object' }
});
try {
const result = JSON.parse(response.choices[0].message.content);
return result;
} catch (error) {
return {
action: 'done',
parameters: {
result: 'Error parsing LLM response',
rawResponse: response.choices[0].message.content
},
reasoning: 'Failed to parse JSON response'
};
}
}
// Main execution
const goal = $input.first().json.goal;
const screenshot = $input.first().json.screenshot;
const history = $input.first().json.history || [];
const pageInfo = $input.first().json.pageInfo || {};
const action = await planNextAction(goal, screenshot, history, pageInfo);
return [{
json: {
action: action.action,
parameters: action.parameters,
reasoning: action.reasoning,
confidence: action.confidence || 0.9
}
}];
Anthropic Claude Integration
Claude excels at following complex multi-step instructions:
// Anthropic Claude Agent Controller
const { Anthropic } = require('@anthropic-ai/sdk');
const anthropic = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY
});
async function claudeAgentStep(goal, context, availableTools) {
const response = await anthropic.messages.create({
model: 'claude-3-opus-20240229',
max_tokens: 4096,
temperature: 0.1,
system: `You are an autonomous web browsing agent. You have access to browser automation tools.
Your available tools:
${JSON.stringify(availableTools, null, 2)}
Follow these principles:
1. Break complex tasks into steps
2. Wait for page loads after navigation
3. Use specific selectors (IDs are preferred)
4. Handle pagination by checking for "next" buttons
5. Extract structured data when possible
6. Report completion with extracted data`,
messages: [
{
role: 'user',
content: [
{
type: 'text',
text: `Goal: ${goal}\n\nCurrent Context:\n${JSON.stringify(context, null, 2)}`
}
]
}
],
tools: [
{
name: 'navigate',
description: 'Navigate to a URL',
input_schema: {
type: 'object',
properties: {
url: { type: 'string', description: 'The URL to navigate to' }
},
required: ['url']
}
},
{
name: 'click',
description: 'Click an element',
input_schema: {
type: 'object',
properties: {
selector: { type: 'string', description: 'CSS selector for the element' }
},
required: ['selector']
}
},
{
name: 'type',
description: 'Type text into an input field',
input_schema: {
type: 'object',
properties: {
selector: { type: 'string' },
text: { type: 'string' }
},
required: ['selector', 'text']
}
},
{
name: 'extract',
description: 'Extract data from the page',
input_schema: {
type: 'object',
properties: {
selector: { type: 'string' },
attributes: {
type: 'array',
items: { type: 'string' },
description: 'Attributes to extract (e.g., ["href", "data-id"])'
}
},
required: ['selector']
}
},
{
name: 'complete',
description: 'Mark task as complete',
input_schema: {
type: 'object',
properties: {
success: { type: 'boolean' },
data: { type: 'object' },
summary: { type: 'string' }
},
required: ['success']
}
}
]
});
// Parse tool use from response
const toolUse = response.content.find(c => c.type === 'tool_use');
if (toolUse) {
return {
action: toolUse.name,
parameters: toolUse.input,
reasoning: response.content.find(c => c.type === 'text')?.text || 'Tool execution'
};
}
return {
action: 'unknown',
reasoning: response.content.find(c => c.type === 'text')?.text || 'No tool selected'
};
}
// Execute
const result = await claudeAgentStep(
$input.first().json.goal,
$input.first().json.context,
$input.first().json.availableTools
);
return [{ json: result }];
Local LLM Integration (Ollama)
For privacy-sensitive applications, run local models via Ollama:
// Local LLM Agent Controller using Ollama
const axios = require('axios');
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://localhost:11434';
async function localAgentStep(goal, context, screenshot = null) {
// Prepare prompt
let prompt = `You are a web automation agent. Your goal: ${goal}\n\n`;
prompt += `Current page: ${context.url}\n`;
prompt += `Title: ${context.title}\n`;
if (context.previousActions?.length > 0) {
prompt += `\nPrevious actions:\n`;
context.previousActions.forEach((action, i) => {
prompt += `${i + 1}. ${action.action}: ${action.result}\n`;
});
}
if (screenshot) {
prompt += `\n[Screenshot provided as base64 image data]\n`;
}
prompt += `\nRespond with a JSON object containing:
- "action": One of [navigate, click, type, scroll, extract, complete]
- "parameters": Action-specific parameters
- "reasoning": Brief explanation of your decision\n`;
// Call Ollama
const response = await axios.post(`${OLLAMA_URL}/api/generate`, {
model: 'llama3.1-vision', // or llava, bakllava, etc.
prompt,
images: screenshot ? [screenshot.replace(/^data:image\/\w+;base64,/, '')] : [],
stream: false,
format: 'json',
options: {
temperature: 0.2,
num_predict: 500
}
});
try {
const result = JSON.parse(response.data.response);
return result;
} catch (error) {
return {
action: 'complete',
parameters: { success: false, error: 'Failed to parse response' },
reasoning: response.data.response
};
}
}
// Execute
const result = await localAgentStep(
$input.first().json.goal,
$input.first().json.context,
$input.first().json.screenshot
);
return [{ json: result }];
Agent Loop Workflow in n8n
Create a recursive workflow that continues until the task is complete:
{
"name": "AI Browser Agent Loop",
"nodes": [
{
"parameters": {
"jsCode": "// Initialize or continue agent session\nconst session = $input.first().json.session || {\n id: 'session_' + Date.now(),\n goal: $input.first().json.goal,\n steps: [],\n data: {},\n status: 'running'\n};\n\n// Check if we've exceeded step limit\nif (session.steps.length >= 25) {\n session.status = 'max_steps_reached';\n return [{ json: session }];\n}\n\nreturn [{ json: session }];"
},
"name": "Initialize Session",
"type": "n8n-nodes-base.function",
"typeVersion": 1
},
{
"parameters": {
"jsCode": "// Check if task is complete\nconst session = $input.first().json;\n\nif (session.status !== 'running') {\n return [{ json: { complete: true, session } }];\n}\n\n// Get current page state from Playwright\nconst browserService = require('/opt/n8n/custom/browser-service.js');\nconst service = new browserService({ headless: true });\n\n// Take screenshot\nconst screenshotResult = await service.screenshot(session.id, { fullPage: false });\n\n// Get page info\nconst pageInfo = await service.evaluate(session.id, () => ({\n url: window.location.href,\n title: document.title,\n headings: Array.from(document.querySelectorAll('h1, h2')).map(h => h.textContent.trim())\n}));\n\nreturn [{ \n json: { \n complete: false, \n session, \n screenshot: screenshotResult.screenshot,\n pageInfo: pageInfo.result\n } \n}];"
},
"name": "Get Page State",
"type": "n8n-nodes-base.function",
"typeVersion": 1
},
{
"parameters": {
"jsCode": "// Call LLM to plan next action\nconst { OpenAI } = require('openai');\nconst openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });\n\nconst state = $input.first().json;\n\nconst response = await openai.chat.completions.create({\n model: 'gpt-4o',\n messages: [\n {\n role: 'system',\n content: 'You are a web automation agent. Analyze the screenshot and decide the next action.'\n },\n {\n role: 'user',\n content: [\n { type: 'text', text: `Goal: ${state.session.goal}` },\n { type: 'image_url', image_url: { url: state.screenshot } }\n ]\n }\n ],\n response_format: { type: 'json_object' },\n max_tokens: 500\n});\n\nconst action = JSON.parse(response.choices[0].message.content);\n\nreturn [{ \n json: {\n session: state.session,\n action,\n screenshot: state.screenshot\n }\n}];"
},
"name": "LLM Planning",
"type": "n8n-nodes-base.function",
"typeVersion": 1
},
{
"parameters": {
"jsCode": "// Execute the planned action\nconst state = $input.first().json;\nconst action = state.action;\n\nconst browserService = require('/opt/n8n/custom/browser-service.js');\nconst service = new browserService({ headless: true });\n\nlet result;\nswitch (action.action) {\n case 'navigate':\n result = await service.navigate(state.session.id, action.parameters.url);\n break;\n case 'click':\n result = await service.click(state.session.id, action.parameters.selector);\n break;\n case 'type':\n result = await service.type(state.session.id, action.parameters.selector, action.parameters.text);\n break;\n case 'extract':\n result = await service.extract(state.session.id, action.parameters.selector);\n break;\n case 'complete':\n result = { success: true, complete: true };\n break;\n default:\n result = { error: 'Unknown action' };\n}\n\n// Update session\nstate.session.steps.push({\n action: action.action,\n parameters: action.parameters,\n result,\n timestamp: new Date().toISOString()\n});\n\nif (action.action === 'complete') {\n state.session.status = 'completed';\n state.session.data = action.parameters.data;\n}\n\nreturn [{ json: state.session }];"
},
"name": "Execute Action",
"type": "n8n-nodes-base.function",
"typeVersion": 1
},
{
"parameters": {
"conditions": {
"boolean": [{\n "value1": "={{ $json.status }}",\n "value2": "completed"\n }]\n }\n },
"name": "Task Complete?",
"type": "n8n-nodes-base.if",
"typeVersion": 1
},
{
"parameters": {
"url": "={{ $workflow.webhookUrl }}",
"options": {}
},
"name": "Loop Back",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 1
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ JSON.stringify({\n success: true,\n goal: $input.first().json.goal,\n steps: $input.first().json.steps.length,\n data: $input.first().json.data\n }) }}"
},
"name": "Return Results",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1
}
],
"connections": {
"Initialize Session": {
"main": [[{ "node": "Get Page State", "type": "main", "index": 0 }]]
},
"Get Page State": {
"main": [[{ "node": "LLM Planning", "type": "main", "index": 0 }]]
},
"LLM Planning": {
"main": [[{ "node": "Execute Action", "type": "main", "index": 0 }]]
},
"Execute Action": {
"main": [[{ "node": "Task Complete?", "type": "main", "index": 0 }]]
},
"Task Complete?": {
"main": [\n [{ "node": "Return Results", "type": "main", "index": 0 }],\n [{ "node": "Loop Back", "type": "main", "index": 0 }]\n ]\n }\n }\n}"
5. Advanced Browser Automation Patterns
This section covers sophisticated techniques for handling complex web automation scenarios, from authentication to data extraction.
Handling Authentication Flows
Modern websites use multi-factor authentication and OAuth flows:
// Authentication Handler
class AuthHandler {
async handleLogin(sessionId, credentials, browserService) {
const { page } = browserService.browsers.get(sessionId);
// Step 1: Navigate to login page
await browserService.navigate(sessionId, credentials.loginUrl);
await page.waitForTimeout(2000);
// Step 2: Fill credentials
await browserService.type(sessionId, credentials.usernameSelector, credentials.username);
await browserService.type(sessionId, credentials.passwordSelector, credentials.password);
// Step 3: Submit login
await browserService.click(sessionId, credentials.submitSelector);
// Step 4: Handle 2FA if present
const has2FA = await this.detect2FA(page);
if (has2FA) {
const code = await this.get2FACode(credentials.twoFactorMethod);
await browserService.type(sessionId, credentials.twoFASelector, code);
await browserService.click(sessionId, credentials.twoFASubmitSelector);
}
// Step 5: Wait for dashboard/post-login state
await page.waitForSelector(credentials.postLoginSelector, { timeout: 30000 });
// Step 6: Save session state
const storageState = await page.context().storageState();
await this.saveSession(sessionId, storageState);
return { success: true, authenticated: true };
}
async detect2FA(page) {
const twoFAIndicators = [
'text=input[type="tel"]', // OTP input
'input[name*="code"]',
'input[placeholder*="code" i]',
'text=Two-factor',
'text=Verification code'
];
for (const indicator of twoFAIndicators) {
const visible = await page.locator(indicator).isVisible().catch(() => false);
if (visible) return true;
}
return false;
}
async get2FACode(method) {
switch (method.type) {
case 'totp':
// Generate TOTP code
const speakeasy = require('speakeasy');
return speakeasy.totp({
secret: method.secret,
encoding: 'base32'
});
case 'email':
// Poll email for code
return await this.pollEmailForCode(method.emailConfig);
case 'sms':
// Retrieve from SMS API
return await this.getSMSCode(method.phoneNumber);
default:
throw new Error('Unsupported 2FA method');
}
}
async saveSession(sessionId, storageState) {
// Save to Redis or database for reuse
const redis = require('redis');
const client = redis.createClient();
await client.set(`session:${sessionId}:storage`, JSON.stringify(storageState));
await client.expire(`session:${sessionId}:storage`, 86400); // 24 hours
}
async restoreSession(sessionId, browserService) {
const redis = require('redis');
const client = redis.createClient();
const storageState = await client.get(`session:${sessionId}:storage`);
if (storageState) {
// Launch browser with saved state
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
storageState: JSON.parse(storageState)
});
const page = await context.newPage();
browserService.browsers.set(sessionId, { browser, context, page });
return { success: true, restored: true };
}
return { success: false, restored: false };
}
}
Dynamic Content and Infinite Scroll
Handle modern SPAs with dynamic loading:
// Dynamic Content Handler
class DynamicContentHandler {
async waitForDynamicContent(sessionId, browserService, options = {}) {
const { page } = browserService.browsers.get(sessionId);
const {
contentSelector,
loadingIndicator,
maxWaitTime = 30000,
checkInterval = 500
} = options;
const startTime = Date.now();
let lastContentCount = 0;
let stableCount = 0;
while (Date.now() - startTime < maxWaitTime) {
// Wait for loading to complete
if (loadingIndicator) {
await page.locator(loadingIndicator).waitFor({
state: 'hidden',
timeout: 5000
}).catch(() => {});
}
// Check content count
const currentCount = await page.locator(contentSelector).count();
if (currentCount === lastContentCount) {
stableCount++;
if (stableCount >= 3) {
// Content has stabilized
break;
}
} else {
stableCount = 0;
lastContentCount = currentCount;
}
await page.waitForTimeout(checkInterval);
}
return {
success: true,
elementCount: lastContentCount,
waitTime: Date.now() - startTime
};
}
async handleInfiniteScroll(sessionId, browserService, options = {}) {
const { page } = browserService.browsers.get(sessionId);
const {
itemSelector,
maxScrolls = 10,
scrollDelay = 1000,
stopCondition = null
} = options;
let scrollCount = 0;
let itemsBefore = 0;
while (scrollCount < maxScrolls) {
// Count items before scroll
itemsBefore = await page.locator(itemSelector).count();
// Scroll to bottom
await page.evaluate(() => {
window.scrollTo(0, document.body.scrollHeight);
});
// Wait for new content to load
await page.waitForTimeout(scrollDelay);
// Check if new items loaded
const itemsAfter = await page.locator(itemSelector).count();
if (itemsAfter === itemsBefore) {
// No new content loaded
break;
}
// Check custom stop condition
if (stopCondition) {
const shouldStop = await page.evaluate(stopCondition);
if (shouldStop) break;
}
scrollCount++;
}
return {
success: true,
scrollCount,
totalItems: await page.locator(itemSelector).count()
};
}
async handlePagination(sessionId, browserService, options) {
const { page } = browserService.browsers.get(sessionId);
const { itemSelector, nextButtonSelector, maxPages = 10 } = options;
const allItems = [];
let currentPage = 1;
while (currentPage <= maxPages) {
// Extract items from current page
const items = await browserService.extract(sessionId, itemSelector);
allItems.push(...items.data);
// Check for next button
const hasNext = await page.locator(nextButtonSelector).isVisible().catch(() => false);
if (!hasNext) break;
// Click next
const nextDisabled = await page.locator(nextButtonSelector).evaluate(
el => el.disabled || el.classList.contains('disabled')
);
if (nextDisabled) break;
await browserService.click(sessionId, nextButtonSelector);
await page.waitForTimeout(2000);
currentPage++;
}
return {
success: true,
pagesScraped: currentPage,
totalItems: allItems.length,
items: allItems
};
}
}
CAPTCHA and Anti-Bot Bypass
Handle protection mechanisms:
// Anti-Detection and CAPTCHA Handler
class AntiBotHandler {
async solveCaptcha(sessionId, page, captchaType) {
switch (captchaType) {
case 'recaptcha_v2':
return await this.solveReCaptchaV2(page);
case 'recaptcha_v3':
return await this.solveReCaptchaV3(page);
case 'hcaptcha':
return await this.solveHCaptcha(page);
case 'image':
return await this.solveImageCaptcha(page);
default:
throw new Error(`Unsupported CAPTCHA type: ${captchaType}`);
}
}
async solveReCaptchaV2(page) {
// Use 2captcha or similar service
const apiKey = process.env.CAPTCHA_API_KEY;
// Get site key
const siteKey = await page.evaluate(() => {
const element = document.querySelector('.g-recaptcha');
return element ? element.getAttribute('data-sitekey') : null;
});
if (!siteKey) {
throw new Error('reCAPTCHA site key not found');
}
// Submit to solving service
const axios = require('axios');
const submitResponse = await axios.post('http://2captcha.com/in.php', {
key: apiKey,
method: 'userrecaptcha',
googlekey: siteKey,
pageurl: page.url(),
json: 1
});
const captchaId = submitResponse.data.request;
// Poll for solution
let solution = null;
let attempts = 0;
while (!solution && attempts < 60) {
await new Promise(r => setTimeout(r, 5000));
const result = await axios.get(`http://2captcha.com/res.php?key=${apiKey}&action=get&id=${captchaId}&json=1`);
if (result.data.status === 1) {
solution = result.data.request;
}
attempts++;
}
if (!solution) {
throw new Error('CAPTCHA solving timeout');
}
// Inject solution
await page.evaluate((token) => {
document.getElementById('g-recaptcha-response').innerHTML = token;
}, solution);
return { success: true, solution };
}
async applyStealthMeasures(sessionId, browserService) {
const { page } = browserService.browsers.get(sessionId);
// Inject stealth scripts
await page.evaluateOnNewDocument(() => {
// Override navigator.webdriver
Object.defineProperty(navigator, 'webdriver', {
get: () => undefined
});
// Fake plugins
Object.defineProperty(navigator, 'plugins', {
get: () => [
{ name: 'Chrome PDF Plugin' },
{ name: 'Native Client' },
{ name: 'Widevine Content Decryption Module' }
]
});
// Fake languages
Object.defineProperty(navigator, 'languages', {
get: () => ['en-US', 'en']
});
// Override permissions
const originalQuery = window.navigator.permissions.query;
window.navigator.permissions.query = (parameters) => (
parameters.name === 'notifications' ?
Promise.resolve({ state: Notification.permission }) :
originalQuery(parameters)
);
// Hide automation indicators
delete window.navigator.__proto__.webdriver;
// Canvas fingerprint randomization
const originalToDataURL = HTMLCanvasElement.prototype.toDataURL;
HTMLCanvasElement.prototype.toDataURL = function(...args) {
const ctx = this.getContext('2d');
if (ctx) {
// Add subtle noise
const imageData = ctx.getImageData(0, 0, this.width, this.height);
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
data[i] += Math.random() > 0.5 ? 1 : -1;
}
ctx.putImageData(imageData, 0, 0);
}
return originalToDataURL.apply(this, args);
};
});
// Set realistic viewport and behavior
await page.setViewportSize({ width: 1920, height: 1080 });
return { success: true };
}
async simulateHumanBehavior(sessionId, browserService) {
const { page } = browserService.browsers.get(sessionId);
// Random mouse movements
await this.randomMouseMovements(page);
// Random scrolling
await this.randomScrolling(page);
// Random delays between actions
await page.waitForTimeout(500 + Math.random() * 1500);
return { success: true };
}
async randomMouseMovements(page) {
const viewport = page.viewportSize();
const points = [];
// Generate bezier curve points
for (let i = 0; i < 5; i++) {
points.push({
x: Math.random() * viewport.width,
y: Math.random() * viewport.height
});
}
for (const point of points) {
await page.mouse.move(point.x, point.y);
await page.waitForTimeout(50 + Math.random() * 100);
}
}
async randomScrolling(page) {
const scrollAmount = 100 + Math.random() * 400;
const direction = Math.random() > 0.5 ? 1 : -1;
await page.evaluate((amount, dir) => {
window.scrollBy(0, amount * dir);
}, scrollAmount, direction);
await page.waitForTimeout(200 + Math.random() * 300);
}
}
Data Extraction and Structured Output
Extract and structure data effectively:
// Intelligent Data Extractor
class DataExtractor {
async extractStructuredData(sessionId, browserService, config) {
const { page } = browserService.browsers.get(sessionId);
const {
schema,
listSelector,
itemSelector,
aiEnhancement = true
} = config;
// Extract raw data
const rawData = await this.extractRawData(page, listSelector, itemSelector);
// Apply schema transformation
let structuredData = this.applySchema(rawData, schema);
// AI enhancement if enabled
if (aiEnhancement) {
structuredData = await this.enhanceWithAI(structuredData, schema);
}
// Validation
const validation = this.validateData(structuredData, schema);
return {
success: validation.valid,
data: structuredData,
validation,
rawCount: rawData.length
};
}
async extractRawData(page, listSelector, itemSelector) {
return await page.evaluate((list, item) => {
const container = document.querySelector(list);
if (!container) return [];
const items = container.querySelectorAll(item);
return Array.from(items).map((element, index) => ({
index,
html: element.outerHTML,
text: element.innerText,
attributes: Object.fromEntries(
Array.from(element.attributes).map(attr => [attr.name, attr.value])
)
}));
}, listSelector, itemSelector);
}
applySchema(rawData, schema) {
return rawData.map(item => {
const structured = {};
for (const [field, config] of Object.entries(schema)) {
if (typeof config === 'string') {
// Simple selector
const element = document.createElement('div');
element.innerHTML = item.html;
const found = element.querySelector(config);
structured[field] = found ? found.textContent.trim() : null;
} else {
// Complex config
const element = document.createElement('div');
element.innerHTML = item.html;
if (config.selector) {
const found = element.querySelector(config.selector);
let value = found ? found.textContent.trim() : null;
// Apply transformations
if (config.transform === 'number') {
value = parseFloat(value.replace(/[^0-9.-]/g, ''));
} else if (config.transform === 'date') {
value = new Date(value).toISOString();
} else if (config.attribute) {
value = found ? found.getAttribute(config.attribute) : null;
}
structured[field] = value;
}
}
}
return structured;
});
}
async enhanceWithAI(data, schema) {
const { OpenAI } = require('openai');
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const response = await openai.chat.completions.create({
model: 'gpt-4o-mini',
messages: [{
role: 'system',
content: `Enhance and clean the following data according to the schema.
Fix obvious errors, standardize formats, and fill missing values where possible.
Schema: ${JSON.stringify(schema)}
Data: ${JSON.stringify(data)}
Return only the enhanced JSON array.`
}],
response_format: { type: 'json_object' }
});
try {
return JSON.parse(response.choices[0].message.content).data || data;
} catch {
return data;
}
}
validateData(data, schema) {
const errors = [];
const requiredFields = Object.entries(schema)
.filter(([, config]) => config.required)
.map(([field]) => field);
for (const [index, item] of data.entries()) {
for (const field of requiredFields) {
if (!item[field] || item[field] === null || item[field] === '') {
errors.push({
index,
field,
error: 'Required field is missing or empty'
});
}
}
// Type validation
for (const [field, config] of Object.entries(schema)) {
if (config.type && item[field] !== null) {
const actualType = typeof item[field];
if (actualType !== config.type && !(config.type === 'number' && !isNaN(item[field]))) {
errors.push({
index,
field,
error: `Expected type ${config.type}, got ${actualType}`,
value: item[field]
});
}
}
}
}
return {
valid: errors.length === 0,
errors,
totalItems: data.length,
validItems: data.length - new Set(errors.map(e => e.index)).size
};
}
}
6. Integrating Third-Party Browser Services
While self-hosted Playwright offers maximum control, third-party services provide scalability and anti-detection features out of the box.
Firecrawl Integration
Firecrawl converts any website into clean markdown or structured data:
// Firecrawl Integration
class FirecrawlService {
constructor(apiKey) {
this.apiKey = apiKey;
this.baseUrl = 'https://api.firecrawl.dev/v1';
}
async scrapeUrl(url, options = {}) {
const axios = require('axios');
const response = await axios.post(`${this.baseUrl}/scrape`, {
url,
formats: options.formats || ['markdown', 'html'],
onlyMainContent: options.onlyMainContent !== false,
includeTags: options.includeTags || [],
excludeTags: options.excludeTags || [],
waitFor: options.waitFor || 0,
timeout: options.timeout || 30000,
actions: options.actions || [] // Pre-scrape actions
}, {
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json'
}
});
return response.data;
}
async crawlWebsite(url, options = {}) {
const axios = require('axios');
const response = await axios.post(`${this.baseUrl}/crawl`, {
url,
excludePaths: options.excludePaths || [],
includePaths: options.includePaths || [],
maxDepth: options.maxDepth || 2,
limit: options.limit || 100,
allowBackwardLinks: options.allowBackwardLinks || false,
allowExternalLinks: options.allowExternalLinks || false,
scrapeOptions: {
formats: options.formats || ['markdown'],
onlyMainContent: true
}
}, {
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json'
}
});
// Returns job ID for async processing
return response.data;
}
async getCrawlStatus(jobId) {
const axios = require('axios');
const response = await axios.get(`${this.baseUrl}/crawl/${jobId}`, {
headers: {
'Authorization': `Bearer ${this.apiKey}`
}
});
return response.data;
}
async searchAndExtract(query, options = {}) {
const axios = require('axios');
// Firecrawl's search API combines search with extraction
const response = await axios.post(`${this.baseUrl}/search`, {
query,
limit: options.limit || 5,
lang: options.lang || 'en',
country: options.country || 'us',
scrapeOptions: {
formats: ['markdown']
}
}, {
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json'
}
});
return response.data;
}
// n8n Function Node implementation
async n8nScrapeNode(input) {
const result = await this.scrapeUrl(
input.json.url,
input.json.options || {}
);
return [{ json: result }];
}
}
// n8n Usage
const firecrawl = new FirecrawlService(process.env.FIRECRAWL_API_KEY);
return await firecrawl.n8nScrapeNode($input.first());
Browserbase Integration
Browserbase provides headless browsers with built-in stealth:
// Browserbase Integration
class BrowserbaseService {
constructor(apiKey, projectId) {
this.apiKey = apiKey;
this.projectId = projectId;
this.baseUrl = 'https://www.browserbase.com/v1';
this.wsEndpoint = null;
}
async createSession(options = {}) {
const axios = require('axios');
const response = await axios.post(`${this.baseUrl}/sessions`, {
projectId: this.projectId,
browserSettings: {
headless: options.headless !== false,
viewport: options.viewport || { width: 1920, height: 1080 }
},
proxy: options.proxy || {
type: 'browserbase',
country: options.country || 'us'
},
timeouts: {
inactivity: options.inactivityTimeout || 300000,
session: options.sessionTimeout || 600000
}
}, {
headers: {
'X-BB-API-Key': this.apiKey,
'Content-Type': 'application/json'
}
});
this.sessionId = response.data.id;
this.wsEndpoint = response.data.connectUrl;
return {
sessionId: this.sessionId,
wsEndpoint: this.wsEndpoint,
status: response.data.status
};
}
async connectPlaywright() {
const { chromium } = require('playwright');
const browser = await chromium.connectOverCDP(this.wsEndpoint);
const context = browser.contexts()[0] || await browser.newContext();
const page = await context.newPage();
return { browser, context, page };
}
async getSessionLogs(sessionId) {
const axios = require('axios');
const response = await axios.get(
`${this.baseUrl}/sessions/${sessionId}/logs`,
{
headers: {
'X-BB-API-Key': this.apiKey
}
}
);
return response.data;
}
async getSessionRecording(sessionId) {
const axios = require('axios');
const response = await axios.get(
`${this.baseUrl}/sessions/${sessionId}/recording`,
{
headers: {
'X-BB-API-Key': this.apiKey
}
}
);
return response.data;
}
async endSession(sessionId) {
const axios = require('axios');
await axios.delete(`${this.baseUrl}/sessions/${sessionId}`, {
headers: {
'X-BB-API-Key': this.apiKey
}
});
return { success: true };
}
// Integration with n8n workflows
async n8nBrowserbaseNode(input) {
const { url, actions } = input.json;
// Create session
const session = await this.createSession(input.json.options);
// Connect Playwright
const { page } = await this.connectPlaywright();
// Execute actions
const results = [];
for (const action of actions) {
const result = await this.executeAction(page, action);
results.push(result);
}
// Get logs and recording
const logs = await this.getSessionLogs(session.sessionId);
// Cleanup
await this.endSession(session.sessionId);
return [{
json: {
session,
results,
logs: logs.logs
}
}];
}
async executeAction(page, action) {
switch (action.type) {
case 'navigate':
await page.goto(action.url);
return { type: 'navigate', url: action.url, success: true };
case 'click':
await page.click(action.selector);
return { type: 'click', selector: action.selector, success: true };
case 'type':
await page.fill(action.selector, action.text);
return { type: 'type', selector: action.selector, success: true };
case 'screenshot':
const screenshot = await page.screenshot({ encoding: 'base64' });
return { type: 'screenshot', image: screenshot };
case 'extract':
const data = await page.$$eval(action.selector, elements =>
elements.map(el => el.textContent.trim())
);
return { type: 'extract', data };
default:
return { error: 'Unknown action type' };
}
}
}
ScrapingBee Alternative
// ScrapingBee Integration for simple proxy-based scraping
class ScrapingBeeService {
constructor(apiKey) {
this.apiKey = apiKey;
this.baseUrl = 'https://app.scrapingbee.com/api/v1';
}
async scrape(options) {
const axios = require('axios');
const params = {
api_key: this.apiKey,
url: options.url,
render_js: options.renderJs !== false,
premium_proxy: options.premiumProxy !== false,
stealth_proxy: options.stealthProxy || false,
country_code: options.countryCode || 'us',
wait: options.wait || 0,
wait_for: options.waitFor || null,
timeout: options.timeout || 30000
};
// Add extraction rules if provided
if (options.extractRules) {
params.extract_rules = JSON.stringify(options.extractRules);
}
const response = await axios.get(this.baseUrl, { params });
return {
success: true,
data: response.data,
headers: response.headers
};
}
// n8n Function Node
async n8nScrapeNode(input) {
const result = await this.scrape(input.json);
return [{ json: result }];
}
}
7. MCP (Model Context Protocol) Integration
The Model Context Protocol enables seamless integration between AI agents and browser automation tools.
Understanding MCP
MCP standardizes how AI models interact with external tools:
┌─────────────────────────────────────────────────────────────────────────┐
│ MODEL CONTEXT PROTOCOL (MCP) │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ AI │◄──────────────────►│ MCP │ │
│ │ Model │ JSON-RPC │ Server │ │
│ │ (Claude) │ Protocol │ │ │
│ └─────────────┘ └──────┬──────┘ │
│ │ │
│ Tool Calls │ │
│ ▼ │
│ ┌─────────────┐ │
│ │ Browser │ │
│ │ Automation │ │
│ │ (Playwright)│ │
│ └─────────────┘ │
│ │
│ MCP Capabilities: │
│ • tools/list - Discover available browser actions │
│ • tools/call - Execute navigate, click, type, screenshot │
│ • resources/read - Get page content, screenshots │
│ • prompts/get - Get workflow templates │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Creating an MCP Server for Playwright
// MCP Server for Playwright Browser Automation
const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
const { chromium } = require('playwright');
class PlaywrightMCPService {
constructor() {
this.server = new Server({
name: 'playwright-browser-server',
version: '1.0.0'
}, {
capabilities: {
tools: {},
resources: {}
}
});
this.sessions = new Map();
this.setupHandlers();
}
setupHandlers() {
// List available tools
this.server.setRequestHandler('tools/list', async () => ({
tools: [
{
name: 'browser_launch',
description: 'Launch a new browser session',
inputSchema: {
type: 'object',
properties: {
headless: { type: 'boolean', default: true },
browser: { type: 'string', enum: ['chromium', 'firefox', 'webkit'], default: 'chromium' }
}
}
},
{
name: 'browser_navigate',
description: 'Navigate to a URL',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string' },
url: { type: 'string' }
},
required: ['sessionId', 'url']
}
},
{
name: 'browser_screenshot',
description: 'Take a screenshot of the current page',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string' },
fullPage: { type: 'boolean', default: false }
},
required: ['sessionId']
}
},
{
name: 'browser_click',
description: 'Click an element on the page',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string' },
selector: { type: 'string' },
description: { type: 'string' }
},
required: ['sessionId', 'selector']
}
},
{
name: 'browser_type',
description: 'Type text into an input field',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string' },
selector: { type: 'string' },
text: { type: 'string' },
description: { type: 'string' }
},
required: ['sessionId', 'selector', 'text']
}
},
{
name: 'browser_extract',
description: 'Extract data from the page',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string' },
selector: { type: 'string' },
attributes: { type: 'array', items: { type: 'string' } }
},
required: ['sessionId', 'selector']
}
},
{
name: 'browser_scroll',
description: 'Scroll the page',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string' },
direction: { type: 'string', enum: ['up', 'down'], default: 'down' },
amount: { type: 'number', default: 500 }
},
required: ['sessionId']
}
},
{
name: 'browser_evaluate',
description: 'Execute JavaScript on the page',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string' },
script: { type: 'string' }
},
required: ['sessionId', 'script']
}
},
{
name: 'browser_close',
description: 'Close a browser session',
inputSchema: {
type: 'object',
properties: {
sessionId: { type: 'string' }
},
required: ['sessionId']
}
}
]
}));
// Handle tool calls
this.server.setRequestHandler('tools/call', async (request) => {
const { name, arguments: args } = request.params;
switch (name) {
case 'browser_launch':
return await this.handleLaunch(args);
case 'browser_navigate':
return await this.handleNavigate(args);
case 'browser_screenshot':
return await this.handleScreenshot(args);
case 'browser_click':
return await this.handleClick(args);
case 'browser_type':
return await this.handleType(args);
case 'browser_extract':
return await this.handleExtract(args);
case 'browser_scroll':
return await this.handleScroll(args);
case 'browser_evaluate':
return await this.handleEvaluate(args);
case 'browser_close':
return await this.handleClose(args);
default:
throw new Error(`Unknown tool: ${name}`);
}
});
}
async handleLaunch(args) {
const sessionId = `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const browser = await chromium.launch({
headless: args.headless !== false
});
const context = await browser.newContext({
viewport: { width: 1920, height: 1080 }
});
const page = await context.newPage();
this.sessions.set(sessionId, { browser, context, page });
return {
content: [{
type: 'text',
text: JSON.stringify({
sessionId,
status: 'launched',
browser: args.browser || 'chromium'
})
}]
};
}
async handleNavigate(args) {
const session = this.sessions.get(args.sessionId);
if (!session) throw new Error('Session not found');
await session.page.goto(args.url, { waitUntil: 'networkidle' });
return {
content: [{
type: 'text',
text: JSON.stringify({
url: session.page.url(),
title: await session.page.title()
})
}]
};
}
async handleScreenshot(args) {
const session = this.sessions.get(args.sessionId);
if (!session) throw new Error('Session not found');
const screenshot = await session.page.screenshot({
fullPage: args.fullPage,
encoding: 'base64',
type: 'jpeg',
quality: 80
});
return {
content: [{
type: 'image',
data: screenshot,
mimeType: 'image/jpeg'
}, {
type: 'text',
text: `Screenshot captured. Page URL: ${session.page.url()}`
}]
};
}
async handleClick(args) {
const session = this.sessions.get(args.sessionId);
if (!session) throw new Error('Session not found');
await session.page.click(args.selector);
await session.page.waitForTimeout(500);
return {
content: [{
type: 'text',
text: `Clicked element: ${args.description || args.selector}`
}]
};
}
async handleType(args) {
const session = this.sessions.get(args.sessionId);
if (!session) throw new Error('Session not found');
await session.page.fill(args.selector, args.text);
return {
content: [{
type: 'text',
text: `Typed "${args.text}" into ${args.description || args.selector}`
}]
};
}
async handleExtract(args) {
const session = this.sessions.get(args.sessionId);
if (!session) throw new Error('Session not found');
const elements = await session.page.locator(args.selector).all();
const data = [];
for (const element of elements) {
const item = { text: await element.textContent() };
if (args.attributes) {
for (const attr of args.attributes) {
item[attr] = await element.getAttribute(attr);
}
}
data.push(item);
}
return {
content: [{
type: 'text',
text: JSON.stringify({ count: data.length, data }, null, 2)
}]
};
}
async handleScroll(args) {
const session = this.sessions.get(args.sessionId);
if (!session) throw new Error('Session not found');
const direction = args.direction === 'up' ? -1 : 1;
await session.page.evaluate((amount, dir) => {
window.scrollBy(0, amount * dir);
}, args.amount || 500, direction);
await session.page.waitForTimeout(300);
return {
content: [{
type: 'text',
text: `Scrolled ${args.direction} by ${args.amount || 500}px`
}]
};
}
async handleEvaluate(args) {
const session = this.sessions.get(args.sessionId);
if (!session) throw new Error('Session not found');
const result = await session.page.evaluate(args.script);
return {
content: [{
type: 'text',
text: JSON.stringify({ result }, null, 2)
}]
};
}
async handleClose(args) {
const session = this.sessions.get(args.sessionId);
if (!session) throw new Error('Session not found');
await session.browser.close();
this.sessions.delete(args.sessionId);
return {
content: [{
type: 'text',
text: JSON.stringify({ sessionId: args.sessionId, status: 'closed' })
}]
};
}
async start() {
const transport = new StdioServerTransport();
await this.server.connect(transport);
console.error('Playwright MCP server running on stdio');
}
}
// Start server
const service = new PlaywrightMCPService();
service.start().catch(console.error);
Using MCP with Claude Desktop
Configure Claude Desktop to use your MCP server:
// claude_desktop_config.json
{
"mcpServers": {
"playwright": {
"command": "node",
"args": ["/path/to/playwright-mcp-server.js"],
"env": {
"NODE_PATH": "/opt/n8n/custom/node_modules"
}
}
}
}
Now Claude can control browsers directly:
Human: Navigate to example.com and find all the product prices
Claude: I'll help you navigate to example.com and extract the product prices. Let me start by launching a browser and navigating to the site.
[Uses browser_launch tool]
[Uses browser_navigate tool with url: "https://example.com"]
[Uses browser_screenshot tool to see the page]
[Uses browser_extract tool with selector for products]
I found the following products and prices:
- Product A: $29.99
- Product B: $49.99
- Product C: $99.99
8. Production Deployment Patterns
Deploying browser agents at scale requires careful consideration of resource management, monitoring, and fault tolerance.
Kubernetes Deployment
# playwright-agent-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: n8n-playwright-agent
namespace: automation
spec:
replicas: 3
selector:
matchLabels:
app: n8n-playwright
template:
metadata:
labels:
app: n8n-playwright
spec:
containers:
- name: n8n
image: your-registry/n8n-playwright:latest
ports:
- containerPort: 5678
env:
- name: N8N_BASIC_AUTH_ACTIVE
value: "true"
- name: OPENAI_API_KEY
valueFrom:
secretKeyRef:
name: n8n-secrets
key: openai-api-key
- name: ANTHROPIC_API_KEY
valueFrom:
secretKeyRef:
name: n8n-secrets
key: anthropic-api-key
resources:
requests:
memory: "2Gi"
cpu: "1000m"
limits:
memory: "4Gi"
cpu: "2000m"
volumeMounts:
- name: n8n-data
mountPath: /home/node/.n8n
- name: tmp
mountPath: /tmp
volumes:
- name: n8n-data
persistentVolumeClaim:
claimName: n8n-pvc
- name: tmp
emptyDir: {}
securityContext:
runAsUser: 1000
runAsGroup: 1000
---
apiVersion: v1
kind: Service
metadata:
name: n8n-playwright-service
namespace: automation
spec:
selector:
app: n8n-playwright
ports:
- port: 5678
targetPort: 5678
type: ClusterIP
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: n8n-playwright-hpa
namespace: automation
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: n8n-playwright-agent
minReplicas: 3
maxReplicas: 20
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80
Session Pool Management
// Session Pool for Efficient Browser Management
class BrowserSessionPool {
constructor(maxSessions = 10) {
this.maxSessions = maxSessions;
this.available = [];
this.inUse = new Map();
this.waiting = [];
// Cleanup stale sessions every 5 minutes
setInterval(() => this.cleanup(), 300000);
}
async acquire(sessionId, options = {}) {
// Check if session already exists and is available
const existingIndex = this.available.findIndex(s => s.sessionId === sessionId);
if (existingIndex !== -1) {
const session = this.available.splice(existingIndex, 1)[0];
this.inUse.set(sessionId, session);
return session;
}
// If at capacity, wait for a session
if (this.inUse.size >= this.maxSessions) {
return new Promise((resolve) => {
this.waiting.push({ sessionId, options, resolve });
});
}
// Create new session
const session = await this.createSession(sessionId, options);
this.inUse.set(sessionId, session);
return session;
}
async createSession(sessionId, options) {
const { chromium } = require('playwright');
const browser = await chromium.launch({
headless: options.headless !== false,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
'--disable-blink-features=AutomationControlled'
]
});
const context = await browser.newContext({
viewport: { width: 1920, height: 1080 },
userAgent: this.getRandomUserAgent()
});
// Add anti-detection scripts
await context.addInitScript(() => {
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
});
const page = await context.newPage();
return {
sessionId,
browser,
context,
page,
createdAt: new Date(),
lastUsed: new Date(),
requestCount: 0
};
}
release(sessionId) {
const session = this.inUse.get(sessionId);
if (!session) return;
session.lastUsed = new Date();
session.requestCount++;
this.inUse.delete(sessionId);
// Recycle or destroy based on usage
if (session.requestCount > 100) {
// Max usage reached, close browser
this.destroySession(session);
} else {
this.available.push(session);
}
// Process waiting queue
if (this.waiting.length > 0) {
const next = this.waiting.shift();
this.acquire(next.sessionId, next.options).then(next.resolve);
}
}
async destroySession(session) {
try {
await session.browser.close();
} catch (e) {
console.error('Error closing browser:', e);
}
}
async cleanup() {
const now = new Date();
const maxIdleTime = 600000; // 10 minutes
// Remove idle sessions
this.available = this.available.filter(session => {
const idleTime = now - session.lastUsed;
if (idleTime > maxIdleTime) {
this.destroySession(session);
return false;
}
return true;
});
// Log pool stats
console.log(`Session pool: ${this.available.length} available, ${this.inUse.size} in use, ${this.waiting.length} waiting`);
}
getRandomUserAgent() {
const agents = [
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'
];
return agents[Math.floor(Math.random() * agents.length)];
}
getStats() {
return {
available: this.available.length,
inUse: this.inUse.size,
waiting: this.waiting.length,
maxSessions: this.maxSessions
};
}
}
// Export for n8n
module.exports = BrowserSessionPool;
Monitoring and Observability
// Monitoring Integration for Browser Agents
class BrowserAgentMonitor {
constructor() {
this.metrics = {
sessionsCreated: 0,
sessionsClosed: 0,
navigationCount: 0,
screenshotCount: 0,
errorCount: 0,
avgResponseTime: 0,
totalResponseTime: 0
};
this.activeSessions = new Map();
this.errorLog = [];
}
recordSessionCreated(sessionId) {
this.metrics.sessionsCreated++;
this.activeSessions.set(sessionId, {
createdAt: new Date(),
actions: []
});
this.sendMetric('browser_session_created', 1, { session_id: sessionId });
}
recordSessionClosed(sessionId, reason = 'normal') {
this.metrics.sessionsClosed++;
const session = this.activeSessions.get(sessionId);
if (session) {
const duration = Date.now() - session.createdAt.getTime();
this.sendMetric('browser_session_duration', duration, {
session_id: sessionId,
reason
});
this.activeSessions.delete(sessionId);
}
}
recordAction(sessionId, action, duration, success) {
const session = this.activeSessions.get(sessionId);
if (session) {
session.actions.push({
action,
duration,
success,
timestamp: new Date()
});
}
this.metrics.totalResponseTime += duration;
this.metrics.avgResponseTime =
this.metrics.totalResponseTime / (this.metrics.sessionsCreated || 1);
if (!success) {
this.metrics.errorCount++;
}
this.sendMetric(`browser_action_${action}`, duration, {
session_id: sessionId,
success: success.toString()
});
}
recordError(sessionId, error, context) {
const errorEntry = {
sessionId,
error: error.message || error,
stack: error.stack,
context,
timestamp: new Date().toISOString()
};
this.errorLog.push(errorEntry);
this.metrics.errorCount++;
// Keep only last 1000 errors
if (this.errorLog.length > 1000) {
this.errorLog = this.errorLog.slice(-1000);
}
// Send to error tracking service
this.sendError(errorEntry);
}
sendMetric(name, value, tags = {}) {
// Example: Send to Datadog, Prometheus, or other monitoring
const metric = {
name,
value,
tags,
timestamp: Date.now()
};
// Log or send to monitoring service
console.log(`[METRIC] ${name}: ${value}`, tags);
// Send to Prometheus pushgateway
// this.pushToPrometheus(metric);
}
sendError(errorEntry) {
console.error('[ERROR]', errorEntry);
// Could integrate with Sentry, Rollbar, etc.
// Sentry.captureException(errorEntry.error, {
// extra: { context: errorEntry.context }
// });
}
getHealthCheck() {
const activeCount = this.activeSessions.size;
const errorRate = this.metrics.errorCount / (this.metrics.sessionsCreated || 1);
return {
status: errorRate > 0.5 ? 'unhealthy' : errorRate > 0.1 ? 'degraded' : 'healthy',
activeSessions: activeCount,
metrics: this.metrics,
errorRate: errorRate.toFixed(4)
};
}
getDashboardData() {
return {
metrics: this.metrics,
activeSessions: Array.from(this.activeSessions.entries()).map(([id, session]) => ({
id,
duration: Date.now() - session.createdAt.getTime(),
actionCount: session.actions.length
})),
recentErrors: this.errorLog.slice(-10)
};
}
}
module.exports = BrowserAgentMonitor;
Queue-Based Work Distribution
// Redis-based Task Queue for Distributed Browser Agents
const Redis = require('ioredis');
class BrowserTaskQueue {
constructor(redisUrl) {
this.redis = new Redis(redisUrl);
this.subscriber = new Redis(redisUrl);
this.processing = new Map();
}
async enqueue(task) {
const taskId = `task_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const taskData = {
id: taskId,
status: 'pending',
priority: task.priority || 5,
createdAt: new Date().toISOString(),
payload: task
};
// Add to sorted set (priority queue)
await this.redis.zadd('browser:tasks:pending',
-taskData.priority, // Negative for descending order
JSON.stringify(taskData)
);
// Also store full task data
await this.redis.hset('browser:tasks:data', taskId, JSON.stringify(taskData));
return taskId;
}
async dequeue(workerId, timeout = 30) {
// Use Redis blocking pop from sorted set
const result = await this.redis.bzpopmin('browser:tasks:pending', timeout);
if (!result) return null;
const taskData = JSON.parse(result[1]);
taskData.status = 'processing';
taskData.workerId = workerId;
taskData.startedAt = new Date().toISOString();
// Update task status
await this.redis.hset('browser:tasks:data', taskData.id, JSON.stringify(taskData));
await this.redis.sadd(`browser:worker:${workerId}:tasks`, taskData.id);
this.processing.set(taskData.id, taskData);
return taskData;
}
async complete(taskId, result) {
const task = this.processing.get(taskId);
if (!task) return;
task.status = 'completed';
task.completedAt = new Date().toISOString();
task.result = result;
// Move to completed set
await this.redis.zadd('browser:tasks:completed', Date.now(), JSON.stringify(task));
await this.redis.hdel('browser:tasks:data', taskId);
await this.redis.srem(`browser:worker:${task.workerId}:tasks`, taskId);
this.processing.delete(taskId);
// Publish completion event
await this.redis.publish('browser:tasks:completed', JSON.stringify({
taskId,
result: result.success
}));
}
async fail(taskId, error, retryable = true) {
const task = this.processing.get(taskId);
if (!task) return;
task.status = 'failed';
task.failedAt = new Date().toISOString();
task.error = error;
task.retryCount = (task.retryCount || 0) + 1;
if (retryable && task.retryCount < 3) {
// Re-queue with lower priority
task.status = 'pending';
task.priority = Math.max(1, task.priority - 1);
await this.redis.zadd('browser:tasks:pending',
-task.priority,
JSON.stringify(task)
);
} else {
// Move to dead letter queue
await this.redis.zadd('browser:tasks:dead', Date.now(), JSON.stringify(task));
}
await this.redis.hdel('browser:tasks:data', taskId);
await this.redis.srem(`browser:worker:${task.workerId}:tasks`, taskId);
this.processing.delete(taskId);
}
async getQueueStats() {
const [pending, processing, completed, dead] = await Promise.all([
this.redis.zcard('browser:tasks:pending'),
this.redis.hlen('browser:tasks:data'),
this.redis.zcard('browser:tasks:completed'),
this.redis.zcard('browser:tasks:dead')
]);
return {
pending,
processing,
completed,
dead,
processingByThisWorker: this.processing.size
};
}
async subscribeToCompleted(callback) {
this.subscriber.subscribe('browser:tasks:completed');
this.subscriber.on('message', (channel, message) => {
if (channel === 'browser:tasks:completed') {
callback(JSON.parse(message));
}
});
}
}
module.exports = BrowserTaskQueue;
9. Complete Use Case: Building an AI-Powered E-commerce Price Monitor
This section walks through building a complete, production-ready workflow that demonstrates all concepts covered.
Architecture Overview
┌─────────────────────────────────────────────────────────────────────────┐
│ AI-POWERED PRICE MONITORING SYSTEM │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ │
│ │ Scheduler │─── Cron: Every 6 hours ───┐ │
│ │ (n8n Cron) │ │ │
│ └─────────────────┘ ▼ │
│ ┌─────────────────┐ │
│ │ Task Queue │ │
│ ┌─────────────────┐ │ (Redis) │ │
│ │ Product DB │◄────────────────│ │ │
│ │ (PostgreSQL) │ └────────┬────────┘ │
│ └─────────────────┘ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Worker Pool │ │
│ │ (n8n Workers) │ │
│ └────────┬────────┘ │
│ │ │
│ ┌──────────────────────────┼──────────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐│
│ │ Playwright │ │ Firecrawl │ │ Browserbase ││
│ │ (Self-hosted)│ │ (Fallback) │ │ (Enterprise) ││
│ └──────┬───────┘ └──────────────┘ └──────────────┘│
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ LLM Agent │─── GPT-4o Vision Analysis │
│ │ Controller │ │
│ └──────┬───────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ DATA PIPELINE │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │ Price │─►│ Change │─►│ Alert │─►│ History │ │ │
│ │ │ Extract │ │ Detect │ │ Notify │ │ Store │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Database Schema
-- Products to monitor
CREATE TABLE monitored_products (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
url TEXT NOT NULL,
vendor VARCHAR(100) NOT NULL,
category VARCHAR(100),
selector_config JSONB, -- CSS selectors for extraction
current_price DECIMAL(10,2),
currency VARCHAR(3) DEFAULT 'USD',
last_check TIMESTAMP WITH TIME ZONE,
last_price_change TIMESTAMP WITH TIME ZONE,
status VARCHAR(50) DEFAULT 'active',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Price history
CREATE TABLE price_history (
id SERIAL PRIMARY KEY,
product_id INTEGER REFERENCES monitored_products(id),
price DECIMAL(10,2) NOT NULL,
currency VARCHAR(3) DEFAULT 'USD',
availability VARCHAR(50),
scraped_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
screenshot_url TEXT,
metadata JSONB
);
-- Price change alerts
CREATE TABLE price_alerts (
id SERIAL PRIMARY KEY,
product_id INTEGER REFERENCES monitored_products(id),
old_price DECIMAL(10,2) NOT NULL,
new_price DECIMAL(10,2) NOT NULL,
price_change_pct DECIMAL(5,2),
alert_type VARCHAR(50), -- 'drop', 'increase', 'restock'
sent_at TIMESTAMP WITH TIME ZONE,
notification_channels TEXT[],
status VARCHAR(50) DEFAULT 'pending'
);
-- Selector learning
CREATE TABLE selector_patterns (
id SERIAL PRIMARY KEY,
vendor VARCHAR(100) NOT NULL,
page_structure_hash VARCHAR(64),
selectors JSONB NOT NULL,
success_rate DECIMAL(3,2),
last_used TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Indexes
CREATE INDEX idx_products_vendor ON monitored_products(vendor);
CREATE INDEX idx_products_status ON monitored_products(status);
CREATE INDEX idx_price_history_product ON price_history(product_id, scraped_at DESC);
CREATE INDEX idx_price_alerts_status ON price_alerts(status);
n8n Workflow Implementation
// Main Price Monitoring Workflow (n8n Function Node)
const { Pool } = require('pg');
const BrowserSessionPool = require('/opt/n8n/custom/browser-session-pool');
const BrowserAgentMonitor = require('/opt/n8n/custom/browser-agent-monitor');
const db = new Pool({
connectionString: process.env.DATABASE_URL
});
const sessionPool = new BrowserSessionPool(5);
const monitor = new BrowserAgentMonitor();
async function monitorProduct(product) {
const sessionId = `price_check_${product.id}_${Date.now()}`;
try {
// Acquire browser session
const session = await sessionPool.acquire(sessionId, { headless: true });
monitor.recordSessionCreated(sessionId);
// Navigate to product page
const startTime = Date.now();
await session.page.goto(product.url, { waitUntil: 'networkidle' });
const navigationTime = Date.now() - startTime;
monitor.recordAction(sessionId, 'navigate', navigationTime, true);
// Take screenshot for AI analysis
const screenshot = await session.page.screenshot({
encoding: 'base64',
fullPage: false
});
// Extract price using configured selectors
let priceData = null;
if (product.selector_config) {
try {
const priceText = await session.page.locator(product.selector_config.price_selector).textContent();
priceData = parsePrice(priceText);
monitor.recordAction(sessionId, 'extract', Date.now() - startTime, true);
} catch (e) {
// Fall back to AI extraction
priceData = await extractPriceWithAI(session.page, screenshot);
}
} else {
// No selectors configured, use AI
priceData = await extractPriceWithAI(session.page, screenshot);
}
// Check availability
const availability = await checkAvailability(session.page, product.selector_config);
// Save price history
await db.query(`
INSERT INTO price_history (product_id, price, currency, availability, metadata)
VALUES ($1, $2, $3, $4, $5)
`, [product.id, priceData.amount, priceData.currency, availability, {
screenshot_taken: true,
extraction_method: priceData.method
}]);
// Check for price changes
if (product.current_price && priceData.amount !== product.current_price) {
const changePct = ((priceData.amount - product.current_price) / product.current_price) * 100;
const alertType = priceData.amount < product.current_price ? 'drop' : 'increase';
await db.query(`
INSERT INTO price_alerts (product_id, old_price, new_price, price_change_pct, alert_type)
VALUES ($1, $2, $3, $4, $5)
`, [product.id, product.current_price, priceData.amount, changePct, alertType]);
// Trigger notification workflow
await triggerNotification(product, priceData, changePct);
}
// Update product
await db.query(`
UPDATE monitored_products
SET current_price = $1, last_check = NOW(),
last_price_change = CASE WHEN current_price != $1 THEN NOW() ELSE last_price_change END
WHERE id = $2
`, [priceData.amount, product.id]);
// Release session
sessionPool.release(sessionId);
monitor.recordSessionClosed(sessionId, 'success');
return {
success: true,
product: product.name,
price: priceData.amount,
availability
};
} catch (error) {
monitor.recordError(sessionId, error, { product: product.id });
monitor.recordSessionClosed(sessionId, 'error');
// Try Firecrawl fallback
return await fallbackToFirecrawl(product);
}
}
async function extractPriceWithAI(page, screenshot) {
// Call OpenAI Vision API
const { OpenAI } = require('openai');
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const response = await openai.chat.completions.create({
model: 'gpt-4o',
messages: [{
role: 'system',
content: 'Extract the current price from this product page screenshot. Return JSON with: amount (number), currency (string), formatted_price (string).'
}, {
role: 'user',
content: [
{ type: 'image_url', image_url: { url: `data:image/jpeg;base64,${screenshot}` } }
]
}],
response_format: { type: 'json_object' }
});
const result = JSON.parse(response.choices[0].message.content);
result.method = 'ai_vision';
return result;
}
async function fallbackToFirecrawl(product) {
const FirecrawlService = require('/opt/n8n/custom/firecrawl-service');
const firecrawl = new FirecrawlService(process.env.FIRECRAWL_API_KEY);
const result = await firecrawl.scrapeUrl(product.url, {
formats: ['markdown'],
onlyMainContent: true
});
// Extract price from markdown using regex or AI
const priceMatch = result.data.markdown.match(/\$[\d,]+\.?\d*/);
if (priceMatch) {
return {
success: true,
product: product.name,
price: parseFloat(priceMatch[0].replace(/[$,]/g, '')),
method: 'firecrawl_fallback'
};
}
throw new Error('Could not extract price from Firecrawl result');
}
function parsePrice(priceText) {
// Parse various price formats
const cleaned = priceText.replace(/[^\d.,]/g, '');
const amount = parseFloat(cleaned.replace(',', ''));
const currency = priceText.includes('$') ? 'USD' :
priceText.includes('€') ? 'EUR' : 'USD';
return { amount, currency, method: 'selector' };
}
async function checkAvailability(page, selectorConfig) {
if (!selectorConfig?.availability_selector) return 'unknown';
try {
const text = await page.locator(selectorConfig.availability_selector).textContent();
const lower = text.toLowerCase();
if (lower.includes('in stock') || lower.includes('available')) return 'in_stock';
if (lower.includes('out of stock') || lower.includes('unavailable')) return 'out_of_stock';
return 'unknown';
} catch {
return 'unknown';
}
}
async function triggerNotification(product, priceData, changePct) {
// Trigger separate notification workflow
// Implementation depends on your notification setup (email, Slack, webhook)
}
// Main execution
const products = await db.query(`
SELECT * FROM monitored_products
WHERE status = 'active'
AND (last_check IS NULL OR last_check < NOW() - INTERVAL '6 hours')
ORDER BY last_check NULLS FIRST
LIMIT 50
`);
const results = [];
for (const product of products.rows) {
const result = await monitorProduct(product);
results.push(result);
}
return [{
json: {
processed: results.length,
successful: results.filter(r => r.success).length,
failed: results.filter(r => !r.success).length,
results
}
}];
Notification Workflow
// Alert Notification Workflow (n8n Function Node)
const pendingAlerts = await db.query(`
SELECT pa.*, p.name, p.url, p.vendor
FROM price_alerts pa
JOIN monitored_products p ON pa.product_id = p.id
WHERE pa.status = 'pending'
ORDER BY pa.price_change_pct DESC
`);
for (const alert of pendingAlerts.rows) {
const message = {
product: alert.name,
vendor: alert.vendor,
url: alert.url,
oldPrice: alert.old_price,
newPrice: alert.new_price,
change: alert.price_change_pct > 0 ? `+${alert.price_change_pct.toFixed(2)}%` : `${alert.price_change_pct.toFixed(2)}%`,
type: alert.alert_type
};
// Send to Slack
await $item('Slack', 0).execute({
json: {
channel: '#price-alerts',
text: `🚨 Price Alert: *${message.product}*\n` +
`Price ${message.type === 'drop' ? 'dropped' : 'increased'} from $${message.oldPrice} to $${message.newPrice} (${message.change})\n` +
`<${message.url}|View Product>`
}
});
// Send email
await $item('Send Email', 0).execute({
json: {
to: process.env.ALERT_EMAIL,
subject: `Price ${message.type === 'drop' ? 'Drop' : 'Increase'}: ${message.product}`,
html: `<h1>Price Alert</h1>
<p>Product: ${message.product}</p>
<p>Old Price: $${message.oldPrice}</p>
<p>New Price: $${message.newPrice}</p>
<p>Change: ${message.change}</p>
<p><a href="${message.url}">View Product</a></p>`
}
});
// Mark as sent
await db.query(`
UPDATE price_alerts
SET status = 'sent', sent_at = NOW()
WHERE id = $1
`, [alert.id]);
}
return [{ json: { notified: pendingAlerts.rows.length } }];
10. Future Trends and Conclusion
The browser automation landscape continues evolving rapidly. Understanding emerging trends ensures your implementations remain cutting-edge.
Emerging Trends
1. Multimodal Agent Capabilities
- Vision-language models enabling agents to understand UI semantically
- Natural language navigation replacing selector-based approaches
- Agents that learn from human demonstrations
2. Autonomous Agent Frameworks
- Self-improving agents that optimize their own strategies
- Multi-agent collaboration for complex tasks
- Reinforcement learning for navigation efficiency
3. Browser-Native AI
- Chrome DevTools AI integration
- Browser APIs designed specifically for automation
- Built-in CAPTCHA and bot detection bypasses
4. Privacy-Preserving Automation
- Federated learning for site-specific strategies
- Local LLM execution for sensitive data
- Zero-knowledge proof systems for verification
5. Real-Time Web Integration
- WebSocket-based live data extraction
- Event-driven monitoring systems
- Reactive agents responding to website changes
Best Practices Summary
┌─────────────────────────────────────────────────────────────────────────┐
│ BEST PRACTICES CHECKLIST │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ Architecture: │
│ □ Use session pools to manage browser instances efficiently │
│ □ Implement circuit breakers for external service calls │
│ □ Design for horizontal scaling with stateless workers │
│ □ Separate concerns: planning, execution, and data extraction │
│ │
│ Security: │
│ □ Store credentials in environment variables, never in code │
│ □ Implement rate limiting to avoid being blocked │
│ □ Use stealth measures to reduce detection │
│ □ Rotate user agents and IP addresses │
│ │
│ Reliability: │
│ □ Implement comprehensive retry logic with exponential backoff │
│ □ Use dead letter queues for failed tasks │
│ □ Maintain complete audit trails for compliance │
│ □ Set resource limits to prevent runaway processes │
│ │
│ Performance: │
│ □ Cache session state when possible │
│ □ Use headless mode for production │
│ □ Implement connection pooling for databases │
│ □ Profile and optimize slow selectors │
│ │
│ Monitoring: │
│ □ Track success rates, latencies, and error patterns │
│ □ Set up alerts for unusual activity │
│ □ Collect screenshots of failures for debugging │
│ □ Monitor resource utilization (CPU, memory, disk) │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Final Thoughts
AI-powered browser automation represents a paradigm shift in how we interact with the web programmatically. By combining n8n's workflow orchestration with Playwright's browser control and modern LLMs, you can build agents that:
- Understand natural language instructions
- Adapt to changing website layouts
- Handle complex authentication flows
- Extract structured data intelligently
- Scale to enterprise workloads
The key to success lies in understanding that browser agents are not just automation scripts—they are AI systems that require careful architecture, monitoring, and maintenance. Treat them as production systems, not one-off hacks.
As the technology matures, we expect to see even tighter integration between LLMs and browser environments, making autonomous web agents as commonplace as API integrations are today.
Additional Resources
- Playwright Documentation
- n8n Workflow Automation
- Firecrawl API Reference
- Browserbase Documentation
- Model Context Protocol Specification
- OpenAI Vision API
- Anthropic Claude Documentation
This article was written by the automation experts at Tropical Media. For assistance implementing browser automation solutions, contact our team at [email protected].
OpenClaw MCP Integration with n8n: Building Production-Grade Agentic AI Workflows
Master the integration of OpenClaw's Model Context Protocol with n8n to build autonomous AI agents. Learn MCP server setup, tool orchestration, secure authentication, and enterprise deployment patterns for the future of agentic automation.
Microsoft Scout and OpenClaw Enterprise Integration: Building Autonomous AI Agents for Microsoft 365
A comprehensive guide to Microsoft Scout's revolutionary AI agent built on OpenClaw framework. Learn how to deploy autonomous agents for Teams, Outlook, OneDrive, and SharePoint using MCP protocol, Windows MXC sandboxing, and enterprise-grade security.