using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Serialization; namespace ClawdDotNet.Tools.WebFetch; /// Wie viel einer HTML-Seite geliefert wird. public enum AbrufModus { /// Hauptinhalt als Markdown; ist er zu kurz, der Volltext (Standard). Auto, /// Der ganze sichtbare Text der Seite. Voll } /// Ein aufbereiteter Abruf, bereit fuer das Modell. 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); /// /// Entscheidet, was von einer Antwort beim Modell ankommt (#602). /// /// 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. /// public static class WebInhalt { /// Weniger Hauptinhalt als das gilt als gescheiterte Extraktion. public const int MindestZeichenHauptinhalt = 200; public const int StandardMaxZeichen = 12_000; /// /// Kompakt und ohne Unicode-Escapes. Der Standard-Encoder schreibt jeden Umlaut als /// รค - 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. /// 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); } /// Welche Art Inhalt vorliegt - nach Content-Type, mit Blick auf den Anfang, wenn der fehlt oder luegt. 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(" /// Ein Abschnitt ab , moeglichst an einer Absatz- oder Wortgrenze /// geschnitten. Aneinandergehaengt ergeben die Abschnitte genau den ganzen Text. /// 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); } }