Tôi nhận được task: “build agent tự động điền form đăng ký hội thảo, submit, rồi lấy confirmation number về”. Tôi nghĩ đơn giản, một buổi sáng xong. Ba ngày sau, tôi mới hiểu mình đã sai ở đâu.

Vấn đề đầu tiên: trang dùng Cloudflare Turnstile. Agent gọi click(selector) vào nút Submit, nhận về Error: click intercepted. Vấn đề thứ hai: form có bước “xác nhận email” yêu cầu user click link trong email. Vấn đề thứ ba: trang có phiên bản responsive khác hoàn toàn, agent test trên desktop viewport, production bot chạy trên mobile viewport.

Browser automation là một trong những tool category phức tạp nhất mà agent có thể được trang bị. Không phải vì concept khó, mà vì web là môi trường được build cho người dùng, không cho code. Bài này đi qua hai approach chính: DOM-based (Playwright) và vision-based (Computer Use), khi nào dùng cái nào, và những pitfall mà hầu hết tutorial không đề cập.

Hai approach: DOM và Vision

Browser automation cho agent chia thành hai trường phái rõ ràng.

DOM-based (Playwright, Selenium, Puppeteer): Agent nhìn vào cấu trúc HTML của trang, tìm element bằng selector, gọi action. Precise, nhanh, rẻ. Nhưng trang phải có DOM ổn định và accessible.

Vision-based (Computer Use, Skyvern): Agent nhìn screenshot, hiểu giao diện như người, click vào pixel. Flexible, không cần biết DOM, nhưng chậm hơn và đắt hơn gấp nhiều lần.

Hai approach không thay thế nhau. Chúng giải quyết hai nhóm vấn đề khác nhau.

Approach 1: Playwright tool wrapping

Playwright là thư viện automation browser trưởng thành nhất hiện tại. Async API, cross-browser, headless + headful. Wrap nó thành tools cho agent là pattern rõ ràng nhất.

Schema tool browser

bài 11 về tool design, tôi đề cập đến nguyên tắc: schema phải đủ rõ để LLM chọn đúng tool với đúng args. Với browser tools, điều này đặc biệt quan trọng vì sai một selector là action không chạy, không có error message hữu ích cho LLM retry.

BROWSER_TOOLS = [
    {
        "name": "browser_goto",
        "description": "Navigate to a URL. Returns page title and URL after navigation.",
        "input_schema": {
            "type": "object",
            "properties": {
                "url": {
                    "type": "string",
                    "description": "Full URL including https://"
                }
            },
            "required": ["url"]
        }
    },
    {
        "name": "browser_click",
        "description": (
            "Click an element on the page. "
            "Prefer text-based selectors (e.g. 'text=Submit') over CSS selectors. "
            "Returns 'clicked' or error message."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "selector": {
                    "type": "string",
                    "description": "Playwright selector: CSS, XPath, or text= / role= locators"
                }
            },
            "required": ["selector"]
        }
    },
    {
        "name": "browser_fill",
        "description": "Fill a text input or textarea. Clears existing value first.",
        "input_schema": {
            "type": "object",
            "properties": {
                "selector": {"type": "string"},
                "value": {"type": "string", "description": "Text to type into the field"}
            },
            "required": ["selector", "value"]
        }
    },
    {
        "name": "browser_screenshot",
        "description": (
            "Take a screenshot of current page state. "
            "Use when you need to verify what the page looks like before acting."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "path": {
                    "type": "string",
                    "description": "File path to save PNG, e.g. /tmp/screenshot.png"
                }
            },
            "required": ["path"]
        }
    },
    {
        "name": "browser_get_text",
        "description": "Get visible text content from an element or full page.",
        "input_schema": {
            "type": "object",
            "properties": {
                "selector": {
                    "type": "string",
                    "description": "CSS selector, or 'body' to get all page text"
                }
            },
            "required": ["selector"]
        }
    },
]

Implementation async

import asyncio
from playwright.async_api import async_playwright, Page, Browser

