From a8f950feb6e936cc14bdd29b8a0ed72873a2f117 Mon Sep 17 00:00:00 2001 From: Ryker_Feng <90562015+18062706139fcz@users.noreply.github.com> Date: Wed, 1 Jul 2026 23:41:58 +0800 Subject: [PATCH] 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. --- README.md | 2 +- README_zh.md | 2 +- backend/docs/CONFIGURATION.md | 59 ++- .../community/browserless/__init__.py | 4 +- .../browserless/browserless_client.py | 113 ++++++ .../deerflow/community/browserless/tools.py | 298 ++++++++++++++- backend/tests/test_browserless_client.py | 358 +++++++++++++++++- backend/tests/test_doctor.py | 37 ++ config.example.yaml | 23 ++ ...06-29-community-browserless-web-capture.md | 247 ++++++++++++ frontend/src/content/en/harness/tools.mdx | 35 +- frontend/src/content/zh/harness/tools.mdx | 34 +- scripts/doctor.py | 40 +- 13 files changed, 1230 insertions(+), 22 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-29-community-browserless-web-capture.md diff --git a/README.md b/README.md index eda6fc688..fba1f3e6a 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/README_zh.md b/README_zh.md index bf05a1b3d..c684de3fe 100644 --- a/README_zh.md +++ b/README_zh.md @@ -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 的内容包装方式不会再悄悄把建议吞掉。 diff --git a/backend/docs/CONFIGURATION.md b/backend/docs/CONFIGURATION.md index dc4162df6..8302910dd 100644 --- a/backend/docs/CONFIGURATION.md +++ b/backend/docs/CONFIGURATION.md @@ -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 diff --git a/backend/packages/harness/deerflow/community/browserless/__init__.py b/backend/packages/harness/deerflow/community/browserless/__init__.py index d03906c20..3758c985f 100644 --- a/backend/packages/harness/deerflow/community/browserless/__init__.py +++ b/backend/packages/harness/deerflow/community/browserless/__init__.py @@ -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"] diff --git a/backend/packages/harness/deerflow/community/browserless/browserless_client.py b/backend/packages/harness/deerflow/community/browserless/browserless_client.py index c5fa2cbf8..1c2363b8a 100644 --- a/backend/packages/harness/deerflow/community/browserless/browserless_client.py +++ b/backend/packages/harness/deerflow/community/browserless/browserless_client.py @@ -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}" diff --git a/backend/packages/harness/deerflow/community/browserless/tools.py b/backend/packages/harness/deerflow/community/browserless/tools.py index 649b6da6a..aff213df0 100644 --- a/backend/packages/harness/deerflow/community/browserless/tools.py +++ b/backend/packages/harness/deerflow/community/browserless/tools.py @@ -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) diff --git a/backend/tests/test_browserless_client.py b/backend/tests/test_browserless_client.py index b419f6e2c..e1591b3f4 100644 --- a/backend/tests/test_browserless_client.py +++ b/backend/tests/test_browserless_client.py @@ -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 = "" + 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() diff --git a/backend/tests/test_doctor.py b/backend/tests/test_doctor.py index be7b7e68a..4d2a3d0f9 100644 --- a/backend/tests/test_doctor.py +++ b/backend/tests/test_doctor.py @@ -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 # --------------------------------------------------------------------------- diff --git a/config.example.yaml b/config.example.yaml index 520995518..e4e45db6d 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -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. diff --git a/docs/superpowers/plans/2026-06-29-community-browserless-web-capture.md b/docs/superpowers/plans/2026-06-29-community-browserless-web-capture.md new file mode 100644 index 000000000..74d83569b --- /dev/null +++ b/docs/superpowers/plans/2026-06-29-community-browserless-web-capture.md @@ -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/` 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/. +``` + +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. diff --git a/frontend/src/content/en/harness/tools.mdx b/frontend/src/content/en/harness/tools.mdx index 0f17bc812..b08d61edb 100644 --- a/frontend/src/content/en/harness/tools.mdx +++ b/frontend/src/content/en/harness/tools.mdx @@ -190,7 +190,7 @@ Install: `cd backend && uv add 'deerflow-harness[groundroute]'` ### Web fetch (page content extraction) - + ```yaml tools: @@ -202,6 +202,17 @@ limits. ```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. + + +```yaml tools: - use: deerflow.community.exa.tools:web_fetch_tool api_key: $EXA_API_KEY @@ -230,6 +241,28 @@ tools: +### 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 diff --git a/frontend/src/content/zh/harness/tools.mdx b/frontend/src/content/zh/harness/tools.mdx index c3fbc6afb..ed22d4a17 100644 --- a/frontend/src/content/zh/harness/tools.mdx +++ b/frontend/src/content/zh/harness/tools.mdx @@ -184,7 +184,7 @@ GroundRoute 是一个元搜索层:一个 API 接入六个搜索引擎(Serper ### 网页内容抓取 - + ```yaml tools: @@ -195,6 +195,17 @@ tools: ```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。 + + +```yaml tools: - use: deerflow.community.exa.tools:web_fetch_tool api_key: $EXA_API_KEY @@ -209,6 +220,27 @@ tools: +### 网页截图 + +```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`。 + ### 图像搜索 diff --git a/scripts/doctor.py b/scripts/doctor.py index 20fc4642e..72ea0cb50 100644 --- a/scripts/doctor.py +++ b/scripts/doctor.py @@ -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}= and reference it as ${var}", + fix=f"Move the {key_field} to .env as {var}= 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 ──────────────────────────────────────────────────────────────