Web Browsing Agent with Playwright
Web Browsing Agent Architecture
What is a Web Browsing Agent?
Web browsing agents automate web interaction by navigating pages, reading content, clicking links, filling forms, and extracting structured data. They combine LLM reasoning with browser automation to accomplish complex web tasks.
Why this matters: The web contains most of the world's information, but it's locked behind complex interfaces, dynamic JavaScript, anti-bot measures, and inconsistent page structures. A browsing agent can navigate these challenges to extract data that would take a human hours to collect manually.
Common Misconception
"Web scraping is just sending HTTP requests and parsing HTML."
Modern web pages are JavaScript-heavy single-page applications that require a real browser engine. Simple HTTP requests miss dynamically loaded content, cookie consent walls, and lazy-loaded images. Playwright handles all of this by running an actual Chromium browser.
Real-World Analogy
Think of the agent as a very efficient research assistant. You give them a task ("Find the cheapest round-trip flight from NYC to London for next weekend"), and they methodically visit airline websites, navigate search forms, extract prices, compare options, and report back with a structured answer โ handling popups, redirects, and JavaScript along the way.
Project Overview
We will build a web browsing agent that:
- Navigates websites using Playwright with anti-bot stealth
- Parses DOM content and extracts structured data
- Follows links and navigates multi-page flows
- Extracts information based on user goals
- Handles dynamic content (JavaScript, SPAs, cookie consent)
Expected outcome: A web browsing agent that can extract data from complex websites.
Difficulty: Advanced (requires understanding of web technologies and browser automation)
Tools & Setup
| Tool | Version | Purpose |
|---|---|---|
| Python | 3.11+ | Core language |
| Playwright | 1.40+ | Browser automation |
| BeautifulSoup4 | 4.12+ | HTML parsing |
| OpenAI | 1.0+ | LLM backbone |
| lxml | 4.9+ | Fast HTML parsing |
Step 1: Environment Setup
python -m venv venv
source venv/bin/activate
pip install playwright beautifulsoup4 openai lxml
playwright install chromium
export OPENAI_API_KEY="sk-your-key"
Step 2: Browser Controller
# browser.py
"""Playwright-based browser controller with anti-bot stealth measures."""
import logging
import random
import time
from typing import Dict, List, Optional
from dataclasses import dataclass
from playwright.sync_api import sync_playwright, Page, Browser, BrowserContext
logger = logging.getLogger(__name__)
USER_AGENTS = [
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36",
]
@dataclass
class BrowserConfig:
headless: bool = True
timeout: int = 30000
viewport_width: int = 1280
viewport_height: int = 720
user_agent: str = USER_AGENTS[0]
stealth_mode: bool = True
request_delay: float = 1.0 # seconds between requests
class BrowserController:
"""Manages Playwright browser with stealth and error recovery.
Args:
config: Browser configuration options.
"""
def __init__(self, config: BrowserConfig = None):
self.config = config or BrowserConfig()
self.playwright = None
self.browser: Optional[Browser] = None
self.context: Optional[BrowserContext] = None
self.page: Optional[Page] = None
def start(self) -> None:
"""Launch browser with stealth configurations."""
self.playwright = sync_playwright().start()
self.browser = self.playwright.chromium.launch(
headless=self.config.headless,
args=[
"--disable-blink-features=AutomationControlled",
"--disable-dev-shm-usage",
],
)
ua = random.choice(USER_AGENTS) if self.config.stealth_mode else self.config.user_agent
self.context = self.browser.new_context(
viewport={
"width": self.config.viewport_width,
"height": self.config.viewport_height,
},
user_agent=ua,
java_script_enabled=True,
)
# Stealth: override navigator.webdriver
self.context.add_init_script("""
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
""")
self.page = self.context.new_page()
self.page.set_default_timeout(self.config.timeout)
logger.info("Browser started with stealth mode")
def stop(self) -> None:
"""Clean up browser resources."""
if self.browser:
self.browser.close()
if self.playwright:
self.playwright.stop()
logger.info("Browser stopped")
def navigate(self, url: str) -> Dict:
"""Navigate to URL with error handling and anti-bot delays."""
try:
time.sleep(random.uniform(0.5, self.config.request_delay))
response = self.page.goto(url, wait_until="domcontentloaded")
self.page.wait_for_load_state("networkidle")
return {
"success": True,
"url": self.page.url,
"title": self.page.title(),
"status": response.status if response else None,
}
except Exception as e:
logger.error(f"Navigation failed: {e}")
return {"success": False, "error": str(e)}
def click(self, selector: str) -> Dict:
"""Click an element with wait for navigation."""
try:
self.page.click(selector)
self.page.wait_for_load_state("networkidle")
return {"success": True, "url": self.page.url}
except Exception as e:
logger.error(f"Click failed: {e}")
return {"success": False, "error": str(e)}
def fill(self, selector: str, value: str) -> Dict:
"""Fill a form field."""
try:
self.page.fill(selector, value)
return {"success": True}
except Exception as e:
logger.error(f"Fill failed: {e}")
return {"success": False, "error": str(e)}
def scroll_to_bottom(self) -> None:
"""Auto-scroll to load lazy content."""
self.page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
time.sleep(1)
def get_content(self) -> str:
return self.page.content()
def get_text(self) -> str:
return self.page.inner_text("body")
def screenshot(self, path: str) -> None:
self.page.screenshot(path=path, full_page=True)
Step 3: DOM Parser and Content Extractor
# parser.py
"""DOM parser with noise removal, content scoring, and structured extraction."""
import re
import logging
from typing import Dict, List, Optional
from bs4 import BeautifulSoup
logger = logging.getLogger(__name__)
class DOMParser:
"""Parse HTML with noise removal and content scoring.
Removes: scripts, styles, nav, footer, header, ads, sidebars.
Extracts: text, links, forms, tables, metadata.
"""
NOISE_TAGS = ["script", "style", "nav", "footer", "header", "aside", "noscript"]
NOISE_CLASSES = re.compile(r"(sidebar|ad|popup|modal|cookie|banner)", re.I)
def parse(self, html: str) -> Dict:
"""Parse HTML and extract structured content."""
soup = BeautifulSoup(html, "lxml")
self._remove_noise(soup)
return {
"title": soup.title.string if soup.title else "",
"text": self._extract_text(soup),
"links": self._extract_links(soup),
"forms": self._extract_forms(soup),
"tables": self._extract_tables(soup),
"structured": self._extract_structured(soup),
"meta": self._extract_meta(soup),
}
def _remove_noise(self, soup: BeautifulSoup) -> None:
"""Remove non-content elements."""
for tag in soup.find_all(self.NOISE_TAGS):
tag.decompose()
for tag in soup.find_all(class_=self.NOISE_CLASSES):
tag.decompose()
for tag in soup.find_all(id=self.NOISE_CLASSES):
tag.decompose()
def _extract_links(self, soup: BeautifulSoup) -> List[Dict]:
links = []
for a in soup.find_all("a", href=True):
text = a.get_text(strip=True)
if text and len(text) > 2:
links.append({"text": text[:100], "href": a["href"]})
return links[:50]
def _extract_forms(self, soup: BeautifulSoup) -> List[Dict]:
forms = []
for form in soup.find_all("form"):
inputs = []
for inp in form.find_all(["input", "select", "textarea"]):
inputs.append({
"type": inp.get("type", "text"),
"name": inp.get("name", ""),
"placeholder": inp.get("placeholder", ""),
})
forms.append({
"action": form.get("action", ""),
"method": form.get("method", "get"),
"inputs": inputs,
})
return forms
def _extract_text(self, soup: BeautifulSoup) -> str:
text = soup.get_text(separator="\n", strip=True)
lines = [line.strip() for line in text.splitlines() if line.strip()]
return "\n".join(lines[:200])
def _extract_tables(self, soup: BeautifulSoup) -> List[List[List[str]]]:
result = []
for table in soup.find_all("table")[:5]:
rows = []
for tr in table.find_all("tr"):
cells = [td.get_text(strip=True) for td in tr.find_all(["td", "th"])]
rows.append(cells)
result.append(rows)
return result
def _extract_structured(self, soup: BeautifulSoup) -> Dict:
data = {}
lists = soup.find_all(["ul", "ol"])
if lists:
data["lists"] = [
[li.get_text(strip=True) for li in lst.find_all("li")]
for lst in lists[:10]
]
return data
def _extract_meta(self, soup: BeautifulSoup) -> Dict:
meta = {}
for tag in soup.find_all("meta"):
name = tag.get("name") or tag.get("property", "")
content = tag.get("content", "")
if name and content:
meta[name] = content
return meta
Step 4: Web Browsing Agent
# agent.py
"""Web browsing agent with LLM-driven decision making and error recovery."""
import json
import re
import logging
from typing import Dict, List
from openai import OpenAI
from browser import BrowserController, BrowserConfig
from parser import DOMParser
logger = logging.getLogger(__name__)
SYSTEM_PROMPT = """You are a web browsing agent. You navigate websites to accomplish user goals.
Available actions:
- navigate(url): Go to a URL
- click(selector): Click an element (CSS selector)
- fill(selector, value): Fill a form field
- extract(): Get page content
- done(answer): Task complete
Analyze the page content and decide the next action. Always explain your reasoning.
If stuck after 3 attempts at the same page, try a different approach or give up."""
class WebBrowsingAgent:
"""LLM-driven web browsing agent with error recovery.
Args:
model: OpenAI model for decision making.
max_steps: Maximum navigation steps per task.
"""
def __init__(
self,
model: str = "gpt-4-turbo-preview",
max_steps: int = 15,
):
self.client = OpenAI()
self.model = model
self.max_steps = max_steps
self.browser = BrowserController()
self.parser = DOMParser()
self.history: List[Dict] = []
def start(self) -> None:
self.browser.start()
def stop(self) -> None:
self.browser.stop()
def run(self, goal: str) -> Dict:
"""Execute a web browsing task from goal to completion.
Args:
goal: Natural language task description.
Returns:
Dict with success status, answer, steps taken, and history.
"""
self.history = []
repeated_pages = 0
for step in range(self.max_steps):
current_page = self.browser.get_content()
parsed = self.parser.parse(current_page)
# Detect stuck loops
if self._is_loop(parsed["title"]):
repeated_pages += 1
if repeated_pages >= 3:
return {
"success": False,
"error": "Agent stuck in loop",
"steps": step + 1,
"history": self.history,
}
else:
repeated_pages = 0
context = self._build_context(goal, parsed, step)
try:
response = self.client.chat.completions.create(
model=self.model,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": context},
],
temperature=0.0,
)
action = self._parse_action(response.choices[0].message.content)
except Exception as e:
logger.error(f"LLM call failed: {e}")
return {
"success": False,
"error": f"LLM error: {e}",
"steps": step + 1,
"history": self.history,
}
self.history.append({
"step": step + 1,
"action": action,
"page_title": parsed["title"],
})
if action["type"] == "done":
return {
"success": True,
"answer": action.get("answer", ""),
"steps": step + 1,
"history": self.history,
}
result = self._execute_action(action)
if not result["success"]:
logger.warning(f"Action failed: {result.get('error')}")
self.history[-1]["error"] = result.get("error")
return {
"success": False,
"error": "Max steps reached",
"steps": self.max_steps,
"history": self.history,
}
def _build_context(self, goal: str, parsed: Dict, step: int) -> str:
text_preview = parsed["text"][:2000]
links_preview = json.dumps(parsed["links"][:10], indent=2)
forms_preview = json.dumps(parsed["forms"][:3], indent=2)
return f"""Goal: {goal}
Current page: {parsed['title']}
URL: {self.browser.page.url}
Step: {step + 1} of {self.max_steps}
Page content:
{text_preview}
Available links:
{links_preview}
Available forms:
{forms_preview}
What action should I take next? Respond with one of:
- navigate(url): Go to a URL
- click(selector): Click an element
- fill(selector, value): Fill a form field
- extract(): Get page content
- done(answer): Task complete with answer"""
def _parse_action(self, response: str) -> Dict:
for action_type in ["navigate", "click", "fill", "extract", "done"]:
if f"{action_type}(" in response:
match = re.search(rf"{action_type}\(([^)]*)\)", response)
if match:
args = match.group(1).split(",")
return {
"type": action_type,
"args": [a.strip().strip("\"'") for a in args],
}
return {"type": "done", "args": ["Could not determine action"]}
def _execute_action(self, action: Dict) -> Dict:
if action["type"] == "navigate":
return self.browser.navigate(action["args"][0])
elif action["type"] == "click":
return self.browser.click(action["args"][0])
elif action["type"] == "fill":
return self.browser.fill(action["args"][0], action["args"][1])
elif action["type"] == "extract":
return {"success": True, "content": self.browser.get_text()}
elif action["type"] == "done":
return {"success": True}
return {"success": False, "error": "Unknown action"}
def _is_loop(self, page_title: str) -> bool:
if len(self.history) < 2:
return False
return self.history[-1]["page_title"] == page_title
Mathematical Foundation
Page Relevance Score:
Where each parameter means:
- โ Term frequency of query in document
- โ Position on page (lower = more relevant)
- โ Link relevance score
Intuition: Combines text matching, position, and link quality to rank page content. In practice, content scoring helps the DOM parser identify the main content area, filtering out sidebars and navigation.
Navigation Strategy:
Where is the action-value function and is the step limit (15 for typical tasks).
Intuition: Choose actions that maximize progress toward goal while respecting resource limits. The LLM acts as the policy function, evaluating each state and selecting the optimal action.
Performance Considerations
| Metric | Value | Cost Impact |
|---|---|---|
| Task Completion | 75%+ | For simple web tasks |
| Avg Steps | 5-8 | Depends on complexity |
| Page Load Time | 1-5s | Network dependent |
| Extraction Accuracy | 85%+ | For structured content |
| Cost per Task | $0.05-0.20 | ~5-10 LLM calls per task |
| Browser Memory | 500MB | Per browser instance |
Latency breakdown: Page load (2s) + DOM parse (100ms) + LLM decision (1.5s) + Action execution (1s) = ~4.6s per step. A 6-step task takes ~28 seconds total.
Security Notes
- Respect robots.txt โ Always check before scraping
- Rate limiting โ Add 1-2s delays between requests to avoid overloading servers
- Terms of Service โ Check website ToS before automated access
- Data privacy โ Don't collect personal data without consent
- CAPTCHA handling โ Detect and escalate to human, don't attempt to bypass
- Credential safety โ Never store login credentials in code
Interview Questions
1. How do you handle dynamic content (JavaScript-rendered pages)?
Answer: Dynamic content strategies: (1) Wait for network idle โ Playwright waits for no network requests, (2) Explicit waits โ wait_for_selector() for specific elements, (3) Scroll handling โ auto-scroll to load lazy content, (4) Cookie consent โ detect and dismiss popups automatically, (5) SPA navigation โ handle client-side routing. Playwright handles most cases automatically. For complex SPAs: use wait_for_load_state("networkidle"). Test with real websites to identify patterns.
2. How do you extract structured data from messy HTML?
Answer: Extraction strategies: (1) Noise removal โ remove scripts, styles, ads, nav, footer, (2) Content scoring โ find main content by text density, (3) Table parsing โ extract tabular data as structured JSON, (4) List parsing โ extract ordered/unordered lists, (5) Pattern matching โ CSS selectors for known patterns. BeautifulSoup with lxml parser is fastest. Key: preprocess HTML to remove noise before extraction. Use content scoring to find main content area when layout is unknown.
3. What is the role of the LLM in web browsing?
Answer: LLM responsibilities: (1) Goal understanding โ parse user request into web actions, (2) Page analysis โ understand page structure and content, (3) Action planning โ decide next action (click, fill, navigate), (4) Error recovery โ handle unexpected page states, (5) Data synthesis โ combine information from multiple pages. The LLM acts as the "brain" while the browser is the "body". Key: provide clear context (page content, available actions, forms) to the LLM for optimal decisions.
4. How do you handle anti-bot measures?
Answer: Anti-bot strategies: (1) Real user agent โ rotate realistic browser user agents, (2) Rate limiting โ add 1-2s delays between requests, (3) Session management โ maintain cookies and sessions, (4) Stealth mode โ override navigator.webdriver, disable automation flags, (5) CAPTCHA handling โ detect and escalate to human. Important: respect robots.txt and terms of service. For production: use headless browsers with stealth plugins. Balance automation with ethical web scraping.
5. How do you evaluate web browsing agent performance?
Answer: Evaluation metrics: (1) Task completion rate โ % of tasks successfully completed (target: 75%+), (2) Step efficiency โ average steps to complete task (target: 5-8), (3) Accuracy โ correct data extracted (target: 85%+), (4) Speed โ time to complete task (target: <45s), (5) Robustness โ handles website changes, (6) Safety โ doesn't violate terms of service. Use benchmark datasets (WebArena, Mind2Web) for evaluation. A/B test different prompts and strategies.
6. How would you extend the agent for e-commerce tasks?
Answer: E-commerce extensions: (1) Product search โ navigate search, filter results, sort by price, (2) Price comparison โ extract prices from multiple sites, (3) Cart management โ add/remove items, (4) Checkout flow โ handle multi-step checkout, (5) Account management โ login, saved addresses, payment methods. Key challenges: handling dynamic pricing (prices change hourly), stock availability (real-time checks), and anti-bot measures (e-commerce sites are aggressive). Use structured extraction for product data.
7. What are the legal considerations for web scraping?
Answer: Legal considerations: (1) Terms of service โ check website ToS, (2) robots.txt โ respect crawl directives, (3) Copyright โ don't reproduce copyrighted content verbatim, (4) Privacy โ don't collect personal data without consent, (5) Computer fraud laws โ don't bypass access controls (CFAA). Best practices: check robots.txt first, add delays (1-2s), don't overload servers, respect rate limits, store only what you need. Key: consult legal counsel for commercial scraping.
8. How do you handle multi-page navigation flows?
Answer: Multi-page strategies: (1) State machine โ track current state and valid transitions, (2) Session management โ maintain cookies and login state, (3) Backtracking โ return to previous state if stuck, (4) Goal decomposition โ break complex flows into steps, (5) Error recovery โ handle unexpected page states. Example: checkout flow = cart โ login โ shipping โ payment โ confirmation. Key: maintain state across pages, handle errors gracefully, and detect loops (repeated page titles).
Common Pitfalls & Solutions
| Pitfall | Solution |
|---|---|
| JavaScript not rendering | Use Playwright with wait_for_load_state("networkidle") |
| Anti-bot detection | Rotate user agents, add delays, use stealth mode |
| Cookie consent popups | Detect and dismiss automatically via common selectors |
| Dynamic content loading | Auto-scroll, wait for network idle |
| Complex page layouts | Use content scoring to find main content area |
| Session expiration | Maintain cookies, refresh sessions periodically |
| Broken selectors | Use multiple selector strategies (CSS, XPath, text) |
| Memory leaks | Properly close browser contexts with try/finally |
| Agent loops | Detect repeated page titles, break after 3 repetitions |
Summary with Key Takeaways
- Playwright provides the best balance of features and reliability for web browsing automation
- DOM parsing requires noise removal and content scoring for accurate extraction
- LLM reasoning is essential for understanding page structure and deciding actions
- Anti-bot measures require careful implementation and ethical considerations
- Multi-page flows need state management, loop detection, and error recovery
- Always respect robots.txt and terms of service โ check before scraping
- Test with real websites to identify patterns and edge cases
- Cost per task ($0.05-0.20) must be justified by the value of extracted data
KnowledgeCheck
-
Why is Playwright preferred over Selenium for modern web browsing agents?
- a) It's faster
- b) It handles JavaScript rendering and SPAs better with auto-waiting
- c) It's simpler
- d) It's free
-
What is the first step in DOM parsing for accurate extraction?
- a) Extract links
- b) Remove noise (scripts, styles, ads, nav, footer)
- c) Parse tables
- d) Extract text
-
How does the agent detect it's stuck in a navigation loop?
- a) It counts total steps
- b) It detects repeated page titles across consecutive steps
- c) It checks the URL
- d) It monitors CPU usage
-
What is the recommended delay between requests for ethical scraping?
- a) 0ms (as fast as possible)
- b) 1-2 seconds
- c) 30 seconds
- d) 5 minutes
-
What is the purpose of content scoring in DOM parsing?
- a) Rank search results
- b) Find main content area by text density and structure
- c) Count words
- d) Measure page load time
-
What should you check before scraping any website?
- a) Page speed
- b) Terms of service and robots.txt
- c) Page rank
- d) Number of visitors
Answers: 1-b, 2-b, 3-b, 4-b, 5-b, 6-b