"""Thin web fetch tool (Firecrawl safeFetch subset, stdlib + url_safety)."""
from __future__ import annotations
import json
import re
from html import unescape
from typing import Any, Callable
from butler.utilities.env_parse import env_truthy, int_env, float_env
from butler.defaults.env_defaults import TOOL_ENABLE_WEB_FETCH_DEFAULT
def web_fetch_enabled() -> bool:
return bool(env_truthy("BUTLER_ENABLE_WEB_FETCH", default=TOOL_ENABLE_WEB_FETCH_DEFAULT))
def _max_bytes() -> int:
try:
return int(max(4096, int_env("BUTLER_WEB_FETCH_MAX_BYTES", 65536)))
except ValueError:
return 65536
def _timeout_seconds() -> float:
try:
return float(max(3.0, float_env("BUTLER_WEB_FETCH_TIMEOUT", 20)))
except ValueError:
return 20.0
def _strip_html_regex(html: str) -> str:
"""Fallback HTML stripping with regex (used when trafilatura is unavailable)."""
text = re.sub(r"(?is)", " ", html)
text = re.sub(r"(?is)", " ", text)
text = re.sub(r"<[^>]+>", " ", text)
text = unescape(text)
text = re.sub(r"\s+", " ", text).strip()
return text
def _extract_with_trafilatura(html: str, url: str = "") -> dict[str, str] | None:
"""Extract main content with trafilatura; returns None if unavailable."""
try:
import trafilatura # noqa: F401
except ImportError:
return None
del trafilatura
from butler.tools.web_fetch_ops import extract_with_trafilatura_safe
result = extract_with_trafilatura_safe(html, url=url)
return result if isinstance(result, dict) else None
def _strip_html(html: str, *, url: str = "") -> str | dict[str, str]:
"""Extract content from HTML; prefers trafilatura, falls back to regex."""
result = _extract_with_trafilatura(html, url=url)
if result is not None:
return result
return _strip_html_regex(html)
def tool_web_fetch(url: str, *, max_chars: int = 8000, **_: Any) -> str:
if not web_fetch_enabled():
return json.dumps({
"error": "web_fetch disabled",
"hint": "设置 BUTLER_ENABLE_WEB_FETCH=1 或改用 MCP/Firecrawl",
})
from butler.registry.url_safety import is_safe_url
target = str(url or "").strip()
if not target:
return json.dumps({"error": "url required"})
if not is_safe_url(target):
return json.dumps({"error": "URL 未通过安全校验(私网/非法主机)"})
cap = max(500, min(32_000, int(max_chars or 8000)))
from butler.tools.web_fetch_ops import fetch_url_bytes_safe
raw, ctype, err_json = fetch_url_bytes_safe(
target,
timeout=_timeout_seconds(),
max_bytes=_max_bytes() + 1,
headers={
"User-Agent": "Butler-web-fetch/1.0",
"Accept": "text/html,application/json,*/*",
},
)
if err_json is not None:
return str(err_json)
assert raw is not None and ctype is not None
if len(raw) > _max_bytes():
raw = raw[:_max_bytes()]
truncated = True
else:
truncated = False
decoded = raw.decode("utf-8", errors="replace")
extra_meta: dict[str, str] = {}
if "html" in ctype.lower():
extracted = _strip_html(decoded, url=target)
if isinstance(extracted, dict):
body = extracted.pop("text", decoded)
extra_meta = extracted
else:
body = extracted
else:
body = decoded
if len(body) > cap:
body = body[:cap] + "\n…(truncated)"
result = {
"ok": True,
"url": target,
"content_type": ctype,
"truncated": truncated,
"chars": len(body),
"text": body,
}
if extra_meta:
result["metadata"] = extra_meta
return json.dumps(result, ensure_ascii=False)
def register_web_fetch_tool(register_fn: Callable[..., None]) -> None:
register_fn(
name="web_fetch",
description=(
"Fetch a public HTTP(S) URL as text (HTML stripped). "
"When NOT to use: private IPs, file://, or when Firecrawl MCP is configured."
),
schema={
"type": "object",
"properties": {
"url": {"type": "string", "description": "Public https URL"},
"max_chars": {"type": "integer", "default": 8000, "minimum": 500, "maximum": 32000},
},
"required": ["url"],
},
handler=tool_web_fetch,
toolset="network",
)
__all__ = ["register_web_fetch_tool", "tool_web_fetch", "web_fetch_enabled"]