Skip to main content
Wrap any browser automation agent with Raysurfer’s search() and upload() to cache Playwright scripts. On repeat tasks, the LLM receives the cached script as context and decides whether to reuse it, modify it, or generate a new one.
Cached scripts are always reviewed by the LLM before execution — never run directly.

How It Works

1. search(task)        → find cached Playwright scripts for this task
2. LLM reviews         → decides: reuse, modify, or generate fresh
3. Execute script      → run Playwright in headless browser
4. upload(task, script) → cache successful scripts for next time
First run generates a script from scratch. Subsequent runs retrieve the cached script and let the LLM decide.

Setup

pip install raysurfer anthropic playwright
playwright install chromium

export ANTHROPIC_API_KEY=sk-ant-...
export RAYSURFER_API_KEY=rs_...

Quick Example

import asyncio
import os
from raysurfer import AsyncRaySurfer, FileWritten
from anthropic import AsyncAnthropic

SYSTEM_PROMPT = (
    "You are a browser automation expert. Write Python scripts using "
    "playwright.sync_api with chromium in headless mode. "
    "Print the final result to stdout. "
    "Respond with ONLY a JSON object: {\"script\": \"<python code>\", \"explanation\": \"<one line>\"}"
)

async def run_browser_task(task: str, url: str | None = None):
    rs = AsyncRaySurfer(api_key=os.environ["RAYSURFER_API_KEY"])
    anthropic = AsyncAnthropic()

    # 1. Search for cached scripts
    cached_script = None
    response = await rs.search(task=task, top_k=1, min_verdict_score=0.3)
    if response.matches:
        cached_script = response.matches[0].code_block.source

    # 2. Build prompt — include cached script if found
    url_line = f"\nStarting URL: {url}" if url else ""
    if cached_script:
        user_msg = (
            f"Task: {task}{url_line}\n\n"
            f"A cached script for a similar task:\n```python\n{cached_script}\n```\n\n"
            "Review it: run as-is if it fits, modify for the current task, "
            "or write a new script if not relevant."
        )
    else:
        user_msg = f"Task: {task}{url_line}"

    # 3. LLM generates or approves the script
    llm_response = await anthropic.messages.create(
        model="claude-sonnet-4-20250514",
        max_tokens=4096,
        system=SYSTEM_PROMPT,
        messages=[{"role": "user", "content": user_msg}],
    )

    import json
    parsed = json.loads(llm_response.content[0].text)
    script = parsed["script"]

    # 4. Execute the Playwright script
    import subprocess, sys, tempfile
    with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
        f.write(script)
        f.flush()
        result = subprocess.run([sys.executable, f.name], capture_output=True, text=True, timeout=60)

    if result.returncode == 0:
        print(f"Output: {result.stdout.strip()}")
        # 5. Cache the successful script
        await rs.upload(
            task=task,
            file_written=FileWritten(path="browser_script.py", content=script),
            succeeded=True,
            execution_logs=result.stdout,
            tags=["browser-automation", "playwright"],
        )
    else:
        print(f"Error: {result.stderr.strip()}")

    await rs.close()

asyncio.run(run_browser_task("Get the titles and prices of the first 5 books", url="https://books.toscrape.com"))

What Gets Cached

The cached artifact is a complete, runnable Playwright Python script:
from playwright.sync_api import sync_playwright
import json

def scrape_books():
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        page = browser.new_page()
        page.goto('https://books.toscrape.com', timeout=10000)
        page.wait_for_load_state('networkidle')

        books = []
        for book in page.locator('article.product_pod').all()[:5]:
            title = book.locator('h3 a').get_attribute('title')
            price = book.locator('p.price_color').inner_text()
            books.append({'title': title, 'price': price})

        print(json.dumps(books, indent=2))
        browser.close()

if __name__ == '__main__':
    scrape_books()
On the next similar request, this script is passed to the LLM as context. The LLM can reuse it directly or modify it for the new task.

Handling Optional Elements

Real pages have cookie banners, login modals, age gates, and other elements that may or may not appear. Cached scripts should check for these before interacting:
from playwright.sync_api import sync_playwright

def scrape_with_guards():
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        page = browser.new_page()
        page.goto('https://example-store.com', timeout=15000)
        page.wait_for_load_state('networkidle')

        # Cookie consent banner
        cookie_btn = page.locator('button:has-text("Accept"), #cookie-accept, [data-testid="cookie-accept"]')
        if cookie_btn.count() > 0:
            cookie_btn.first.click()
            page.wait_for_timeout(500)

        # Age verification gate
        age_gate = page.locator('[data-testid="age-gate"], .age-verification')
        if age_gate.count() > 0:
            confirm_btn = age_gate.locator('button:has-text("Yes"), button:has-text("I am over")')
            if confirm_btn.count() > 0:
                confirm_btn.first.click()
                page.wait_for_timeout(500)

        # Dismiss newsletter/promo popup
        popup_close = page.locator('[aria-label="Close"], .modal-close, .popup-close, button:has-text("No thanks")')
        if popup_close.count() > 0:
            popup_close.first.click()
            page.wait_for_timeout(500)

        # "Load more" button — click until all items are visible or button disappears
        while True:
            load_more = page.locator('button:has-text("Load more"), button:has-text("Show more")')
            if load_more.count() == 0 or not load_more.first.is_visible():
                break
            load_more.first.click()
            page.wait_for_timeout(1000)

        # Login wall — skip scraping if login is required
        login_wall = page.locator('form[action*="login"], [data-testid="login-required"]')
        if login_wall.count() > 0:
            print("Login required — cannot proceed")
            browser.close()
            return

        # Now scrape the actual content
        items = page.locator('.product-card').all()
        results = []
        for item in items[:10]:
            name = item.locator('.product-name')
            price = item.locator('.product-price')
            results.append({
                'name': name.inner_text() if name.count() > 0 else 'N/A',
                'price': price.inner_text() if price.count() > 0 else 'N/A',
            })

        import json
        print(json.dumps(results, indent=2))
        browser.close()

if __name__ == '__main__':
    scrape_with_guards()
Common patterns to guard against:
ElementHow to detectWhat to do
Cookie consentbutton:has-text("Accept"), #cookie-acceptClick accept, wait briefly
Newsletter popup.modal-close, [aria-label="Close"]Click close
Age verification.age-verification, data-testid="age-gate"Click confirm
”Load more” buttonbutton:has-text("Load more")Click in loop until gone
Login wallform[action*="login"]Exit early or log in
CAPTCHA.captcha, iframe[src*="recaptcha"]Exit early — can’t automate
Region selector.region-picker, select[name="country"]Select region, wait for reload
These guards get cached alongside the scraping logic, so subsequent runs of the same task handle the same popups automatically.

Standalone Wrapper

For a ready-to-use wrapper class, see the raysurfer-browser-agent example:
from raysurfer_browser_agent import RaysurferBrowserAgent

async with RaysurferBrowserAgent() as agent:
    # First run: generates script from scratch, caches it
    result = await agent.run("Get the top 3 stories from Hacker News")

    # Second run: LLM reviews cached script and reuses it
    result = await agent.run("Get the top 3 stories from Hacker News")
    print(result.output)       # scraped data
    print(result.cached)        # True — cached script was found
    print(result.reused_cache)  # True — LLM chose to reuse it