"""HTTP 类工具的直接执行器(非沙箱路径)。 ``fetch_url``:抓取指定 URL 的 HTML 并用 html2text 转成 markdown。 executor 上下文由 ``ToolRuntime._build_exec_context`` 注入,至少包含: - ``http_client``:长生命周期的 ``httpx.AsyncClient``(禁止裸 ``async with httpx.AsyncClient()``)。 - ``trace_id``:本次执行的 trace_id(可空)。 设计约束(见 backend/CLAUDE.md): - 禁止在调用处裸 ``async with httpx.AsyncClient()``,必须复用注入的 client。 - HTTP 错误统一返回 ``ToolResult(ok=False, stderr=...)``,不抛异常(让模型自决策)。 """ from __future__ import annotations from typing import Any import html2text import httpx from app.core.config import settings from app.modules.tool_runtime.definition import ToolInput, ToolResult # 默认参数 _DEFAULT_MAX_CHARS = 8000 # 合理的浏览器 UA,避免被部分站点拒绝 _USER_AGENT = "Mozilla/5.0 (compatible; HunjingBot/1.0; +https://github.com/hunjing/hunjing)" # 模块级 html2text 配置(避免每次调用都构造配置对象) _H2T = html2text.HTML2Text() _H2T.ignore_links = False _H2T.ignore_images = True _H2T.body_width = 0 # 不折行,交给上层截断 def _get_http_client(context: dict[str, Any] | None) -> httpx.AsyncClient | None: """从 context 取注入的 httpx.AsyncClient;缺失返回 None。""" if context is None: return None client = context.get("http_client") return client if isinstance(client, httpx.AsyncClient) else None def _ok_result(stdout: str, trace_id: str = "") -> ToolResult: return ToolResult( ok=True, exit_code=0, stdout=stdout, stderr="", duration_ms=0, timeout=False, truncated=False, trace_id=trace_id, ) def _err_result(stderr: str, trace_id: str = "") -> ToolResult: return ToolResult( ok=False, exit_code=1, stdout="", stderr=stderr, duration_ms=0, timeout=False, truncated=False, trace_id=trace_id, ) async def _fetch_url_executor(input: ToolInput, context: dict[str, Any] | None) -> ToolResult: """抓取指定 URL 的网页正文并转 markdown。 input: url (str, required): 要抓取的 URL。 max_chars (int, optional): 正文最大字符数,默认 8000。 context: http_client (httpx.AsyncClient): 注入的 HTTP 客户端。 trace_id (str): 本次执行 trace_id。 """ trace_id = (context or {}).get("trace_id", "") or "" url = input.get("url") if not isinstance(url, str) or not url.strip(): return _err_result("fetch_url 需要 'url' 字符串输入。", trace_id) raw_max = input.get("max_chars", _DEFAULT_MAX_CHARS) try: max_chars = int(raw_max) if raw_max is not None else _DEFAULT_MAX_CHARS except (TypeError, ValueError): max_chars = _DEFAULT_MAX_CHARS if max_chars < 1: max_chars = _DEFAULT_MAX_CHARS client = _get_http_client(context) if client is None: return _err_result("fetch_url 缺少注入的 http_client。", trace_id) try: resp = await client.get( url, headers={"User-Agent": _USER_AGENT}, timeout=settings.tool_web_http_timeout_seconds, follow_redirects=True, ) except httpx.HTTPError as exc: return _err_result(f"fetch_url 请求失败:{exc}", trace_id) if resp.status_code >= 400: return _err_result(f"fetch_url 返回 HTTP {resp.status_code}", trace_id) content_type = resp.headers.get("content-type", "").lower() html_text = resp.text # 无论 content-type 如何都尝试转 markdown(部分站点 content-type 不规范) try: markdown = _H2T.handle(html_text) except Exception: # noqa: BLE001 - html2text 解析失败时回退原文 markdown = html_text # 规范化多余空白并截断 cleaned = "\n".join(line.rstrip() for line in markdown.splitlines()) cleaned = cleaned.strip() if len(cleaned) > max_chars: cleaned = f"{cleaned[:max_chars]}\n\n[...已截断,共 {len(cleaned)} 字符]" if not cleaned: return _err_result(f"fetch_url 未抓取到正文内容(content-type={content_type})。", trace_id) return _ok_result(cleaned, trace_id)