WebSearchProvider.cs 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232
  1. using System;
  2. using System.Linq;
  3. using System.Net;
  4. using System.Net.Http;
  5. using System.Net.Http.Headers;
  6. using System.Text;
  7. using System.Text.RegularExpressions;
  8. using System.Threading.Tasks;
  9. using System.Xml.Linq;
  10. using Newtonsoft.Json.Linq;
  11. namespace LocalizationTool.Services
  12. {
  13. /// <summary>
  14. /// AI 助手联网查资料(web_search / fetch_url 两个工具的实现)。
  15. /// 搜索后端由配置选择:
  16. /// bing = 必应搜索 RSS(默认,免 Key,国内可直连)
  17. /// bocha = 博查 AI 搜索(api.bochaai.com,需 ApiKey,国内服务)
  18. /// tavily = Tavily(api.tavily.com,需 ApiKey,海外服务)
  19. /// </summary>
  20. public class WebSearchProvider
  21. {
  22. private const string UserAgent =
  23. "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36";
  24. private readonly Config.WebSearchConfig _cfg;
  25. public WebSearchProvider(Config.WebSearchConfig cfg)
  26. {
  27. _cfg = cfg ?? new Config.WebSearchConfig();
  28. }
  29. /// <summary>bing 后端免 Key;其余后端要求填 ApiKey。</summary>
  30. public bool IsConfigured =>
  31. string.Equals(_cfg.Provider, "bing", StringComparison.OrdinalIgnoreCase)
  32. || !string.IsNullOrWhiteSpace(_cfg.ApiKey);
  33. /// <summary>联网搜索:返回 [{title, url, snippet}]。query 为必应时支持中英文。</summary>
  34. public async Task<JArray> SearchAsync(string query, int count)
  35. {
  36. if (string.IsNullOrWhiteSpace(query))
  37. throw new ArgumentException("搜索关键词不能为空");
  38. if (!IsConfigured)
  39. throw new InvalidOperationException($"搜索后端 {_cfg.Provider} 需要 ApiKey,请在配置文件 WebSearch 段填写");
  40. count = Math.Min(Math.Max(count, 1), 10);
  41. switch ((_cfg.Provider ?? "").Trim().ToLowerInvariant())
  42. {
  43. case "bocha": return await SearchBochaAsync(query, count).ConfigureAwait(false);
  44. case "tavily": return await SearchTavilyAsync(query, count).ConfigureAwait(false);
  45. default: return await SearchBingRssAsync(query, count).ConfigureAwait(false);
  46. }
  47. }
  48. /// <summary>必应搜索 RSS 输出:免 Key、结构稳定,直接解析 XML。</summary>
  49. private static async Task<JArray> SearchBingRssAsync(string query, int count)
  50. {
  51. var url = $"https://www.bing.com/search?q={Uri.EscapeDataString(query)}&format=rss&count={count}";
  52. using (var client = new HttpClient())
  53. {
  54. client.Timeout = TimeSpan.FromSeconds(20);
  55. client.DefaultRequestHeaders.UserAgent.ParseAdd(UserAgent);
  56. using (var resp = await client.GetAsync(url).ConfigureAwait(false))
  57. {
  58. resp.EnsureSuccessStatusCode();
  59. var xml = await resp.Content.ReadAsStringAsync().ConfigureAwait(false);
  60. var doc = XDocument.Parse(xml);
  61. var ns = doc.Root?.Name.Namespace ?? XNamespace.None;
  62. var arr = new JArray();
  63. foreach (var item in doc.Descendants(ns + "item").Take(count))
  64. {
  65. arr.Add(new JObject
  66. {
  67. ["title"] = (string)item.Element(ns + "title"),
  68. ["url"] = (string)item.Element(ns + "link"),
  69. ["snippet"] = (string)item.Element(ns + "description"),
  70. });
  71. }
  72. if (arr.Count == 0)
  73. throw new Exception("搜索无结果或被必应拦截,请稍后重试或更换关键词");
  74. return arr;
  75. }
  76. }
  77. }
  78. /// <summary>博查 AI 搜索(国内,需 Key):https://open.bochaai.com</summary>
  79. private async Task<JArray> SearchBochaAsync(string query, int count)
  80. {
  81. var payload = new JObject
  82. {
  83. ["query"] = query,
  84. ["count"] = count,
  85. ["summary"] = true, // 返回网页正文摘要,token 友好
  86. };
  87. var json = await PostJsonAsync("https://api.bochaai.com/v1/web-search", payload).ConfigureAwait(false);
  88. var arr = new JArray();
  89. var pages = json["data"]?["webPages"]?["value"] as JArray;
  90. foreach (var p in (pages ?? new JArray()).Take(count))
  91. {
  92. arr.Add(new JObject
  93. {
  94. ["title"] = (string)p["name"],
  95. ["url"] = (string)p["url"],
  96. ["snippet"] = (string)p["summary"] ?? (string)p["snippet"],
  97. });
  98. }
  99. return arr;
  100. }
  101. /// <summary>Tavily 搜索(海外,需 Key):https://tavily.com</summary>
  102. private async Task<JArray> SearchTavilyAsync(string query, int count)
  103. {
  104. var payload = new JObject
  105. {
  106. ["query"] = query,
  107. ["max_results"] = count,
  108. };
  109. var json = await PostJsonAsync("https://api.tavily.com/search", payload).ConfigureAwait(false);
  110. var arr = new JArray();
  111. foreach (var p in (json["results"] as JArray ?? new JArray()).Take(count))
  112. {
  113. arr.Add(new JObject
  114. {
  115. ["title"] = (string)p["title"],
  116. ["url"] = (string)p["url"],
  117. ["snippet"] = (string)p["content"],
  118. });
  119. }
  120. return arr;
  121. }
  122. private async Task<JObject> PostJsonAsync(string url, JObject payload)
  123. {
  124. using (var client = new HttpClient())
  125. {
  126. client.Timeout = TimeSpan.FromSeconds(30);
  127. client.DefaultRequestHeaders.UserAgent.ParseAdd(UserAgent);
  128. client.DefaultRequestHeaders.Authorization =
  129. new AuthenticationHeaderValue("Bearer", _cfg.ApiKey);
  130. var body = new StringContent(payload.ToString(), Encoding.UTF8, "application/json");
  131. using (var resp = await client.PostAsync(url, body).ConfigureAwait(false))
  132. {
  133. var text = await resp.Content.ReadAsStringAsync().ConfigureAwait(false);
  134. if (!resp.IsSuccessStatusCode)
  135. throw new Exception($"搜索请求失败: {(int)resp.StatusCode} {Truncate(text, 300)}");
  136. return JObject.Parse(text);
  137. }
  138. }
  139. }
  140. /// <summary>
  141. /// 抓取网页并提取正文文本(去掉 script/style/标签),供模型直接阅读。
  142. /// 自动识别 UTF-8 / GBK 等编码;返回文本截断到 maxChars。
  143. /// </summary>
  144. public async Task<string> FetchUrlAsync(string url, int maxChars = 6000)
  145. {
  146. if (string.IsNullOrWhiteSpace(url) || !url.StartsWith("http", StringComparison.OrdinalIgnoreCase))
  147. throw new ArgumentException("url 必须以 http/https 开头");
  148. using (var client = new HttpClient())
  149. {
  150. client.Timeout = TimeSpan.FromSeconds(25);
  151. client.DefaultRequestHeaders.UserAgent.ParseAdd(UserAgent);
  152. client.DefaultRequestHeaders.Accept.ParseAdd("text/html,application/xhtml+xml,*/*");
  153. using (var resp = await client.GetAsync(url, HttpCompletionOption.ResponseContentRead).ConfigureAwait(false))
  154. {
  155. if (!resp.IsSuccessStatusCode)
  156. throw new Exception($"网页请求失败: {(int)resp.StatusCode} {resp.RequestMessage?.RequestUri}");
  157. var bytes = await resp.Content.ReadAsByteArrayAsync().ConfigureAwait(false);
  158. var html = DecodeHtml(bytes, resp.Content.Headers.ContentType?.CharSet);
  159. var text = HtmlToText(html);
  160. if (string.IsNullOrWhiteSpace(text))
  161. throw new Exception("网页没有可提取的正文(可能是纯脚本渲染的页面)");
  162. return Truncate(text, maxChars);
  163. }
  164. }
  165. }
  166. /// <summary>按 Content-Type 头 → 页面 meta 声明 → UTF-8 的顺序确定编码;gb2312 升级为 gb18030。</summary>
  167. private static string DecodeHtml(byte[] bytes, string headerCharset)
  168. {
  169. var head = Encoding.ASCII.GetString(bytes, 0, Math.Min(bytes.Length, 4096));
  170. var charset = headerCharset;
  171. if (string.IsNullOrWhiteSpace(charset))
  172. {
  173. var m = Regex.Match(head, @"<meta[^>]+charset\s*=\s*[""']?\s*([\w-]+)", RegexOptions.IgnoreCase);
  174. if (m.Success) charset = m.Groups[1].Value;
  175. }
  176. try
  177. {
  178. if (!string.IsNullOrWhiteSpace(charset))
  179. {
  180. charset = charset.Trim().ToLowerInvariant();
  181. if (charset == "gb2312" || charset == "gbk") charset = "gb18030"; // gb2312 是 gb18030 子集,避免生僻字乱码
  182. if (charset != "utf-8" && charset != "utf8")
  183. return Encoding.GetEncoding(charset).GetString(bytes);
  184. }
  185. }
  186. catch { } // 未知编码名 → 回落 UTF-8
  187. return Encoding.UTF8.GetString(bytes);
  188. }
  189. /// <summary>HTML → 纯文本:去脚本/样式/注释,块级标签转换行,解码实体,压缩空白。</summary>
  190. private static string HtmlToText(string html)
  191. {
  192. html = Regex.Replace(html, "<script[\\s\\S]*?</script>", " ", RegexOptions.IgnoreCase);
  193. html = Regex.Replace(html, "<style[\\s\\S]*?</style>", " ", RegexOptions.IgnoreCase);
  194. html = Regex.Replace(html, "<!--[\\s\\S]*?-->", " ");
  195. html = Regex.Replace(html, "<(br|/p|/div|/li|/tr|/h[1-6])[^>]*>", "\n", RegexOptions.IgnoreCase);
  196. html = Regex.Replace(html, "<[^>]+>", " ");
  197. html = WebUtility.HtmlDecode(html);
  198. html = Regex.Replace(html, "[ \\t\\r\\f\\v]+", " ");
  199. html = Regex.Replace(html, " ?\\n ?", "\n");
  200. html = Regex.Replace(html, "\\n{3,}", "\n\n");
  201. return html.Trim();
  202. }
  203. private static string Truncate(string s, int max) =>
  204. string.IsNullOrEmpty(s) || s.Length <= max ? s : s.Substring(0, max) + "…(已截断)";
  205. }
  206. }