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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user