diff --git a/Directory.Build.props b/Directory.Build.props index b27cf73..bcabeb8 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -18,7 +18,7 @@ ClawdDotNet.Core.BuildInfo bleibt daneben bestehen: das ist ein von Hand gefuehrter Zaehler mit Aenderungstext, keine Versionsangabe. --> - 0.1.6 + 0.1.7 + diff --git a/src/ClawdDotNet.Tools.WebFetch/ClawdDotNet.Tools.WebFetch.csproj b/src/ClawdDotNet.Tools.WebFetch/ClawdDotNet.Tools.WebFetch.csproj index acd15d3..6cbd48c 100644 --- a/src/ClawdDotNet.Tools.WebFetch/ClawdDotNet.Tools.WebFetch.csproj +++ b/src/ClawdDotNet.Tools.WebFetch/ClawdDotNet.Tools.WebFetch.csproj @@ -4,6 +4,10 @@ + + + + net10.0 enable diff --git a/src/ClawdDotNet.Tools.WebFetch/HtmlExtraktor.cs b/src/ClawdDotNet.Tools.WebFetch/HtmlExtraktor.cs new file mode 100644 index 0000000..e44b279 --- /dev/null +++ b/src/ClawdDotNet.Tools.WebFetch/HtmlExtraktor.cs @@ -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; + +/// Was die Extraktion aus einer Seite gewonnen hat. +public sealed record ExtrahierterInhalt( + string Titel, + string? Sprache, + string? KanonischeUrl, + DateTimeOffset? Veroeffentlicht, + string Bereich, + string Markdown, + string? Hinweis); + +/// +/// Holt den Hauptinhalt einer HTML-Seite als kompaktes Markdown (#602, Stufe 1). +/// +/// 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. +/// +/// Bereichswahl vom Genauen zum Groben: ein angegebener Selektor, sonst das +/// article mit dem meisten Text, sonst main, sonst der Block mit der hoechsten +/// Textdichte (Absatztext abzueglich Linktext), sonst der ganze Body. +/// +public static class HtmlExtraktor +{ + /// Unter so viel Text gilt ein article/main als leer und wird uebergangen. + private const int MindestTextBereich = 200; + + /// So viel des Seitentexts muss ein article tragen, um als Hauptinhalt zu gelten. + 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); + + /// + /// Linkziele als [Text](URL) ausgeben. Standard aus: Auf linkreichen Seiten + /// (Wikipedia) verdoppelten die Adressen den Text, und die meisten Auftraege brauchen den + /// Inhalt, nicht die Ziele. + /// + 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 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); + } + + /// Der ganze sichtbare Text der Seite, ohne Skripte - Stufe "vollstaendig". + 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 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"); + } + + /// Absatztext zaehlt, uebriger Text und Linktext ziehen ab - so gewinnt der Artikel gegen die Linkliste. + 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 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(); +} diff --git a/src/ClawdDotNet.Tools.WebFetch/WebFetchTool.cs b/src/ClawdDotNet.Tools.WebFetch/WebFetchTool.cs index 5b7c46a..d86dc69 100644 --- a/src/ClawdDotNet.Tools.WebFetch/WebFetchTool.cs +++ b/src/ClawdDotNet.Tools.WebFetch/WebFetchTool.cs @@ -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. """; /// @@ -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 FetchPageAsync( string url, IReadOnlyCollection allowedDomains, - IReadOnlyDictionary config, CancellationToken ct) + IReadOnlyDictionary 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; + + /// Beschreibungen aus Feeds sind oft ganze Artikel - fuer die Auswahl reicht der Anfang. + private const int MaxRssBeschreibung = 400; + + private static string Kappen(string text, int max) + => text.Length <= max ? text : text[..max].TrimEnd() + " …"; + private async Task FetchRssAsync( - string url, IReadOnlyCollection allowedDomains, CancellationToken ct) + string url, IReadOnlyCollection allowedDomains, JsonElement input, CancellationToken ct) { + var limit = Math.Clamp(GetInt(input, "limit") ?? 20, 1, 50); using var http = CreateHttpClient(new Dictionary()); 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[] - { - " diff --git a/src/ClawdDotNet.Tools.WebFetch/WebInhalt.cs b/src/ClawdDotNet.Tools.WebFetch/WebInhalt.cs new file mode 100644 index 0000000..7f34a7b --- /dev/null +++ b/src/ClawdDotNet.Tools.WebFetch/WebInhalt.cs @@ -0,0 +1,160 @@ +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ClawdDotNet.Tools.WebFetch; + +/// Wie viel einer HTML-Seite geliefert wird. +public enum AbrufModus +{ + /// Hauptinhalt als Markdown; ist er zu kurz, der Volltext (Standard). + Auto, + + /// Der ganze sichtbare Text der Seite. + Voll +} + +/// Ein aufbereiteter Abruf, bereit fuer das Modell. +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); + +/// +/// Entscheidet, was von einer Antwort beim Modell ankommt (#602). +/// +/// 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. +/// +public static class WebInhalt +{ + /// Weniger Hauptinhalt als das gilt als gescheiterte Extraktion. + public const int MindestZeichenHauptinhalt = 200; + + public const int StandardMaxZeichen = 12_000; + + /// + /// Kompakt und ohne Unicode-Escapes. Der Standard-Encoder schreibt jeden Umlaut als + /// ä - 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. + /// + 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); + } + + /// Welche Art Inhalt vorliegt - nach Content-Type, mit Blick auf den Anfang, wenn der fehlt oder luegt. + 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(" + /// Ein Abschnitt ab , moeglichst an einer Absatz- oder Wortgrenze + /// geschnitten. Aneinandergehaengt ergeben die Abschnitte genau den ganzen Text. + /// + 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); + } +} diff --git a/tests/ClawdDotNet.Tools.Tests/WebFetch/HtmlExtraktorTests.cs b/tests/ClawdDotNet.Tools.Tests/WebFetch/HtmlExtraktorTests.cs new file mode 100644 index 0000000..3ab8397 --- /dev/null +++ b/tests/ClawdDotNet.Tools.Tests/WebFetch/HtmlExtraktorTests.cs @@ -0,0 +1,295 @@ +using System.Text.Json; +using ClawdDotNet.Tools.WebFetch; +using Shouldly; + +namespace ClawdDotNet.Tools.Tests.WebFetch; + +/// +/// 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. +/// +public sealed class HtmlExtraktorTests +{ + private static readonly Uri Basis = new("https://www.beispiel.de/politik/artikel-123.html"); + + private const string Artikelseite = """ + + + + Stadtrat beschliesst Radweg | Beispielzeitung + + + + + + + +
Startseite Sport
+ + +
+
+

