package tools import ( "context" "fmt" "net/http" "net/url" "strconv" "strings" "sync" "time" "golang.org/x/net/html" "git.gopher.ink/mirrors2/AutoZero/execx" ) // websearch 工具:关键词 → 一组带标题/链接/摘要的搜索结果。 // // 为什么必须有:一次真实的「B 站投币视频下载工具」任务里,模型没有检索能力, // 只能凭记忆猜接口,于是连着吃了十几次 `code:-400`、手写 wbi 签名、又试图 // clone 文档仓库(撞 120 秒超时挂死),烧掉约 20 步。同样的问题在别家有检索 // 工具的运行时里是**三次搜索调用**就定位到 `bilibili-API-collect` 仓库。 // 这就是 G1:工具箱里缺那把扳手,模型就绕行。 // // 后端选择:Brave 的搜索结果页,**无需 API key**。本项目已有 // `github.com/imroc/req/v3` 的指纹仿真与 `golang.org/x/net/html`(都在依赖里)。 // // 实测的诚实结论(2026-10-09,本机):解析没问题——真实页面上稳定拿到 // `SocialSisterYi/bilibili-API-collect` 作为第 1 条。但公开搜索页对程序化请求 // 会**间歇 429**:同一分钟内 curl 走 HTTP/2 稳定 200,而 Go 客户端在 // chrome / 无仿真 / firefox / safari 四种画像下时通时不通,与画像无关,是 // 按来源的累计限流。所以这个后端不能当"永远在"的依赖:限流时给出的建议是 // 换 browser 工具(真浏览器指纹)或 fetch 已知 URL,而不是让模型继续打。 // 若要稳定,正解是接一个有 key 的搜索 API(Brave Search API / Tavily / Exa)—— // 那属于配置项,不在本次范围。 const ( searchTimeout = 25 * time.Second searchMaxResult = 8 searchSnippetBytes = 220 // 每条摘要的字节上限(按字符边界截) // 后端很慷慨地回 429,所以:全局最小间隔 + 有限次退避重试。 searchMinInterval = 2 * time.Second searchRetryCap = 30 * time.Second searchAttempts = 3 // 首次 + 两次退避;再多就是替后端扛流量,且一次调用拖几十秒 ) // searchBackoff 是两次重试之间的等待(受 Retry-After 下限约束)。 var searchBackoff = []time.Duration{3 * time.Second, 8 * time.Second} // searchEngines 是可试的后端;顺序即优先级。留成切片是为了 Brave 失效时 // 加第二个后端(各家的解析规则不同,加之前先实测确认能通)。 var searchEngines = []struct { name string build func(q string) string lookup func(*html.Node) []searchResult }{ {"brave", braveSearchURL, extractBrave}, } // searchResult 是一条搜索结果。 type searchResult struct { Title string URL string Snippet string } // WebSearch 返回 websearch 工具。与 fetch 同为网络类:审批档位一致, // 一次批准后按工具名缓存(会话内不再打扰)。 func WebSearch() *Tool { return &Tool{ Name: "websearch", Description: "Search the web by keywords and get back titles + URLs + snippets — use it BEFORE guessing: " + "library/API names, error messages, doc locations. Cheap (one HTTP round trip) and the usual way to " + "find the authoritative repo/doc, which fetch/bash-curl cannot do (they need a URL you already know). " + "Then read the promising link with the fetch tool.", SideEffect: true, Kind: KindNetwork, Parameters: map[string]any{ "type": "object", "properties": map[string]any{ "query": map[string]any{"type": "string", "description": "search keywords; put the exact error string or API name in quotes"}, "count": map[string]any{"type": "integer", "description": fmt.Sprintf("results to return, default %d", searchMaxResult)}, }, "required": []string{"query"}, }, Run: func(ctx context.Context, input string) (Result, error) { m, err := arg(input) if err != nil { return Result{}, err } q := strings.TrimSpace(str(m, "query")) if q == "" { return Result{}, fmt.Errorf("query is empty") } limit := searchMaxResult if v, ok := m["count"].(float64); ok && int(v) > 0 && int(v) <= searchMaxResult { limit = int(v) } rctx, cancel := context.WithTimeout(ctx, searchTimeout) defer cancel() var tried []string for _, eng := range searchEngines { body, err := searchHTTPGet(rctx, eng.build(q)) if err != nil { // 把后端真实状态带上:429/403(被限流)与"版面不认"是两种 // 完全不同的故障,混成一句会让模型做出错误的重试决定。 tried = append(tried, eng.name+": "+err.Error()) continue } root, perr := html.Parse(strings.NewReader(body)) if perr != nil { tried = append(tried, eng.name+": 页面解析失败 "+perr.Error()) continue } if rs := dedupe(eng.lookup(root)); len(rs) > 0 { return TextResult(renderResults(q, rs, limit)), nil } tried = append(tried, fmt.Sprintf("%s: 响应 %d 字节但没解析出结果(版面可能改了)", eng.name, len(body))) } // 两种故障给两种建议:限流是"换条路,别再打",版面不认是"重试一次再换工具"。 joined := strings.Join(tried, "; ") if strings.Contains(joined, "rate limited") { return Result{}, fmt.Errorf("web search rate limited by the engine (%s) — retrying now makes it worse. "+ "Options: use the browser tool on https://search.brave.com/search?q=… (real browser fingerprint), "+ "fetch a URL you already know, or answer from what you have", joined) } return Result{}, fmt.Errorf("web search failed — %s; "+ "the engine markup may have changed: retry once with different keywords, then use the fetch tool", joined) }, } } // ---- HTTP ---- // searchHTTPGet 复用 fetch 的客户端:同一套浏览器指纹仿真与代理配置, // 不另开一份连接池。 // // 两次实时试验之间就被 Brave 打过 429,所以这里必须有: // - 全局最小间隔(并发批次里模型可能一口气发好几条搜索,自己把自己打爆); // - 429/503 的有限退避重试(尊重 Retry-After)。 // // 仍然失败时把真实状态交给上层——"限流"和"版面不认"要给出不同的建议。 func searchHTTPGet(ctx context.Context, u string) (string, error) { waitSearchSlot(ctx) var ( lastErr error retryAfter time.Duration ) for attempt := 0; attempt < searchAttempts; attempt++ { if attempt > 0 { d := searchBackoff[attempt-1] if retryAfter > d { d = retryAfter } select { case <-time.After(d): case <-ctx.Done(): return "", ctx.Err() } } waitSearchSlot(ctx) resp, err := fetchHTTPClient().R().SetContext(ctx).Get(u) if err != nil { lastErr = err continue } switch { case resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode == http.StatusServiceUnavailable: retryAfter = retryAfterDelay(resp.Header.Get("Retry-After")) lastErr = fmt.Errorf("HTTP %d (rate limited)", resp.StatusCode) continue case resp.StatusCode >= 400: return "", fmt.Errorf("HTTP %d", resp.StatusCode) } return string(resp.Bytes()), nil } return "", lastErr } // retryAfterDelay 解析 Retry-After 的秒数形式;解析失败或非正值返回 0。 // 上限 searchRetryCap:头部写"一小时"这种,不该让工具挂着不走。 func retryAfterDelay(v string) time.Duration { n, err := strconv.Atoi(strings.TrimSpace(v)) if err != nil || n <= 0 { return 0 } if d := time.Duration(n) * time.Second; d > searchRetryCap { return searchRetryCap } else { return d } } // searchMinInterval 是全局限流闸:两次搜索请求之间至少隔这么多。 // 用 Mutex 跨睡眠持有——并发批次里的多条搜索自然被串行化。 var searchGate = struct { sync.Mutex last time.Time }{} func waitSearchSlot(ctx context.Context) { searchGate.Lock() defer searchGate.Unlock() if d := time.Since(searchGate.last); d < searchMinInterval { select { case <-time.After(searchMinInterval - d): case <-ctx.Done(): return } } searchGate.last = time.Now() } // ---- Brave 版面解析 ---- func braveSearchURL(q string) string { return "https://search.brave.com/search?q=" + url.QueryEscape(q) + "&source=web" } // braveSkipHosts 是导航/图片代理一类不该出现在结果里的域名。 var braveSkipHosts = []string{"search.brave.com", "imgs.search.brave.com", "brave.com"} // extractBrave 取所有 class 含 "snippet" 的结果块。 // // Brave 的 class 带 svelte 哈希(`snippet svelte-jmfu5f`),所以按**词**匹配而不是 // 全字符串比较。找不到时返回空,由调用方决定怎么交代。 func extractBrave(root *html.Node) []searchResult { var out []searchResult var walk func(*html.Node) walk = func(n *html.Node) { if n.Type == html.ElementNode && hasClass(n, "snippet") { // 只认真正的结果块;视频/图片等其它 data-type 不是我们要的 if dt := attr(n, "data-type"); dt != "" && dt != "web" { return } if r, ok := parseBraveSnippet(n); ok { out = append(out, r) return // 结果块内部不再递归(避免嵌套块重复计数) } } for c := n.FirstChild; c != nil; c = c.NextSibling { walk(c) } } walk(root) return out } // parseBraveSnippet 从一个结果块里抽 URL / 标题 / 摘要。 func parseBraveSnippet(n *html.Node) (searchResult, bool) { var r searchResult a := firstAnchor(n) if a != nil { r.URL = absURL(attr(a, "href")) // 锚点整棵子树还包含站点名与面包屑("GitHub github.com › x › y"), // 直接 textOf(a) 会把它们混进标题;优先取 .title。 if t := firstByClass(a, "title"); t != nil { r.Title = textOf(t) } else { r.Title = textOf(a) } } if r.URL == "" || !httpURL(r.URL) || hostIn(r.URL, braveSkipHosts) { return r, false } if r.Title == "" { r.Title = r.URL } // 摘要:Brave 两种版面都见过(generic-snippet 与 snippet-description),都认。 // 认不出就留空——**不要**退回整块文本,那会把站点名、面包屑、URL 一起吐给模型。 for _, class := range []string{"generic-snippet", "snippet-description"} { if d := firstByClass(n, class); d != nil { r.Snippet = textOf(d) break } } return r, true } // firstAnchor 返回块里第一个 http(s) 链接(Brave 把图标包在 里,需要跳过空 href)。 func firstAnchor(n *html.Node) *html.Node { var found *html.Node var walk func(*html.Node) walk = func(x *html.Node) { if found != nil { return } if x.Type == html.ElementNode && x.Data == "a" { if h := absURL(attr(x, "href")); httpURL(h) { found = x return } } for c := x.FirstChild; c != nil; c = c.NextSibling { walk(c) } } walk(n) return found } // firstByClass 按 class 词查找第一个元素(含自身)。 func firstByClass(n *html.Node, class string) *html.Node { if n.Type == html.ElementNode { if hasClass(n, class) { return n } } for c := n.FirstChild; c != nil; c = c.NextSibling { if got := firstByClass(c, class); got != nil { return got } } return nil } // ---- DOM 小工具 ---- func attr(n *html.Node, key string) string { for _, a := range n.Attr { if a.Key == key { return a.Val } } return "" } // hasClass 判断 class 属性里是否有某个**完整词**(svelte 哈希不会误命中)。 func hasClass(n *html.Node, class string) bool { for _, w := range strings.Fields(attr(n, "class")) { if w == class { return true } } return false } // textOf 收集子树可见文本(跳过 script/style,空白压缩)。 func textOf(n *html.Node) string { var b strings.Builder var walk func(*html.Node) walk = func(x *html.Node) { if x.Type == html.ElementNode && fetchSkipElems[strings.ToLower(x.Data)] { return } if x.Type == html.TextNode { b.WriteString(x.Data) b.WriteString(" ") } for c := x.FirstChild; c != nil; c = c.NextSibling { walk(c) } } walk(n) return strings.TrimSpace(fetchSpaceRe.ReplaceAllString(b.String(), " ")) } // ---- 输出 ---- // absURL 把结果里可能出现的相对链接补成绝对链接。 func absURL(h string) string { h = strings.TrimSpace(h) if h == "" || strings.HasPrefix(h, "//") { if h != "" { return "https:" + h } return h } // 带跟踪跳转的 brave 链接(/search?...)不是我们要的结果 if strings.HasPrefix(h, "/") { return "https://search.brave.com" + h } return h } func httpURL(u string) bool { return strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://") } func hostIn(u string, hosts []string) bool { pu, err := url.Parse(u) if err != nil { return false } for _, h := range hosts { if pu.Host == h || strings.HasSuffix(pu.Host, "."+h) { return true } } return false } // dedupe 按 URL 去重(Brave 同一结果常出现两次:图标 + 标题)。 func dedupe(rs []searchResult) []searchResult { seen := map[string]bool{} out := make([]searchResult, 0, len(rs)) for _, r := range rs { key := strings.TrimSuffix(strings.TrimPrefix(r.URL, "https://"), "/") if key == "" || seen[key] { continue } seen[key] = true out = append(out, r) } return out } // renderResults 排版给模型看:编号 + 标题 + URL + 摘要。 func renderResults(q string, rs []searchResult, limit int) string { var b strings.Builder fmt.Fprintf(&b, "web search: %q (%d results)\n", q, min(limit, len(rs))) for i, r := range rs { if i >= limit { break } snip, cut := execx.Truncate([]byte(r.Snippet), searchSnippetBytes) if cut { snip = append(snip, "…"...) } fmt.Fprintf(&b, "\n%d. %s\n %s\n %s\n", i+1, r.Title, r.URL, snip) } b.WriteString("\nnext: pick the most authoritative link and read it with fetch") return strings.TrimRight(b.String(), "\n") }