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>
461 lines
17 KiB
C#
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();
|
|
}
|