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 = """
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
Abschnitt
Länge
Nord
1,2 km
Süd
0,8 km
Beschluss 2026/117
""";
[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("matched nothing");
}
[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("invalid");
}
[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.
Erster Schritt
Unterpunkt A
Unterpunkt B
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);
}