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