An AI agent that browses a website like a person and returns structured data. Good for sites that change often or are hard to scrape with selectors.
Create it locally
Main file
"""Module defines the main entry point for the Scrapely Actor.
One Browser Use agent runs one task in one browser and stores its structured result as dataset rows.
To build Scrapely Actors, utilize the Scrapely SDK toolkit, read more at the official documentation:
"""
from __future__ import annotations
import asyncio
import contextlib
import json
import os
import shutil
import subprocess
import tempfile
import time
import urllib.request
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING
from urllib.parse import unquote, urlsplit
import psutil
from scrapely import Actor
from browser_use import ActionResult, Agent, Browser, ChatOpenAI, Tools
from browser_use.browser import BrowserProfile, ProxySettings
from browser_use.browser.watchdogs.local_browser_watchdog import LocalBrowserWatchdog
from browser_use.dom.views import DEFAULT_INCLUDE_ATTRIBUTES
from pydantic import BaseModel, Field
from .compat import agent_signal_options, run_agent_with_actor_signals
from .config import MAX_ITEMS, RunConfig, normalize_input
if TYPE_CHECKING:
from collections.abc import Iterable, Mapping
# The agent reads links through the `read_page_links` action below and returns the result through `done`. The built-in
# file-system, `extract`, and page-query actions let a model collect the data again and again without finishing, and
# `search` takes it off the start page to a web search. The rest have no use in an extraction run.
EXCLUDED_ACTIONS = [
'evaluate',
'extract',
'find_elements',
'find_text',
'read_file',
'replace_file',
'save_as_pdf',
'screenshot',
'search',
'search_page',
'upload_file',
'write_file',
]
PAGE_LINKS_SCRIPT = """() => Array.from(document.querySelectorAll('a[href]'))
.slice(0, 2000)
.map((anchor) => ({
title: (anchor.textContent || '').replace(/\\s+/g, ' ').trim(),
url: anchor.href,
}))"""
# The template launches Chrome itself and hands Browser Use the running browser, since Browser Use's own launch gives
# Chrome a fixed 30 s to open its debugging port and the first tab 4 s to attach, which a cold start on a small machine
# (e.g. a CI runner) can exceed. A launch that doesn't come up in time is retried with a fresh browser, and a failed
# connection to a running browser is retried after a pause.
BROWSER_LAUNCH_ATTEMPTS = 2
BROWSER_LAUNCH_TIMEOUT_SECS = 90
BROWSER_CONNECT_ATTEMPTS = 5
BROWSER_RETRY_DELAY_SECS = 2
# Browser Use's 15 s navigation limit in older releases is tight for a first page load through a proxy. A value set in
# the environment wins.
BROWSER_EVENT_TIMEOUTS_SECS = {'TIMEOUT_NavigateToUrlEvent': 60}
# Upper bound on the links `read_page_links` hands to the model, which keeps its prompt small on link-heavy pages.
MAX_ACTION_LINKS = 300
class Item(BaseModel):
"""One item extracted by the browser agent."""
title: str = Field(description='item title exactly as displayed')
# A plain string, since OpenAI structured outputs reject the `uri` format that `HttpUrl` adds to the schema.
# `is_http_url` validates it after the run.
url: str = Field(description='exact absolute HTTP(S) URL of the item, including path and query')
class Items(BaseModel):
"""Structured result returned by Browser Use."""
items: list[Item] = Field(min_length=1, max_length=MAX_ITEMS, description='items in displayed order')
def make_llm(model_name: str) -> ChatOpenAI:
"""Build the chat client.
All LLM calls go through the Scrapely LLM gateway (OpenAI-compatible, OpenRouter model IDs) with the run's
Scrapely token as the key, so token usage is billed to the Scrapely account running the Actor.
"""
# Temperature 0 keeps the agent's choices repeatable for the same page.
return ChatOpenAI(
model=model_name,
temperature=0,
api_key=os.environ['SCRAPELY_TOKEN'],
base_url=os.getenv('SCRAPELY_LLM_BASE_URL', 'https://api.scrape.ly/v2/llm/api/v1'),
)
def is_http_url(value: str) -> bool:
"""Check that a value is an absolute HTTP(S) URL."""
parts = urlsplit(value)
return parts.scheme in {'http', 'https'} and bool(parts.hostname)
def to_browser_use_proxy(proxy_url: str) -> ProxySettings:
"""Convert a Scrapely Proxy URL to Browser Use's structured settings."""
parts = urlsplit(proxy_url)
if parts.scheme not in {'http', 'https'} or not parts.hostname or not parts.port:
msg = 'Scrapely Proxy returned an invalid HTTP(S) URL'
raise ValueError(msg)
return ProxySettings(
server=f'{parts.scheme}://{parts.hostname}:{parts.port}',
username=unquote(parts.username or ''),
password=unquote(parts.password or ''),
)
def normalize_items(items: Iterable[Item], *, limit: int) -> list[dict[str, object]]:
"""Create flat, ordered, de-duplicated dataset rows from items with an absolute HTTP(S) URL."""
rows: list[dict[str, object]] = []
seen: set[str] = set()
for item in items:
url = item.url.strip()
if url in seen or not is_http_url(url):
continue
seen.add(url)
rows.append({'rank': len(rows) + 1, 'title': item.title, 'url': url})
if len(rows) >= limit:
break
return rows
def _normalize_title(value: str) -> str:
return ' '.join(value.split())
def ground_item_links(items: Iterable[Item], links: Iterable[Mapping[str, object]]) -> list[Item]:
"""Swap each model URL for the page href under the same title, when exactly one such href exists.
Models tend to shorten or mistype long URLs, so a link the page shows for the title is more reliable. Items whose
title matches no link, or several different links, pass through with the model's URL.
"""
exact_links: dict[str, Item | None] = {}
for link in links:
exact = Item(title=_normalize_title(str(link.get('title', ''))), url=str(link.get('url', '')))
if not exact.title or not is_http_url(exact.url):
continue
key = exact.title
previous = exact_links.get(key)
if key not in exact_links:
exact_links[key] = exact
elif previous is not None and previous.url != exact.url:
exact_links[key] = None
return [exact_links.get(_normalize_title(item.title)) or item for item in items]
async def snapshot_page_links(browser: Browser) -> list[Mapping[str, object]]:
"""Read a bounded set of exact absolute anchor hrefs from the active page."""
page = await browser.must_get_current_page()
payload = json.loads(await page.evaluate(PAGE_LINKS_SCRIPT))
if not isinstance(payload, list) or not all(isinstance(item, dict) for item in payload):
msg = 'Browser returned an invalid page-link snapshot.'
raise RuntimeError(msg)
return payload
async def try_read_page_links(browser: Browser) -> list[Mapping[str, object]]:
"""Read the page links, or return none when the final page can't be inspected (e.g. a PDF or an error page)."""
try:
return await snapshot_page_links(browser)
except Exception:
Actor.log.warning('Could not read links from the final page; keeping the URLs returned by the model.')
return []
def format_page_links(links: Iterable[Mapping[str, object]]) -> str:
"""Render the titled, absolute HTTP(S) links of a page as JSON lines, de-duplicated and in page order."""
lines: list[str] = []
seen: set[tuple[str, str]] = set()
for link in links:
title = _normalize_title(str(link.get('title', '')))
url = str(link.get('url', ''))
if not title or not is_http_url(url) or (title, url) in seen:
continue
seen.add((title, url))
lines.append(json.dumps({'title': title, 'url': url}, ensure_ascii=False))
if len(lines) >= MAX_ACTION_LINKS:
break
return '\n'.join(lines)
def build_tools() -> Tools:
"""Create the agent's actions: the built-in ones minus `EXCLUDED_ACTIONS`, plus `read_page_links`."""
tools = Tools(exclude_actions=EXCLUDED_ACTIONS)
@tools.action(
'Return the title and exact absolute URL (href) of every link on the current page, in page order. '
'Use it to read the items, then call done.'
)
# Browser Use injects `browser_session` by name and rejects a string annotation, which `from __future__ import
# annotations` would turn the type into, so the parameter stays unannotated.
async def read_page_links(browser_session) -> ActionResult: # noqa: ANN001
page_links = format_page_links(await snapshot_page_links(browser_session))
return ActionResult(
extracted_content=(
f'Links on the current page, one JSON object per line:\n{page_links}\n'
'Pick the requested items from these links and call done with them now.'
),
)
return tools
@dataclass
class LaunchedChrome:
"""A Chrome process started by the template, with its debugging endpoint."""
process: subprocess.Popen[bytes]
user_data_dir: str
cdp_url: str
def find_chrome() -> str:
"""Return the Chrome executable: the one in the Scrapely image, else one Browser Use finds on the machine."""
path = Actor.configuration.chrome_executable_path or LocalBrowserWatchdog._find_installed_browser_path() # noqa: SLF001
if not path:
msg = 'No Chrome found - install one (e.g. `playwright install chromium`)'
raise RuntimeError(msg)
return path
def chrome_args(*, headless: bool, proxy: ProxySettings | None, user_data_dir: str) -> list[str]:
"""Build Chrome's command-line flags, the same set Browser Use launches its own browser with."""
profile = BrowserProfile(
# Chrome's sandbox fails to start on hosts that restrict user namespaces (e.g. Ubuntu 24.04). Browser Use only
# disables it inside Docker, while Playwright and the other Chrome templates disable it everywhere.
chromium_sandbox=False,
enable_default_extensions=False,
headless=headless,
proxy=proxy,
user_data_dir=user_data_dir,
)
# Port 0 lets Chrome pick a free port and write it to `DevToolsActivePort` in the profile directory.
return [*profile.get_args(), '--remote-debugging-port=0', 'about:blank']
def read_cdp_url(user_data_dir: str) -> str | None:
"""Return the debugging endpoint once Chrome has opened its port and answers on it."""
try:
port = int((Path(user_data_dir) / 'DevToolsActivePort').read_text().split()[0])
cdp_url = f'http://127.0.0.1:{port}'
with urllib.request.urlopen(f'{cdp_url}/json/version', timeout=2): # noqa: S310
return cdp_url
except (OSError, ValueError, IndexError):
return None
def kill_chrome(chrome: LaunchedChrome) -> None:
"""Kill the Chrome process tree and remove its profile directory."""
try:
root = psutil.Process(chrome.process.pid)
processes = [*root.children(recursive=True), root]
except psutil.Error:
processes = []
for process in processes:
with contextlib.suppress(psutil.Error):
process.kill()
psutil.wait_procs(processes, timeout=5)
shutil.rmtree(chrome.user_data_dir, ignore_errors=True)
async def launch_chrome(*, headless: bool, proxy: ProxySettings | None) -> LaunchedChrome:
"""Start Chrome and wait until its debugging endpoint answers."""
executable = find_chrome()
for attempt in range(1, BROWSER_LAUNCH_ATTEMPTS + 1):
# `BrowserProfile` copies a profile directory to a new temporary one unless its name has this prefix.
user_data_dir = tempfile.mkdtemp(prefix='browser-use-user-data-dir-')
process = subprocess.Popen( # noqa: ASYNC220, S603
[executable, *chrome_args(headless=headless, proxy=proxy, user_data_dir=user_data_dir)],
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
chrome = LaunchedChrome(process=process, user_data_dir=user_data_dir, cdp_url='')
deadline = time.monotonic() + BROWSER_LAUNCH_TIMEOUT_SECS
try:
while process.poll() is None and time.monotonic() < deadline:
cdp_url = await asyncio.to_thread(read_cdp_url, user_data_dir)
if cdp_url:
chrome.cdp_url = cdp_url
return chrome
await asyncio.sleep(0.25)
finally:
if not chrome.cdp_url:
kill_chrome(chrome)
reason = f'exited with code {process.returncode}' if process.returncode is not None else 'timed out'
Actor.log.warning('Chrome launch %s (attempt %d/%d).', reason, attempt, BROWSER_LAUNCH_ATTEMPTS)
msg = f'Chrome did not start after {BROWSER_LAUNCH_ATTEMPTS} attempts'
raise RuntimeError(msg)
def build_browser(config: RunConfig, *, cdp_url: str, headless: bool, proxy: ProxySettings | None) -> Browser:
"""Create a Browser Use session on the launched Chrome, kept open for post-agent link grounding."""
for name, secs in BROWSER_EVENT_TIMEOUTS_SECS.items():
os.environ.setdefault(name, str(secs))
return Browser(
cdp_url=cdp_url,
headless=headless,
keep_alive=True,
# Browser Use answers the proxy's authentication challenge with these credentials.
proxy=proxy,
wait_between_actions=config.action_delay_secs,
)
async def connect_browser(
config: RunConfig,
chrome: LaunchedChrome,
*,
headless: bool,
proxy: ProxySettings | None,
) -> Browser:
"""Connect Browser Use to the launched Chrome, retrying while its first tab is still coming up."""
attempt = 1
while True:
browser = build_browser(config, cdp_url=chrome.cdp_url, headless=headless, proxy=proxy)
try:
await browser.start()
except Exception:
# `kill` on a session started from `cdp_url` only disconnects; Chrome itself stays up for the next attempt.
await _safe_kill(browser)
if attempt >= BROWSER_CONNECT_ATTEMPTS:
raise
Actor.log.warning('Browser connection failed (attempt %d/%d); retrying.', attempt, BROWSER_CONNECT_ATTEMPTS)
attempt += 1
await asyncio.sleep(BROWSER_RETRY_DELAY_SECS)
else:
return browser
def build_agent(config: RunConfig, *, llm: ChatOpenAI, browser: Browser) -> Agent:
"""Construct the Browser Use agent for the configured task."""
task = (
f'{config.task}\n\n'
f'Return at most {config.max_items} items. Use only titles and URLs shown on the pages; never invent them. '
'Call read_page_links to get the exact titles and URLs of the links on the current page, and pick the items '
'from its result. Do not click links or search the web just to read them. '
'As soon as you have the items, call done with them.'
)
return Agent(
task=task,
llm=llm,
browser=browser,
tools=build_tools(),
# Open the start page before the first LLM step, so the agent never spends a step or guesses the URL.
initial_actions=[{'navigate': {'url': config.start_url, 'new_tab': False}}],
output_model_schema=Items,
# Browser Use omits href from its default DOM representation. Without this, models tend to mistake HN's
# visible source-domain label for the title link's full destination.
include_attributes=[*DEFAULT_INCLUDE_ATTRIBUTES, 'href'],
use_vision=False,
use_judge=False,
**agent_signal_options(),
)
def require_result(result: Items | None) -> Items:
"""Reject an agent run that ended without a structured result."""
if result is None:
msg = 'The agent stopped without returning a structured result.'
raise RuntimeError(msg)
return result
def require_rows(rows: list[dict[str, object]]) -> list[dict[str, object]]:
"""Reject an agent result with no usable rows."""
if not rows:
msg = 'The agent returned no items with an absolute HTTP(S) URL.'
raise RuntimeError(msg)
return rows
async def _safe_kill(browser: Browser | None) -> None:
if browser is None:
return
try:
await browser.kill()
except Exception:
Actor.log.warning('Browser cleanup failed.', exc_info=True)
async def _write_failure(config: RunConfig, started_at: float, error: Exception) -> None:
await Actor.set_value(
'OUTPUT',
{
'framework': 'browser-use',
'status': 'failed',
'model': config.model_name,
'startUrl': config.start_url,
'itemCount': 0,
'elapsedSecs': round(time.monotonic() - started_at, 2),
'error': (str(error) or type(error).__name__)[:500],
},
)
async def main() -> None:
"""Run one isolated, bounded Browser Use task under the Actor lifecycle."""
async with Actor:
raw_input = (await Actor.get_input()) or {}
if not isinstance(raw_input, dict):
msg = 'Actor input must be a JSON object'
raise TypeError(msg)
config = normalize_input(raw_input)
started_at = time.monotonic()
chrome: LaunchedChrome | None = None
browser: Browser | None = None
try:
llm = make_llm(config.model_name)
proxy_configuration = await Actor.create_proxy_configuration(
actor_proxy_input=config.proxy_configuration,
)
proxy_url = await proxy_configuration.new_url() if proxy_configuration else None
proxy = to_browser_use_proxy(proxy_url) if proxy_url else None
headless = Actor.configuration.headless
async with asyncio.timeout(config.deadline_secs):
chrome = await launch_chrome(headless=headless, proxy=proxy)
browser = await connect_browser(config, chrome, headless=headless, proxy=proxy)
agent = build_agent(config, llm=llm, browser=browser)
history = await run_agent_with_actor_signals(agent, max_steps=config.max_steps)
result = require_result(history.structured_output)
page_links = await try_read_page_links(browser)
grounded_items = ground_item_links(result.items, page_links)
rows = require_rows(normalize_items(grounded_items, limit=config.max_items))
await Actor.push_data(rows)
summary = {
'framework': 'browser-use',
'status': 'succeeded',
'model': config.model_name,
'startUrl': config.start_url,
'itemCount': len(rows),
'elapsedSecs': round(time.monotonic() - started_at, 2),
}
await Actor.set_value('OUTPUT', summary)
await Actor.set_status_message(f'Extracted {len(rows)} item(s).')
Actor.log.info('Done; wrote %d dataset row(s) and OUTPUT.', len(rows))
except Exception as error:
await _write_failure(config, started_at, error)
raise
finally:
await _safe_kill(browser)
if chrome is not None:
kill_chrome(chrome)
Browser Use scraping agent
An AI agent that browses a website like a person and returns structured data. Good for sites that change often or are hard to scrape with selectors.
Included features
- Scrapely SDK — Actor lifecycle, input, storage and proxies
- Browser Use — AI browser agent
- Headless Chrome — renders client-side JavaScript
- Scrapely LLM gateway — call OpenRouter models with the run token; usage is billed to your Scrapely account, no provider API key needed
- Input schema — define and validate the Actor's input (
.actor/input_schema.json) - Dataset schema — structured results with a preview view (
.actor/dataset_schema.json)
Getting started locally
python -m pip install -r requirements.txt
scrapely run # run the Actor locally
LLM calls go to SCRAPELY_LLM_BASE_URL (set automatically on the platform) with the run's
SCRAPELY_TOKEN, so the agent works inside a Scrapely run without any provider API key.
Deploying to Scrapely
Create an Actor from this template in the Scrapely Console (Develop new → template → Git). Scrapely creates a repository in your account, pushes this code, and builds the Actor. Every push to your repository triggers a new build.