| 1 | #nullable enable |
| 2 | |
| 3 | using System.Net; |
| 4 | using System.Text.Json; |
| 5 | |
| 6 | namespace CascadeIDE.Services; |
| 7 | |
| 8 | /// <summary> |
| 9 | /// Одноразовый запрос «мгновенного ответа» DuckDuckGo (HTTPS, без API-ключа). |
| 10 | /// Не предназначено для высокочастотного скрейпинга; для редких справок агента. |
| 11 | /// </summary> |
| 12 | internal 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 | |