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:
@@ -18,7 +18,7 @@
|
||||
ClawdDotNet.Core.BuildInfo bleibt daneben bestehen: das ist ein von Hand
|
||||
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.
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
<package pattern="FluentFTP" />
|
||||
<package pattern="SQLitePCLRaw.*" />
|
||||
<package pattern="WTelegramClient" />
|
||||
<!-- WebFetch Stufe 1: HTML5-Parser mit CSS-Selektoren (#602) -->
|
||||
<package pattern="AngleSharp" />
|
||||
<!-- Oberflaeche (Avalonia-Portierung) -->
|
||||
<package pattern="Avalonia" />
|
||||
<package pattern="Avalonia.*" />
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user