Files
RichardandClaude Opus 5 7dbcc44226 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>
2026-09-15 12:19:27 +02:00

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