128 lines
4.3 KiB
Python
128 lines
4.3 KiB
Python
"""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)
|