"""Web view tool — open a URL in a real browser and extract readable content."""
import base64
import re
from playwright.async_api import async_playwright
from .base import Tool, ToolResult
_TIMEOUT = 30_000 # ms for page load
_MAX_TEXT = 20_000 # chars — cap huge pages
def _clean(text: str) -> str:
"""Collapse excessive blank lines and strip trailing whitespace."""
lines = [line.rstrip() for line in text.splitlines()]
result: list[str] = []
blank_run = 0
for line in lines:
if line == "":
blank_run += 1
if blank_run <= 2:
result.append("")
else:
blank_run = 0
result.append(line)
return "\n".join(result).strip()
class WebViewTool(Tool):
name = "web_view"
description = (
"Open a URL in a real headless browser and return clean readable text. "
"Use this to browse web pages — it executes JavaScript, waits for the page "
"to finish loading, and strips HTML/scripts so you get the actual content. "
"Optionally takes a screenshot so you can see the page visually. "
"Use http_request instead when working with REST APIs, JSON endpoints, "
"or services that need custom headers/auth."
)
parameters = {
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "Full URL to open (must start with http:// or https://)",
},
"screenshot": {
"type": "boolean",
"description": "If true, also capture a screenshot of the page (default: false)",
},
"wait_until": {
"type": "string",
"enum": ["load", "domcontentloaded", "networkidle"],
"description": (
"When to consider the page loaded. "
"'networkidle' (default) waits for no network activity — best for SPAs. "
"'load' is faster but may miss dynamic content."
),
},
},
"required": ["url"],
}
async def execute(self, params: dict) -> ToolResult:
url: str = params["url"].strip()
take_screenshot: bool = params.get("screenshot", False)
wait_until: str = params.get("wait_until", "networkidle")
if not url.startswith(("http://", "https://")):
return ToolResult(success=False, output="URL must start with http:// or https://",
error="invalid url")
try:
async with async_playwright() as pw:
browser = await pw.chromium.launch(headless=True)
context = await browser.new_context(
viewport={"width": 1280, "height": 800},
user_agent=(
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
),
)
page = await context.new_page()
try:
await page.goto(url, wait_until=wait_until, timeout=_TIMEOUT)
except Exception:
# Timeout waiting for networkidle is often fine — page may still be usable
pass
title = await page.title()
final_url = page.url
# Extract readable text: hide noise, grab visible text
text = await page.evaluate("""() => {
const kill = ['script','style','noscript','iframe',
'nav','header','footer','aside',
'[role="navigation"]','[role="banner"]',
'[role="contentinfo"]'];
const clone = document.body.cloneNode(true);
kill.forEach(sel => {
clone.querySelectorAll(sel).forEach(el => el.remove());
});
return clone.innerText || clone.textContent || '';
}""")
text = _clean(text)
if len(text) > _MAX_TEXT:
text = text[:_MAX_TEXT] + f"\n\n[… truncated at {_MAX_TEXT} chars]"
output_parts = []
if title:
output_parts.append(f"Title: {title}")
if final_url != url:
output_parts.append(f"Final URL: {final_url}")
output_parts.append("")
output_parts.append(text)
output = "\n".join(output_parts)
# Screenshot
screenshot_b64: str | None = None
if take_screenshot:
png = await page.screenshot(full_page=False)
screenshot_b64 = base64.b64encode(png).decode()
await context.close()
await browser.close()
metadata: dict = {}
if screenshot_b64:
metadata = {"base64": screenshot_b64, "mime": "image/png", "is_image": True}
return ToolResult(success=True, output=output, metadata=metadata or None)
except Exception as e:
return ToolResult(success=False, output=f"Browser error: {e}", error=str(e))