Files
RichardandClaude Opus 5.5 ad71682c2d i18n: Englisch als Hauptsprache, Sprachpakete fuer Desktop und WebUI (#661)
Umbau durch Gemini Flash, Durchsicht und Nacharbeit durch Claude.

Umfang:
- Desktop: LocalizationManager (JSON-Pakete, Rueckfall auf Englisch,
  Umschalten zur Laufzeit), Localize-Markup-Erweiterung, Sprachwahl in den
  Einstellungen (AppSettings.Language, Vorgabe "en"); 317 Schluessel je Sprache
- WebUI: alle Ansichten auf t()/data-i18n, 656 Schluessel je Sprache
- Agenten-Werkzeuge: Beschreibungen, Schemata und Meldungen auf Englisch
- Tests an die englischen Meldungen angepasst, keine Pruefung abgeschwaecht

Nacharbeit bei der Durchsicht:
- ~25 Kommentarbloecke mit Begruendungen waren ersatzlos entfallen, darunter
  sicherheitsrelevante (NTFS-Datenstrom beim shared:-Praefix, Bremse gegen
  Agentenschleifen, doppelter POST beim Anlegen einer Instanz). Wieder
  eingesetzt: auf Englisch, wo die Datei umgestellt ist, sonst im Wortlaut.
- Drei WebUI-Schluessel fehlten (logs.paused, instances.instanceOfflineError,
  instances.noInstanceActiveError). Weil t() bei fehlendem Schluessel den
  Schluessel selbst liefert, griff der "|| err.message"-Rueckfall nie — die
  Oberflaeche haette den Schluesselnamen angezeigt.

Geprueft: Schluesselgleichheit en/de, Platzhalter je Schluessel, jeder im
Code benutzte Schluessel vorhanden, node --check fuer alle Module,
1.220 Tests gruen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 09:43:51 +02:00

378 lines
16 KiB
C#

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 => """
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.
""";
/// <summary>
/// Freigegeben: Dieses Werkzeug liest ausschließlich. Beide Aktionen enden in einem
/// <c>GetAsync</c>; es gibt keinen Schreibzugriff — weder auf Dateien noch auf den
/// StateStore. Drei Abrufe gleichzeitig unterscheiden sich im Ergebnis durch nichts
/// von drei Abrufen nacheinander.
///
/// <para>Zum Vergleich das Nachbarwerkzeug WebMonitor: Es sieht ähnlich aus, merkt
/// sich aber den zuletzt gesehenen Stand über <c>StateStore.SetAsync</c> und bleibt
/// deshalb seriell.</para>
/// </summary>
public bool IsParallelSafe => true;
/// <summary>
/// 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.
/// </summary>
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<ToolResult> ExecuteAsync(
JsonElement input,
AgentToolContext context,
CancellationToken ct)
{
try
{
var url = input.GetProperty("url").GetString()!;
var action = input.GetProperty("action").GetString()!;
IReadOnlyDictionary<string, object?>? config = context.ToolConfig.TryGetValue("WebFetch", out var c) && c is JsonElement je
? JsonSerializer.Deserialize<Dictionary<string, object?>>(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<string>)?.ToList() ?? new List<string>();
// 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<ToolResult> FetchPageAsync(
string url, IReadOnlyCollection<string> allowedDomains,
IReadOnlyDictionary<string, object?> 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;
/// <summary>Beschreibungen aus Feeds sind oft ganze Artikel - fuer die Auswahl reicht der Anfang.</summary>
private const int MaxRssBeschreibung = 400;
private static string Kappen(string text, int max)
=> text.Length <= max ? text : text[..max].TrimEnd() + " …";
private async Task<ToolResult> FetchRssAsync(
string url, IReadOnlyCollection<string> allowedDomains, JsonElement input, CancellationToken ct)
{
var limit = Math.Clamp(GetInt(input, "limit") ?? 20, 1, 50);
using var http = CreateHttpClient(new Dictionary<string, object?>());
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<object>();
// 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, "<script.*?>.*?</script>", "", RegexOptions.Singleline | RegexOptions.IgnoreCase);
html = Regex.Replace(html, "<style.*?>.*?</style>", "", 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;
/// <summary>
/// Folgt Weiterleitungen selbst und prüft jedes Ziel erneut gegen Whitelist und
/// Netzsperren. HttpClient würde sonst ungefragt bis ins lokale Netz folgen.
/// </summary>
private static async Task<(HttpResponseMessage? Response, ToolResult? Error)> GetFollowingRedirectsAsync(
HttpClient http, string url, IReadOnlyCollection<string> 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<string, object?> 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;
}
/// <summary>
/// Sicheres Lesen eines int-Werts aus der Config (funktioniert mit JsonElement und primitiven Typen).
/// </summary>
private static int GetConfigInt(IReadOnlyDictionary<string, object?> 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
};
}
}