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
@@ -4,6 +4,10 @@
<ProjectReference Include="..\ClawdDotNet.Core\ClawdDotNet.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="AngleSharp" Version="1.8.1" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
@@ -0,0 +1,460 @@
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
using AngleSharp.Dom;
using AngleSharp.Html.Parser;
namespace ClawdDotNet.Tools.WebFetch;
/// <summary>Was die Extraktion aus einer Seite gewonnen hat.</summary>
public sealed record ExtrahierterInhalt(
string Titel,
string? Sprache,
string? KanonischeUrl,
DateTimeOffset? Veroeffentlicht,
string Bereich,
string Markdown,
string? Hinweis);
/// <summary>
/// Holt den Hauptinhalt einer HTML-Seite als kompaktes Markdown (#602, Stufe 1).
///
/// <para>Vorher wurde die ganze Seite per Regex zu Fliesstext: Navigation, Fusszeile und
/// Cookie-Hinweis gingen mit ins Modell, Ueberschriften, Listen, Tabellen und Links gingen
/// verloren - und bei 16.000 Zeichen war Schluss, oft bevor der eigentliche Inhalt anfing.
/// Hier entscheidet ein richtiger HTML5-Parser, was Inhalt ist, und die Struktur bleibt als
/// Markdown lesbar, das kaum Zeichen kostet.</para>
///
/// <para>Bereichswahl vom Genauen zum Groben: ein angegebener Selektor, sonst das
/// <c>article</c> mit dem meisten Text, sonst <c>main</c>, sonst der Block mit der hoechsten
/// Textdichte (Absatztext abzueglich Linktext), sonst der ganze Body.</para>
/// </summary>
public static class HtmlExtraktor
{
/// <summary>Unter so viel Text gilt ein article/main als leer und wird uebergangen.</summary>
private const int MindestTextBereich = 200;
/// <summary>So viel des Seitentexts muss ein article tragen, um als Hauptinhalt zu gelten.</summary>
private const double AnteilArtikel = 0.4;
private const int MaxTabellenzeilen = 200;
private const int MaxTabellenspalten = 12;
private static readonly string[] Stoerelemente =
[
"script", "style", "noscript", "template", "svg", "canvas", "iframe", "object", "embed",
"form", "button", "input", "select", "textarea", "dialog", "[hidden]", "[aria-hidden=true]"
];
private static readonly string[] Seitenrahmen =
[
"nav", "aside", "header", "footer",
"[role=navigation]", "[role=banner]", "[role=contentinfo]", "[role=complementary]", "[role=search]"
];
private static readonly Regex StoerKlasse = new(
@"(^|[\s_-])(cookie|consent|gdpr|newsletter|share|sharing|social|advert|anzeige|werbung|banner|promo|related|breadcrumb|sidebar|comments?|popup|modal|paywall)([\s_-]|$)",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
private static readonly Regex Leerraum = new(@"\s+", RegexOptions.CultureInvariant);
/// <param name="mitLinks">
/// Linkziele als <c>[Text](URL)</c> ausgeben. Standard aus: Auf linkreichen Seiten
/// (Wikipedia) verdoppelten die Adressen den Text, und die meisten Auftraege brauchen den
/// Inhalt, nicht die Ziele.
/// </param>
public static ExtrahierterInhalt Extrahiere(string html, Uri basis, string? selector = null, bool mitLinks = false)
{
var doc = new HtmlParser().ParseDocument(html);
var titel = Meta(doc, "meta[property='og:title']") ?? Normalisiere(doc.Title ?? "");
var sprache = doc.DocumentElement.GetAttribute("lang");
var kanonisch = AbsoluteUrl(basis, doc.QuerySelector("link[rel=canonical]")?.GetAttribute("href"));
var datum = Veroeffentlichung(doc);
foreach (var e in doc.QuerySelectorAll(string.Join(",", Stoerelemente)).ToList())
e.Remove();
string? hinweis = null;
IReadOnlyList<IElement> bereich = [];
var bereichsName = "";
if (!string.IsNullOrWhiteSpace(selector))
{
try
{
bereich = doc.QuerySelectorAll(selector).ToList();
bereichsName = "selector";
if (bereich.Count == 0)
hinweis = $"Selektor '{selector}' traf nichts - Hauptinhalt verwendet.";
}
catch (Exception ex) when (ex is DomException or ArgumentException)
{
hinweis = $"Selektor '{selector}' ist ungueltig ({ex.Message}) - Hauptinhalt verwendet.";
}
}
if (bereich.Count == 0)
{
EntferneSeitenrahmen(doc);
(bereich, bereichsName) = Hauptinhalt(doc);
}
var sb = new StringBuilder();
foreach (var element in bereich)
{
Block(sb);
// Ohne Basis loest AbsoluteUrl nichts auf - dann bleibt vom Link nur der Text.
Schreibe(element, sb, mitLinks ? basis : null, einzug: 0);
}
return new ExtrahierterInhalt(titel, string.IsNullOrWhiteSpace(sprache) ? null : sprache, kanonisch, datum,
bereichsName, Aufraeumen(sb.ToString()), hinweis);
}
/// <summary>Der ganze sichtbare Text der Seite, ohne Skripte - Stufe "vollstaendig".</summary>
public static string Volltext(string html)
{
var doc = new HtmlParser().ParseDocument(html);
foreach (var e in doc.QuerySelectorAll(string.Join(",", Stoerelemente)).ToList())
e.Remove();
return Normalisiere(doc.Body?.TextContent ?? doc.DocumentElement.TextContent);
}
// ─── Bereichswahl ───
private static void EntferneSeitenrahmen(IDocument doc)
{
foreach (var e in doc.QuerySelectorAll(string.Join(",", Seitenrahmen)).ToList())
{
// Die Kopfzeile eines Artikels (h1, Autor, Datum) ist Inhalt, nicht Seitenrahmen.
if (e.LocalName is "header" or "footer" && e.Closest("article, main, [role=main]") is not null)
continue;
e.Remove();
}
foreach (var e in doc.QuerySelectorAll("div, section, aside, ul, p, span, figure").ToList())
{
if (e.Closest("article, main") == e) continue;
var kennung = $"{e.Id} {e.ClassName}";
if (StoerKlasse.IsMatch(kennung) && e.QuerySelector("h1") is null)
e.Remove();
}
}
private static (IReadOnlyList<IElement> Bereich, string Name) Hauptinhalt(IDocument doc)
{
var main = doc.QuerySelector("main, [role=main]");
var seitentext = Normalisiere((main ?? doc.Body ?? doc.DocumentElement).TextContent).Length;
// Ein article ist nur dann "der" Inhalt, wenn er einen guten Teil der Seite ausmacht.
// Uebersichtsseiten bestehen aus vielen kleinen article-Kacheln - bei heise.de gewann
// sonst ein einzelner Teaser mit 398 Zeichen, und der Rest der Seite war weg.
var artikel = doc.QuerySelectorAll("article")
.Select(a => (Element: a, Laenge: Normalisiere(a.TextContent).Length))
.Where(a => a.Laenge >= Math.Max(MindestTextBereich, AnteilArtikel * seitentext))
.OrderByDescending(a => a.Laenge)
.FirstOrDefault();
if (artikel.Element is not null)
return ([artikel.Element], "article");
if (main is not null && Normalisiere(main.TextContent).Length >= MindestTextBereich)
return ([main], "main");
var dichtester = doc.QuerySelectorAll("section, div")
.Select(e => (Element: e, Wert: Dichte(e)))
.Where(e => e.Wert > MindestTextBereich)
.OrderByDescending(e => e.Wert)
.FirstOrDefault();
if (dichtester.Element is not null)
return ([dichtester.Element], "hauptinhalt");
return (doc.Body is { } body ? [body] : [doc.DocumentElement], "body");
}
/// <summary>Absatztext zaehlt, uebriger Text und Linktext ziehen ab - so gewinnt der Artikel gegen die Linkliste.</summary>
private static double Dichte(IElement e)
{
var absatz = e.QuerySelectorAll("p, li, td, h1, h2, h3").Sum(p => Normalisiere(p.TextContent).Length);
var gesamt = Normalisiere(e.TextContent).Length;
var links = e.QuerySelectorAll("a").Sum(a => Normalisiere(a.TextContent).Length);
return absatz - 0.5 * Math.Max(0, gesamt - absatz) - 0.5 * links;
}
// ─── Markdown ───
private static void Schreibe(INode knoten, StringBuilder sb, Uri? basis, int einzug)
{
if (knoten is IText text)
{
sb.Append(Leerraum.Replace(text.Data, " "));
return;
}
if (knoten is not IElement e)
return;
switch (e.LocalName)
{
case "h1" or "h2" or "h3" or "h4" or "h5" or "h6":
{
var inhalt = Inline(e, basis);
if (inhalt.Length == 0) return;
Block(sb);
sb.Append('#', e.LocalName[1] - '0').Append(' ').Append(inhalt);
Block(sb);
return;
}
case "br":
sb.Append('\n');
return;
case "hr":
Block(sb);
sb.Append("---");
Block(sb);
return;
case "ul" or "ol":
Liste(e, sb, basis, einzug);
return;
case "table":
Tabelle(e, sb, basis);
return;
case "pre":
Block(sb);
sb.Append("```\n").Append(e.TextContent.Trim('\n')).Append("\n```");
Block(sb);
return;
case "code":
sb.Append('`').Append(Normalisiere(e.TextContent)).Append('`');
return;
case "blockquote":
{
var innen = new StringBuilder();
foreach (var kind in e.ChildNodes) Schreibe(kind, innen, basis, einzug);
var zeilen = Aufraeumen(innen.ToString()).Split('\n');
Block(sb);
sb.Append(string.Join("\n", zeilen.Select(z => "> " + z)));
Block(sb);
return;
}
case "a":
{
var inhalt = Inline(e, basis);
var ziel = AbsoluteUrl(basis, e.GetAttribute("href"));
if (inhalt.Length == 0) return;
sb.Append(ziel is null || ziel == inhalt ? inhalt : $"[{inhalt}]({ziel})");
return;
}
case "strong" or "b":
{
var inhalt = Inline(e, basis);
if (inhalt.Length > 0) sb.Append("**").Append(inhalt).Append("**");
return;
}
case "em" or "i":
{
var inhalt = Inline(e, basis);
if (inhalt.Length > 0) sb.Append('_').Append(inhalt).Append('_');
return;
}
case "img":
{
var alt = Normalisiere(e.GetAttribute("alt") ?? "");
if (alt.Length > 0) sb.Append("[Bild: ").Append(alt).Append(']');
return;
}
case "p" or "div" or "section" or "article" or "main" or "header" or "footer" or "figure"
or "figcaption" or "dl" or "dt" or "dd" or "address" or "details" or "summary" or "body":
Block(sb);
foreach (var kind in e.ChildNodes) Schreibe(kind, sb, basis, einzug);
Block(sb);
return;
default:
foreach (var kind in e.ChildNodes) Schreibe(kind, sb, basis, einzug);
return;
}
}
private static void Liste(IElement liste, StringBuilder sb, Uri? basis, int einzug)
{
Block(sb);
var nummer = 1;
foreach (var li in liste.Children.Where(c => c.LocalName == "li"))
{
var zeile = new StringBuilder();
foreach (var kind in li.ChildNodes)
{
if (kind is IElement { LocalName: "ul" or "ol" } unterliste)
{
var unter = new StringBuilder();
Liste(unterliste, unter, basis, einzug + 1);
zeile.Append('\n').Append(unter.ToString().Trim('\n'));
}
else
{
Schreibe(kind, zeile, basis, einzug);
}
}
var inhalt = zeile.ToString().Trim();
if (inhalt.Length == 0) continue;
var marke = liste.LocalName == "ol" ? $"{nummer++}. " : "- ";
sb.Append(new string(' ', einzug * 2)).Append(marke).Append(Leerraum.Replace(inhalt.Split('\n')[0], " ").Trim());
foreach (var weitere in inhalt.Split('\n').Skip(1))
sb.Append('\n').Append(weitere);
sb.Append('\n');
}
Block(sb);
}
private static void Tabelle(IElement tabelle, StringBuilder sb, Uri? basis)
{
var zeilen = tabelle.QuerySelectorAll("tr")
.Where(tr => tr.Closest("table") == tabelle)
.Select(tr => tr.Children
.Where(c => c.LocalName is "td" or "th")
.Take(MaxTabellenspalten)
.Select(c => Inline(c, basis).Replace("|", "\\|"))
.ToList())
.Where(z => z.Count > 0 && z.Any(c => c.Length > 0))
.Take(MaxTabellenzeilen)
.ToList();
if (zeilen.Count == 0)
return;
Block(sb);
// Eine Spalte oder eine Zeile ist keine Tabelle, sondern Layout - dann als Text.
var spalten = zeilen.Max(z => z.Count);
if (spalten == 1 || zeilen.Count == 1)
{
sb.Append(string.Join("\n", zeilen.Select(z => string.Join(" ", z.Where(c => c.Length > 0)))));
Block(sb);
return;
}
string Zeile(List<string> z) => "| " + string.Join(" | ", z.Concat(Enumerable.Repeat("", spalten - z.Count))) + " |";
sb.Append(Zeile(zeilen[0])).Append('\n');
sb.Append("| ").Append(string.Join(" | ", Enumerable.Repeat("---", spalten))).Append(" |");
foreach (var z in zeilen.Skip(1))
sb.Append('\n').Append(Zeile(z));
Block(sb);
}
private static string Inline(IElement e, Uri? basis)
{
var sb = new StringBuilder();
foreach (var kind in e.ChildNodes) Schreibe(kind, sb, basis, 0);
return Normalisiere(sb.ToString());
}
private static void Block(StringBuilder sb)
{
var ende = sb.Length;
while (ende > 0 && sb[ende - 1] is ' ' or '\t') ende--;
sb.Length = ende;
if (sb.Length == 0) return;
if (sb.Length >= 2 && sb[^1] == '\n' && sb[^2] == '\n') return;
sb.Append(sb[^1] == '\n' ? "\n" : "\n\n");
}
private static string Aufraeumen(string markdown)
{
var aus = new StringBuilder();
var imCode = false;
var leer = 0;
foreach (var roh in markdown.Split('\n'))
{
if (roh.TrimStart().StartsWith("```", StringComparison.Ordinal))
{
imCode = !imCode;
aus.Append(roh.Trim()).Append('\n');
leer = 0;
continue;
}
if (imCode)
{
aus.Append(roh.TrimEnd()).Append('\n');
continue;
}
var zeile = roh.TrimEnd();
var einzug = zeile.Length - zeile.TrimStart().Length;
var kern = Regex.Replace(zeile.TrimStart(), " {2,}", " ");
var istListe = kern.StartsWith("- ", StringComparison.Ordinal) || Regex.IsMatch(kern, @"^\d+\. ");
zeile = (istListe ? new string(' ', einzug) : "") + kern;
if (zeile.Length == 0)
{
if (++leer > 1) continue;
}
else
{
leer = 0;
}
aus.Append(zeile).Append('\n');
}
return aus.ToString().Trim();
}
// ─── Metadaten ───
private static DateTimeOffset? Veroeffentlichung(IDocument doc)
{
var kandidaten = new[]
{
doc.QuerySelector("meta[property='article:published_time']")?.GetAttribute("content"),
doc.QuerySelector("meta[itemprop=datePublished]")?.GetAttribute("content"),
doc.QuerySelector("meta[name=date]")?.GetAttribute("content"),
doc.QuerySelector("time[datetime]")?.GetAttribute("datetime")
};
foreach (var wert in kandidaten)
{
if (DateTimeOffset.TryParse(wert, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var datum))
return datum;
}
return null;
}
private static string? Meta(IDocument doc, string selector)
{
var wert = doc.QuerySelector(selector)?.GetAttribute("content");
return string.IsNullOrWhiteSpace(wert) ? null : Normalisiere(wert);
}
private static string? AbsoluteUrl(Uri? basis, string? href)
{
if (basis is null || string.IsNullOrWhiteSpace(href)) return null;
href = href.Trim();
if (href.StartsWith('#') || href.StartsWith("javascript:", StringComparison.OrdinalIgnoreCase))
return null;
return Uri.TryCreate(basis, href, out var ziel) && ziel.Scheme is "http" or "https" or "mailto"
? ziel.ToString()
: null;
}
private static string Normalisiere(string text) => Leerraum.Replace(text, " ").Trim();
}
+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>
+160
View File
@@ -0,0 +1,160 @@
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace ClawdDotNet.Tools.WebFetch;
/// <summary>Wie viel einer HTML-Seite geliefert wird.</summary>
public enum AbrufModus
{
/// <summary>Hauptinhalt als Markdown; ist er zu kurz, der Volltext (Standard).</summary>
Auto,
/// <summary>Der ganze sichtbare Text der Seite.</summary>
Voll
}
/// <summary>Ein aufbereiteter Abruf, bereit fuer das Modell.</summary>
public sealed record AufbereiteterInhalt(
string Art,
string Bereich,
string Text,
int Gesamtzeichen,
int Offset,
int? NaechsterOffset,
string? Titel,
string? Sprache,
string? KanonischeUrl,
DateTimeOffset? Veroeffentlicht,
string? Hinweis);
/// <summary>
/// Entscheidet, was von einer Antwort beim Modell ankommt (#602).
///
/// <para>JSON, Text und XML bleiben, wie sie sind - die Agenten im Testbetrieb fragen ueber
/// fetch vor allem Schnittstellen ab (Wetter, Kurse, Warnungen). HTML geht durch die
/// Extraktion; liefert die zu wenig (typisch: Seite baut ihren Inhalt per JavaScript), kommt
/// der Volltext mit einem Hinweis. Lange Inhalte werden nicht still abgeschnitten, sondern in
/// Abschnitten geliefert: Die Antwort nennt, ab wo es weitergeht.</para>
/// </summary>
public static class WebInhalt
{
/// <summary>Weniger Hauptinhalt als das gilt als gescheiterte Extraktion.</summary>
public const int MindestZeichenHauptinhalt = 200;
public const int StandardMaxZeichen = 12_000;
/// <summary>
/// Kompakt und ohne Unicode-Escapes. Der Standard-Encoder schreibt jeden Umlaut als
/// <c>ä</c> - sechs Zeichen statt einem, bei deutschen Seiten in jeder Zeile. Die
/// Antwort geht an ein Modell, nicht in eine HTML-Seite; das Escaping schuetzt hier nichts.
/// </summary>
public static JsonSerializerOptions JsonOptionen { get; } = new()
{
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
public static AufbereiteterInhalt Aufbereiten(
string body, string? mediaType, Uri quelle, AbrufModus modus, string? selector,
int offset = 0, int maxZeichen = StandardMaxZeichen, bool mitLinks = false)
{
var art = Art(mediaType, body);
string text;
var bereich = "roh";
string? titel = null, sprache = null, kanonisch = null, hinweis = null;
DateTimeOffset? datum = null;
if (art != "html")
{
text = body.Trim();
}
else if (modus == AbrufModus.Voll)
{
text = HtmlExtraktor.Volltext(body);
bereich = "vollstaendig";
}
else
{
var ex = HtmlExtraktor.Extrahiere(body, quelle, selector, mitLinks);
(titel, sprache, kanonisch, datum, hinweis) = (ex.Titel, ex.Sprache, ex.KanonischeUrl, ex.Veroeffentlicht, ex.Hinweis);
text = ex.Markdown;
bereich = ex.Bereich;
if (text.Length < MindestZeichenHauptinhalt && string.IsNullOrWhiteSpace(selector))
{
var voll = HtmlExtraktor.Volltext(body);
if (voll.Length > text.Length)
{
text = voll;
bereich = "vollstaendig";
hinweis = "Hauptinhalt zu kurz - vollstaendiger Seitentext geliefert. Baut die Seite ihren "
+ "Inhalt per JavaScript auf, fehlt er auch hier.";
}
}
}
var (teil, start, naechster) = Abschnitt(text, offset, maxZeichen);
if (naechster is not null)
{
var fortsetzung = $"Inhalt gekuerzt ({teil.Length} von {text.Length} Zeichen) - mit offset={naechster} weiterlesen, nur falls noetig.";
hinweis = hinweis is null ? fortsetzung : $"{hinweis} {fortsetzung}";
}
return new AufbereiteterInhalt(art, bereich, teil, text.Length, start, naechster,
string.IsNullOrWhiteSpace(titel) ? null : titel, sprache, kanonisch, datum, hinweis);
}
/// <summary>Welche Art Inhalt vorliegt - nach Content-Type, mit Blick auf den Anfang, wenn der fehlt oder luegt.</summary>
public static string Art(string? mediaType, string body)
{
var mt = mediaType?.ToLowerInvariant() ?? "";
var anfang = body.TrimStart();
var siehtAusWieJson = anfang.StartsWith('{') || anfang.StartsWith('[');
if (mt.Contains("json")) return "json";
if (mt.Contains("html")) return siehtAusWieJson ? "json" : "html";
if (mt.Contains("xml")) return "xml";
if (mt.StartsWith("text/", StringComparison.Ordinal)) return "text";
if (siehtAusWieJson) return "json";
if (anfang.StartsWith('<'))
{
return anfang.Contains("<html", StringComparison.OrdinalIgnoreCase)
|| anfang.Contains("<body", StringComparison.OrdinalIgnoreCase)
? "html"
: "xml";
}
return "text";
}
/// <summary>
/// Ein Abschnitt ab <paramref name="offset"/>, moeglichst an einer Absatz- oder Wortgrenze
/// geschnitten. Aneinandergehaengt ergeben die Abschnitte genau den ganzen Text.
/// </summary>
internal static (string Teil, int Start, int? Naechster) Abschnitt(string text, int offset, int maxZeichen)
{
maxZeichen = Math.Max(500, maxZeichen);
var start = Math.Clamp(offset, 0, text.Length);
var ende = Math.Min(text.Length, start + maxZeichen);
if (ende < text.Length)
{
var mindestens = start + (int)(maxZeichen * 0.6);
var absatz = text.LastIndexOf("\n\n", ende - 1, ende - start, StringComparison.Ordinal);
if (absatz >= mindestens)
{
ende = absatz + 2;
}
else
{
var wort = text.LastIndexOfAny([' ', '\n'], ende - 1, ende - start);
if (wort >= mindestens) ende = wort + 1;
}
}
return (text[start..ende], start, ende < text.Length ? ende : null);
}
}