Files
ClawdDotNet/tests/ClawdDotNet.Tools.Tests/WebFetch/HtmlExtractorTests.cs
T
RichardandClaude Opus 5.5 77062facc9 Stufe 4c (#764): Bezeichner auf Englisch - Typen, Methoden, Felder, Parameter, Locals, Testnamen
Mit Roslyn umbenannt (Verweise folgen, Zeichenketten bleiben unberuehrt).
Nicht umbenannt, weil gespeichert oder ueber die API gebunden (Stufe 4e):
Properties, Record-Parameter, Enum-Member sowie die Parameter der
Endpunkt-Handler (Routen-/Query-Bindung ueber den Namen). Die JSON-Felder
anonymer Objekte bleiben ausdruecklich benannt (JsonFilter: pfad).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:18:43 +02:00

296 lines
12 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Text.Json;
using ClawdDotNet.Tools.WebFetch;
using Shouldly;
namespace ClawdDotNet.Tools.Tests.WebFetch;
/// <summary>
/// Stage 1 of WebFetch (#602): main content as compact Markdown instead of the whole page as
/// running text. The sample pages are self-written and reproduce typical structures.
/// </summary>
public sealed class HtmlExtractorTests
{
private static readonly Uri Basis = new("https://www.beispiel.de/politik/artikel-123.html");
private const string ArticlePage = """
<!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 Article_returns_main_content_without_navigation_cookie_notice_and_scripts()
{
var content = HtmlExtractor.Extract(ArticlePage, Basis);
content.Bereich.ShouldBe("article");
content.Markdown.ShouldContain("Der Stadtrat hat am Montag");
content.Markdown.ShouldNotContain("Wirtschaft");
content.Markdown.ShouldNotContain("Cookies");
content.Markdown.ShouldNotContain("GEHEIMER-SKRIPTTEXT");
content.Markdown.ShouldNotContain("Impressum");
content.Markdown.ShouldNotContain("Zehn Tipps");
}
[Fact]
public void Structure_is_kept_as_markdown()
{
var md = HtmlExtractor.Extract(ArticlePage, 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 The_article_header_is_content_not_page_frame()
=> HtmlExtractor.Extract(ArticlePage, Basis).Markdown.ShouldContain("Von Erika Beispiel");
[Fact]
public void With_links_relative_targets_become_absolute()
=> HtmlExtractor.Extract(ArticlePage, Basis, withLinks: true).Markdown
.ShouldContain("[Beschlussvorlage](https://www.beispiel.de/dokumente/beschluss.pdf)");
[Fact]
public void Without_asking_for_links_only_the_link_text_stays()
{
var md = HtmlExtractor.Extract(ArticlePage, Basis).Markdown;
md.ShouldContain("Laut Beschlussvorlage kostet");
md.ShouldNotContain("https://www.beispiel.de/dokumente");
}
[Fact]
public void Overview_page_with_many_teaser_articles_takes_the_whole_main_area()
{
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 content = HtmlExtractor.Extract(html, Basis);
content.Bereich.ShouldBe("main");
content.Markdown.ShouldContain("## Meldung 1");
content.Markdown.ShouldContain("## Meldung 6");
}
[Fact]
public void Metadata_is_included()
{
var content = HtmlExtractor.Extract(ArticlePage, Basis);
content.Titel.ShouldBe("Stadtrat beschliesst Radweg");
content.Sprache.ShouldBe("de");
content.KanonischeUrl.ShouldBe("https://www.beispiel.de/politik/radweg-123.html");
content.Veroeffentlicht.ShouldBe(new DateTimeOffset(2026, 9, 14, 8, 30, 0, TimeSpan.FromHours(2)));
}
[Fact]
public void The_markdown_text_is_much_shorter_than_the_page()
=> HtmlExtractor.Extract(ArticlePage, Basis).Markdown.Length.ShouldBeLessThan(ArticlePage.Length / 2);
[Fact]
public void A_selector_fetches_only_that_part()
{
var content = HtmlExtractor.Extract(ArticlePage, Basis, "table");
content.Bereich.ShouldBe("selector");
content.Markdown.ShouldStartWith("| Abschnitt | Länge |");
content.Markdown.ShouldNotContain("Stadtrat");
content.Hinweis.ShouldBeNull();
}
[Fact]
public void A_selector_without_matches_falls_back_to_the_main_content_with_a_hint()
{
var content = HtmlExtractor.Extract(ArticlePage, Basis, "#gibt-es-nicht");
content.Bereich.ShouldBe("article");
content.Hinweis.ShouldNotBeNull().ShouldContain("matched nothing");
}
[Fact]
public void An_invalid_selector_does_not_crash_the_tool()
{
var content = HtmlExtractor.Extract(ArticlePage, Basis, "div[[[");
content.Markdown.ShouldContain("Der Stadtrat hat am Montag");
content.Hinweis.ShouldNotBeNull().ShouldContain("invalid");
}
[Fact]
public void Without_article_and_main_text_density_finds_the_content_block()
{
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 content = HtmlExtractor.Extract(html, Basis);
content.Bereich.ShouldBe("hauptinhalt");
content.Markdown.ShouldContain("Messstation im Stadtpark");
content.Markdown.ShouldNotContain("Pollenkalender");
content.Markdown.ShouldNotContain("Gamma");
}
[Fact]
public void Nested_lists_keep_their_indent()
{
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 = HtmlExtractor.Extract(html, Basis).Markdown;
md.ShouldContain("1. Erster Schritt\n - Unterpunkt A\n - Unterpunkt B\n2. Zweiter Schritt");
}
[Fact]
public void Full_text_contains_everything_visible_except_scripts()
{
var full = HtmlExtractor.FullText(ArticlePage);
full.ShouldContain("Wirtschaft");
full.ShouldContain("Der Stadtrat hat am Montag");
full.ShouldNotContain("GEHEIMER-SKRIPTTEXT");
}
}
/// <summary>What arrives at the model for which response (#602).</summary>
public sealed class WebContentTests
{
private static readonly Uri Source = new("https://api.beispiel.de/v1/wetter");
[Theory]
[InlineData("application/json")]
[InlineData("text/html")] // servers that deliver JSON as HTML do exist
[InlineData(null)]
public void Json_stays_unchanged(string? mediaType)
{
const string json = """{"current":{"time":"2026-09-15T12:00","temperature_2m":18.4}}""";
var content = WebContent.Prepare(json, mediaType, Source, FetchMode.Auto, null);
content.Art.ShouldBe("json");
content.Bereich.ShouldBe("roh");
content.Text.ShouldBe(json);
}
[Fact]
public void Too_little_main_content_falls_back_to_the_full_text_with_a_hint()
{
// Overview page: the content is a link list in the navigation, the body only holds a
// placeholder that JavaScript would fill. The extraction rightly removes the navigation
// and keeps almost nothing - then the full text is the better answer.
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 content = WebContent.Prepare(html, "text/html", Source, FetchMode.Auto, null);
content.Bereich.ShouldBe("vollstaendig");
content.Hinweis.ShouldNotBeNull().ShouldContain("JavaScript");
}
[Fact]
public void Mode_full_returns_the_whole_text()
{
const string html = "<html><body><nav>Menue</nav><article><p>Inhalt</p></article></body></html>";
var content = WebContent.Prepare(html, "text/html", Source, FetchMode.Voll, null);
content.Bereich.ShouldBe("vollstaendig");
content.Text.ShouldContain("Menue");
}
[Fact]
public void Long_content_comes_in_sections_that_together_form_the_whole_text()
{
var text = string.Join("\n\n", Enumerable.Range(1, 400).Select(i => $"Absatz {i}: " + new string('x', 60)));
var parts = new List<string>();
int? offset = 0;
while (offset is not null)
{
var content = WebContent.Prepare(text, "text/plain", Source, FetchMode.Auto, null, offset.Value, 5_000);
parts.Add(content.Text);
if (content.NaechsterOffset is not null)
content.Hinweis.ShouldNotBeNull().ShouldContain($"offset={content.NaechsterOffset}");
offset = content.NaechsterOffset;
}
parts.Count.ShouldBeGreaterThan(1);
string.Concat(parts).ShouldBe(text);
parts[0].ShouldEndWith("\n\n", customMessage: "an einer Absatzgrenze geschnitten");
}
[Fact]
public void Umlauts_are_not_escaped_in_the_answer()
{
var json = JsonSerializer.Serialize(new { text = "Grüße aus Köln – 18 °C" }, WebContent.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 The_kind_of_content_is_recognised(string? mediaType, string body, string expected)
=> WebContent.KindOf(mediaType, body).ShouldBe(expected);
}