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>
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user