Files
ClawdDotNet/src/ClawdDotNet.Tools.WebFetch/HtmlExtraktor.cs
T
RichardandClaude Opus 5.5 ad71682c2d i18n: Englisch als Hauptsprache, Sprachpakete fuer Desktop und WebUI (#661)
Umbau durch Gemini Flash, Durchsicht und Nacharbeit durch Claude.

Umfang:
- Desktop: LocalizationManager (JSON-Pakete, Rueckfall auf Englisch,
  Umschalten zur Laufzeit), Localize-Markup-Erweiterung, Sprachwahl in den
  Einstellungen (AppSettings.Language, Vorgabe "en"); 317 Schluessel je Sprache
- WebUI: alle Ansichten auf t()/data-i18n, 656 Schluessel je Sprache
- Agenten-Werkzeuge: Beschreibungen, Schemata und Meldungen auf Englisch
- Tests an die englischen Meldungen angepasst, keine Pruefung abgeschwaecht

Nacharbeit bei der Durchsicht:
- ~25 Kommentarbloecke mit Begruendungen waren ersatzlos entfallen, darunter
  sicherheitsrelevante (NTFS-Datenstrom beim shared:-Praefix, Bremse gegen
  Agentenschleifen, doppelter POST beim Anlegen einer Instanz). Wieder
  eingesetzt: auf Englisch, wo die Datei umgestellt ist, sonst im Wortlaut.
- Drei WebUI-Schluessel fehlten (logs.paused, instances.instanceOfflineError,
  instances.noInstanceActiveError). Weil t() bei fehlendem Schluessel den
  Schluessel selbst liefert, griff der "|| err.message"-Rueckfall nie — die
  Oberflaeche haette den Schluesselnamen angezeigt.

Geprueft: Schluesselgleichheit en/de, Platzhalter je Schluessel, jeder im
Code benutzte Schluessel vorhanden, node --check fuer alle Module,
1.220 Tests gruen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 09:43:51 +02:00

461 lines
17 KiB
C#

using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
using AngleSharp.Dom;
using AngleSharp.Html.Parser;
namespace ClawdDotNet.Tools.WebFetch;
/// <summary>Was die Extraktion aus einer Seite gewonnen hat.</summary>
public sealed record ExtrahierterInhalt(
string Titel,
string? Sprache,
string? KanonischeUrl,
DateTimeOffset? Veroeffentlicht,
string Bereich,
string Markdown,
string? Hinweis);
/// <summary>
/// Holt den Hauptinhalt einer HTML-Seite als kompaktes Markdown (#602, Stufe 1).
///
/// <para>Vorher wurde die ganze Seite per Regex zu Fliesstext: Navigation, Fusszeile und
/// Cookie-Hinweis gingen mit ins Modell, Ueberschriften, Listen, Tabellen und Links gingen
/// verloren - und bei 16.000 Zeichen war Schluss, oft bevor der eigentliche Inhalt anfing.
/// Hier entscheidet ein richtiger HTML5-Parser, was Inhalt ist, und die Struktur bleibt als
/// Markdown lesbar, das kaum Zeichen kostet.</para>
///
/// <para>Bereichswahl vom Genauen zum Groben: ein angegebener Selektor, sonst das
/// <c>article</c> mit dem meisten Text, sonst <c>main</c>, sonst der Block mit der hoechsten
/// Textdichte (Absatztext abzueglich Linktext), sonst der ganze Body.</para>
/// </summary>
public static class HtmlExtraktor
{
/// <summary>Unter so viel Text gilt ein article/main als leer und wird uebergangen.</summary>
private const int MindestTextBereich = 200;
/// <summary>So viel des Seitentexts muss ein article tragen, um als Hauptinhalt zu gelten.</summary>
private const double AnteilArtikel = 0.4;
private const int MaxTabellenzeilen = 200;
private const int MaxTabellenspalten = 12;
private static readonly string[] Stoerelemente =
[
"script", "style", "noscript", "template", "svg", "canvas", "iframe", "object", "embed",
"form", "button", "input", "select", "textarea", "dialog", "[hidden]", "[aria-hidden=true]"
];
private static readonly string[] Seitenrahmen =
[
"nav", "aside", "header", "footer",
"[role=navigation]", "[role=banner]", "[role=contentinfo]", "[role=complementary]", "[role=search]"
];
private static readonly Regex StoerKlasse = new(
@"(^|[\s_-])(cookie|consent|gdpr|newsletter|share|sharing|social|advert|anzeige|werbung|banner|promo|related|breadcrumb|sidebar|comments?|popup|modal|paywall)([\s_-]|$)",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
private static readonly Regex Leerraum = new(@"\s+", RegexOptions.CultureInvariant);
/// <param name="mitLinks">
/// Linkziele als <c>[Text](URL)</c> ausgeben. Standard aus: Auf linkreichen Seiten
/// (Wikipedia) verdoppelten die Adressen den Text, und die meisten Auftraege brauchen den
/// Inhalt, nicht die Ziele.
/// </param>
public static ExtrahierterInhalt Extrahiere(string html, Uri basis, string? selector = null, bool mitLinks = false)
{
var doc = new HtmlParser().ParseDocument(html);
var titel = Meta(doc, "meta[property='og:title']") ?? Normalisiere(doc.Title ?? "");
var sprache = doc.DocumentElement.GetAttribute("lang");
var kanonisch = AbsoluteUrl(basis, doc.QuerySelector("link[rel=canonical]")?.GetAttribute("href"));
var datum = Veroeffentlichung(doc);
foreach (var e in doc.QuerySelectorAll(string.Join(",", Stoerelemente)).ToList())
e.Remove();
string? hinweis = null;
IReadOnlyList<IElement> bereich = [];
var bereichsName = "";
if (!string.IsNullOrWhiteSpace(selector))
{
try
{
bereich = doc.QuerySelectorAll(selector).ToList();
bereichsName = "selector";
if (bereich.Count == 0)
hinweis = $"Selector '{selector}' matched nothing - main content used.";
}
catch (Exception ex) when (ex is DomException or ArgumentException)
{
hinweis = $"Selector '{selector}' is invalid ({ex.Message}) - main content used.";
}
}
if (bereich.Count == 0)
{
EntferneSeitenrahmen(doc);
(bereich, bereichsName) = Hauptinhalt(doc);
}
var sb = new StringBuilder();
foreach (var element in bereich)
{
Block(sb);
// Ohne Basis loest AbsoluteUrl nichts auf - dann bleibt vom Link nur der Text.
Schreibe(element, sb, mitLinks ? basis : null, einzug: 0);
}
return new ExtrahierterInhalt(titel, string.IsNullOrWhiteSpace(sprache) ? null : sprache, kanonisch, datum,
bereichsName, Aufraeumen(sb.ToString()), hinweis);
}
/// <summary>Der ganze sichtbare Text der Seite, ohne Skripte - Stufe "vollstaendig".</summary>
public static string Volltext(string html)
{
var doc = new HtmlParser().ParseDocument(html);
foreach (var e in doc.QuerySelectorAll(string.Join(",", Stoerelemente)).ToList())
e.Remove();
return Normalisiere(doc.Body?.TextContent ?? doc.DocumentElement.TextContent);
}
// ─── Bereichswahl ───
private static void EntferneSeitenrahmen(IDocument doc)
{
foreach (var e in doc.QuerySelectorAll(string.Join(",", Seitenrahmen)).ToList())
{
// Die Kopfzeile eines Artikels (h1, Autor, Datum) ist Inhalt, nicht Seitenrahmen.
if (e.LocalName is "header" or "footer" && e.Closest("article, main, [role=main]") is not null)
continue;
e.Remove();
}
foreach (var e in doc.QuerySelectorAll("div, section, aside, ul, p, span, figure").ToList())
{
if (e.Closest("article, main") == e) continue;
var kennung = $"{e.Id} {e.ClassName}";
if (StoerKlasse.IsMatch(kennung) && e.QuerySelector("h1") is null)
e.Remove();
}
}
private static (IReadOnlyList<IElement> Bereich, string Name) Hauptinhalt(IDocument doc)
{
var main = doc.QuerySelector("main, [role=main]");
var seitentext = Normalisiere((main ?? doc.Body ?? doc.DocumentElement).TextContent).Length;
// Ein article ist nur dann "der" Inhalt, wenn er einen guten Teil der Seite ausmacht.
// Uebersichtsseiten bestehen aus vielen kleinen article-Kacheln - bei heise.de gewann
// sonst ein einzelner Teaser mit 398 Zeichen, und der Rest der Seite war weg.
var artikel = doc.QuerySelectorAll("article")
.Select(a => (Element: a, Laenge: Normalisiere(a.TextContent).Length))
.Where(a => a.Laenge >= Math.Max(MindestTextBereich, AnteilArtikel * seitentext))
.OrderByDescending(a => a.Laenge)
.FirstOrDefault();
if (artikel.Element is not null)
return ([artikel.Element], "article");
if (main is not null && Normalisiere(main.TextContent).Length >= MindestTextBereich)
return ([main], "main");
var dichtester = doc.QuerySelectorAll("section, div")
.Select(e => (Element: e, Wert: Dichte(e)))
.Where(e => e.Wert > MindestTextBereich)
.OrderByDescending(e => e.Wert)
.FirstOrDefault();
if (dichtester.Element is not null)
return ([dichtester.Element], "hauptinhalt");
return (doc.Body is { } body ? [body] : [doc.DocumentElement], "body");
}
/// <summary>Absatztext zaehlt, uebriger Text und Linktext ziehen ab - so gewinnt der Artikel gegen die Linkliste.</summary>
private static double Dichte(IElement e)
{
var absatz = e.QuerySelectorAll("p, li, td, h1, h2, h3").Sum(p => Normalisiere(p.TextContent).Length);
var gesamt = Normalisiere(e.TextContent).Length;
var links = e.QuerySelectorAll("a").Sum(a => Normalisiere(a.TextContent).Length);
return absatz - 0.5 * Math.Max(0, gesamt - absatz) - 0.5 * links;
}
// ─── Markdown ───
private static void Schreibe(INode knoten, StringBuilder sb, Uri? basis, int einzug)
{
if (knoten is IText text)
{
sb.Append(Leerraum.Replace(text.Data, " "));
return;
}
if (knoten is not IElement e)
return;
switch (e.LocalName)
{
case "h1" or "h2" or "h3" or "h4" or "h5" or "h6":
{
var inhalt = Inline(e, basis);
if (inhalt.Length == 0) return;
Block(sb);
sb.Append('#', e.LocalName[1] - '0').Append(' ').Append(inhalt);
Block(sb);
return;
}
case "br":
sb.Append('\n');
return;
case "hr":
Block(sb);
sb.Append("---");
Block(sb);
return;
case "ul" or "ol":
Liste(e, sb, basis, einzug);
return;
case "table":
Tabelle(e, sb, basis);
return;
case "pre":
Block(sb);
sb.Append("```\n").Append(e.TextContent.Trim('\n')).Append("\n```");
Block(sb);
return;
case "code":
sb.Append('`').Append(Normalisiere(e.TextContent)).Append('`');
return;
case "blockquote":
{
var innen = new StringBuilder();
foreach (var kind in e.ChildNodes) Schreibe(kind, innen, basis, einzug);
var zeilen = Aufraeumen(innen.ToString()).Split('\n');
Block(sb);
sb.Append(string.Join("\n", zeilen.Select(z => "> " + z)));
Block(sb);
return;
}
case "a":
{
var inhalt = Inline(e, basis);
var ziel = AbsoluteUrl(basis, e.GetAttribute("href"));
if (inhalt.Length == 0) return;
sb.Append(ziel is null || ziel == inhalt ? inhalt : $"[{inhalt}]({ziel})");
return;
}
case "strong" or "b":
{
var inhalt = Inline(e, basis);
if (inhalt.Length > 0) sb.Append("**").Append(inhalt).Append("**");
return;
}
case "em" or "i":
{
var inhalt = Inline(e, basis);
if (inhalt.Length > 0) sb.Append('_').Append(inhalt).Append('_');
return;
}
case "img":
{
var alt = Normalisiere(e.GetAttribute("alt") ?? "");
if (alt.Length > 0) sb.Append("[Image: ").Append(alt).Append(']');
return;
}
case "p" or "div" or "section" or "article" or "main" or "header" or "footer" or "figure"
or "figcaption" or "dl" or "dt" or "dd" or "address" or "details" or "summary" or "body":
Block(sb);
foreach (var kind in e.ChildNodes) Schreibe(kind, sb, basis, einzug);
Block(sb);
return;
default:
foreach (var kind in e.ChildNodes) Schreibe(kind, sb, basis, einzug);
return;
}
}
private static void Liste(IElement liste, StringBuilder sb, Uri? basis, int einzug)
{
Block(sb);
var nummer = 1;
foreach (var li in liste.Children.Where(c => c.LocalName == "li"))
{
var zeile = new StringBuilder();
foreach (var kind in li.ChildNodes)
{
if (kind is IElement { LocalName: "ul" or "ol" } unterliste)
{
var unter = new StringBuilder();
Liste(unterliste, unter, basis, einzug + 1);
zeile.Append('\n').Append(unter.ToString().Trim('\n'));
}
else
{
Schreibe(kind, zeile, basis, einzug);
}
}
var inhalt = zeile.ToString().Trim();
if (inhalt.Length == 0) continue;
var marke = liste.LocalName == "ol" ? $"{nummer++}. " : "- ";
sb.Append(new string(' ', einzug * 2)).Append(marke).Append(Leerraum.Replace(inhalt.Split('\n')[0], " ").Trim());
foreach (var weitere in inhalt.Split('\n').Skip(1))
sb.Append('\n').Append(weitere);
sb.Append('\n');
}
Block(sb);
}
private static void Tabelle(IElement tabelle, StringBuilder sb, Uri? basis)
{
var zeilen = tabelle.QuerySelectorAll("tr")
.Where(tr => tr.Closest("table") == tabelle)
.Select(tr => tr.Children
.Where(c => c.LocalName is "td" or "th")
.Take(MaxTabellenspalten)
.Select(c => Inline(c, basis).Replace("|", "\\|"))
.ToList())
.Where(z => z.Count > 0 && z.Any(c => c.Length > 0))
.Take(MaxTabellenzeilen)
.ToList();
if (zeilen.Count == 0)
return;
Block(sb);
// Eine Spalte oder eine Zeile ist keine Tabelle, sondern Layout - dann als Text.
var spalten = zeilen.Max(z => z.Count);
if (spalten == 1 || zeilen.Count == 1)
{
sb.Append(string.Join("\n", zeilen.Select(z => string.Join(" ", z.Where(c => c.Length > 0)))));
Block(sb);
return;
}
string Zeile(List<string> z) => "| " + string.Join(" | ", z.Concat(Enumerable.Repeat("", spalten - z.Count))) + " |";
sb.Append(Zeile(zeilen[0])).Append('\n');
sb.Append("| ").Append(string.Join(" | ", Enumerable.Repeat("---", spalten))).Append(" |");
foreach (var z in zeilen.Skip(1))
sb.Append('\n').Append(Zeile(z));
Block(sb);
}
private static string Inline(IElement e, Uri? basis)
{
var sb = new StringBuilder();
foreach (var kind in e.ChildNodes) Schreibe(kind, sb, basis, 0);
return Normalisiere(sb.ToString());
}
private static void Block(StringBuilder sb)
{
var ende = sb.Length;
while (ende > 0 && sb[ende - 1] is ' ' or '\t') ende--;
sb.Length = ende;
if (sb.Length == 0) return;
if (sb.Length >= 2 && sb[^1] == '\n' && sb[^2] == '\n') return;
sb.Append(sb[^1] == '\n' ? "\n" : "\n\n");
}
private static string Aufraeumen(string markdown)
{
var aus = new StringBuilder();
var imCode = false;
var leer = 0;
foreach (var roh in markdown.Split('\n'))
{
if (roh.TrimStart().StartsWith("```", StringComparison.Ordinal))
{
imCode = !imCode;
aus.Append(roh.Trim()).Append('\n');
leer = 0;
continue;
}
if (imCode)
{
aus.Append(roh.TrimEnd()).Append('\n');
continue;
}
var zeile = roh.TrimEnd();
var einzug = zeile.Length - zeile.TrimStart().Length;
var kern = Regex.Replace(zeile.TrimStart(), " {2,}", " ");
var istListe = kern.StartsWith("- ", StringComparison.Ordinal) || Regex.IsMatch(kern, @"^\d+\. ");
zeile = (istListe ? new string(' ', einzug) : "") + kern;
if (zeile.Length == 0)
{
if (++leer > 1) continue;
}
else
{
leer = 0;
}
aus.Append(zeile).Append('\n');
}
return aus.ToString().Trim();
}
// ─── Metadaten ───
private static DateTimeOffset? Veroeffentlichung(IDocument doc)
{
var kandidaten = new[]
{
doc.QuerySelector("meta[property='article:published_time']")?.GetAttribute("content"),
doc.QuerySelector("meta[itemprop=datePublished]")?.GetAttribute("content"),
doc.QuerySelector("meta[name=date]")?.GetAttribute("content"),
doc.QuerySelector("time[datetime]")?.GetAttribute("datetime")
};
foreach (var wert in kandidaten)
{
if (DateTimeOffset.TryParse(wert, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var datum))
return datum;
}
return null;
}
private static string? Meta(IDocument doc, string selector)
{
var wert = doc.QuerySelector(selector)?.GetAttribute("content");
return string.IsNullOrWhiteSpace(wert) ? null : Normalisiere(wert);
}
private static string? AbsoluteUrl(Uri? basis, string? href)
{
if (basis is null || string.IsNullOrWhiteSpace(href)) return null;
href = href.Trim();
if (href.StartsWith('#') || href.StartsWith("javascript:", StringComparison.OrdinalIgnoreCase))
return null;
return Uri.TryCreate(basis, href, out var ziel) && ziel.Scheme is "http" or "https" or "mailto"
? ziel.ToString()
: null;
}
private static string Normalisiere(string text) => Leerraum.Replace(text, " ").Trim();
}