using System.Net; using System.Text.Json; using System.Text.RegularExpressions; using System.Xml.Linq; using ClawdDotNet.Core.Tools; using Microsoft.Extensions.Logging; namespace ClawdDotNet.Tools.WebFetch; public sealed class WebFetchTool : IAgentTool { public string Name => "WebFetch"; /// #715: Brings text from outside into the context — web pages. public bool ReadsForeignContent => true; public string Description => """ Fetches web pages, JSON/text APIs, and RSS/Atom feeds. Only whitelist domains, no JavaScript rendering. fetch: For HTML, by default only the main content is returned as compact Markdown (headings, paragraphs, lists, tables, links) - the most cost-effective way. With "selector" (CSS, e.g. "table", "#content", "article h2") you selectively retrieve only the part you need - even more economical. Link targets (URLs) are only included with links=true - only set if you really need the URLs. mode="full" returns the full page text and costs much more: only use when main content is missing or incomplete. JSON, text and XML are returned unchanged. If you only need specific entries from a large JSON response (e.g., a city), use "filter": only objects containing the text will be returned - much cheaper than pagination. Long content is returned in chunks: the response includes nextOffset - only continue reading if the remainder is really needed for the task. rss: Title, link, date, and short description per item ("limit" caps the count). Always choose the most efficient path for your purpose. """; /// /// Freigegeben: Dieses Werkzeug liest ausschließlich. Beide Aktionen enden in einem /// GetAsync; es gibt keinen Schreibzugriff — weder auf Dateien noch auf den /// StateStore. Drei Abrufe gleichzeitig unterscheiden sich im Ergebnis durch nichts /// von drei Abrufen nacheinander. /// /// Zum Vergleich das Nachbarwerkzeug WebMonitor: Es sieht ähnlich aus, merkt /// sich aber den zuletzt gesehenen Stand über StateStore.SetAsync und bleibt /// deshalb seriell. /// public bool IsParallelSafe => true; /// /// Wiederholbar (#554): Ein zweiter Abruf derselben Adresse ändert nichts an der /// Welt. Die Antwort kann inzwischen eine andere sein — das ist aber kein Schaden, /// sondern der Zweck eines Abrufs. /// public bool IsRepeatable => true; public JsonElement InputSchema { get; } = JsonDocument.Parse(""" { "type": "object", "required": ["action", "url"], "properties": { "action": { "type": "string", "enum": ["fetch", "rss"], "description": "fetch=fetch webpage or API, rss=read RSS/Atom feed" }, "url": { "type": "string" }, "selector":{ "type": "string", "description": "fetch, optional: CSS selector for the required section, e.g. 'table', 'article', '#content'" }, "mode": { "type": "string", "enum": ["auto", "full"], "description": "fetch: auto (default) = main content as Markdown; full = full page text, only if auto is insufficient" }, "links": { "type": "boolean", "description": "fetch: include link targets as [Text](URL) (default false - expensive on link-heavy pages)" }, "filter": { "type": "string", "description": "fetch, JSON/JSONP only: only return entries containing this text (case-insensitive), e.g. 'Berlin'" }, "offset": { "type": "integer", "description": "fetch: continuation of long content - value from nextOffset of previous response" }, "limit": { "type": "integer", "description": "rss: maximum number of items (default 20, max 50)" } } } """).RootElement.Clone(); public async Task ExecuteAsync( JsonElement input, AgentToolContext context, CancellationToken ct) { try { var url = input.GetProperty("url").GetString()!; var action = input.GetProperty("action").GetString()!; IReadOnlyDictionary? config = context.ToolConfig.TryGetValue("WebFetch", out var c) && c is JsonElement je ? JsonSerializer.Deserialize>(je.GetRawText()) : context.ToolConfig; if (config == null) return ToolResult.Fail("WebFetch configuration missing."); var allowedDomains = config.GetValueOrDefault("allowedDomains") is JsonElement ad ? ad.EnumerateArray().Select(x => x.GetString()!).ToList() : (config.GetValueOrDefault("allowedDomains") as IEnumerable)?.ToList() ?? new List(); // Adresse prüfen: Schema, privates Netz, Whitelist if (!UrlGuard.TryValidate(url, allowedDomains, out _, out var urlError)) return ToolResult.Fail(urlError!); return action switch { "fetch" => await FetchPageAsync(url, allowedDomains, config, input, ct), "rss" => await FetchRssAsync(url, allowedDomains, input, ct), _ => ToolResult.Fail($"Unknown action: {action}") }; } catch (Exception ex) { context.Logger.LogError(ex, "Error in WebFetch"); return ToolResult.Fail($"Error: {ex.Message}"); } } private async Task FetchPageAsync( string url, IReadOnlyCollection allowedDomains, IReadOnlyDictionary config, JsonElement input, CancellationToken ct) { using var http = CreateHttpClient(config); var fetchedAt = DateTime.UtcNow; var (response, redirectError) = await GetFollowingRedirectsAsync(http, url, allowedDomains, ct); if (redirectError is not null) return redirectError; // response ist genau dann gesetzt, wenn keine Absage kam - dem Tupel sieht der // Compiler das nicht an. Die Pruefung sagt dasselbe nachvollziehbar und ersetzt // das Ausrufezeichen, das hier stand. if (response is null) return ToolResult.Fail($"No response from {url}."); using var _ = response; if (!response.IsSuccessStatusCode) return ToolResult.Fail($"Page unreachable: {url} → HTTP {(int)response.StatusCode} {response.ReasonPhrase}"); var body = await response.Content.ReadAsStringAsync(ct); var maxKb = GetConfigInt(config, "maxResponseKb", 512); if (body.Length > maxKb * 1024) body = body[..(maxKb * 1024)]; // #602: Relative Links gegen die Adresse aufloesen, von der die Seite tatsaechlich kam. var endadresse = response.RequestMessage?.RequestUri ?? new Uri(url); var modus = string.Equals(GetString(input, "mode"), "full", StringComparison.OrdinalIgnoreCase) ? AbrufModus.Voll : AbrufModus.Auto; var inhalt = WebInhalt.Aufbereiten( body, response.Content.Headers.ContentType?.MediaType, endadresse, modus, GetString(input, "selector"), GetInt(input, "offset") ?? 0, GetConfigInt(config, "maxContentChars", WebInhalt.StandardMaxZeichen), mitLinks: input.ValueKind == JsonValueKind.Object && input.TryGetProperty("links", out var l) && l.ValueKind == JsonValueKind.True, filter: GetString(input, "filter")); // Stand der Daten: Last-Modified oder das Datum aus der Seite. Nicht mehr der // Date-Header - der ist die Uhrzeit des Servers beim Abruf, also fetchedAt. var result = new { fetchedAt, dataAsOf = response.Content.Headers.LastModified?.UtcDateTime ?? inhalt.Veroeffentlicht?.UtcDateTime, source = url, finalUrl = endadresse.ToString() == url ? null : endadresse.ToString(), contentType = inhalt.Art, extraction = inhalt.Bereich, title = inhalt.Titel, language = inhalt.Sprache, canonicalUrl = inhalt.KanonischeUrl, totalChars = inhalt.Gesamtzeichen, offset = inhalt.Offset == 0 ? (int?)null : inhalt.Offset, nextOffset = inhalt.NaechsterOffset, hint = inhalt.Hinweis, data = new { text = inhalt.Text } }; return ToolResult.Ok(JsonSerializer.Serialize(result, WebInhalt.JsonOptionen)); } private static string? GetString(JsonElement input, string name) => input.ValueKind == JsonValueKind.Object && input.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null; private static int? GetInt(JsonElement input, string name) => input.ValueKind == JsonValueKind.Object && input.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number && v.TryGetInt32(out var i) ? i : null; /// Beschreibungen aus Feeds sind oft ganze Artikel - fuer die Auswahl reicht der Anfang. private const int MaxRssBeschreibung = 400; private static string Kappen(string text, int max) => text.Length <= max ? text : text[..max].TrimEnd() + " …"; private async Task FetchRssAsync( string url, IReadOnlyCollection allowedDomains, JsonElement input, CancellationToken ct) { var limit = Math.Clamp(GetInt(input, "limit") ?? 20, 1, 50); using var http = CreateHttpClient(new Dictionary()); var fetchedAt = DateTime.UtcNow; var (response, redirectError) = await GetFollowingRedirectsAsync(http, url, allowedDomains, ct); if (redirectError is not null) return redirectError; // response ist genau dann gesetzt, wenn keine Absage kam - dem Tupel sieht der // Compiler das nicht an. Die Pruefung sagt dasselbe nachvollziehbar und ersetzt // das Ausrufezeichen, das hier stand. if (response is null) return ToolResult.Fail($"No response from {url}."); using var _ = response; if (!response.IsSuccessStatusCode) return ToolResult.Fail($"RSS feed unreachable: {url} → HTTP {(int)response.StatusCode} {response.ReasonPhrase}"); var xml = await response.Content.ReadAsStringAsync(ct); var doc = XDocument.Parse(xml); var items = new List(); // Support RSS and Atom. // Ohne Wurzelelement gibt es keinen Namensraum — dann gilt der leere. Vorher // stand das Ausweichen nur an der ersten von fünf Stellen, die übrigen vier // rechneten mit einem möglicherweise fehlenden Namensraum weiter. var ns = doc.Root?.Name.Namespace ?? XNamespace.None; if (doc.Root?.Name.LocalName == "rss") { foreach (var item in doc.Descendants("item").Take(limit)) { items.Add(new { title = item.Element("title")?.Value, link = item.Element("link")?.Value, pubDate = item.Element("pubDate")?.Value, description = Kappen(StripHtml(item.Element("description")?.Value ?? ""), MaxRssBeschreibung) }); } } else // Atom { foreach (var entry in doc.Descendants(ns + "entry").Take(limit)) { items.Add(new { title = entry.Element(ns + "title")?.Value, link = entry.Element(ns + "link")?.Attribute("href")?.Value, pubDate = entry.Element(ns + "updated")?.Value ?? entry.Element(ns + "published")?.Value, description = Kappen(StripHtml(entry.Element(ns + "summary")?.Value ?? entry.Element(ns + "content")?.Value ?? ""), MaxRssBeschreibung) }); } } var result = new { fetchedAt = fetchedAt, dataAsOf = fetchedAt, // RSS current state source = url, data = new { items } }; return ToolResult.Ok(JsonSerializer.Serialize(result, WebInhalt.JsonOptionen)); } private static string StripHtml(string html) { if (string.IsNullOrWhiteSpace(html)) return ""; // Script und Style entfernen html = Regex.Replace(html, ".*?", "", RegexOptions.Singleline | RegexOptions.IgnoreCase); html = Regex.Replace(html, ".*?", "", RegexOptions.Singleline | RegexOptions.IgnoreCase); // Alle anderen Tags entfernen html = Regex.Replace(html, "<.*?>", " ", RegexOptions.Singleline); // Entities dekodieren html = WebUtility.HtmlDecode(html); // Whitespace säubern html = Regex.Replace(html, @"\s+", " "); html = html.Replace(" \n ", "\n").Replace(" \r ", "\r").Trim(); return html; } private const int MaxRedirects = 5; /// /// Folgt Weiterleitungen selbst und prüft jedes Ziel erneut gegen Whitelist und /// Netzsperren. HttpClient würde sonst ungefragt bis ins lokale Netz folgen. /// private static async Task<(HttpResponseMessage? Response, ToolResult? Error)> GetFollowingRedirectsAsync( HttpClient http, string url, IReadOnlyCollection allowedDomains, CancellationToken ct) { var current = url; for (var hop = 0; hop <= MaxRedirects; hop++) { if (!UrlGuard.TryValidate(current, allowedDomains, out var uri, out var error)) { var context = hop == 0 ? "" : $" (Redirect {hop} from {url})"; return (null, ToolResult.Fail($"{error}{context}")); } var response = await http.GetAsync(uri, ct); if (!IsRedirect(response.StatusCode)) return (response, null); var location = response.Headers.Location; response.Dispose(); if (location is null) return (null, ToolResult.Fail($"Redirect without target address at {current}.")); // Relative Weiterleitungen gegen die aktuelle Adresse auflösen. current = location.IsAbsoluteUri ? location.ToString() : new Uri(uri!, location).ToString(); } return (null, ToolResult.Fail( $"More than {MaxRedirects} redirects starting from {url} — aborted.")); } private static bool IsRedirect(HttpStatusCode status) => status is HttpStatusCode.MovedPermanently or HttpStatusCode.Found or HttpStatusCode.SeeOther or HttpStatusCode.TemporaryRedirect or HttpStatusCode.PermanentRedirect; private static HttpClient CreateHttpClient(IReadOnlyDictionary config) { // Weiterleitungen bewusst NICHT automatisch verfolgen — sie werden einzeln // aufgelöst und geprüft. // // #580: Die Zieladresse wird beim Verbinden geprüft, nicht nur der Name in der URL. // Ohne Proxy, weil über einen Proxy der Proxy auflöst und verbindet — die Prüfung // sähe dann nur dessen Adresse, nie das eigentliche Ziel. var handler = new SocketsHttpHandler { AllowAutoRedirect = false, UseProxy = false, ConnectCallback = UrlGuard.ConnectAsync }; var client = new HttpClient(handler, disposeHandler: true); var timeout = GetConfigInt(config, "timeoutSeconds", 15); client.Timeout = TimeSpan.FromSeconds(timeout); client.DefaultRequestHeaders.UserAgent.ParseAdd( config.GetValueOrDefault("userAgent")?.ToString() ?? "ClawdDotNet-Agent/1.0"); client.DefaultRequestHeaders.CacheControl = new System.Net.Http.Headers.CacheControlHeaderValue { NoCache = true, NoStore = true }; return client; } /// /// Sicheres Lesen eines int-Werts aus der Config (funktioniert mit JsonElement und primitiven Typen). /// private static int GetConfigInt(IReadOnlyDictionary config, string key, int defaultValue) { var val = config.GetValueOrDefault(key); return val switch { null => defaultValue, int i => i, long l => (int)l, System.Text.Json.JsonElement je when je.ValueKind == System.Text.Json.JsonValueKind.Number => je.GetInt32(), _ => int.TryParse(val.ToString(), out var parsed) ? parsed : defaultValue }; } }