Files
ClawdDotNet/src/ClawdDotNet.Tools.WebFetch/WebInhalt.cs
T
RichardandClaude Opus 5 f74e49ba35 Freigabe-Hygiene (#631, #579, #609, #610) und JSON-Filter fuer WebFetch (#632)
#631: Derselbe Vorschlag (gleicher Agent, gleiches Werkzeug, gleiche Argumente unabhaengig
von Schluesselreihenfolge) wird nicht erneut angelegt - der Agent erfaehrt die bestehende
Nummer. Offene Vorschlaege verfallen nach 24 h (Status Expired).

#609: Werkzeuge koennen den Freigabeschluessel aus dem Inhalt bestimmen
(IAgentTool.StagingAktion). Database: SELECT/SHOW/DESCRIBE -> query, INSERT/UPDATE/DELETE
-> write, DDL -> schema; write und schema sind freigabepflichtig. Ausgefuehrt wird nach
Statement-Art, schreibende Statements melden die Zeilenzahl.

#579: Database-Vorpruefung (Zugriffsebene, Tabellenfreigabe, SqlGuard) - was das Werkzeug
ablehnen wuerde, kommt nicht mehr in die Freigabeliste.

#610: SqlGuard ueberspringt IF [NOT] EXISTS und erkennt DESCRIBE/DESC <tabelle>.

#632: WebFetch fetch mit filter liefert aus JSON/JSONP nur passende Eintraege; beim
Blaettern durch grosses JSON empfiehlt der Hinweis den filter.

Version 0.1.10.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 18:51:06 +02:00

179 lines
7.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, string? filter = null)
{
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" && !string.IsNullOrWhiteSpace(filter))
{
(text, bereich, hinweis) = JsonFilter.Anwenden(body, filter.Trim());
if (bereich == "gefiltert") art = "json";
}
else 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.";
}
}
}
if (art == "html" && !string.IsNullOrWhiteSpace(filter))
{
const string nurJson = "filter gilt nur fuer JSON - fuer HTML nimm selector.";
hinweis = hinweis is null ? nurJson : $"{hinweis} {nurJson}";
}
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.";
// #632: Jeder weitere Abschnitt bleibt im Kontext und wird bei jedem folgenden Schritt
// erneut bezahlt. Fuer die DWD-Warnungen las ein Agent fuenf Abschnitte - 160.000 Tokens
// fuer eine Frage nach einer einzigen Stadt.
if (art == "json" && bereich != "gefiltert")
fortsetzung += " Suchst du bestimmte Eintraege, hole sie mit 'filter' (z.B. filter=\"Berlin\") statt weiterzublaettern.";
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);
}
}