class BrowserToolExecutor:
    def __init__(self):
        self._playwright = None
        self._browser: Browser | None = None
        self._page: Page | None = None

    async def start(self, headless: bool = True):
        self._playwright = await async_playwright().start()
        self._browser = await self._playwright.chromium.launch(headless=headless)
        context = await self._browser.new_context(
            viewport={"width": 1280, "height": 800},
            user_agent=(
                "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"
            )
        )
        self._page = await context.new_page()

    async def stop(self):
        if self._browser:
            await self._browser.close()
        if self._playwright:
            await self._playwright.stop()

    async def execute(self, tool_name: str, args: dict) -> str:
        page = self._page
        if page is None:
            return "Error: browser not started"

        try:
            if tool_name == "browser_goto":
                response = await page.goto(args["url"], wait_until="domcontentloaded")
                title = await page.title()
                return f"Navigated to: {page.url}\nTitle: {title}\nStatus: {response.status if response else 'unknown'}"

            elif tool_name == "browser_click":
                selector = args["selector"]
                await page.locator(selector).first.click(timeout=5000)
                return f"Clicked: {selector}"

            elif tool_name == "browser_fill":
                selector = args["selector"]
                await page.locator(selector).first.fill(args["value"])
                return f"Filled {selector} with value"

            elif tool_name == "browser_screenshot":
                path = args.get("path", "/tmp/screenshot.png")
                await page.screenshot(path=path, full_page=False)
                return f"Screenshot saved to {path}"

            elif tool_name == "browser_get_text":
                selector = args.get("selector", "body")
                locator = page.locator(selector).first
                text = await locator.inner_text()
                # Truncate to avoid flooding context
                return text[:3000] if len(text) > 3000 else text

            else:
                return f"Unknown tool: {tool_name}"

        except Exception as e:
            return f"Error: {type(e).__name__}: {str(e)}"

Tích hợp vào agent loop

import anthropic

async def browser_agent(task: str, max_iterations: int = 20):
    client = anthropic.Anthropic()
    executor = BrowserToolExecutor()
    await executor.start(headless=True)

    messages = [{"role": "user", "content": task}]
    system = (
        "You are a browser automation agent. "
        "Use tools to complete the task step by step. "
        "Take a screenshot when uncertain about page state. "
        "Prefer text= and role= selectors over CSS selectors. "
        "If a click fails, try browser_get_text to understand the page first."
    )

    try:
        for i in range(max_iterations):
            response = client.messages.create(
                model="claude-sonnet-4-6",
                max_tokens=2048,
                system=system,
                tools=BROWSER_TOOLS,
                messages=messages,
            )
            messages.append({"role": "assistant", "content": response.content})

            if response.stop_reason == "end_turn":
                final_text = next(
                    (b.text for b in response.content if hasattr(b, "text")),
                    "Task completed"
                )
                return final_text

            if response.stop_reason == "tool_use":
                tool_results = []
                for block in response.content:
                    if block.type == "tool_use":
                        result = await executor.execute(block.name, block.input)
                        tool_results.append({
                            "type": "tool_result",
                            "tool_use_id": block.id,
                            "content": result,
                        })
                messages.append({"role": "user", "content": tool_results})

        return "Max iterations exceeded"
    finally:
        await executor.stop()


# Chạy
if __name__ == "__main__":
    result = asyncio.run(browser_agent(
        "Go to https://example.com, take a screenshot, and report the page title."
    ))
    print(result)

State management cross-page

Một vấn đề thực tế: agent điều hướng qua nhiều trang, mỗi trang có state khác nhau. Ví dụ: checkout flow gồm cart, địa chỉ giao hàng, thanh toán, xác nhận. Nếu agent mất track đang ở trang nào, nó có thể click “Back” nhầm, mất cart.

Pattern đơn giản: thêm tool browser_get_url để agent tự kiểm tra vị trí hiện tại trước mỗi action quan trọng.

{
    "name": "browser_get_url",
    "description": "Get current page URL. Use to verify navigation succeeded.",
    "input_schema": {"type": "object", "properties": {}}
}

Và trong system prompt, hướng dẫn agent: “Before each major action, confirm the current URL matches your expectation.”

Approach 2: Claude Computer Use API

Computer Use là một tính năng beta của Anthropic cho phép Claude nhìn screenshot và trả về các action dạng mouse_move, left_click, type, key. Đây là cách tiếp cận hoàn toàn khác: model không biết gì về DOM, nó chỉ thấy pixels.

Khi nào Computer Use thắng Playwright

Computer Use phù hợp khi:

  • Trang có heavy JavaScript render, DOM thay đổi liên tục, selector không ổn định
  • Ứng dụng desktop (via VNC hoặc virtual display) không có web DOM
  • Trang cố tình obfuscate DOM để chặn automation (mặc dù anti-bot vẫn có thể block)
  • Prototype nhanh, không muốn viết selector

