"""Web search tool — DuckDuckGo HTML POST + API fallback (no API key)."""
from __future__ import annotations
import json
import logging
import os
import re
import time
from html import unescape
from typing import Any, Callable, cast
from urllib.parse import quote_plus, unquote
from butler.utilities.env_parse import env_truthy, float_env, int_env
logger = logging.getLogger(__name__)
_BUDGET_MAX_SEC = 300.0
_PER_ATTEMPT_TIMEOUT_MAX_SEC = 30.0
_DDG_HTML_URL = "https://html.duckduckgo.com/html/"
_DDG_LITE_URL = "https://lite.duckduckgo.com/lite/"
_DDG_API_URL = "https://api.duckduckgo.com/"
_HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
),
"Accept": "text/html,application/xhtml+xml,application/json",
"Accept-Language": "zh-CN,zh;q=0.9,en-US,en;q=0.8",
}
def web_search_enabled() -> bool:
return bool(env_truthy("BUTLER_ENABLE_WEB_SEARCH", default=False))
def _per_attempt_timeout_cap() -> float:
try:
raw = float_env("BUTLER_WEB_SEARCH_TIMEOUT", 15)
return float(min(_PER_ATTEMPT_TIMEOUT_MAX_SEC, max(3.0, raw)))
except ValueError:
return 15.0
def _total_budget() -> float:
try:
raw = float_env("BUTLER_WEB_SEARCH_BUDGET", 60)
return float(min(_BUDGET_MAX_SEC, max(5.0, raw)))
except ValueError:
return 60.0
def _proxy_configured() -> bool:
for key in (
"HTTP_PROXY",
"HTTPS_PROXY",
"ALL_PROXY",
"http_proxy",
"https_proxy",
"all_proxy",
):
if str(os.environ.get(key) or "").strip():
return True
return False
def _try_direct_with_proxy() -> bool:
"""When proxy env is set, skip direct attempts unless explicitly enabled."""
if not _proxy_configured():
return True
return bool(env_truthy("BUTLER_WEB_SEARCH_TRY_DIRECT", default=False))
def _search_strategies() -> list[tuple[str, bool]]:
"""Return (kind, trust_env). Proxy-only hosts skip direct to preserve budget."""
kinds = ("html_post", "html_lite", "api")
if _proxy_configured() and not _try_direct_with_proxy():
return [(kind, True) for kind in kinds]
if _proxy_configured():
return [(kind, False) for kind in kinds] + [(kind, True) for kind in kinds]
return [(kind, True) for kind in kinds] + [(kind, False) for kind in kinds]
def _max_retries() -> int:
try:
return max(1, int(int_env("BUTLER_WEB_SEARCH_RETRIES", 2, min=1)))
except ValueError:
return 2
def _unwrap_ddg_href(href: str) -> str:
raw = str(href or "").strip()
match = re.search(r"uddg=([^&]+)", raw)
if match:
return unquote(match.group(1))
return raw
def _strip_html(text: str) -> str:
return unescape(re.sub(r"<[^>]+>", "", text or "").strip())
def parse_ddg_html_results(html: str, max_results: int) -> list[dict[str, str]]:
"""Parse DuckDuckGo HTML SERP into title/snippet/url rows."""
cap = max(1, max_results)
results: list[dict[str, str]] = []
seen: set[str] = set()
blocks = re.findall(
r']+class="result__a"[^>]+href="([^"]*)"[^>]*>(.*?).*?'
r']+class="result__snippet"[^>]*>(.*?)',
html,
re.DOTALL | re.IGNORECASE,
)
for href, title_html, snippet_html in blocks:
url = _unwrap_ddg_href(href)
title = _strip_html(title_html)
if not title or not url or url in seen:
continue
seen.add(url)
results.append({
"title": title,
"snippet": _strip_html(snippet_html)[:300],
"url": url,
})
if len(results) >= cap:
return results
# Lite / alternate markup fallback.
for href, title_html in re.findall(
r']+rel="nofollow"[^>]+href="([^"]+)"[^>]*>(.*?)',
html,
re.DOTALL | re.IGNORECASE,
):
url = _unwrap_ddg_href(href)
title = _strip_html(title_html)
if not title or not url.startswith("http") or url in seen:
continue
seen.add(url)
results.append({"title": title, "snippet": "", "url": url})
if len(results) >= cap:
break
return results
def _parse_ddg_api_payload(payload: dict[str, Any], max_results: int) -> list[dict[str, str]]:
cap = max(1, max_results)
results: list[dict[str, str]] = []
seen: set[str] = set()
def _add(text: str, url: str) -> None:
if len(results) >= cap or not url or url in seen:
return
seen.add(url)
results.append({
"title": (text or url)[:200],
"snippet": (text or "")[:300],
"url": url,
})
abstract = str(payload.get("AbstractURL") or "").strip()
abstract_text = str(payload.get("AbstractText") or payload.get("Abstract") or "").strip()
if abstract:
_add(abstract_text or abstract, abstract)
related = payload.get("RelatedTopics") or []
if isinstance(related, list):
for item in related:
if not isinstance(item, dict):
continue
topics = item.get("Topics")
if isinstance(topics, list):
for sub in topics:
if not isinstance(sub, dict):
continue
_add(str(sub.get("Text") or ""), str(sub.get("FirstURL") or ""))
else:
_add(str(item.get("Text") or ""), str(item.get("FirstURL") or ""))
return results
def _fetch_with_httpx(
method: str,
url: str,
*,
trust_env: bool,
timeout: float,
data: dict[str, str] | None = None,
) -> str:
import httpx
per = max(3.0, timeout)
connect_cap = 5.0 if trust_env else 3.0
timeouts = httpx.Timeout(
connect=min(connect_cap, per),
read=per,
write=min(10.0, per),
pool=min(5.0, per),
)
with httpx.Client(
timeout=timeouts,
follow_redirects=True,
trust_env=trust_env,
headers=_HEADERS,
) as client:
if method == "post":
resp = client.post(url, data=data or {})
else:
resp = client.get(url)
resp.raise_for_status()
return str(resp.text)
def _search_ddg_html_post(
query: str,
max_results: int,
*,
trust_env: bool,
timeout: float,
) -> list[dict[str, str]]:
html = _fetch_with_httpx(
"post",
_DDG_HTML_URL,
trust_env=trust_env,
timeout=timeout,
data={"q": query},
)
return parse_ddg_html_results(html, max_results)
def _search_ddg_api(
query: str,
max_results: int,
*,
trust_env: bool,
timeout: float,
) -> list[dict[str, str]]:
url = (
f"{_DDG_API_URL}?q={quote_plus(query)}"
"&format=json&no_html=1&no_redirect=1&skip_disambig=1"
)
text = _fetch_with_httpx("get", url, trust_env=trust_env, timeout=timeout)
payload = json.loads(text)
if not isinstance(payload, dict):
return []
return _parse_ddg_api_payload(payload, max_results)
def _search_ddg_lite_get(
query: str,
max_results: int,
*,
trust_env: bool,
timeout: float,
) -> list[dict[str, str]]:
url = f"{_DDG_LITE_URL}?q={quote_plus(query)}"
html = _fetch_with_httpx("get", url, trust_env=trust_env, timeout=timeout)
return parse_ddg_html_results(html, max_results)
def _search_duckduckgo(query: str, max_results: int = 5) -> list[dict[str, str]]:
"""Try DDG backends within a total time budget; direct-first when proxy env is set."""
q = str(query or "").strip()
if not q:
return []
budget = _total_budget()
deadline = time.monotonic() + budget
strategies = _search_strategies()
max_rounds = _max_retries()
round_idx = 0
from butler.tools.web_search_ops import try_search_attempt_safe
while time.monotonic() < deadline and round_idx < max_rounds:
round_idx += 1
for kind, trust_env in strategies:
remaining = deadline - time.monotonic()
if remaining < 2.5:
break
per_timeout = min(_per_attempt_timeout_cap(), max(3.0, remaining))
def _run(
_kind: str = kind,
_trust_env: bool = trust_env,
_timeout: float = per_timeout,
) -> list[dict[str, str]]:
if _kind == "html_post":
return _search_ddg_html_post(
q,
max_results,
trust_env=_trust_env,
timeout=_timeout,
)
if _kind == "html_lite":
return _search_ddg_lite_get(
q,
max_results,
trust_env=_trust_env,
timeout=_timeout,
)
return _search_ddg_api(
q,
max_results,
trust_env=_trust_env,
timeout=_timeout,
)
rows = try_search_attempt_safe(
_run,
kind=kind,
trust_env=trust_env,
round_idx=round_idx,
query=q,
)
if rows:
return cast(list[dict[str, str]], rows)[:max_results]
return []
def tool_web_search(query: str = "", max_results: int = 5, **_: Any) -> str:
"""Search the web and return structured results."""
if not web_search_enabled():
return json.dumps({
"error": "web_search disabled",
"hint": "设置 BUTLER_ENABLE_WEB_SEARCH=1 启用,或使用 /config set BUTLER_ENABLE_WEB_SEARCH 1",
}, ensure_ascii=False)
q = (query or "").strip()
if not q:
return json.dumps({"error": "query is required"}, ensure_ascii=False)
cap = max(1, min(10, int(max_results or 5)))
started = time.monotonic()
results = _search_duckduckgo(q, max_results=cap)
elapsed = round(time.monotonic() - started, 2)
if not results:
return json.dumps({
"ok": True,
"query": q,
"results": [],
"elapsed_seconds": elapsed,
"budget_seconds": _total_budget(),
"message": "No results found or search service unavailable",
"hint": (
"DuckDuckGo 不可达或零结果(已用尽本轮 web_search 时间预算)。"
"下一步:1) mcp_firecrawl_firecrawl_search 取 URL;"
"2) 对命中 URL 用 mcp_firecrawl_scrape 读正文;"
"3) 直接总结并附完整 https 来源。勿重复 web_search,勿调 feedback/agent。"
),
"fallback": "firecrawl_search_then_scrape",
}, ensure_ascii=False)
return json.dumps({
"ok": True,
"query": q,
"count": len(results),
"results": results,
}, ensure_ascii=False)
def register_web_search_tool(register_fn: Callable[..., None]) -> None:
register_fn(
name="web_search",
description=(
"Search the web for information using a keyword query. "
"Returns titles, snippets and URLs. Use this before web_fetch "
"when you need to find relevant pages."
),
schema={
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search query (keywords or question)",
},
"max_results": {
"type": "integer",
"default": 5,
"minimum": 1,
"maximum": 10,
"description": "Maximum number of results to return",
},
},
"required": ["query"],
},
handler=tool_web_search,
toolset="network",
)
__all__ = [
"parse_ddg_html_results",
"register_web_search_tool",
"tool_web_search",
"web_search_enabled",
"_proxy_configured",
"_search_duckduckgo",
"_search_strategies",
"_total_budget",
"_try_direct_with_proxy",
]