- 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>
161 lines
6.0 KiB
C#
161 lines
6.0 KiB
C#
using System.Text.Encodings.Web;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
|
|
namespace ClawdDotNet.Tools.WebFetch;
|
|
|
|
/// <summary>Wie viel einer HTML-Seite geliefert wird.</summary>
|
|
public enum AbrufModus
|
|
{
|
|
/// <summary>Hauptinhalt als Markdown; ist er zu kurz, der Volltext (Standard).</summary>
|
|
Auto,
|
|
|
|
/// <summary>Der ganze sichtbare Text der Seite.</summary>
|
|
Voll
|
|
}
|
|
|
|
/// <summary>Ein aufbereiteter Abruf, bereit fuer das Modell.</summary>
|
|
public sealed record AufbereiteterInhalt(
|
|
string Art,
|
|
string Bereich,
|
|
string Text,
|
|
int Gesamtzeichen,
|
|
int Offset,
|
|
int? NaechsterOffset,
|
|
string? Titel,
|
|
string? Sprache,
|
|
string? KanonischeUrl,
|
|
DateTimeOffset? Veroeffentlicht,
|
|
string? Hinweis);
|
|
|
|
/// <summary>
|
|
/// Entscheidet, was von einer Antwort beim Modell ankommt (#602).
|
|
///
|
|
/// <para>JSON, Text und XML bleiben, wie sie sind - die Agenten im Testbetrieb fragen ueber
|
|
/// fetch vor allem Schnittstellen ab (Wetter, Kurse, Warnungen). HTML geht durch die
|
|
/// Extraktion; liefert die zu wenig (typisch: Seite baut ihren Inhalt per JavaScript), kommt
|
|
/// der Volltext mit einem Hinweis. Lange Inhalte werden nicht still abgeschnitten, sondern in
|
|
/// Abschnitten geliefert: Die Antwort nennt, ab wo es weitergeht.</para>
|
|
/// </summary>
|
|
public static class WebInhalt
|
|
{
|
|
/// <summary>Weniger Hauptinhalt als das gilt als gescheiterte Extraktion.</summary>
|
|
public const int MindestZeichenHauptinhalt = 200;
|
|
|
|
public const int StandardMaxZeichen = 12_000;
|
|
|
|
/// <summary>
|
|
/// Kompakt und ohne Unicode-Escapes. Der Standard-Encoder schreibt jeden Umlaut als
|
|
/// <c>ä</c> - sechs Zeichen statt einem, bei deutschen Seiten in jeder Zeile. Die
|
|
/// Antwort geht an ein Modell, nicht in eine HTML-Seite; das Escaping schuetzt hier nichts.
|
|
/// </summary>
|
|
public static JsonSerializerOptions JsonOptionen { get; } = new()
|
|
{
|
|
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
|
};
|
|
|
|
public static AufbereiteterInhalt Aufbereiten(
|
|
string body, string? mediaType, Uri quelle, AbrufModus modus, string? selector,
|
|
int offset = 0, int maxZeichen = StandardMaxZeichen, bool mitLinks = false)
|
|
{
|
|
var art = Art(mediaType, body);
|
|
|
|
string text;
|
|
var bereich = "roh";
|
|
string? titel = null, sprache = null, kanonisch = null, hinweis = null;
|
|
DateTimeOffset? datum = null;
|
|
|
|
if (art != "html")
|
|
{
|
|
text = body.Trim();
|
|
}
|
|
else if (modus == AbrufModus.Voll)
|
|
{
|
|
text = HtmlExtraktor.Volltext(body);
|
|
bereich = "vollstaendig";
|
|
}
|
|
else
|
|
{
|
|
var ex = HtmlExtraktor.Extrahiere(body, quelle, selector, mitLinks);
|
|
(titel, sprache, kanonisch, datum, hinweis) = (ex.Titel, ex.Sprache, ex.KanonischeUrl, ex.Veroeffentlicht, ex.Hinweis);
|
|
text = ex.Markdown;
|
|
bereich = ex.Bereich;
|
|
|
|
if (text.Length < MindestZeichenHauptinhalt && string.IsNullOrWhiteSpace(selector))
|
|
{
|
|
var voll = HtmlExtraktor.Volltext(body);
|
|
if (voll.Length > text.Length)
|
|
{
|
|
text = voll;
|
|
bereich = "vollstaendig";
|
|
hinweis = "Hauptinhalt zu kurz - vollstaendiger Seitentext geliefert. Baut die Seite ihren "
|
|
+ "Inhalt per JavaScript auf, fehlt er auch hier.";
|
|
}
|
|
}
|
|
}
|
|
|
|
var (teil, start, naechster) = Abschnitt(text, offset, maxZeichen);
|
|
if (naechster is not null)
|
|
{
|
|
var fortsetzung = $"Inhalt gekuerzt ({teil.Length} von {text.Length} Zeichen) - mit offset={naechster} weiterlesen, nur falls noetig.";
|
|
hinweis = hinweis is null ? fortsetzung : $"{hinweis} {fortsetzung}";
|
|
}
|
|
|
|
return new AufbereiteterInhalt(art, bereich, teil, text.Length, start, naechster,
|
|
string.IsNullOrWhiteSpace(titel) ? null : titel, sprache, kanonisch, datum, hinweis);
|
|
}
|
|
|
|
/// <summary>Welche Art Inhalt vorliegt - nach Content-Type, mit Blick auf den Anfang, wenn der fehlt oder luegt.</summary>
|
|
public static string Art(string? mediaType, string body)
|
|
{
|
|
var mt = mediaType?.ToLowerInvariant() ?? "";
|
|
var anfang = body.TrimStart();
|
|
var siehtAusWieJson = anfang.StartsWith('{') || anfang.StartsWith('[');
|
|
|
|
if (mt.Contains("json")) return "json";
|
|
if (mt.Contains("html")) return siehtAusWieJson ? "json" : "html";
|
|
if (mt.Contains("xml")) return "xml";
|
|
if (mt.StartsWith("text/", StringComparison.Ordinal)) return "text";
|
|
|
|
if (siehtAusWieJson) return "json";
|
|
if (anfang.StartsWith('<'))
|
|
{
|
|
return anfang.Contains("<html", StringComparison.OrdinalIgnoreCase)
|
|
|| anfang.Contains("<body", StringComparison.OrdinalIgnoreCase)
|
|
? "html"
|
|
: "xml";
|
|
}
|
|
|
|
return "text";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ein Abschnitt ab <paramref name="offset"/>, moeglichst an einer Absatz- oder Wortgrenze
|
|
/// geschnitten. Aneinandergehaengt ergeben die Abschnitte genau den ganzen Text.
|
|
/// </summary>
|
|
internal static (string Teil, int Start, int? Naechster) Abschnitt(string text, int offset, int maxZeichen)
|
|
{
|
|
maxZeichen = Math.Max(500, maxZeichen);
|
|
var start = Math.Clamp(offset, 0, text.Length);
|
|
var ende = Math.Min(text.Length, start + maxZeichen);
|
|
|
|
if (ende < text.Length)
|
|
{
|
|
var mindestens = start + (int)(maxZeichen * 0.6);
|
|
var absatz = text.LastIndexOf("\n\n", ende - 1, ende - start, StringComparison.Ordinal);
|
|
if (absatz >= mindestens)
|
|
{
|
|
ende = absatz + 2;
|
|
}
|
|
else
|
|
{
|
|
var wort = text.LastIndexOfAny([' ', '\n'], ende - 1, ende - start);
|
|
if (wort >= mindestens) ende = wort + 1;
|
|
}
|
|
}
|
|
|
|
return (text[start..ende], start, ende < text.Length ? ende : null);
|
|
}
|
|
}
|