Playwright thắng khi:

  • Trang có DOM accessible, test automation được hỗ trợ
  • Cần tốc độ cao (fill 50 fields trong 5 giây)
  • Budget quan trọng (xem bảng cost bên dưới)
  • Cần reliability cao, ít false positive

Computer Use API ví dụ đơn giản

import anthropic
import base64
from pathlib import Path

def computer_use_agent(screenshot_path: str, task: str) -> list:
    """
    Gửi screenshot cho Claude Computer Use, nhận về list actions.
    Đây là simplified demo. Production cần loop + execute actions thật.
    """
    client = anthropic.Anthropic()

    # Encode screenshot thành base64
    image_data = base64.standard_b64encode(
        Path(screenshot_path).read_bytes()
    ).decode("utf-8")

    response = client.beta.messages.create(
        model="claude-opus-4-5",  # Computer Use hiện chỉ tốt trên Opus
        max_tokens=1024,
        tools=[
            {
                "type": "computer_20241022",
                "name": "computer",
                "display_width_px": 1280,
                "display_height_px": 800,
            }
        ],
        messages=[
            {
                "role": "user",
                "content": [
                    {
                        "type": "image",
                        "source": {
                            "type": "base64",
                            "media_type": "image/png",
                            "data": image_data,
                        },
                    },
                    {
                        "type": "text",
                        "text": task,
                    },
                ],
            }
        ],
        betas=["computer-use-2024-10-22"],
    )

    # Extract actions từ response
    actions = []
    for block in response.content:
        if block.type == "tool_use" and block.name == "computer":
            actions.append(block.input)

    return actions


# Demo output (không chạy thật):
# actions = [
#   {"action": "left_click", "coordinate": [640, 400]},
#   {"action": "type", "text": "hello@example.com"},
# ]

Loop thực tế của Computer Use phức tạp hơn: sau mỗi action, cần chụp screenshot mới, gửi lại cho model, nhận action tiếp theo. Mỗi vòng là một API call với image.

Browser Use, Browserbase, Skyvern

Ngoài tự build, có các managed services:

Browser Use (thư viện Python open source): wrap Playwright với LLM integration sẵn. Agent tự chọn action dựa trên DOM + vision hybrid. Tiết kiệm boilerplate, nhưng ít control hơn khi cần custom behavior. Phù hợp prototype nhanh.

Browserbase: cloud browser infrastructure. Không cần quản lý Chromium, không lo memory leak, có built-in session management và proxy rotation. Đắt hơn self-host, nhưng production-ready từ ngày đầu.

Skyvern: open source agent framework chuyên browser automation, dùng vision + DOM hybrid. Có UI để debug agent trace. Phù hợp khi task phức tạp, nhiều trang, cần observability.

DOM vs Vision: bảng so sánh

Tiêu chíDOM (Playwright)Vision (Computer Use)
Tốc độ action~50-200ms/action~2-5s/action (screenshot + API)
Chi phí per actionGần 0 (chỉ tốn token phân tích)~$0.01-0.05 (image token Opus)
Reliability với stable DOMCaoTrung bình (có thể miss click)
Reliability với dynamic DOMThấp (selector break)Cao
Setup timeCần viết/test selectorKhông cần selector
Anti-bot bypassKhông (dễ detect)Khó hơn (giống người)
Debug khi failDễ (biết selector nào sai)Khó (pixel level)
Yêu cầu DOM accessibleKhông

Cost so sánh thực tế với một task điền form 10 fields:

  • Playwright: ~500 input tokens + ~300 output tokens cho planning, sau đó gần 0 cho actions. Tổng < $0.01.
  • Computer Use (Opus): 10 vòng lặp, mỗi vòng 1 screenshot (~800px × 600px ≈ 200K pixel tokens), planning, action. Tổng khoảng $0.10-0.20 per task.

Vision đắt hơn DOM khoảng 10-20x cho cùng task. Đi sâu phần cost ở bài 22.

Pitfall 1: Anti-bot block agent

Đây là pitfall đau nhất tôi gặp, và không có giải pháp hoàn hảo.

Cloudflare Bot Management, hCaptcha, và nhiều anti-bot solution khác detect browser automation qua nhiều signal: headless browser fingerprint, mouse movement không tự nhiên, thiếu WebGL/Canvas fingerprint, timing quá đều.

Playwright với default settings bị block bởi hầu hết enterprise-grade anti-bot. Một số cách giảm nhẹ:

