feat(community): add Browserless web_capture screenshot tool (#3881)

* feat(community): add Browserless web_capture screenshot tool

Add a web_capture tool that renders a page via Browserless /screenshot and
presents it through the artifact system, alongside the existing Browserless
web_fetch provider.

Hardening:
- SSRF guard: reject URLs resolving to private/loopback/link-local (incl. the
  169.254.169.254 cloud-metadata endpoint)/reserved/multicast/unspecified
  addresses; opt out via allow_private_addresses for internal targets.
- Surface a warning when Browserless renders a target page that itself
  responded with a non-2xx/3xx status (X-Response-Code), so an error/anti-bot
  page is not mistaken for valid visual evidence.
- Dedupe colliding output filenames instead of silently overwriting prior
  captures.

Docs: comment out token: $BROWSERLESS_TOKEN in tool examples (an unset $VAR
fails AppConfig startup) and document allow_private_addresses.

* fix(community): format web_capture guard + document local Browserless startup

Address PR #3881 review: fix the lint-backend failure (ruff format on
browserless/tools.py) and add local Browserless startup instructions to
CONFIGURATION.md so reviewers can run the service to try web_fetch/web_capture.
This commit is contained in:
Ryker_Feng 2026-07-01 23:41:58 +08:00 committed by GitHub
parent dd05e1a76d
commit a8f950feb6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1230 additions and 22 deletions

View File

@ -602,7 +602,7 @@ Users can explicitly activate an enabled skill for a single turn by starting the
When you install `.skill` archives through the Gateway, DeerFlow accepts standard optional frontmatter metadata such as `version`, `author`, and `compatibility` instead of rejecting otherwise valid external skills.
Tools follow the same philosophy. DeerFlow comes with a core toolset — web search, web fetch, file operations, bash execution — and supports custom tools via MCP servers and Python functions. Swap anything. Add anything.
Tools follow the same philosophy. DeerFlow comes with a core toolset — web search, web fetch, rendered web capture, file operations, bash execution — and supports custom tools via MCP servers and Python functions. Swap anything. Add anything.
Gateway-generated follow-up suggestions now normalize both plain-string model output and block/list-style rich content before parsing the JSON array response, so provider-specific content wrappers do not silently drop suggestions.

View File

@ -441,7 +441,7 @@ Skills 采用按需渐进加载,不会一次性把所有内容都塞进上下
通过 Gateway 安装 `.skill` 压缩包时DeerFlow 会接受标准的可选 frontmatter 元数据,比如 `version``author``compatibility`,不会把本来合法的外部 skill 拒之门外。
Tools 也是同样的思路。DeerFlow 自带一组核心工具网页搜索、网页抓取、文件操作、bash 执行;同时也支持通过 MCP Server 和 Python 函数扩展自定义工具。你可以替换任何一项,也可以继续往里加。
Tools 也是同样的思路。DeerFlow 自带一组核心工具:网页搜索、网页抓取、网页渲染截图、文件操作、bash 执行;同时也支持通过 MCP Server 和 Python 函数扩展自定义工具。你可以替换任何一项,也可以继续往里加。
Gateway 生成后续建议时,现在会先把普通字符串输出和 block/list 风格的富文本内容统一归一化,再去解析 JSON 数组响应,因此不同 provider 的内容包装方式不会再悄悄把建议吞掉。

View File

