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";
public string Description => """
Ruft statische Webseiten oder RSS-Feeds ab und extrahiert Text + Timestamps.
Nur Domains aus der Whitelist erlaubt. Kein JavaScript-Rendering.
Aktionen: fetch, rss
""";
///
/// 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;
public JsonElement InputSchema { get; } = JsonDocument.Parse("""
{
"type": "object",
"required": ["action", "url"],
"properties": {
"action": {
"type": "string",
"enum": ["fetch", "rss"],
"description": "fetch=HTML-Seite abrufen und zu Text konvertieren, rss=RSS/Atom-Feed parsen"
},
"url": { "type": "string" },
"selector":{ "type": "string",
"description": "optional: CSS-ähnlicher Hint welcher Teil relevant ist, z.B. 'table', 'article'" }
}
}
""").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 Konfiguration fehlt.");
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, ct),
"rss" => await FetchRssAsync(url, allowedDomains, ct),
_ => ToolResult.Fail($"Unbekannte Aktion: {action}")
};
}
catch (Exception ex)
{
context.Logger.LogError(ex, "Fehler in WebFetch");
return ToolResult.Fail($"Fehler: {ex.Message}");
}
}
private async Task FetchPageAsync(
string url, IReadOnlyCollection allowedDomains,
IReadOnlyDictionary config, 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($"Keine Antwort von {url}.");
using var _ = response;
if (!response.IsSuccessStatusCode)
return ToolResult.Fail($"Seite nicht erreichbar: {url} → HTTP {(int)response.StatusCode} {response.ReasonPhrase}");
DateTime? dataAsOf = response.Content.Headers.LastModified?.UtcDateTime
?? response.Headers.Date?.UtcDateTime;
var html = await response.Content.ReadAsStringAsync(ct);
var maxKb = GetConfigInt(config, "maxResponseKb", 512);
if (html.Length > maxKb * 1024) html = html[..(maxKb * 1024)];
var text = StripHtml(html);
if (dataAsOf == null) dataAsOf = ExtractDateFromHtml(html);
var result = new
{
fetchedAt = fetchedAt,
dataAsOf = dataAsOf,
source = url,
data = new { text }
};
return ToolResult.Ok(JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true }));
}
private async Task FetchRssAsync(
string url, IReadOnlyCollection allowedDomains, CancellationToken ct)
{
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($"Keine Antwort von {url}.");
using var _ = response;
if (!response.IsSuccessStatusCode)
return ToolResult.Fail($"RSS-Feed nicht erreichbar: {url} → HTTP {(int)response.StatusCode} {response.ReasonPhrase}");
var xml = await response.Content.ReadAsStringAsync(ct);
var doc = XDocument.Parse(xml);
var items = new List