package tools import ( "context" "fmt" "net/url" "regexp" "strings" "sync" "time" "github.com/imroc/req/v3" "golang.org/x/net/html" "git.gopher.ink/mirrors2/AutoZero/execx" ) // fetch 工具:纯 HTTP 抓取(req v3 指纹仿真,TLS + HTTP/2 层伪装成真实浏览器), // 不依赖本机浏览器——与 browser 工具(CDP)互补:fetch 毫秒级返回可读文本, // 需要 JS 渲染、登录态或截图时才换 browser。指纹 profile 与代理由配置注入 // (SetFetchDefaults),未配置时默认 Chrome 仿真、直连。 const ( fetchTimeout = 20 * time.Second fetchMaxBytes = 2 << 20 // 响应体读取上限(防超大文件) fetchMaxChars = 16000 // 返回给模型的文本上限(与 bash 截断同量级) ) var ( fetchMu sync.Mutex fetchProfile string fetchProxy string fetchOnce sync.Once fetchClient *req.Client ) // SetFetchDefaults 在配置装载后、首次 fetch 前调用:设置指纹仿真 profile // (chrome | firefox | safari,未知值回退 chrome)与代理 URL。 func SetFetchDefaults(profile, proxy string) { fetchMu.Lock() defer fetchMu.Unlock() fetchProfile, fetchProxy = profile, proxy } func fetchHTTPClient() *req.Client { fetchMu.Lock() profile, proxy := fetchProfile, fetchProxy fetchMu.Unlock() fetchOnce.Do(func() { c := req.C().SetTimeout(fetchTimeout) switch strings.ToLower(strings.TrimSpace(profile)) { case "firefox": c.ImpersonateFirefox() case "safari": c.ImpersonateSafari() default: c.ImpersonateChrome() } if proxy != "" { c.SetProxyURL(proxy) } // 响应体上限:超限截断而非报错,让模型至少看到开头内容。 // 按字符边界退:下游 renderBody 之后还有一道 execx.Truncate(fetchMaxChars), // 所以这一处今天不会把半个字露到模型眼前——但"先夹字节、后面自然有人兜" // 正是这类 bug 长回来的地方,而这里改一次只需要换个函数。 c.SetResponseBodyTransformer(func(raw []byte, _ *req.Request, _ *req.Response) ([]byte, error) { if len(raw) > fetchMaxBytes { raw, _ = execx.Truncate(raw, fetchMaxBytes) } return raw, nil }) fetchClient = c }) return fetchClient } // Fetch 返回 fetch 工具(GET-only:只读语义的网络访问,审批按 network 类走)。 func Fetch() *Tool { return &Tool{ Name: "fetch", Description: "Fetch a URL over HTTP (TLS/HTTP2 fingerprint impersonates a real browser; no local browser needed) and return readable text: HTML is converted to plain text, JSON/XML/markdown returned as-is. GET only, follows redirects. " + "Use it as the fast default for reading web pages, docs and APIs — it works even where headless Chrome is not installed. " + "Switch to the `browser` tool only when the page needs JavaScript rendering, login state, or a screenshot; use bash curl to download binary files.", SideEffect: true, Kind: KindNetwork, // 访问外部网络 Parameters: map[string]any{ "type": "object", "properties": map[string]any{ "url": map[string]any{"type": "string", "description": "Absolute http/https URL to fetch"}, }, "required": []string{"url"}, }, Run: func(ctx context.Context, input string) (Result, error) { m, err := arg(input) if err != nil { return Result{}, err } u := strings.TrimSpace(str(m, "url")) pu, perr := url.Parse(u) if u == "" || perr != nil || (pu.Scheme != "http" && pu.Scheme != "https") { return Result{}, fmt.Errorf("invalid url %q (absolute http/https required)", u) } rctx, cancel := context.WithTimeout(ctx, fetchTimeout) defer cancel() resp, err := fetchHTTPClient().R().SetContext(rctx).Get(u) if err != nil { return Result{}, err } if resp.StatusCode >= 400 { return Result{}, fmt.Errorf("HTTP %d for %s", resp.StatusCode, u) } text := renderBody(resp.GetContentType(), resp.Bytes()) if text == "" { return Result{}, fmt.Errorf("no readable content (content-type %q) — for rendered pages use the browser tool, for binaries use bash curl", resp.GetContentType()) } // 按字符边界截(原实现 out[:16000] 会把汉字劈成半个字节),并把 // "被截过"结构化标出来——模型看到的不是全量,UI 与统计都该知道。 raw, truncated := execx.Truncate([]byte(text), fetchMaxChars) if truncated { raw = append(raw, "\n...[truncated]"...) } return Result{Text: string(raw), Truncated: truncated}, nil }, } } // renderBody 按内容类型把响应体转为可读文本:HTML 转纯文本,文本类原样, // 二进制返回空串(由调用方报错引导换工具)。Content-Type 缺失时按内容嗅探。 func renderBody(contentType string, body []byte) string { if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "audio/") || strings.HasPrefix(contentType, "video/") || strings.Contains(contentType, "octet-stream") || strings.Contains(contentType, "font") { return "" } if isHTMLType(contentType, body) { return htmlToText(body) } if isTextType(contentType) { return string(body) } return "" } func isHTMLType(ct string, body []byte) bool { if strings.Contains(ct, "html") { return true } // 无 Content-Type(或通用 application/octet-stream)时按内容嗅探。 head := body if len(head) > 512 { // 只是嗅探,切坏也不会露到用户面前;但这里紧跟着就 `string(head)`, // 留一个非法字节序列在字符串里,下一个改这个函数的人会以为是安全的。 head, _ = execx.Truncate(head, 512) } low := strings.ToLower(string(head)) return strings.HasPrefix(low, " 提前,块级元素分行, // 行内空白压缩为单空格,实体由解析器解码。 func htmlToText(body []byte) string { root, err := html.Parse(strings.NewReader(string(body))) if err != nil { return string(body) } var title, b strings.Builder var walk func(n *html.Node) walk = func(n *html.Node) { if n.Type == html.ElementNode { name := strings.ToLower(n.Data) switch { case name == "title": for c := n.FirstChild; c != nil; c = c.NextSibling { if c.Type == html.TextNode { title.WriteString(fetchSpaceRe.ReplaceAllString(c.Data, " ")) } } return case fetchSkipElems[name]: return case fetchBlockElems[name]: b.WriteString("\n") } for c := n.FirstChild; c != nil; c = c.NextSibling { walk(c) } if fetchBlockElems[name] { b.WriteString("\n") } return } if n.Type == html.TextNode { b.WriteString(fetchSpaceRe.ReplaceAllString(n.Data, " ")) } for c := n.FirstChild; c != nil; c = c.NextSibling { walk(c) } } walk(root) // 逐行去首尾空白、去空行:源码缩进与连续空行是噪声。 var lines []string for _, line := range strings.Split(b.String(), "\n") { if line = strings.TrimSpace(line); line != "" { lines = append(lines, line) } } if len(lines) == 0 { return "" } text := strings.Join(lines, "\n") if t := strings.TrimSpace(title.String()); t != "" { return "title: " + t + "\n\n" + text } return text }