- 降级链:直连失败 / 判定为动态页 ⇒ 浏览器渲染取正文;返回体带 fetchedBy=direct|browser,
两条档的失败各自留痕(directError/browserError/browserSource/dynamicSuspect),**绝不静默替换**
- 被 SSRF 拒绝的目标**不换路**(红线);渲染器缺位时如实报双失败
- 3 个变异自检(去掉动态页判据 / 去掉 SSRF 短路 / 掩盖双失败)
⚠️ **生产接不通**:internal/capability/browser 没有「取正文」入口(不在本片文件面)⇒
开关默认关、不假装可用;解封形状已在报告里给出(加 RenderText ≈40 行)
104 lines
3.1 KiB
Go
104 lines
3.1 KiB
Go
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
|
||
}
|