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>
This commit is contained in:
@@ -0,0 +1,460 @@
|
||||
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 = $"Selektor '{selector}' traf nichts - Hauptinhalt verwendet.";
|
||||
}
|
||||
catch (Exception ex) when (ex is DomException or ArgumentException)
|
||||
{
|
||||
hinweis = $"Selektor '{selector}' ist ungueltig ({ex.Message}) - Hauptinhalt verwendet.";
|
||||
}
|
||||
}
|
||||
|
||||
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("[Bild: ").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();
|
||||
}
|
||||
Reference in New Issue
Block a user