web_search_tools.py 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
  1. # -*- coding: utf-8 -*-
  2. """
  3. 联网搜索工具模块
  4. 提供两大能力:
  5. - web_search: 通过 DuckDuckGo 搜索互联网公开信息
  6. - web_fetch: 抓取指定网页并提取正文文本
  7. 所有工具函数供 DeepAgent 调用,返回结构化 JSON 文本,由 LLM 解析并生成自然语言回复。
  8. """
  9. import asyncio
  10. import json
  11. import re
  12. import time
  13. import httpx
  14. from ddgs import DDGS
  15. # ── 搜索重试配置 ──
  16. _MAX_RETRIES = 3 # 最大重试次数
  17. _RETRY_BASE_DELAY = 2.0 # 重试基础延迟(秒),按指数增长:2 → 4 → 8
  18. _SEARCH_TIMEOUT = 20.0 # 单次搜索超时(秒)
  19. async def web_search(query: str, max_results: int = 5) -> str:
  20. """搜索互联网获取公开信息。当用户询问的问题超出煤矿通风专业知识库范围、
  21. 或需要了解最新政策/新闻/行业动态时调用此工具。
  22. 优先策略:
  23. - 煤矿安全规程、技术标准 → 优先使用 query_knowledge_base(内部知识库)
  24. - 最新政策动态、行业新闻、通用知识 → 使用 web_search
  25. Args:
  26. query: 搜索关键词,建议使用简洁明确的中文词组
  27. max_results: 最大返回结果数,默认 5 条
  28. Returns:
  29. JSON 格式的搜索结果,包含 query 和 results 列表。
  30. 每条结果含 title(标题)、href(链接)、body(摘要)。
  31. """
  32. if not query or not query.strip():
  33. return json.dumps({"error": "搜索关键词不能为空"}, ensure_ascii=False)
  34. q = query.strip()
  35. last_error = ""
  36. for attempt in range(1, _MAX_RETRIES + 1):
  37. try:
  38. # 在线程池中运行同步 DDGS 调用(避免阻塞事件循环),并设置超时
  39. results = await asyncio.wait_for(
  40. asyncio.to_thread(_do_search, q, max_results),
  41. timeout=_SEARCH_TIMEOUT,
  42. )
  43. formatted = []
  44. for r in results:
  45. formatted.append({
  46. "title": r.get("title", ""),
  47. "href": r.get("href", ""),
  48. "body": r.get("body", ""),
  49. })
  50. return json.dumps(
  51. {"query": q, "results": formatted},
  52. ensure_ascii=False,
  53. )
  54. except asyncio.TimeoutError:
  55. last_error = f"搜索超时(>{_SEARCH_TIMEOUT}s)"
  56. except Exception as e:
  57. last_error = str(e)
  58. if attempt < _MAX_RETRIES:
  59. delay = _RETRY_BASE_DELAY ** attempt
  60. await asyncio.sleep(delay)
  61. return json.dumps(
  62. {"error": f"搜索失败(已重试{_MAX_RETRIES}次): {last_error}", "query": q},
  63. ensure_ascii=False,
  64. )
  65. def _do_search(query: str, max_results: int) -> list[dict]:
  66. """同步搜索辅助函数(在线程池中执行)。"""
  67. with DDGS() as ddgs:
  68. return list(ddgs.text(query, max_results=max_results))
  69. async def web_fetch(url: str) -> str:
  70. """抓取指定网页并提取正文文本。通常在 web_search 获取到相关链接后,
  71. 需要查看页面详细内容时调用。
  72. Args:
  73. url: 要抓取的网页完整 URL(含 https://)
  74. Returns:
  75. JSON 格式,包含 url、title(页面标题)、content(提取的正文文本)、
  76. content_length(正文字符数)。
  77. 正文最多保留 8000 字符,超出部分截断。
  78. """
  79. if not url or not url.strip():
  80. return json.dumps({"error": "URL 不能为空"}, ensure_ascii=False)
  81. url = url.strip()
  82. try:
  83. async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
  84. resp = await client.get(
  85. url,
  86. headers={
  87. "User-Agent": (
  88. "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
  89. "AppleWebKit/537.36 (KHTML, like Gecko) "
  90. "Chrome/125.0.0.0 Safari/537.36"
  91. ),
  92. "Accept": "text/html,application/xhtml+xml",
  93. "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
  94. },
  95. )
  96. resp.raise_for_status()
  97. html = resp.text
  98. except httpx.HTTPStatusError as e:
  99. return json.dumps(
  100. {"error": f"请求失败 HTTP {e.response.status_code}", "url": url},
  101. ensure_ascii=False,
  102. )
  103. except httpx.TimeoutException:
  104. return json.dumps(
  105. {"error": "请求超时(15s)", "url": url},
  106. ensure_ascii=False,
  107. )
  108. except Exception as e:
  109. return json.dumps(
  110. {"error": f"抓取失败: {str(e)}", "url": url},
  111. ensure_ascii=False,
  112. )
  113. # ── 提取标题 ──
  114. title = ""
  115. title_match = re.search(r"<title[^>]*>(.*?)</title>", html, re.IGNORECASE | re.DOTALL)
  116. if title_match:
  117. title = title_match.group(1).strip()
  118. # ── 去除不需要的标签及其内容 ──
  119. for tag in ("script", "style", "nav", "footer", "header", "noscript", "iframe"):
  120. html = re.sub(
  121. rf"<{tag}[^>]*>.*?</{tag}>",
  122. "",
  123. html,
  124. flags=re.IGNORECASE | re.DOTALL,
  125. )
  126. # ── 去除所有 HTML 标签,提取纯文本 ──
  127. text = re.sub(r"<[^>]+>", " ", html)
  128. # ── 清理 HTML 实体 ──
  129. text = re.sub(r"&nbsp;", " ", text)
  130. text = re.sub(r"&amp;", "&", text)
  131. text = re.sub(r"&lt;", "<", text)
  132. text = re.sub(r"&gt;", ">", text)
  133. text = re.sub(r"&quot;", '"', text)
  134. text = re.sub(r"&#?\w+;", " ", text)
  135. text = re.sub(r"\s+", " ", text).strip()
  136. # ── 截断到 8000 字符 ──
  137. max_chars = 8000
  138. if len(text) > max_chars:
  139. text = text[:max_chars] + "…(内容已截断)"
  140. return json.dumps(
  141. {
  142. "url": url,
  143. "title": title,
  144. "content": text,
  145. "content_length": len(text),
  146. },
  147. ensure_ascii=False,
  148. )