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>
296 lines
12 KiB
C#
296 lines
12 KiB
C#
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);
|
||
}
|