using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
using AngleSharp.Dom;
using AngleSharp.Html.Parser;
namespace ClawdDotNet.Tools.WebFetch;
/// Was die Extraktion aus einer Seite gewonnen hat.
public sealed record ExtrahierterInhalt(
string Titel,
string? Sprache,
string? KanonischeUrl,
DateTimeOffset? Veroeffentlicht,
string Bereich,
string Markdown,
string? Hinweis);
///
/// Holt den Hauptinhalt einer HTML-Seite als kompaktes Markdown (#602, Stufe 1).
///
/// 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.
///
/// Bereichswahl vom Genauen zum Groben: ein angegebener Selektor, sonst das
/// article mit dem meisten Text, sonst main, sonst der Block mit der hoechsten
/// Textdichte (Absatztext abzueglich Linktext), sonst der ganze Body.
///
public static class HtmlExtraktor
{
/// Unter so viel Text gilt ein article/main als leer und wird uebergangen.
private const int MindestTextBereich = 200;
/// So viel des Seitentexts muss ein article tragen, um als Hauptinhalt zu gelten.
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);
///
/// Linkziele als [Text](URL) ausgeben. Standard aus: Auf linkreichen Seiten
/// (Wikipedia) verdoppelten die Adressen den Text, und die meisten Auftraege brauchen den
/// Inhalt, nicht die Ziele.
///
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 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);
}
/// Der ganze sichtbare Text der Seite, ohne Skripte - Stufe "vollstaendig".
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 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");
}
/// Absatztext zaehlt, uebriger Text und Linktext ziehen ab - so gewinnt der Artikel gegen die Linkliste.
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 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();
}