WebFetch Stufe 1: Hauptinhalt als kompaktes Markdown, Abschnitte statt Abschneiden (#602)

- HtmlExtraktor (AngleSharp): Selektor > article (>= 40 % des Seitentexts) > main > Textdichte,
  Seitenrahmen und Stoerbloecke entfernt, Ueberschriften/Listen/Tabellen/Code als Markdown,
  Linkziele nur mit links=true, Metadaten (Titel, Sprache, kanonische URL, Datum)
- WebInhalt: JSON/Text/XML unveraendert, Rueckfall auf Volltext bei zu wenig Hauptinhalt,
  mode=full, nextOffset fuer lange Inhalte, JSON kompakt ohne Unicode-Escapes
- RSS: limit, Beschreibungen gekappt; dataAsOf nicht mehr aus dem Date-Header
Gemessen: tagesschau-Artikel 16.000 (abgeschnitten) -> 9.635 Zeichen vollstaendig.
Version 0.1.7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-15 12:19:27 +02:00
co-authored by Claude Opus 5
parent 229cf8ab64
commit 7dbcc44226
7 changed files with 1008 additions and 46 deletions
+86 -45
View File
@@ -12,9 +12,21 @@ 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
Ruft Webseiten, JSON-/Text-Schnittstellen und RSS/Atom-Feeds ab. Nur Domains aus der
Whitelist, kein JavaScript-Rendering.
fetch: Bei HTML kommt standardmaessig nur der Hauptinhalt als kompaktes Markdown
(Ueberschriften, Absaetze, Listen, Tabellen, Links) - der guenstigste Weg. Mit
"selector" (CSS, z.B. "table", "#kurse", "article h2") holst du gezielt nur den Teil,
den du brauchst - noch guenstiger. Linkziele (URLs) kommen nur mit links=true - nur
setzen, wenn du die Adressen wirklich brauchst. mode="full" liefert den ganzen Seitentext und kostet
ein Vielfaches: nur verwenden, wenn der Hauptinhalt fehlt oder unvollstaendig ist.
JSON, Text und XML kommen unveraendert. Lange Inhalte kommen in Abschnitten: die Antwort
nennt nextOffset - nur weiterlesen, wenn der Rest fuer den Auftrag wirklich gebraucht wird.
rss: Titel, Link, Datum und kurze Beschreibung je Eintrag ("limit" begrenzt die Anzahl).
Waehle immer den fuer den Zweck guenstigsten Weg.
""";
/// <summary>
@@ -44,11 +56,19 @@ public sealed class WebFetchTool : IAgentTool
"action": {
"type": "string",
"enum": ["fetch", "rss"],
"description": "fetch=HTML-Seite abrufen und zu Text konvertieren, rss=RSS/Atom-Feed parsen"
"description": "fetch=Seite oder Schnittstelle abrufen, rss=RSS/Atom-Feed lesen"
},
"url": { "type": "string" },
"selector":{ "type": "string",
"description": "optional: CSS-ähnlicher Hint welcher Teil relevant ist, z.B. 'table', 'article'" }
"description": "fetch, optional: CSS-Selektor fuer den benoetigten Teil, z.B. 'table', 'article', '#inhalt'" },
"mode": { "type": "string", "enum": ["auto", "full"],
"description": "fetch: auto (Standard) = Hauptinhalt als Markdown; full = ganzer Seitentext, nur wenn auto nicht reicht" },
"links": { "type": "boolean",
"description": "fetch: Linkziele als [Text](URL) mitliefern (Standard false - kostet auf linkreichen Seiten viel)" },
"offset": { "type": "integer",
"description": "fetch: Fortsetzung eines langen Inhalts - Wert aus nextOffset der vorigen Antwort" },
"limit": { "type": "integer",
"description": "rss: hoechstens so viele Eintraege (Standard 20, maximal 50)" }
}
}
""").RootElement.Clone();
@@ -79,8 +99,8 @@ public sealed class WebFetchTool : IAgentTool
return action switch
{
"fetch" => await FetchPageAsync(url, allowedDomains, config, ct),
"rss" => await FetchRssAsync(url, allowedDomains, ct),
"fetch" => await FetchPageAsync(url, allowedDomains, config, input, ct),
"rss" => await FetchRssAsync(url, allowedDomains, input, ct),
_ => ToolResult.Fail($"Unbekannte Aktion: {action}")
};
}
@@ -93,7 +113,7 @@ public sealed class WebFetchTool : IAgentTool
private async Task<ToolResult> FetchPageAsync(
string url, IReadOnlyCollection<string> allowedDomains,
IReadOnlyDictionary<string, object?> config, CancellationToken ct)
IReadOnlyDictionary<string, object?> config, JsonElement input, CancellationToken ct)
{
using var http = CreateHttpClient(config);
var fetchedAt = DateTime.UtcNow;
@@ -113,30 +133,71 @@ public sealed class WebFetchTool : IAgentTool
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 body = await response.Content.ReadAsStringAsync(ct);
var maxKb = GetConfigInt(config, "maxResponseKb", 512);
if (html.Length > maxKb * 1024) html = html[..(maxKb * 1024)];
if (body.Length > maxKb * 1024) body = body[..(maxKb * 1024)];
var text = StripHtml(html);
if (dataAsOf == null) dataAsOf = ExtractDateFromHtml(html);
// #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);
// 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 = fetchedAt,
dataAsOf = dataAsOf,
fetchedAt,
dataAsOf = response.Content.Headers.LastModified?.UtcDateTime ?? inhalt.Veroeffentlicht?.UtcDateTime,
source = url,
data = new { text }
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, new JsonSerializerOptions { WriteIndented = true }));
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, CancellationToken ct)
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;
@@ -167,27 +228,27 @@ public sealed class WebFetchTool : IAgentTool
if (doc.Root?.Name.LocalName == "rss")
{
foreach (var item in doc.Descendants("item").Take(20))
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 = StripHtml(item.Element("description")?.Value ?? "")
description = Kappen(StripHtml(item.Element("description")?.Value ?? ""), MaxRssBeschreibung)
});
}
}
else // Atom
{
foreach (var entry in doc.Descendants(ns + "entry").Take(20))
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 = StripHtml(entry.Element(ns + "summary")?.Value ?? entry.Element(ns + "content")?.Value ?? "")
description = Kappen(StripHtml(entry.Element(ns + "summary")?.Value ?? entry.Element(ns + "content")?.Value ?? ""), MaxRssBeschreibung)
});
}
}
@@ -200,7 +261,7 @@ public sealed class WebFetchTool : IAgentTool
data = new { items }
};
return ToolResult.Ok(JsonSerializer.Serialize(result, new JsonSerializerOptions { WriteIndented = true }));
return ToolResult.Ok(JsonSerializer.Serialize(result, WebInhalt.JsonOptionen));
}
private static string StripHtml(string html)
@@ -224,26 +285,6 @@ public sealed class WebFetchTool : IAgentTool
return html;
}
private static DateTime? ExtractDateFromHtml(string html)
{
// Suche nach meta-Tags
var patterns = new[]
{
"<meta.*?property=\"article:published_time\".*?content=\"(.*?)\"",
"<meta.*?name=\"date\".*?content=\"(.*?)\"",
"<time.*?datetime=\"(.*?)\""
};
foreach (var pattern in patterns)
{
var match = Regex.Match(html, pattern, RegexOptions.IgnoreCase);
if (match.Success && DateTime.TryParse(match.Groups[1].Value, out var dt))
return dt.ToUniversalTime();
}
return null;
}
private const int MaxRedirects = 5;
/// <summary>