Stadtrat beschliesst Radweg

Von Erika Beispiel

+

Der Stadtrat hat am Montag mit großer Mehrheit den Bau eines Radwegs entlang der Hauptstraße beschlossen. + Die Arbeiten sollen im Frühjahr beginnen.

+

Kosten und Zeitplan

+

Laut Beschlussvorlage kostet das Vorhaben rund 2,4 Millionen Euro.

+
    +
  • Planung bis März
  • +
  • Bau von April bis Oktober
  • +
+ + + + +
AbschnittLänge
Nord1,2 km
Süd0,8 km
+
Beschluss 2026/117
+
+ +
+
Impressum · Datenschutz · Kontakt
+ + + """; + + [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 => + $"

Meldung {i}

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.

")); + var html = $"
{teaser}
"; + + 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 = """ + + +
+

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.

+

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.

+
+ + + """; + + 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 = """ +
+

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.

+
  1. Erster Schritt
    • Unterpunkt A
    • Unterpunkt B
  2. Zweiter Schritt
+
+ """; + + 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"); + } +} + +/// Was bei welcher Antwort beim Modell ankommt (#602). +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 = "
Lade …
"; + + 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 = "

Inhalt

"; + + 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(); + 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", "", "xml")] + [InlineData("text/csv", "a,b", "text")] + [InlineData(null, "x", "html")] + [InlineData(null, "", "xml")] + public void Die_Art_des_Inhalts_wird_erkannt(string? mediaType, string body, string erwartet) + => WebInhalt.Art(mediaType, body).ShouldBe(erwartet); +}