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
@@ -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);
}