package io import ( "context" "fmt" "log/slog" "net/http" "strings" "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" ) // Compile-time assertion that webFetchTool implements sdk.Tool. var _ sdk.Tool = (*webFetchTool)(nil) // webFetchTool fetches a URL over HTTP and returns its readable text, limited by // size and content type, extracting text from HTML when appropriate. type webFetchTool struct { client *http.Client allowedHosts []string maxBytes int64 log *slog.Logger // renderer 是「浏览器渲染取正文」这一档(design/28 §1 M5 / §8 B5)。 // nil = 关着(默认,见 WithBrowserFallback)。 renderer BodyRenderer } func newWebFetchTool(t *tools) *webFetchTool { return &webFetchTool{ client: t.client, allowedHosts: t.allowedHosts, maxBytes: t.maxFetchBytes, log: t.log, renderer: t.bodyRenderer, } } // Name implements sdk.Tool. func (w *webFetchTool) Name() string { return "web-fetch" } // Description implements sdk.Tool. func (w *webFetchTool) Description() string { return "Fetch a URL and return its readable text, limited by size and content type." } // Parameters implements sdk.Tool. func (w *webFetchTool) Parameters() (map[string]any, error) { return map[string]any{ "type": "object", "properties": map[string]any{ "url": map[string]any{"type": "string", "description": "Absolute http(s) URL."}, "max_bytes": map[string]any{"type": "integer", "description": "Response size cap in bytes."}, }, "required": []any{"url"}, }, nil } // Execute implements sdk.Tool. // // 取正文走**降级链**(design/28 §1 M5 / §8 B5):直连 fetch →(失败 / 判定为动态页) // ⇒ 浏览器渲染。结果里的 `fetchedBy` 是**如实标注**(direct / browser);两条档的 // 失败原文各自留在 directError / browserError 里,绝不静默替换、绝不掩盖。 func (w *webFetchTool) Execute(ctx context.Context, args any) (any, error) { m, ok := args.(map[string]any) if !ok { return nil, fmt.Errorf("io: web-fetch: args must be an object") } rawURL := argString(m, "url") if strings.TrimSpace(rawURL) == "" { return nil, fmt.Errorf("io: web-fetch: url is required") } max := w.maxBytes if v, ok := argInt(m, "max_bytes"); ok && v > 0 { max = int64(v) } body, err := w.fetchBody(ctx, rawURL, max) if err != nil { return nil, err } out := map[string]any{ "url": rawURL, "content_type": body.contentType, "text": body.text, "truncated": body.truncated, // fetchedBy:正文**实际**来自哪条路。它是 M5 的硬要求(不许静默替换)—— // 见到 browser 就意味着直连失败过、或被判成了动态页。 "fetchedBy": body.fetchedBy, } // 以下字段只在**确有其事**时出现(有内容的失败/来源才写,不塞空串)。 if body.directError != "" { out["directError"] = body.directError } if body.browserSource != "" { out["browserSource"] = body.browserSource } if body.browserError != "" { out["browserError"] = body.browserError } if body.dynamicSuspect { out["dynamicSuspect"] = true } return out, nil }