Files
Cyrene_v2/plugins/tools/io/web_fetch.go
T
AskaEth c2e413ec38
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(io): web-fetch 直连→浏览器渲染降级档 + fetchedBy 如实标注(Z5 机制)
- 降级链:直连失败 / 判定为动态页 ⇒ 浏览器渲染取正文;返回体带 fetchedBy=direct|browser,
  两条档的失败各自留痕(directError/browserError/browserSource/dynamicSuspect),**绝不静默替换**
- 被 SSRF 拒绝的目标**不换路**(红线);渲染器缺位时如实报双失败
- 3 个变异自检(去掉动态页判据 / 去掉 SSRF 短路 / 掩盖双失败)
⚠️ **生产接不通**:internal/capability/browser 没有「取正文」入口(不在本片文件面)⇒
   开关默认关、不假装可用;解封形状已在报告里给出(加 RenderText ≈40 行)
2026-10-07 09:34:47 +08:00

104 lines
3.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}