@ -235,7 +235,8 @@ tools:
**Built-in Tools**:
- `web_search` - Search the web (DuckDuckGo, Tavily, Brave, Exa, InfoQuest, Firecrawl, fastCRW, GroundRoute)
- `web_fetch` - Fetch web pages (Jina AI, Exa, InfoQuest, Firecrawl, fastCRW, GroundRoute)
- `web_fetch` - Fetch web pages (Jina AI, Exa, InfoQuest, Firecrawl, fastCRW, GroundRoute, Browserless)
- `web_capture` - Capture rendered webpage screenshots as artifacts (Browserless)
- `image_search` - Search for reference images (DuckDuckGo, InfoQuest, Serper)
- `ls` - List directory contents
- `read_file` - Read file contents
@ -243,6 +244,61 @@ tools:
- `str_replace` - String replacement in files
- `bash` - Execute bash commands
Browserless can be configured as an opt-in visual capture tool:
```yaml
tools:
- name: web_capture
group: web
use: deerflow.community.browserless.tools:web_capture_tool
base_url: http://localhost:3032
# token: $BROWSERLESS_TOKEN
output_format: png
full_page: true
viewport_width: 1280
viewport_height: 720
# allow_private_addresses: false # SSRF guard; keep false in production
```
`web_capture` writes screenshots to the current thread's `/mnt/user-data/outputs`
directory and presents the image path through the standard artifact mechanism. By
default it refuses URLs that resolve to private, loopback, link-local, or
cloud-metadata addresses; set `allow_private_addresses: true` only when you
intentionally point the tool at an internal target.
Both `web_fetch` (Browserless provider) and `web_capture` need a running
Browserless instance. You can point `base_url` at [Browserless Cloud](https://www.browserless.io/)
(set `BROWSERLESS_TOKEN`) or run one locally with Docker:
```bash
# Browserless listens on port 3000 inside the container; map it to 3032 to
# match the default base_url (http://localhost:3032). Recent Browserless
# images always require a token — if you don't pass one, a random token is
# generated and requests without it are rejected — so set it explicitly.
docker run -d --name browserless -p 3032:3000 -e "TOKEN=local-dev-token" ghcr.io/browserless/chromium
```
Then set the same token so the tool sends it (uncomment `token: $BROWSERLESS_TOKEN`
in the config above):
```bash
export BROWSERLESS_TOKEN=local-dev-token
```
Verify the instance is reachable before enabling the tool:
```bash
curl -sS "http://localhost:3032/screenshot?token=local-dev-token" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com", "options": {"type": "png"}}' \
-o /tmp/browserless-check.png # writes a PNG on success
```
For Docker Compose deployments, run Browserless as a service and point `base_url`
at the service name (e.g. `http://browserless:3000`) instead of `localhost`. See
the [Browserless project](https://github.com/browserless/browserless) for full
deployment and configuration options.
### Sandbox
DeerFlow supports multiple sandbox execution modes. Configure your preferred mode in `config.yaml`:
@ -456,6 +512,7 @@ models:
- `BRAVE_SEARCH_API_KEY` - Brave Search API key
- `SERPER_API_KEY` - Serper (Google Search/Images API) key for `web_search` and `image_search`
- `GROUNDROUTE_API_KEY` - GroundRoute meta-search API key for `web_search` and `web_fetch` (routes across Serper, Brave, Exa, Tavily, Firecrawl, Perplexity with gain-share pricing)
- `BROWSERLESS_TOKEN` - Browserless Cloud token for `web_capture` (optional for self-hosted Browserless)
- `DEER_FLOW_PROJECT_ROOT` - Project root for relative runtime paths
- `DEER_FLOW_CONFIG_PATH` - Custom config file path
- `DEER_FLOW_EXTENSIONS_CONFIG_PATH` - Custom extensions config file path

View File

@ -1,4 +1,4 @@
from .browserless_client import BrowserlessClient
from .tools import web_fetch_tool
from .tools import web_capture_tool, web_fetch_tool
__all__ = ["BrowserlessClient", "web_fetch_tool"]
__all__ = ["BrowserlessClient", "web_capture_tool", "web_fetch_tool"]

View File

@ -1,4 +1,5 @@
import logging
from dataclasses import dataclass
from typing import Any
import httpx
@ -6,6 +7,22 @@ import httpx
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class BrowserlessScreenshotResult:
content: bytes
content_type: str
target_status_code: str
target_status: str
final_url: str
def _get_header(headers: Any, name: str) -> str:
value = headers.get(name)
if value:
return str(value)
return str(headers.get(name.lower(), ""))
class BrowserlessClient:
"""Client for Browserless headless Chrome API."""
@ -96,3 +113,99 @@ class BrowserlessClient:
except Exception as e:
logger.error(f"Browserless fetch failed: {e}")
return f"Error: Browserless fetch failed: {e!s}"
async def capture_screenshot(
self,
url: str,
full_page: bool = True,
output_format: str = "png",
quality: int | None = None,
viewport: dict[str, int] | None = None,
wait_for_selector: str = "",
wait_for_selector_timeout_ms: int = 5000,
wait_for_timeout_ms: int = 0,
best_attempt: bool = False,
) -> BrowserlessScreenshotResult | str:
"""Capture a rendered screenshot of a URL using Browserless.
Args:
url: URL to render.
full_page: Capture the full page instead of just the viewport.
output_format: Image format: png, jpeg, or webp.
quality: Optional quality for jpeg/webp outputs.
viewport: Optional browser viewport dictionary.
wait_for_selector: CSS selector to wait for before capture.
wait_for_selector_timeout_ms: Timeout for selector wait.
wait_for_timeout_ms: Extra wait after navigation.
best_attempt: Continue when waits time out.
Returns:
Screenshot result with binary content, or an error string.
"""
payload: dict[str, Any] = {
"url": url,
"options": {
"fullPage": full_page,
"type": output_format,
},
}
if quality is not None:
payload["options"]["quality"] = quality
if viewport:
payload["viewport"] = viewport
if wait_for_selector:
payload["waitForSelector"] = {
"selector": wait_for_selector,
"timeout": wait_for_selector_timeout_ms,
}
if wait_for_timeout_ms > 0:
payload["waitForTimeout"] = wait_for_timeout_ms
if best_attempt:
payload["bestAttempt"] = True
params = {"token": self.token} if self.token else None
logger.debug(f"Capturing URL screenshot via Browserless: {url}")
try:
async with httpx.AsyncClient(timeout=self.timeout_s) as client:
resp = await client.post(
f"{self.base_url}/screenshot",
json=payload,
params=params,
headers={
"Content-Type": "application/json",
"Cache-Control": "no-cache",
},
)
code = resp.status_code
logger.debug(
"Browserless screenshot response: code=%s, target_code=%s, target_status=%s",
code,
resp.headers.get("X-Response-Code", ""),
resp.headers.get("X-Response-Status", ""),
)
if code != 200:
return f"Error: Browserless HTTP {code}: {resp.text[:200]}"
content = resp.content
if not content:
return "Error: Browserless returned empty screenshot response"
return BrowserlessScreenshotResult(
content=content,
content_type=_get_header(resp.headers, "Content-Type"),
target_status_code=_get_header(resp.headers, "X-Response-Code"),
target_status=_get_header(resp.headers, "X-Response-Status"),
final_url=_get_header(resp.headers, "X-Response-URL"),
)
except httpx.TimeoutException:
return f"Error: Browserless screenshot request timed out after {self.timeout_s}s"
except httpx.RequestError as e:
logger.error(f"Browserless screenshot request failed: {e}")
return f"Error: Browserless screenshot request failed: {e!s}"
except Exception as e:
logger.error(f"Browserless screenshot failed: {e}")
return f"Error: Browserless screenshot failed: {e!s}"

View File

@ -1,17 +1,40 @@
import asyncio
import ipaddress
import logging
import os
import re
import socket
from datetime import UTC, datetime
from pathlib import Path
from typing import Annotated
from urllib.parse import urlparse
from langchain.tools import tool
from langchain.tools import InjectedToolCallId, tool
from langchain_core.messages import ToolMessage
from langgraph.types import Command
from deerflow.config import get_app_config
from deerflow.config.paths import VIRTUAL_PATH_PREFIX
from deerflow.tools.types import Runtime
from deerflow.utils.readability import ReadabilityExtractor
from .browserless_client import BrowserlessClient
from .browserless_client import BrowserlessClient, BrowserlessScreenshotResult
logger = logging.getLogger(__name__)
# readability_extractor runs CPU-bound parsing; always call via asyncio.to_thread
_readability_extractor = ReadabilityExtractor()
_OUTPUTS_VIRTUAL_PREFIX = f"{VIRTUAL_PATH_PREFIX}/outputs"
_OUTPUT_FORMAT_TO_EXTENSION = {
"png": "png",
"jpeg": "jpeg",
"webp": "webp",
}
_SAFE_FILENAME_RE = re.compile(r"[^A-Za-z0-9._-]+")
# Hostnames that always resolve to a loopback/link-local/cloud-metadata target.
_BLOCKED_HOSTNAMES = {"localhost", "metadata.google.internal"}
# Cap collision-suffix probing so a saturated outputs directory cannot spin forever.
_MAX_FILENAME_COLLISION_PROBES = 1000
def _get_tool_config(tool_name: str) -> dict | None:
@ -23,10 +46,10 @@ def _get_tool_config(tool_name: str) -> dict | None:
return extras if extras is not None else {}
def _get_browserless_client() -> BrowserlessClient:
cfg = _get_tool_config("web_fetch")
def _get_browserless_client(tool_name: str = "web_fetch") -> BrowserlessClient:
cfg = _get_tool_config(tool_name)
base_url = "http://localhost:3032"
token = ""
token = os.getenv("BROWSERLESS_TOKEN", "")
timeout_s = 30.0
if cfg is not None:
base_url = cfg.get("base_url", base_url)
@ -36,6 +59,190 @@ def _get_browserless_client() -> BrowserlessClient:
return BrowserlessClient(base_url=base_url, token=token, timeout_s=timeout_s)
def _as_bool(value: object, default: bool) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, str):
lowered = value.strip().lower()
if lowered in {"1", "true", "yes", "on"}:
return True
if lowered in {"0", "false", "no", "off"}:
return False
return default
def _as_int(value: object, default: int) -> int:
if isinstance(value, int) and not isinstance(value, bool):
return value
if isinstance(value, str):
try:
return int(value.strip())
except ValueError:
return default
return default
def _as_optional_quality(value: object, output_format: str) -> int | None:
if output_format not in {"jpeg", "webp"}:
return None
quality = _as_int(value, -1)
return quality if 0 <= quality <= 100 else None
def _normalize_output_format(value: object) -> str:
output_format = str(value or "png").strip().lower()
return output_format if output_format in _OUTPUT_FORMAT_TO_EXTENSION else "png"
def _resolve_host_addresses(hostname: str) -> list[ipaddress._BaseAddress]:
"""Resolve a hostname to all of its IP addresses for SSRF screening.
Returns an empty list when resolution fails so callers can decide how to
treat an unresolvable host.
"""
addresses: list[ipaddress._BaseAddress] = []
try:
infos = socket.getaddrinfo(hostname, None)
except (socket.gaierror, UnicodeError):
return addresses
for info in infos:
sockaddr = info[4]
try:
addresses.append(ipaddress.ip_address(sockaddr[0]))
except ValueError:
continue
return addresses
def _is_blocked_address(address: ipaddress._BaseAddress) -> bool:
"""Return True for addresses that must never be reachable via this tool."""
return address.is_private or address.is_loopback or address.is_link_local or address.is_reserved or address.is_multicast or address.is_unspecified
def _validate_capture_url(url: str, allow_private_addresses: bool = False) -> str | None:
"""Validate a capture URL for scheme and (unless opted out) SSRF safety.
Blocks requests that resolve to loopback, private, link-local (incl. the
169.254.169.254 cloud-metadata endpoint), reserved, multicast, or
unspecified addresses. Operators who intentionally point the tool at an
internal Browserless target can opt out via ``allow_private_addresses``.
"""
parsed = urlparse(url)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
return "Error: Only http:// and https:// URLs are supported"
if allow_private_addresses:
return None
hostname = parsed.hostname
if not hostname:
return "Error: URL host could not be parsed"
normalized_host = hostname.strip().rstrip(".").lower()
if normalized_host in _BLOCKED_HOSTNAMES:
return "Error: Refusing to capture a private or loopback address"
# A literal IP host is screened directly; a name is screened across every
# address it resolves to, so a DNS record pointing at an internal IP is
# rejected rather than blindly fetched.
try:
literal_ip = ipaddress.ip_address(normalized_host)
except ValueError:
literal_ip = None
if literal_ip is not None:
candidates = [literal_ip]
else:
candidates = _resolve_host_addresses(hostname)
if not candidates:
return "Error: URL host could not be resolved"
if any(_is_blocked_address(addr) for addr in candidates):
return "Error: Refusing to capture a private, loopback, or metadata address"
return None
def _default_capture_stem(url: str) -> str:
parsed = urlparse(url)
parts = [parsed.netloc, *[part for part in parsed.path.split("/") if part]]
raw = "-".join(parts) or "web-capture"
return raw[:80]
def _safe_capture_filename(filename: str | None, url: str, output_format: str) -> str:
extension = _OUTPUT_FORMAT_TO_EXTENSION[output_format]
if filename:
raw_name = Path(filename).name
stem = Path(raw_name).stem or "web-capture"
else:
timestamp = datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
stem = f"{_default_capture_stem(url)}-{timestamp}"
safe_stem = _SAFE_FILENAME_RE.sub("_", stem).strip("._-") or "web-capture"
return f"{safe_stem[:100]}.{extension}"
def _thread_outputs_path(runtime: Runtime) -> Path | str:
if runtime.state is None:
return "Error: Thread runtime state is not available"
thread_data = runtime.state.get("thread_data") or {}
outputs_path = thread_data.get("outputs_path")
if not outputs_path:
return "Error: Thread outputs path is not available"
return Path(outputs_path)
def _tool_message(content: str, tool_call_id: str) -> Command:
return Command(update={"messages": [ToolMessage(content, tool_call_id=tool_call_id)]})
def _dedupe_output_name(outputs_path: Path, output_name: str) -> str:
"""Return a non-colliding filename under ``outputs_path``.
Keeps the original name when free, otherwise appends ``-1``, ``-2``, ...
before the extension so an explicit filename never silently overwrites an
earlier capture. Falls back to a timestamp suffix if the directory is
saturated with the bounded probe range.
"""
candidate = outputs_path / output_name
if not candidate.exists():
return output_name
stem = Path(output_name).stem
suffix = Path(output_name).suffix
for index in range(1, _MAX_FILENAME_COLLISION_PROBES + 1):
probe = f"{stem}-{index}{suffix}"
if not (outputs_path / probe).exists():
return probe
timestamp = datetime.now(UTC).strftime("%Y%m%d-%H%M%S-%f")
return f"{stem}-{timestamp}{suffix}"
def _write_capture_output(outputs_path: Path, output_name: str, content: bytes) -> str:
"""Write ``content`` into ``outputs_path`` and return the actual filename used."""
outputs_path.mkdir(parents=True, exist_ok=True)
final_name = _dedupe_output_name(outputs_path, output_name)
(outputs_path / final_name).write_bytes(content)
return final_name
def _target_status_warning(result: BrowserlessScreenshotResult) -> str:
"""Return a human-readable warning when the captured page itself errored.
Browserless returns HTTP 200 for the render request even when the target
page responded with a 4xx/5xx (or was an error/anti-bot page), so the raw
image alone cannot be trusted as valid visual evidence. The target's real
status is surfaced via the X-Response-Code header.
"""
code = result.target_status_code.strip()
if not code or code.startswith(("2", "3")):
return ""
status = result.target_status.strip()
detail = f"{code} {status}".strip()
return f" (warning: target page responded {detail})"
@tool("web_fetch", parse_docstring=True)
async def web_fetch_tool(url: str) -> str:
"""Fetch the contents of a web page at a given URL using Browserless (headless Chrome).
@ -63,7 +270,7 @@ async def web_fetch_tool(url: str) -> str:
wait_for_timeout_ms = int(raw_wait) if not isinstance(raw_wait, int) else raw_wait
wait_for_selector = cfg.get("wait_for_selector", wait_for_selector)
client = _get_browserless_client()
client = _get_browserless_client("web_fetch")
html = await client.fetch_html(
url=url,
wait_for_event=wait_for_event,
@ -83,3 +290,82 @@ async def web_fetch_tool(url: str) -> str:
except Exception as e:
logger.error(f"Error in web_fetch_tool: {e}")
return f"Error: {str(e)}"
@tool("web_capture", parse_docstring=True)
async def web_capture_tool(
runtime: Runtime,
url: str,
tool_call_id: Annotated[str, InjectedToolCallId],
filename: str | None = None,
full_page: bool | None = None,
output_format: str | None = None,
viewport_width: int | None = None,
viewport_height: int | None = None,
) -> Command:
"""Capture a rendered webpage screenshot and present it as an artifact.
Use this tool when you need a visual capture of a public webpage, especially JavaScript-heavy pages, UI states, dashboards, or visual evidence for a report.
Only capture exact URLs provided by the user or discovered through other tools. Do not use this for private pages behind login unless the user has explicitly configured Browserless outside DeerFlow.
URLs must include the schema: https://example.com is valid while example.com is invalid.
Args:
url: The http(s) URL to capture.
filename: Optional output filename. Directories are ignored and the extension is determined by output_format.
full_page: Optional override for full-page capture.
output_format: Optional image format: png, jpeg, or webp.
viewport_width: Optional viewport width in pixels.
viewport_height: Optional viewport height in pixels.
"""
try:
cfg = _get_tool_config("web_capture") or {}
allow_private_addresses = _as_bool(cfg.get("allow_private_addresses"), False)
url_error = _validate_capture_url(url, allow_private_addresses=allow_private_addresses)
if url_error:
return _tool_message(url_error, tool_call_id)
outputs_path = _thread_outputs_path(runtime)
if isinstance(outputs_path, str):
return _tool_message(outputs_path, tool_call_id)
final_format = _normalize_output_format(output_format or cfg.get("output_format", "png"))
final_full_page = full_page if full_page is not None else _as_bool(cfg.get("full_page"), True)
final_width = viewport_width if viewport_width is not None else _as_int(cfg.get("viewport_width"), 1280)
final_height = viewport_height if viewport_height is not None else _as_int(cfg.get("viewport_height"), 720)
quality = _as_optional_quality(cfg.get("quality"), final_format)
wait_for_selector = str(cfg.get("wait_for_selector") or "")
wait_for_selector_timeout_ms = _as_int(cfg.get("wait_for_selector_timeout_ms"), 5000)
wait_for_timeout_ms = _as_int(cfg.get("wait_for_timeout_ms"), 0)
best_attempt = _as_bool(cfg.get("best_attempt"), False)
output_name = _safe_capture_filename(filename, url, final_format)
client = _get_browserless_client("web_capture")
result = await client.capture_screenshot(
url=url,
full_page=final_full_page,
output_format=final_format,
quality=quality,
viewport={"width": final_width, "height": final_height},
wait_for_selector=wait_for_selector,
wait_for_selector_timeout_ms=wait_for_selector_timeout_ms,
wait_for_timeout_ms=wait_for_timeout_ms,
best_attempt=best_attempt,
)
if isinstance(result, str):
return _tool_message(result, tool_call_id)
final_name = await asyncio.to_thread(_write_capture_output, outputs_path, output_name, result.content)
virtual_path = f"{_OUTPUTS_VIRTUAL_PREFIX}/{final_name}"
message = f"Captured screenshot: {virtual_path}{_target_status_warning(result)}"
return Command(
update={
"artifacts": [virtual_path],
"messages": [ToolMessage(message, tool_call_id=tool_call_id)],
}
)
except Exception as e:
logger.error(f"Error in web_capture_tool: {e}")
return _tool_message(f"Error: {str(e)}", tool_call_id)

View File

@ -1,11 +1,13 @@
"""Tests for Browserless community tools."""
import ipaddress
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from deerflow.community.browserless import tools
from deerflow.community.browserless.browserless_client import BrowserlessClient
from deerflow.community.browserless.browserless_client import BrowserlessClient, BrowserlessScreenshotResult
class AsyncMock(MagicMock):
@ -145,11 +147,108 @@ class TestBrowserlessClient:
assert payload["rejectResourceTypes"] == ["image"]
assert payload["rejectRequestPattern"] == [r"\.css$"]
async def test_capture_screenshot_success(self):
"""capture_screenshot posts to /screenshot and returns image bytes."""
with patch("deerflow.community.browserless.browserless_client.httpx.AsyncClient") as mock_cls:
mock_ctx = MagicMock()
mock_cls.return_value.__aenter__.return_value = mock_ctx
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.content = b"\x89PNG\r\n\x1a\nimage"
mock_resp.text = "<binary>"
mock_resp.headers = {
"Content-Type": "image/png",
"X-Response-Code": "200",
"X-Response-Status": "OK",
"X-Response-URL": "https://example.com/final",
}
mock_ctx.post = AsyncMock(return_value=mock_resp)
client = BrowserlessClient(base_url="http://browserless:3000", token="secret-token")
result = await client.capture_screenshot(
"https://example.com",
full_page=True,
output_format="png",
viewport={"width": 1280, "height": 720},
wait_for_selector="main",
wait_for_selector_timeout_ms=3000,
wait_for_timeout_ms=500,
best_attempt=True,
)
assert isinstance(result, BrowserlessScreenshotResult)
assert result.content == b"\x89PNG\r\n\x1a\nimage"
assert result.content_type == "image/png"
assert result.target_status_code == "200"
assert result.target_status == "OK"
assert result.final_url == "https://example.com/final"
call = mock_ctx.post.call_args
assert call.args == ("http://browserless:3000/screenshot",)
payload = call.kwargs["json"]
assert payload["url"] == "https://example.com"
assert payload["options"] == {
"fullPage": True,
"type": "png",
}
assert call.kwargs["params"] == {"token": "secret-token"}
assert payload["viewport"] == {"width": 1280, "height": 720}
assert payload["waitForSelector"] == {"selector": "main", "timeout": 3000}
assert payload["waitForTimeout"] == 500
assert payload["bestAttempt"] is True
async def test_capture_screenshot_http_error(self):
"""capture_screenshot returns a bounded error on non-200 responses."""
with patch("deerflow.community.browserless.browserless_client.httpx.AsyncClient") as mock_cls:
mock_ctx = MagicMock()
mock_cls.return_value.__aenter__.return_value = mock_ctx
mock_resp = MagicMock()
mock_resp.status_code = 500
mock_resp.text = "Internal browserless error"
mock_resp.headers = {}
mock_ctx.post = AsyncMock(return_value=mock_resp)
client = BrowserlessClient(base_url="http://browserless:3000")
result = await client.capture_screenshot("https://example.com")
assert isinstance(result, str)
assert "Error: Browserless HTTP 500" in result
assert "Internal browserless error" in result
async def test_capture_screenshot_empty_response(self):
"""capture_screenshot returns a clear error for empty binary content."""
with patch("deerflow.community.browserless.browserless_client.httpx.AsyncClient") as mock_cls:
mock_ctx = MagicMock()
mock_cls.return_value.__aenter__.return_value = mock_ctx
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.content = b""
mock_resp.text = ""
mock_resp.headers = {"Content-Type": "image/png"}
mock_ctx.post = AsyncMock(return_value=mock_resp)
client = BrowserlessClient(base_url="http://browserless:3000")
result = await client.capture_screenshot("https://example.com")
assert result == "Error: Browserless returned empty screenshot response"
@pytest.mark.asyncio
class TestBrowserlessTools:
"""Tests for the Browserless tool functions."""
async def test_get_browserless_client_uses_env_token_fallback(self):
"""Browserless tools use BROWSERLESS_TOKEN when config omits token."""
with patch("deerflow.community.browserless.tools._get_tool_config") as mock_cfg:
mock_cfg.return_value = {"base_url": "https://production-sfo.browserless.io"}
with patch.dict("os.environ", {"BROWSERLESS_TOKEN": "env-token"}, clear=True):
client = tools._get_browserless_client("web_capture")
assert client.token == "env-token"
@patch("deerflow.community.browserless.tools._get_browserless_client")
async def test_web_fetch_tool_success(self, mock_get_client):
"""web_fetch_tool successfully fetches and extracts content."""
@ -185,3 +284,260 @@ class TestBrowserlessTools:
result = await tools.web_fetch_tool.ainvoke("https://example.com")
assert result.startswith("Error:")
@patch("deerflow.community.browserless.tools._get_browserless_client")
async def test_web_capture_tool_writes_artifact(self, mock_get_client, tmp_path):
"""web_capture_tool writes screenshots into thread outputs and presents the artifact."""
outputs_dir = tmp_path / "outputs"
outputs_dir.mkdir()
runtime = SimpleNamespace(state={"thread_data": {"outputs_path": str(outputs_dir)}})
mock_client = MagicMock()
mock_client.capture_screenshot = AsyncMock(
return_value=BrowserlessScreenshotResult(
content=b"\x89PNG\r\n\x1a\nimage",
content_type="image/png",
target_status_code="200",
target_status="OK",
final_url="https://example.com/final",
)
)
mock_get_client.return_value = mock_client
with patch("deerflow.community.browserless.tools._get_tool_config") as mock_cfg:
mock_cfg.side_effect = lambda name: {
"web_capture": {
"full_page": False,
"output_format": "png",
"viewport_width": 1024,
"viewport_height": 768,
"wait_for_selector": "main",
"wait_for_selector_timeout_ms": 4000,
"wait_for_timeout_ms": 250,
"best_attempt": True,
}
}.get(name)
with patch(
"deerflow.community.browserless.tools._resolve_host_addresses",
return_value=[ipaddress.ip_address("93.184.216.34")],
):
result = await tools.web_capture_tool.coroutine(
runtime=runtime,
url="https://example.com/dashboard",
tool_call_id="tool-1",
filename="Dashboard Capture.png",
)
artifact_path = result.update["artifacts"][0]
assert artifact_path == "/mnt/user-data/outputs/Dashboard_Capture.png"
written = outputs_dir / "Dashboard_Capture.png"
assert written.read_bytes() == b"\x89PNG\r\n\x1a\nimage"
assert result.update["messages"][0].content == "Captured screenshot: /mnt/user-data/outputs/Dashboard_Capture.png"
mock_cfg.assert_any_call("web_capture")
mock_client.capture_screenshot.assert_called_once_with(
url="https://example.com/dashboard",
full_page=False,
output_format="png",
quality=None,
viewport={"width": 1024, "height": 768},
wait_for_selector="main",
wait_for_selector_timeout_ms=4000,
wait_for_timeout_ms=250,
best_attempt=True,
)
async def test_web_capture_tool_rejects_non_http_url(self, tmp_path):
"""web_capture_tool only accepts explicit http(s) URLs."""
outputs_dir = tmp_path / "outputs"
outputs_dir.mkdir()
runtime = SimpleNamespace(state={"thread_data": {"outputs_path": str(outputs_dir)}})
with patch("deerflow.community.browserless.tools._get_tool_config", return_value=None):
with patch("deerflow.community.browserless.tools._get_browserless_client") as mock_get_client:
result = await tools.web_capture_tool.coroutine(
runtime=runtime,
url="file:///etc/passwd",
tool_call_id="tool-1",
)
assert "Only http:// and https:// URLs are supported" in result.update["messages"][0].content
assert "artifacts" not in result.update
mock_get_client.assert_not_called()
assert list(outputs_dir.iterdir()) == []
async def test_web_capture_tool_rejects_loopback_url(self, tmp_path):
"""web_capture_tool blocks loopback/localhost targets to prevent SSRF."""
outputs_dir = tmp_path / "outputs"
outputs_dir.mkdir()
runtime = SimpleNamespace(state={"thread_data": {"outputs_path": str(outputs_dir)}})
with patch("deerflow.community.browserless.tools._get_tool_config", return_value=None):
with patch("deerflow.community.browserless.tools._get_browserless_client") as mock_get_client:
result = await tools.web_capture_tool.coroutine(
runtime=runtime,
url="http://localhost:8080/admin",
tool_call_id="tool-1",
)
assert "private or loopback" in result.update["messages"][0].content
assert "artifacts" not in result.update
mock_get_client.assert_not_called()
assert list(outputs_dir.iterdir()) == []
async def test_web_capture_tool_rejects_metadata_ip(self, tmp_path):
"""web_capture_tool blocks the cloud-metadata link-local endpoint."""
outputs_dir = tmp_path / "outputs"
outputs_dir.mkdir()
runtime = SimpleNamespace(state={"thread_data": {"outputs_path": str(outputs_dir)}})
with patch("deerflow.community.browserless.tools._get_tool_config", return_value=None):
with patch("deerflow.community.browserless.tools._get_browserless_client") as mock_get_client:
result = await tools.web_capture_tool.coroutine(
runtime=runtime,
url="http://169.254.169.254/latest/meta-data/",
tool_call_id="tool-1",
)
assert "private, loopback, or metadata" in result.update["messages"][0].content
assert "artifacts" not in result.update
mock_get_client.assert_not_called()
async def test_web_capture_tool_rejects_dns_resolving_to_private(self, tmp_path):
"""web_capture_tool blocks a public hostname that resolves to an internal IP."""
outputs_dir = tmp_path / "outputs"
outputs_dir.mkdir()
runtime = SimpleNamespace(state={"thread_data": {"outputs_path": str(outputs_dir)}})
with patch("deerflow.community.browserless.tools._get_tool_config", return_value=None):
with patch(
"deerflow.community.browserless.tools._resolve_host_addresses",
return_value=[ipaddress.ip_address("10.0.0.5")],
):
with patch("deerflow.community.browserless.tools._get_browserless_client") as mock_get_client:
result = await tools.web_capture_tool.coroutine(
runtime=runtime,
url="https://internal.example.com/",
tool_call_id="tool-1",
)
assert "private, loopback, or metadata" in result.update["messages"][0].content
assert "artifacts" not in result.update
mock_get_client.assert_not_called()
async def test_web_capture_tool_allows_private_when_opted_in(self, tmp_path):
"""web_capture_tool honors allow_private_addresses for internal targets."""
outputs_dir = tmp_path / "outputs"
outputs_dir.mkdir()
runtime = SimpleNamespace(state={"thread_data": {"outputs_path": str(outputs_dir)}})
mock_client = MagicMock()
mock_client.capture_screenshot = AsyncMock(
return_value=BrowserlessScreenshotResult(
content=b"\x89PNG\r\n\x1a\nimage",
content_type="image/png",
target_status_code="200",
target_status="OK",
final_url="http://10.0.0.5/final",
)
)
with patch("deerflow.community.browserless.tools._get_tool_config", return_value={"allow_private_addresses": True}):
with patch("deerflow.community.browserless.tools._get_browserless_client", return_value=mock_client):
result = await tools.web_capture_tool.coroutine(
runtime=runtime,
url="http://10.0.0.5/dashboard",
tool_call_id="tool-1",
)
assert result.update["artifacts"][0].startswith("/mnt/user-data/outputs/")
assert (outputs_dir / result.update["artifacts"][0].split("/")[-1]).read_bytes() == b"\x89PNG\r\n\x1a\nimage"
mock_client.capture_screenshot.assert_called_once()
async def test_web_capture_tool_warns_on_target_error_status(self, tmp_path):
"""web_capture_tool surfaces a warning when the captured page itself errored."""
outputs_dir = tmp_path / "outputs"
outputs_dir.mkdir()
runtime = SimpleNamespace(state={"thread_data": {"outputs_path": str(outputs_dir)}})
mock_client = MagicMock()
mock_client.capture_screenshot = AsyncMock(
return_value=BrowserlessScreenshotResult(
content=b"\x89PNG\r\n\x1a\nimage",
content_type="image/png",
target_status_code="404",
target_status="Not Found",
final_url="https://example.com/missing",
)
)
with patch("deerflow.community.browserless.tools._get_tool_config", return_value=None):
with patch(
"deerflow.community.browserless.tools._resolve_host_addresses",
return_value=[ipaddress.ip_address("93.184.216.34")],
):
with patch("deerflow.community.browserless.tools._get_browserless_client", return_value=mock_client):
result = await tools.web_capture_tool.coroutine(
runtime=runtime,
url="https://example.com/missing",
tool_call_id="tool-1",
)
message = result.update["messages"][0].content
assert "warning: target page responded 404 Not Found" in message
assert result.update["artifacts"]
async def test_web_capture_tool_dedupes_existing_filename(self, tmp_path):
"""web_capture_tool appends a suffix instead of overwriting an existing capture."""
outputs_dir = tmp_path / "outputs"
outputs_dir.mkdir()
(outputs_dir / "report.png").write_bytes(b"existing")
runtime = SimpleNamespace(state={"thread_data": {"outputs_path": str(outputs_dir)}})
mock_client = MagicMock()
mock_client.capture_screenshot = AsyncMock(
return_value=BrowserlessScreenshotResult(
content=b"\x89PNG\r\n\x1a\nnew",
content_type="image/png",
target_status_code="200",
target_status="OK",
final_url="https://example.com/",
)
)
with patch("deerflow.community.browserless.tools._get_tool_config", return_value=None):
with patch(
"deerflow.community.browserless.tools._resolve_host_addresses",
return_value=[ipaddress.ip_address("93.184.216.34")],
):
with patch("deerflow.community.browserless.tools._get_browserless_client", return_value=mock_client):
result = await tools.web_capture_tool.coroutine(
runtime=runtime,
url="https://example.com/",
tool_call_id="tool-1",
filename="report.png",
)
assert result.update["artifacts"] == ["/mnt/user-data/outputs/report-1.png"]
assert (outputs_dir / "report.png").read_bytes() == b"existing"
assert (outputs_dir / "report-1.png").read_bytes() == b"\x89PNG\r\n\x1a\nnew"
@patch("deerflow.community.browserless.tools._get_browserless_client")
async def test_web_capture_tool_missing_outputs_path_returns_error(self, mock_get_client):
"""web_capture_tool requires ThreadDataMiddleware outputs_path."""
runtime = SimpleNamespace(state={"thread_data": {}})
with patch("deerflow.community.browserless.tools._get_tool_config", return_value=None):
with patch(
"deerflow.community.browserless.tools._resolve_host_addresses",
return_value=[ipaddress.ip_address("93.184.216.34")],
):
result = await tools.web_capture_tool.coroutine(
runtime=runtime,
url="https://example.com",
tool_call_id="tool-1",
)
assert "Thread outputs path is not available" in result.update["messages"][0].content
assert "artifacts" not in result.update
mock_get_client.assert_not_called()

View File

@ -339,6 +339,43 @@ class TestCheckWebFetch:
assert result.status == "fail"
# ---------------------------------------------------------------------------
# check_web_capture
# ---------------------------------------------------------------------------
class TestCheckWebCapture:
def test_browserless_self_host_without_token_ok(self, tmp_path, monkeypatch):
monkeypatch.delenv("BROWSERLESS_TOKEN", raising=False)
cfg = tmp_path / "config.yaml"
cfg.write_text("config_version: 5\ntools:\n - name: web_capture\n use: deerflow.community.browserless.tools:web_capture_tool\n base_url: http://localhost:3032\n")
result = doctor.check_web_capture(cfg)
assert result.status == "ok"
assert "self-hosted" in result.detail
def test_browserless_token_env_ref_ok(self, tmp_path, monkeypatch):
monkeypatch.setenv("BROWSERLESS_TOKEN", "browserless-test")
cfg = tmp_path / "config.yaml"
cfg.write_text("config_version: 5\ntools:\n - name: web_capture\n use: deerflow.community.browserless.tools:web_capture_tool\n base_url: https://production-sfo.browserless.io\n token: $BROWSERLESS_TOKEN\n")
result = doctor.check_web_capture(cfg)
assert result.status == "ok"
assert "BROWSERLESS_TOKEN set from config" in result.detail
def test_browserless_cloud_without_token_warns(self, tmp_path, monkeypatch):
monkeypatch.delenv("BROWSERLESS_TOKEN", raising=False)
cfg = tmp_path / "config.yaml"
cfg.write_text("config_version: 5\ntools:\n - name: web_capture\n use: deerflow.community.browserless.tools:web_capture_tool\n base_url: https://production-sfo.browserless.io\n")
result = doctor.check_web_capture(cfg)
assert result.status == "warn"
assert "BROWSERLESS_TOKEN" in (result.fix or "")
# ---------------------------------------------------------------------------
# check_image_search
# ---------------------------------------------------------------------------

View File

@ -663,6 +663,29 @@ tools:
# # wait_for_timeout_ms: 2000 # Extra wait after page load in milliseconds
# # wait_for_selector: "article" # CSS selector to wait for before returning
# Web capture tool (uses Browserless /screenshot to render a page as an artifact)
# Browserless captures JavaScript-heavy pages with a real headless Chrome and
# writes the screenshot into the current thread's outputs. It can run against
# a self-hosted Browserless instance without a token, or Browserless Cloud with
# BROWSERLESS_TOKEN. For Docker deployments, use the Docker service name instead
# of localhost.
# - name: web_capture
# group: web
# use: deerflow.community.browserless.tools:web_capture_tool
# base_url: http://localhost:3032 # Browserless instance URL (Docker: http://browserless:3000)
# # token: $BROWSERLESS_TOKEN # Required for Browserless Cloud; optional for self-hosted
# timeout_s: 30 # Request timeout in seconds
# output_format: png # png, jpeg, or webp
# full_page: true # Capture entire page instead of viewport only
# viewport_width: 1280
# viewport_height: 720
# # wait_for_selector: "main" # CSS selector to wait for before capturing
# # wait_for_selector_timeout_ms: 5000
# # wait_for_timeout_ms: 1000 # Extra wait after navigation in milliseconds
# # best_attempt: true # Continue with current page state if waits time out
# # allow_private_addresses: false # SSRF guard: keep false in production. Set true ONLY to
# # # capture internal/private targets (loopback, RFC1918, etc.)
# Web fetch tool (uses Exa)
# NOTE: Only one web_fetch provider can be active at a time.
# Comment out the Jina AI web_fetch entry below before enabling this one.

View File

@ -0,0 +1,247 @@
# Community Browserless Web Capture Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:test-driven-development for code changes. Commit and push steps are intentionally deferred because this local branch must stay uncommitted until user review.
**Goal:** Add an opt-in community `web_capture` tool backed by Browserless `/screenshot` that renders a URL in headless Chrome, saves a PNG/JPEG/WebP screenshot into the current thread outputs directory, and exposes the file as a DeerFlow artifact path.
**Architecture:** Keep the integration inside the existing `deerflow.community.browserless` package. `BrowserlessClient` owns HTTP request construction and binary response handling; `tools.py` owns DeerFlow runtime/config resolution, output filename generation, file persistence, and artifact-state updates. The feature uses existing thread-data outputs semantics rather than inventing a new artifact channel.
**Tech Stack:** Python 3.12, LangChain tool decorators, LangGraph `Command`, `httpx.AsyncClient`, Browserless REST `/screenshot`, pytest.
---
## Technical Design
### User-Facing Tool Contract
Add a new configured tool:
```yaml
tools:
- name: web_capture
group: web
use: deerflow.community.browserless.tools:web_capture_tool
base_url: http://localhost:3032
timeout_s: 30
output_format: png
full_page: true
viewport_width: 1280
viewport_height: 720
# token: $BROWSERLESS_TOKEN
# wait_for_selector: main
# wait_for_selector_timeout_ms: 5000
# wait_for_timeout_ms: 1000
# best_attempt: true
```
The model-callable arguments should stay narrow:
```python
async def web_capture_tool(
runtime: Runtime,
url: str,
tool_call_id: Annotated[str, InjectedToolCallId],
filename: str | None = None,
full_page: bool | None = None,
output_format: str | None = None,
viewport_width: int | None = None,
viewport_height: int | None = None,
) -> Command:
```
Behavior:
- Accept only explicit `http://` or `https://` URLs.
- Save the screenshot under `runtime.state["thread_data"]["outputs_path"]`.
- Return a `Command` that appends the virtual artifact path `/mnt/user-data/outputs/<filename>` and a short `ToolMessage`.
- Use tool config defaults when optional callable args are omitted.
- Read Browserless credentials from the configured `token` value or the `BROWSERLESS_TOKEN` environment variable.
### Browserless API Mapping
Official Browserless docs describe `POST /screenshot` with JSON body:
```json
{
"url": "https://example.com/",
"options": {
"fullPage": true,
"type": "png"
}
}
```
Browserless returns binary image content with `Content-Type: image/png`, `image/jpeg`, or `image/webp` depending on `options.type`. Shared request fields such as `waitForSelector`, `waitForTimeout`, `bestAttempt`, `gotoOptions`, `rejectResourceTypes`, and `rejectRequestPattern` are top-level fields.
This PR supports:
- `url`
- `options.fullPage`
- `options.type`
- `options.quality` for JPEG/WebP only when configured
- `viewport` from config/callable width and height
- `waitForSelector` from config
- `waitForTimeout` from config
- `bestAttempt` from config
This PR intentionally does not support:
- inline `html`
- `addScriptTag`
- `addStyleTag`
- authenticated profiles
- arbitrary launch parameters
- proxy parameters
Those omitted fields are useful, but exposing them directly to an agent increases the chance of unexpected side effects or credential/session leakage. They can be added later behind explicit config if maintainers want them.
### File and Artifact Boundary
The tool writes to the host-side outputs path that `ThreadDataMiddleware` provides, with filesystem writes offloaded through `asyncio.to_thread`:
```python
thread_data = runtime.state.get("thread_data") or {}
outputs_path = thread_data.get("outputs_path")
```
The final user-facing artifact path is always:
```text
/mnt/user-data/outputs/<safe-filename>.<ext>
```
Filename handling:
- If `filename` is provided, strip directories and normalize it to a safe stem.
- If omitted, derive a readable stem from the URL host/path and append a UTC timestamp.
- Enforce extension from `output_format`.
- Use only ASCII letters, digits, dot, dash, and underscore in the final basename.
### Error Handling
`BrowserlessClient.capture_screenshot()` sends `token` as a query parameter, matching the current Browserless `/screenshot` documentation, and returns a small result object instead of raising expected API/network errors:
```python
@dataclass(frozen=True)
class BrowserlessScreenshotResult:
content: bytes
content_type: str
target_status_code: str
target_status: str
final_url: str
```
Expected failures return strings beginning with `Error:` from the client, matching existing `fetch_html()` behavior:
- non-200 Browserless response
- empty image response
- timeout
- request error
The tool converts any error string into a `ToolMessage` and does not mutate artifacts. Unexpected exceptions are logged and returned as `Error: ...`.
### Tests
Use TDD with `backend/tests/test_browserless_client.py`.
Client tests:
- `capture_screenshot` posts to `/screenshot` with `url`, `options.fullPage`, `options.type`, `viewport`, and wait fields.
- Token is sent as the Browserless `token` query parameter.
- Non-200 status returns an error string with status and response snippet.
- Empty binary content returns a clear error.
Tool tests:
- Successful `web_capture_tool` writes bytes to `outputs_path` and returns artifact path in `Command.update["artifacts"]`.
- Tool reads `web_capture` config block, not `web_fetch`.
- Invalid URL is rejected before calling Browserless.
- Missing runtime thread outputs returns a clear error and writes no file.
- Unsafe filename is sanitized to a basename under outputs.
### Documentation Updates
Update:
- `config.example.yaml`: add commented `web_capture` Browserless block.
- `backend/docs/CONFIGURATION.md`: include `web_capture` in tool list and mention Browserless.
- `frontend/src/content/en/harness/tools.mdx`: add Browserless tab for web fetch and a web capture section.
- `frontend/src/content/zh/harness/tools.mdx`: mirror the English documentation.
- `scripts/doctor.py`: recognize `web_capture` and `browserless` token/config status.
- `backend/packages/harness/deerflow/community/browserless/__init__.py`: export `web_capture_tool`.
No `README.md` update is planned because this is a provider-specific opt-in community tool, and existing provider additions in this area document through config/docs pages.
## Execution Tasks
### Task 1: Write Failing Client Tests
**Files:**
- Modify: `backend/tests/test_browserless_client.py`
Steps:
- Add tests for `BrowserlessClient.capture_screenshot()`.
- Run `cd backend && uv run pytest tests/test_browserless_client.py::TestBrowserlessClient -q`.
- Expected red state: `AttributeError: 'BrowserlessClient' object has no attribute 'capture_screenshot'`.
### Task 2: Implement Browserless Screenshot Client
**Files:**
- Modify: `backend/packages/harness/deerflow/community/browserless/browserless_client.py`
- Modify: `backend/tests/test_browserless_client.py`
Steps:
- Add `BrowserlessScreenshotResult`.
- Add `capture_screenshot()` using `httpx.AsyncClient.post(f"{base_url}/screenshot", ...)`.
- Preserve existing `fetch_html()` behavior.
- Run client tests until green.
### Task 3: Write Failing Tool Tests
**Files:**
- Modify: `backend/tests/test_browserless_client.py`
Steps:
- Add tests for `web_capture_tool`.
- Use a fake runtime with `state={"thread_data": {"outputs_path": str(tmp_path)}}`.
- Patch `_get_browserless_client()` and `_get_tool_config()`.
- Run the new tool tests.
- Expected red state: `AttributeError` or missing `web_capture_tool`.
### Task 4: Implement `web_capture_tool`
**Files:**
- Modify: `backend/packages/harness/deerflow/community/browserless/tools.py`
- Modify: `backend/packages/harness/deerflow/community/browserless/__init__.py`
Steps:
- Add helpers for config lookup, URL validation, format validation, filename sanitization, and virtual artifact path generation.
- Add `@tool("web_capture", parse_docstring=True)`.
- Write the screenshot bytes to outputs.
- Return `Command(update={"artifacts": [virtual_path], "messages": [ToolMessage(...)]})`.
- Run Browserless tests until green.
### Task 5: Update Config, Doctor, and Docs
**Files:**
- Modify: `config.example.yaml`
- Modify: `scripts/doctor.py`
- Modify: `backend/docs/CONFIGURATION.md`
- Modify: `frontend/src/content/en/harness/tools.mdx`
- Modify: `frontend/src/content/zh/harness/tools.mdx`
Steps:
- Add commented config block for `web_capture`.
- Extend doctor provider map.
- Update docs with concise config snippets.
- Run targeted tests and lint/format checks.
### Task 6: Verification and Self-Review
Run:
```bash
cd backend && uv run pytest tests/test_browserless_client.py -q
cd backend && uv run ruff check packages/harness/deerflow/community/browserless tests/test_browserless_client.py
cd backend && uv run ruff format --check packages/harness/deerflow/community/browserless tests/test_browserless_client.py
```
Manual review checklist:
- No tracked changes outside the scoped files above.
- No commit created.
- `web_capture` reads the `web_capture` config block.
- Tool does not expose inline HTML, script injection, style injection, or Browserless profile.
- Tool writes only under current thread outputs path.
- Errors do not mutate artifacts.

View File

@ -190,7 +190,7 @@ Install: `cd backend && uv add 'deerflow-harness[groundroute]'`
### Web fetch (page content extraction)
<Tabs items={["Jina AI (default)", "Exa", "InfoQuest", "Firecrawl", "GroundRoute"]}>
<Tabs items={["Jina AI (default)", "Browserless", "Exa", "InfoQuest", "Firecrawl", "GroundRoute"]}>
<Tabs.Tab>
```yaml
tools:
@ -202,6 +202,17 @@ limits.
</Tabs.Tab>
<Tabs.Tab>
```yaml
tools:
- name: web_fetch
group: web
use: deerflow.community.browserless.tools:web_fetch_tool
base_url: http://localhost:3032
# token: $BROWSERLESS_TOKEN # required for Browserless Cloud; omit for self-hosted (a $VAR that is unset fails startup)
```
Renders JavaScript-heavy pages with headless Chrome, then extracts readable Markdown.
</Tabs.Tab>
<Tabs.Tab>
```yaml
tools:
- use: deerflow.community.exa.tools:web_fetch_tool
api_key: $EXA_API_KEY
@ -230,6 +241,28 @@ tools:
</Tabs.Tab>
</Tabs>
### Web capture (rendered screenshots)
```yaml
tools:
- name: web_capture
group: web
use: deerflow.community.browserless.tools:web_capture_tool
base_url: http://localhost:3032
# token: $BROWSERLESS_TOKEN # required for Browserless Cloud; omit for self-hosted (a $VAR that is unset fails startup)
output_format: png
full_page: true
viewport_width: 1280
viewport_height: 720
# allow_private_addresses: false # SSRF guard; keep false in production. Set true ONLY to capture internal targets.
```
Captures rendered web pages with Browserless `/screenshot` and presents the image
through DeerFlow's artifact system. This is useful for visual evidence, UI checks,
and JavaScript-heavy pages where Markdown extraction is not enough. By default it
refuses URLs that resolve to private, loopback, or cloud-metadata addresses;
set `allow_private_addresses: true` only when you intentionally target an internal host.
### Image search
<Tabs items={["DuckDuckGo (default)", "InfoQuest", "Serper"]}>

View File

@ -184,7 +184,7 @@ GroundRoute 是一个元搜索层:一个 API 接入六个搜索引擎Serper
### 网页内容抓取
<Tabs items={["Jina AI默认", "Exa", "GroundRoute"]}>
<Tabs items={["Jina AI默认", "Browserless", "Exa", "GroundRoute"]}>
<Tabs.Tab>
```yaml
tools:
@ -195,6 +195,17 @@ tools:
</Tabs.Tab>
<Tabs.Tab>
```yaml
tools:
- name: web_fetch
group: web
use: deerflow.community.browserless.tools:web_fetch_tool
base_url: http://localhost:3032
# token: $BROWSERLESS_TOKEN # Browserless Cloud 必需;自托管请删除此行(未设置的 $VAR 会导致启动失败)
```
使用无头 Chrome 渲染 JavaScript 较重的页面,再提取可读 Markdown。
</Tabs.Tab>
<Tabs.Tab>
```yaml
tools:
- use: deerflow.community.exa.tools:web_fetch_tool
api_key: $EXA_API_KEY
@ -209,6 +220,27 @@ tools:
</Tabs.Tab>
</Tabs>
### 网页截图
```yaml
tools:
- name: web_capture
group: web
use: deerflow.community.browserless.tools:web_capture_tool
base_url: http://localhost:3032
# token: $BROWSERLESS_TOKEN # Browserless Cloud 必需;自托管请删除此行(未设置的 $VAR 会导致启动失败)
output_format: png
full_page: true
viewport_width: 1280
viewport_height: 720
# allow_private_addresses: false # SSRF 防护;生产环境保持 false。仅在需要截取内网目标时设为 true。
```
使用 Browserless `/screenshot` 渲染网页并截图,然后通过 DeerFlow 的 artifact
机制把图片呈现给用户。适合网页视觉证据、UI 检查,以及仅提取 Markdown 不够的
JavaScript 页面。默认会拒绝解析到私网、回环或云元数据地址的 URL仅在确实需要
截取内网主机时才设置 `allow_private_addresses: true`。
### 图像搜索
<Tabs items={["DuckDuckGo默认", "InfoQuest", "Serper"]}>

View File

@ -482,12 +482,20 @@ def check_web_tool(config_path: Path, *, tool_name: str, label: str) -> CheckRes
"infoquest": "INFOQUEST_API_KEY",
"serper": "SERPER_API_KEY",
},
"web_capture": {
"browserless": "BROWSERLESS_TOKEN",
},
}
key_fields = {
"web_capture": {
"browserless": "token",
},
}
def _configured_key_detail(tool: dict, default_var: str) -> tuple[Status, str] | None:
api_key = tool.get("api_key")
if isinstance(api_key, str) and api_key.strip():
key = api_key.strip()
def _configured_key_detail(tool: dict, default_var: str, key_field: str = "api_key") -> tuple[Status, str] | None:
configured_key = tool.get(key_field)
if isinstance(configured_key, str) and configured_key.strip():
key = configured_key.strip()
if key.startswith("$"):
env_name = key[1:]
val = os.environ.get(env_name)
@ -496,11 +504,15 @@ def check_web_tool(config_path: Path, *, tool_name: str, label: str) -> CheckRes
# The referenced var is unset; fall through to the default
# env var below, which tools use as a runtime fallback.
else:
return ("warn", "literal api_key set in config")
return ("warn", f"literal {key_field} set in config")
val = os.environ.get(default_var)
return ("ok", f"{default_var} set") if val and val.strip() else None
def _browserless_self_hosted(tool: dict) -> bool:
base_url = str(tool.get("base_url") or "http://localhost:3032").lower()
return "browserless.io" not in base_url
for tool in tool_entries:
use = tool.get("use", "")
for provider, detail in free_providers.get(tool_name, {}).items():
@ -511,7 +523,8 @@ def check_web_tool(config_path: Path, *, tool_name: str, label: str) -> CheckRes
use = tool.get("use", "")
for provider, var in key_providers.get(tool_name, {}).items():
if provider in use:
key_status = _configured_key_detail(tool, var)
key_field = key_fields.get(tool_name, {}).get(provider, "api_key")
key_status = _configured_key_detail(tool, var, key_field=key_field)
if key_status:
status, detail = key_status
if status == "warn":
@ -519,9 +532,11 @@ def check_web_tool(config_path: Path, *, tool_name: str, label: str) -> CheckRes
label,
"warn",
f"{provider} ({detail})",
fix=f"Move the API key to .env as {var}=<your-key> and reference it as ${var}",
fix=f"Move the {key_field} to .env as {var}=<your-key> and reference it as ${var}",
)
return CheckResult(label, "ok", f"{provider} ({detail})")
if tool_name == "web_capture" and provider == "browserless" and _browserless_self_hosted(tool):
return CheckResult(label, "ok", "browserless (self-hosted, token optional)")
return CheckResult(
label,
"warn",
@ -560,6 +575,10 @@ def check_web_fetch(config_path: Path) -> CheckResult:
return check_web_tool(config_path, tool_name="web_fetch", label="web fetch configured")
def check_web_capture(config_path: Path) -> CheckResult:
return check_web_tool(config_path, tool_name="web_capture", label="web capture configured")
def check_image_search(config_path: Path) -> CheckResult:
return check_web_tool(config_path, tool_name="image_search", label="image search configured")
@ -712,7 +731,12 @@ def main() -> int:
sections.append(("LLM Provider", llm_checks))
# ── Web Capabilities ─────────────────────────────────────────────────────
search_checks = [check_web_search(config_path), check_web_fetch(config_path), check_image_search(config_path)]
search_checks = [
check_web_search(config_path),
check_web_fetch(config_path),
check_web_capture(config_path),
check_image_search(config_path),
]
sections.append(("Web Capabilities", search_checks))
# ── Sandbox ──────────────────────────────────────────────────────────────