# Dùng playwright-stealth hoặc rebrowser-patches
from playwright_stealth import stealth_async

context = await browser.new_context(...)
page = await context.new_page()
await stealth_async(page)  # Patch các fingerprint tells

Nhưng ngay cả với stealth, Cloudflare Turnstile (và nhất là CAPTCHA) không thể bypass tự động một cách hợp pháp. Nếu gặp CAPTCHA, có ba lựa chọn:

  1. Dùng CAPTCHA solving service (2captcha, Anti-CAPTCHA). Tốn tiền, có latency, grey area về ToS.
  2. Thiết kế lại flow: liệu có API không? Nhiều trang có form UI nhưng cũng có API endpoint phía sau.
  3. Hỏi lại business requirement: nếu trang explicitly chặn automation, có thể có lý do (rate limit, abuse prevention). Tiếp tục bypass là vi phạm ToS.

Nguyên tắc: trước khi build browser agent cho một trang cụ thể, đọc ToS của trang đó.

Pitfall 2: Selector fragile sau deploy

Trang web thay đổi layout. Selector hôm nay đúng, sau khi deploy frontend mới có thể sai. Agent dùng CSS selector như div.checkout-form > div:nth-child(3) > input sẽ break ngay khi developer refactor DOM.

Nguyên tắc khi thiết kế selector cho agent:

  • Ưu tiên text= locator: page.get_by_text("Submit") ổn định hơn CSS.
  • Dùng role= locator: page.get_by_role("button", name="Submit") theo ARIA, ít bị ảnh hưởng bởi visual refactor.
  • Tránh positional selector (nth-child, first-of-type) trừ khi không còn lựa chọn.
  • Dùng data-testid nếu trang hỗ trợ: [data-testid="submit-btn"] là convention được giữ ổn định qua refactor.

Nên có một bước “agent tự discover selector” thay vì hardcode trong prompt:

# Thay vì dặn agent dùng selector cụ thể,
# để agent tự dùng browser_get_text để đọc page rồi quyết định selector
task = (
    "Go to https://example.com/form. "
    "Read the page content to identify the form fields. "
    "Fill in name='John', email='john@example.com', then submit."
)

Pitfall 3: Infinite wait khi page không load

Network chậm, JavaScript render lâu, API third-party timeout. Agent gọi goto, page không bao giờ trigger domcontentloaded. Default Playwright timeout là 30 giây, nhưng trong agent loop, 30 giây × nhiều lần = agent treo.

# Luôn set explicit timeout
await page.goto(url, timeout=10000, wait_until="domcontentloaded")

# Thêm fallback: nếu timeout, agent nhận error message và quyết định retry hay abort
try:
    await page.goto(url, timeout=10000)
except TimeoutError:
    return "Error: page load timeout after 10s. The site may be slow or unavailable."

Cheatsheet: khi nào dùng gì

ScenarioNên dùngLý do
Form điền trên trang có DOM rõ ràngPlaywright DOMNhanh, rẻ, reliable
Scrape data từ trang static HTMLPlaywright + get_textDOM query đơn giản
Trang SPA render heavy, selector không ổnComputer UseKhông phụ thuộc DOM
Ứng dụng desktop (không phải web)Computer Use + VNCKhông có DOM
Trang có CAPTCHA / aggressive anti-botCân nhắc API thay thếAnti-bot không thể bypass hợp pháp
Prototype nhanh không muốn viết codeBrowser Use (thư viện)Boilerplate ít
Production, cần scale, không muốn quản lý infraBrowserbaseManaged infrastructure
Task phức tạp nhiều trang, cần debugSkyvernBuilt-in observability

Lời kết

Browser automation mở ra một lớp tasks mà agent có thể làm mà trước đây cần RPA chuyên biệt hoặc human operator: điền form, extract data từ portal không có API, kiểm tra UI regression, tự động hóa workflow trên web app nội bộ.

Hai approach, DOM và vision, không phải đối thủ mà bổ trợ nhau. Bắt đầu với Playwright DOM vì nhanh và rẻ. Nâng lên Computer Use chỉ khi DOM không đủ. Biết trước về anti-bot, CAPTCHA, và selector fragility để không bị surprise ở production.

Bài tiếp theo, RAG cho agents: retrieval trong vòng lặp, không phải QA, sẽ đi vào một tool category khác: khi agent cần tra cứu knowledge base lớn trong mỗi vòng lặp, không phải chỉ một lần như QA system truyền thống.