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
+1 -1
View File
@@ -18,7 +18,7 @@
ClawdDotNet.Core.BuildInfo bleibt daneben bestehen: das ist ein von Hand ClawdDotNet.Core.BuildInfo bleibt daneben bestehen: das ist ein von Hand
gefuehrter Zaehler mit Aenderungstext, keine Versionsangabe. gefuehrter Zaehler mit Aenderungstext, keine Versionsangabe.
--> -->
<Version>0.1.6</Version> <Version>0.1.7</Version>
<!-- <!--
Symbole in die Assembly einbetten statt als .pdb daneben zu legen. Symbole in die Assembly einbetten statt als .pdb daneben zu legen.
+2
View File
@@ -26,6 +26,8 @@
<package pattern="FluentFTP" /> <package pattern="FluentFTP" />
<package pattern="SQLitePCLRaw.*" /> <package pattern="SQLitePCLRaw.*" />
<package pattern="WTelegramClient" /> <package pattern="WTelegramClient" />
<!-- WebFetch Stufe 1: HTML5-Parser mit CSS-Selektoren (#602) -->
<package pattern="AngleSharp" />
<!-- Oberflaeche (Avalonia-Portierung) --> <!-- Oberflaeche (Avalonia-Portierung) -->
<package pattern="Avalonia" /> <package pattern="Avalonia" />
<package pattern="Avalonia.*" /> <package pattern="Avalonia.*" />
@@ -4,6 +4,10 @@
<ProjectReference Include="..\ClawdDotNet.Core\ClawdDotNet.Core.csproj" /> <ProjectReference Include="..\ClawdDotNet.Core\ClawdDotNet.Core.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<PackageReference Include="AngleSharp" Version="1.8.1" />
</ItemGroup>
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <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 Name => "WebFetch";
public string Description => """ public string Description => """
Ruft statische Webseiten oder RSS-Feeds ab und extrahiert Text + Timestamps. Ruft Webseiten, JSON-/Text-Schnittstellen und RSS/Atom-Feeds ab. Nur Domains aus der
Nur Domains aus der Whitelist erlaubt. Kein JavaScript-Rendering. Whitelist, kein JavaScript-Rendering.
Aktionen: fetch, rss
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> /// <summary>
@@ -44,11 +56,19 @@ public sealed class WebFetchTool : IAgentTool
"action": { "action": {
"type": "string", "type": "string",
"enum": ["fetch", "rss"], "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" }, "url": { "type": "string" },
"selector":{ "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(); """).RootElement.Clone();
@@ -79,8 +99,8 @@ public sealed class WebFetchTool : IAgentTool
return action switch return action switch
{ {
"fetch" => await FetchPageAsync(url, allowedDomains, config, ct), "fetch" => await FetchPageAsync(url, allowedDomains, config, input, ct),
"rss" => await FetchRssAsync(url, allowedDomains, ct), "rss" => await FetchRssAsync(url, allowedDomains, input, ct),
_ => ToolResult.Fail($"Unbekannte Aktion: {action}") _ => ToolResult.Fail($"Unbekannte Aktion: {action}")
}; };
} }
@@ -93,7 +113,7 @@ public sealed class WebFetchTool : IAgentTool
private async Task<ToolResult> FetchPageAsync( private async Task<ToolResult> FetchPageAsync(
string url, IReadOnlyCollection<string> allowedDomains, string url, IReadOnlyCollection<string> allowedDomains,
IReadOnlyDictionary<string, object?> config, CancellationToken ct) IReadOnlyDictionary<string, object?> config, JsonElement input, CancellationToken ct)
{ {
using var http = CreateHttpClient(config); using var http = CreateHttpClient(config);
var fetchedAt = DateTime.UtcNow; var fetchedAt = DateTime.UtcNow;
@@ -113,30 +133,71 @@ public sealed class WebFetchTool : IAgentTool
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
return ToolResult.Fail($"Seite nicht erreichbar: {url} → HTTP {(int)response.StatusCode} {response.ReasonPhrase}"); return ToolResult.Fail($"Seite nicht erreichbar: {url} → HTTP {(int)response.StatusCode} {response.ReasonPhrase}");
DateTime? dataAsOf = response.Content.Headers.LastModified?.UtcDateTime var body = await response.Content.ReadAsStringAsync(ct);
?? response.Headers.Date?.UtcDateTime;
var html = await response.Content.ReadAsStringAsync(ct);
var maxKb = GetConfigInt(config, "maxResponseKb", 512); 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); // #602: Relative Links gegen die Adresse aufloesen, von der die Seite tatsaechlich kam.
if (dataAsOf == null) dataAsOf = ExtractDateFromHtml(html); 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 var result = new
{ {
fetchedAt = fetchedAt, fetchedAt,
dataAsOf = dataAsOf, dataAsOf = response.Content.Headers.LastModified?.UtcDateTime ?? inhalt.Veroeffentlicht?.UtcDateTime,
source = url, 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( 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?>()); using var http = CreateHttpClient(new Dictionary<string, object?>());
var fetchedAt = DateTime.UtcNow; var fetchedAt = DateTime.UtcNow;
@@ -167,27 +228,27 @@ public sealed class WebFetchTool : IAgentTool
if (doc.Root?.Name.LocalName == "rss") 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 items.Add(new
{ {
title = item.Element("title")?.Value, title = item.Element("title")?.Value,
link = item.Element("link")?.Value, link = item.Element("link")?.Value,
pubDate = item.Element("pubDate")?.Value, pubDate = item.Element("pubDate")?.Value,
description = StripHtml(item.Element("description")?.Value ?? "") description = Kappen(StripHtml(item.Element("description")?.Value ?? ""), MaxRssBeschreibung)
}); });
} }
} }
else // Atom 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 items.Add(new
{ {
title = entry.Element(ns + "title")?.Value, title = entry.Element(ns + "title")?.Value,
link = entry.Element(ns + "link")?.Attribute("href")?.Value, link = entry.Element(ns + "link")?.Attribute("href")?.Value,
pubDate = entry.Element(ns + "updated")?.Value ?? entry.Element(ns + "published")?.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 } 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) private static string StripHtml(string html)
@@ -224,26 +285,6 @@ public sealed class WebFetchTool : IAgentTool
return html; 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; private const int MaxRedirects = 5;
/// <summary> /// <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);
}
}
@@ -0,0 +1,295 @@
using System.Text.Json;
using ClawdDotNet.Tools.WebFetch;
using Shouldly;
namespace ClawdDotNet.Tools.Tests.WebFetch;
/// <summary>
/// Stufe 1 von WebFetch (#602): Hauptinhalt als kompaktes Markdown statt der ganzen Seite als
/// Fliesstext. Die Beispielseiten sind selbst geschrieben und bilden typische Aufbauten nach.
/// </summary>
public sealed class HtmlExtraktorTests
{
private static readonly Uri Basis = new("https://www.beispiel.de/politik/artikel-123.html");
private const string Artikelseite = """
<!DOCTYPE html>
<html lang="de">
<head>
<title>Stadtrat beschliesst Radweg | Beispielzeitung</title>
<meta property="og:title" content="Stadtrat beschliesst Radweg">
<meta property="article:published_time" content="2026-09-14T08:30:00+02:00">
<link rel="canonical" href="/politik/radweg-123.html">
<style>.x { color: red }</style>
<script>window.tracking = "GEHEIMER-SKRIPTTEXT";</script>
</head>
<body>
<header class="seitenkopf"><a href="/">Startseite</a> <a href="/sport">Sport</a></header>
<nav><ul><li><a href="/politik">Politik</a></li><li><a href="/wirtschaft">Wirtschaft</a></li></ul></nav>
<div id="cookie-consent">Wir verwenden Cookies. Alle akzeptieren?</div>
<main>
<article>
<header><h1>Stadtrat beschliesst Radweg</h1><p>Von Erika Beispiel</p></header>
<p>Der Stadtrat hat am Montag mit großer Mehrheit den Bau eines Radwegs entlang der Hauptstraße beschlossen.
Die Arbeiten sollen im <strong>Frühjahr</strong> beginnen.</p>
<h2>Kosten und Zeitplan</h2>
<p>Laut <a href="/dokumente/beschluss.pdf">Beschlussvorlage</a> kostet das Vorhaben rund 2,4 Millionen Euro.</p>
<ul>
<li>Planung bis März</li>
<li>Bau von April bis Oktober</li>
</ul>
<table>
<tr><th>Abschnitt</th><th>Länge</th></tr>
<tr><td>Nord</td><td>1,2 km</td></tr>
<tr><td>Süd</td><td>0,8 km</td></tr>
</table>
<pre>Beschluss 2026/117</pre>
</article>
<aside class="related">Das könnte Sie auch interessieren: Zehn Tipps fürs Radfahren</aside>
</main>
<footer>Impressum · Datenschutz · Kontakt</footer>
</body>
</html>
""";
[Fact]
public void Artikel_liefert_den_Hauptinhalt_ohne_Navigation_Cookiehinweis_und_Skripte()
{
var inhalt = HtmlExtraktor.Extrahiere(Artikelseite, Basis);
inhalt.Bereich.ShouldBe("article");
inhalt.Markdown.ShouldContain("Der Stadtrat hat am Montag");
inhalt.Markdown.ShouldNotContain("Wirtschaft");
inhalt.Markdown.ShouldNotContain("Cookies");
inhalt.Markdown.ShouldNotContain("GEHEIMER-SKRIPTTEXT");
inhalt.Markdown.ShouldNotContain("Impressum");
inhalt.Markdown.ShouldNotContain("Zehn Tipps");
}
[Fact]
public void Struktur_bleibt_als_Markdown_erhalten()
{
var md = HtmlExtraktor.Extrahiere(Artikelseite, Basis).Markdown;
md.ShouldContain("# Stadtrat beschliesst Radweg");
md.ShouldContain("## Kosten und Zeitplan");
md.ShouldContain("**Frühjahr**");
md.ShouldContain("- Planung bis März");
md.ShouldContain("| Abschnitt | Länge |");
md.ShouldContain("| --- | --- |");
md.ShouldContain("| Nord | 1,2 km |");
md.ShouldContain("```\nBeschluss 2026/117\n```");
}
[Fact]
public void Die_Kopfzeile_des_Artikels_ist_Inhalt_nicht_Seitenrahmen()
=> HtmlExtraktor.Extrahiere(Artikelseite, Basis).Markdown.ShouldContain("Von Erika Beispiel");
[Fact]
public void Mit_Links_werden_relative_Ziele_absolut()
=> HtmlExtraktor.Extrahiere(Artikelseite, Basis, mitLinks: true).Markdown
.ShouldContain("[Beschlussvorlage](https://www.beispiel.de/dokumente/beschluss.pdf)");
[Fact]
public void Ohne_Wunsch_nach_Links_bleibt_nur_der_Linktext()
{
var md = HtmlExtraktor.Extrahiere(Artikelseite, Basis).Markdown;
md.ShouldContain("Laut Beschlussvorlage kostet");
md.ShouldNotContain("https://www.beispiel.de/dokumente");
}
[Fact]
public void Uebersichtsseite_mit_vielen_Teaser_Artikeln_nimmt_den_ganzen_Hauptbereich()
{
var teaser = string.Concat(Enumerable.Range(1, 6).Select(i =>
$"<article><h2>Meldung {i}</h2><p>Kurzfassung der Meldung {i} mit so viel Text, dass die Kachel fuer sich allein "
+ "die Mindestlaenge fuer einen Bereich erreicht und damit als Kandidat in Frage kaeme, obwohl sie nur einer von vielen ist.</p></article>"));
var html = $"<html><body><nav>Menue</nav><main>{teaser}</main></body></html>";
var inhalt = HtmlExtraktor.Extrahiere(html, Basis);
inhalt.Bereich.ShouldBe("main");
inhalt.Markdown.ShouldContain("## Meldung 1");
inhalt.Markdown.ShouldContain("## Meldung 6");
}
[Fact]
public void Metadaten_werden_mitgeliefert()
{
var inhalt = HtmlExtraktor.Extrahiere(Artikelseite, Basis);
inhalt.Titel.ShouldBe("Stadtrat beschliesst Radweg");
inhalt.Sprache.ShouldBe("de");
inhalt.KanonischeUrl.ShouldBe("https://www.beispiel.de/politik/radweg-123.html");
inhalt.Veroeffentlicht.ShouldBe(new DateTimeOffset(2026, 9, 14, 8, 30, 0, TimeSpan.FromHours(2)));
}
[Fact]
public void Der_Markdown_Text_ist_deutlich_kuerzer_als_die_Seite()
=> HtmlExtraktor.Extrahiere(Artikelseite, Basis).Markdown.Length.ShouldBeLessThan(Artikelseite.Length / 2);
[Fact]
public void Ein_Selektor_holt_gezielt_nur_den_Teil()
{
var inhalt = HtmlExtraktor.Extrahiere(Artikelseite, Basis, "table");
inhalt.Bereich.ShouldBe("selector");
inhalt.Markdown.ShouldStartWith("| Abschnitt | Länge |");
inhalt.Markdown.ShouldNotContain("Stadtrat");
inhalt.Hinweis.ShouldBeNull();
}
[Fact]
public void Ein_Selektor_ohne_Treffer_faellt_mit_Hinweis_auf_den_Hauptinhalt_zurueck()
{
var inhalt = HtmlExtraktor.Extrahiere(Artikelseite, Basis, "#gibt-es-nicht");
inhalt.Bereich.ShouldBe("article");
inhalt.Hinweis.ShouldNotBeNull().ShouldContain("traf nichts");
}
[Fact]
public void Ein_ungueltiger_Selektor_bringt_das_Werkzeug_nicht_zum_Absturz()
{
var inhalt = HtmlExtraktor.Extrahiere(Artikelseite, Basis, "div[[[");
inhalt.Markdown.ShouldContain("Der Stadtrat hat am Montag");
inhalt.Hinweis.ShouldNotBeNull().ShouldContain("ungueltig");
}
[Fact]
public void Ohne_article_und_main_findet_die_Textdichte_den_Inhaltsblock()
{
const string html = """
<html><body>
<div class="menue"><a href="/a">Alpha</a> <a href="/b">Beta</a> <a href="/c">Gamma</a> <a href="/d">Delta</a></div>
<div class="inhalt">
<p>Die Messstation im Stadtpark meldete gestern die höchste Temperatur des Monats. Fachleute erwarten,
dass die warme Phase noch einige Tage anhält und erst am Wochenende kühlere Luft ankommt.</p>
<p>Für Allergiker bleibt die Belastung durch Gräserpollen hoch. Die Behörde empfiehlt, Fenster in den
Morgenstunden geschlossen zu halten und Wäsche nicht im Freien zu trocknen.</p>
</div>
<div class="linkliste"><a href="/1">Mehr Wetter</a> <a href="/2">Pollenkalender</a> <a href="/3">Archiv</a></div>
</body></html>
""";
var inhalt = HtmlExtraktor.Extrahiere(html, Basis);
inhalt.Bereich.ShouldBe("hauptinhalt");
inhalt.Markdown.ShouldContain("Messstation im Stadtpark");
inhalt.Markdown.ShouldNotContain("Pollenkalender");
inhalt.Markdown.ShouldNotContain("Gamma");
}
[Fact]
public void Verschachtelte_Listen_behalten_ihren_Einzug()
{
const string html = """
<html><body><main>
<p>Einleitung mit genug Text, damit der Bereich als Hauptinhalt zaehlt und nicht als leer uebergangen wird. Noch ein Satz zur Sicherheit, damit es sicher reicht.</p>
<ol><li>Erster Schritt<ul><li>Unterpunkt A</li><li>Unterpunkt B</li></ul></li><li>Zweiter Schritt</li></ol>
</main></body></html>
""";
var md = HtmlExtraktor.Extrahiere(html, Basis).Markdown;
md.ShouldContain("1. Erster Schritt\n - Unterpunkt A\n - Unterpunkt B\n2. Zweiter Schritt");
}
[Fact]
public void Volltext_enthaelt_alles_Sichtbare_ausser_Skripten()
{
var voll = HtmlExtraktor.Volltext(Artikelseite);
voll.ShouldContain("Wirtschaft");
voll.ShouldContain("Der Stadtrat hat am Montag");
voll.ShouldNotContain("GEHEIMER-SKRIPTTEXT");
}
}
/// <summary>Was bei welcher Antwort beim Modell ankommt (#602).</summary>
public sealed class WebInhaltTests
{
private static readonly Uri Quelle = new("https://api.beispiel.de/v1/wetter");
[Theory]
[InlineData("application/json")]
[InlineData("text/html")] // Server, die JSON als HTML ausliefern, gibt es
[InlineData(null)]
public void Json_bleibt_unveraendert(string? mediaType)
{
const string json = """{"current":{"time":"2026-09-15T12:00","temperature_2m":18.4}}""";
var inhalt = WebInhalt.Aufbereiten(json, mediaType, Quelle, AbrufModus.Auto, null);
inhalt.Art.ShouldBe("json");
inhalt.Bereich.ShouldBe("roh");
inhalt.Text.ShouldBe(json);
}
[Fact]
public void Zu_wenig_Hauptinhalt_faellt_mit_Hinweis_auf_den_Volltext_zurueck()
{
// Uebersichtsseite: Der Inhalt ist eine Linkliste in der Navigation, im Body steht nur ein
// Platzhalter, den JavaScript fuellen wuerde. Die Extraktion entfernt die Navigation zu Recht
// und behaelt fast nichts - dann ist der Volltext die bessere Antwort.
var html = "<html><body><nav><ul>"
+ string.Concat(Enumerable.Range(1, 60).Select(i => $"<li><a href=\"/rubrik/{i}\">Rubrik {i}</a></li>"))
+ "</ul></nav><div id=\"app\">Lade …</div></body></html>";
var inhalt = WebInhalt.Aufbereiten(html, "text/html", Quelle, AbrufModus.Auto, null);
inhalt.Bereich.ShouldBe("vollstaendig");
inhalt.Hinweis.ShouldNotBeNull().ShouldContain("JavaScript");
}
[Fact]
public void Modus_voll_liefert_den_ganzen_Text()
{
const string html = "<html><body><nav>Menue</nav><article><p>Inhalt</p></article></body></html>";
var inhalt = WebInhalt.Aufbereiten(html, "text/html", Quelle, AbrufModus.Voll, null);
inhalt.Bereich.ShouldBe("vollstaendig");
inhalt.Text.ShouldContain("Menue");
}
[Fact]
public void Lange_Inhalte_kommen_in_Abschnitten_die_zusammen_den_ganzen_Text_ergeben()
{
var text = string.Join("\n\n", Enumerable.Range(1, 400).Select(i => $"Absatz {i}: " + new string('x', 60)));
var teile = new List<string>();
int? offset = 0;
while (offset is not null)
{
var inhalt = WebInhalt.Aufbereiten(text, "text/plain", Quelle, AbrufModus.Auto, null, offset.Value, 5_000);
teile.Add(inhalt.Text);
if (inhalt.NaechsterOffset is not null)
inhalt.Hinweis.ShouldNotBeNull().ShouldContain($"offset={inhalt.NaechsterOffset}");
offset = inhalt.NaechsterOffset;
}
teile.Count.ShouldBeGreaterThan(1);
string.Concat(teile).ShouldBe(text);
teile[0].ShouldEndWith("\n\n", customMessage: "an einer Absatzgrenze geschnitten");
}
[Fact]
public void Umlaute_werden_in_der_Antwort_nicht_escaped()
{
var json = JsonSerializer.Serialize(new { text = "Grüße aus Köln – 18 °C" }, WebInhalt.JsonOptionen);
json.ShouldContain("Grüße aus Köln – 18 °C");
json.ShouldNotContain("\\u00", Case.Sensitive);
}
[Theory]
[InlineData("application/rss+xml", "<rss/>", "xml")]
[InlineData("text/csv", "a,b", "text")]
[InlineData(null, "<html><body>x</body></html>", "html")]
[InlineData(null, "<?xml version=\"1.0\"?><feed/>", "xml")]
public void Die_Art_des_Inhalts_wird_erkannt(string? mediaType, string body, string erwartet)
=> WebInhalt.Art(mediaType, body).ShouldBe(erwartet);
}