Files
ClawdDotNet/src/ClawdDotNet.Tools.WebFetch/WebInhalt.cs
T
RichardandClaude Opus 5 7dbcc44226 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>
2026-09-15 12:19:27 +02:00

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);
}
}