Forge
csharp4405de34
1#nullable enable
2
3using System.Net;
4using System.Text.Json;
5
6namespace CascadeIDE.Services;
7
8/// <summary>
9/// Одноразовый запрос «мгновенного ответа» DuckDuckGo (HTTPS, без API-ключа).
10/// Не предназначено для высокочастотного скрейпинга; для редких справок агента.
11/// </summary>
12internal static class WebPublicSearchClient
13{
14 private const string DdgJsonUriPrefix = "https://api.duckduckgo.com/?q=";
15 private const string DdgJsonUriSuffix = "&format=json&no_html=1&skip_disambig=1";
16
17 private static readonly HttpClient Http = CreateHttpClient();
18
19 private static JsonSerializerOptions ResponseJsonOpts { get; } = new()
20 {
21 PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
22 WriteIndented = false,
23 };
24
25 private static HttpClient CreateHttpClient()
26 {
27 var http = new HttpClient { Timeout = TimeSpan.FromSeconds(22) };
28 http.DefaultRequestVersion = HttpVersion.Version20;
29 http.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", "CascadeIDE/1.0 (web-public-query)");
30 http.DefaultRequestHeaders.TryAddWithoutValidation("Accept", "application/json");
31 return http;
32 }
33
34 /// <returns>Компактный JSON с полями в camelCase или объект с ошибкой (<c>offline_or_error</c>).</returns>
35 internal static async Task<string> FetchDdgInstantAnswerJsonAsync(string query, CancellationToken cancellationToken)
36 {
37 var trimmed = query?.Trim() ?? "";
38 if (trimmed.Length == 0)
39 return JsonSerializer.Serialize(new { query = trimmed, offline_or_error = "empty query" }, ResponseJsonOpts);
40
41 var url = DdgJsonUriPrefix + Uri.EscapeDataString(trimmed) + DdgJsonUriSuffix;
42
43 try
44 {
45 using var response = await Http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
46 var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
47 if (!response.IsSuccessStatusCode)
48 return JsonSerializer.Serialize(new { query = trimmed, offline_or_error = $"HTTP {(int)response.StatusCode}", body_preview = TrimPreview(body), backend = "duckduckgo_instant_answer" }, ResponseJsonOpts);
49
50 using JsonDocument doc = JsonDocument.Parse(body);
51 var root = doc.RootElement;
52
53 string heading = JsonPropString(root, "Heading");
54 string abstractText = JsonPropString(root, "AbstractText");
55 string abstractUrl = JsonPropString(root, "AbstractURL");
56 string? answer = null;
57 if (root.TryGetProperty("Answer", out var ans) && ans.ValueKind == JsonValueKind.String)
58 {
59 var s = ans.GetString()?.Trim();
60 if (!string.IsNullOrEmpty(s))
61 answer = s;
62 }
63
64 var related = new List<Dictionary<string, string>>(12);
65 var remain = 40;
66 if (root.TryGetProperty("RelatedTopics", out var rtRoot) && rtRoot.ValueKind == JsonValueKind.Array)
67 AppendRelatedLeaves(rtRoot, related, ref remain);
68
69 var payload = new
70 {
71 query = trimmed,
72 heading,
73 abstract_text = abstractText,
74 abstract_url = abstractUrl,
75 answer,
76 related_topics = related,
77 backend = "duckduckgo_instant_answer",
78 note = related.Count == 0 && abstractText.Length == 0 && string.IsNullOrEmpty(answer)
79 ? "Ответ может быть пустым: переформулируй или сузь запрос."
80 : (string?)null,
81 };
82
83 return JsonSerializer.Serialize(payload, ResponseJsonOpts);
84 }
85 catch (OperationCanceledException)
86 {
87 throw;
88 }
89 catch (Exception ex)
90 {
91 return JsonSerializer.Serialize(new { query = trimmed, offline_or_error = ex.Message, backend = "duckduckgo_instant_answer" }, ResponseJsonOpts);
92 }
93 }
94
95 private static string JsonPropString(JsonElement obj, string name)
96 {
97 if (!obj.TryGetProperty(name, out var p) || p.ValueKind != JsonValueKind.String)
98 return "";
99 return p.GetString()?.Trim() ?? "";
100 }
101
102 /// <summary>DDG кладёт в <c>RelatedTopics</c> смесь листьев (<c>Text</c>/<c>FirstURL</c>) и групп с <c>Topics</c>: [...].</summary>
103 private static void AppendRelatedLeaves(JsonElement array, List<Dictionary<string, string>> sink, ref int remaining)
104 {
105 foreach (var el in array.EnumerateArray())
106 {
107 if (remaining <= 0)
108 break;
109 if (el.ValueKind != JsonValueKind.Object)
110 continue;
111
112 if (el.TryGetProperty("Topics", out var nested) && nested.ValueKind == JsonValueKind.Array)
113 {
114 AppendRelatedLeaves(nested, sink, ref remaining);
115 continue;
116 }
117
118 string text = JsonPropString(el, "Text");
119 string url = JsonPropString(el, "FirstURL");
120 if (text.Length == 0 && url.Length == 0)
121 continue;
122 sink.Add(new Dictionary<string, string>(StringComparer.Ordinal)
123 {
124 ["text"] = text.Length > 500 ? text[..500] + "…" : text,
125 ["url"] = url,
126 });
127 remaining--;
128 }
129 }
130
131 private static string TrimPreview(string s, int max = 280) =>
132 s.Length <= max ? s : s[..max] + "…";
133}
134
View only · write via MCP/CIDE