按用户指示做**一包提交**,不按工作流拆分。本提交刻意混合了多条并行线:
· 本地 ASR 接管:audio 成为与 chat/embed/image/video 同等的路由类别
(IsLocalRoute 单一判据、audio 健康探测、default_audio_route、
auto 占位、GET /api/ai/routes/audio、回退云端时界面明示「音频已出网」)
· LLM 调用层:ctx 贯穿、ToolCall/ToolSchema、EmptyCompletionError /
TransientUpstreamError(按错误类型而非文案判重试)
· 编排 Agent:general_assistant orchestrate/persistence/spec_driver
· 联网搜索:internal/search(playwright)
· 网盘:backend + 前端
· 前端 UI:导航/路由/工作台若干页
· 交付文档:DELIVERY.md / AR04 / 部署文档的「无 Python」表述据实改写,
新增 eai_agentplatform-asr.service、asr.env、clonezilla-cleanup 清 ~/asr-poc
不分拆的原因:dev 早期,粒度不该打断工作节奏。且实测过——这些改动
**在编译上是同一个单元**(llm.go 的 ctx 签名变更牵动 12 个调用点,
chat_message.go 的 ctx 改动又与编排重写同处一个 hunk),拆出来的中间态编不过。
详见 TOP_CODING_RULES.md G14.5 与 bugs_and_errors.md E09。
Co-Authored-By: Claude Code <noreply@anthropic.com>
160 lines
5.1 KiB
Go
160 lines
5.1 KiB
Go
package search
|
||
|
||
import (
|
||
"crypto/rand"
|
||
"fmt"
|
||
"html"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"regexp"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// SogouBaseURL 搜狗网页搜索地址(墙内可直连、按关键词真实返回结果,
|
||
// 用于在 SearXNG 上游失效时作为第三方搜索源兜底)。
|
||
const SogouBaseURL = "https://www.sogou.com/web"
|
||
|
||
const (
|
||
sogouTimeout = 12 * time.Second
|
||
sogouMaxResult = 8
|
||
)
|
||
|
||
// SogouUserAgent 避免触发反爬降级的真实浏览器 UA。
|
||
const SogouUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0 Safari/537.36"
|
||
|
||
var (
|
||
// sogouItemRe 匹配搜狗结果条目。实际结构(实测):
|
||
// <a href="真实URL" class="special-title" ...>
|
||
// <h3 class="vr-title"><span>标题</span>
|
||
// <p class="title-summary">摘要</p>
|
||
// </h3>
|
||
// </a>
|
||
// 取 <a> 的 href 作 URL,h3 内文本作标题。
|
||
sogouItemRe = regexp.MustCompile(`(?s)<a[^>]*href="([^"]+)"[^>]*>.*?<h3[^>]*class="[^"]*vr-title[^"]*"[^>]*>.*?<span[^>]*>(.*?)</span>`)
|
||
// sogouDescRe 匹配结果摘要(title-summary / str_info / space-txt 等文本容器)。
|
||
sogouDescRe = regexp.MustCompile(`(?s)<(?:p|span|div)[^>]*class="[^"]*(?:title-summary|str_info|space-txt|fb fz-mid|text-info)[^"]*"[^>]*>(.*?)</(?:p|span|div)>`)
|
||
anyTagRe = regexp.MustCompile(`<[^>]+>`)
|
||
)
|
||
|
||
// SogouClient 搜狗网页搜索客户端。随机化部分请求头,降低连续查询触发
|
||
// 人机校验的概率(已验证源响应按关键词真实匹配,而非缓存页)。
|
||
type SogouClient struct {
|
||
timeout time.Duration
|
||
hc *http.Client
|
||
}
|
||
|
||
// NewSogouClient 创建搜狗搜索客户端。
|
||
func NewSogouClient() *SogouClient {
|
||
return &SogouClient{timeout: sogouTimeout, hc: &http.Client{Timeout: sogouTimeout}}
|
||
}
|
||
|
||
// Search 抓取并解析搜狗网页搜索结果,映射为 []Result。
|
||
// 搜狗对数据中心 IP 的连续快速查询偶发人机校验(返回验证码页),
|
||
// 此处做一次短暂退避重试,降低瞬时校验导致的空结果概率。
|
||
func (s *SogouClient) Search(query string) ([]Result, error) {
|
||
for attempt := 0; ; attempt++ {
|
||
res, err := s.searchOnce(query)
|
||
if (err == nil && len(res) > 0) || attempt >= 1 {
|
||
return res, err
|
||
}
|
||
// 空结果或错误:等一小段再试一次
|
||
time.Sleep(1200 * time.Millisecond)
|
||
}
|
||
}
|
||
|
||
func (s *SogouClient) searchOnce(query string) ([]Result, error) {
|
||
params := url.Values{}
|
||
params.Set("query", query)
|
||
searchURL := SogouBaseURL + "?" + params.Encode()
|
||
|
||
req, err := http.NewRequest(http.MethodGet, searchURL, nil)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("构建搜狗请求失败: %w", err)
|
||
}
|
||
req.Header.Set("User-Agent", SogouUserAgent)
|
||
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
||
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
|
||
req.Header.Set("Referer", "https://www.sogou.com/")
|
||
req.Header.Set("X-Forwarded-For", randomIP())
|
||
req.Header.Set("X-Real-IP", randomIP())
|
||
|
||
resp, err := s.hc.Do(req)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("搜狗不可达: %w", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode != http.StatusOK {
|
||
return nil, fmt.Errorf("搜狗返回 %d", resp.StatusCode)
|
||
}
|
||
body, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("读取搜狗响应失败: %w", err)
|
||
}
|
||
return parseSogou(body), nil
|
||
}
|
||
|
||
// parseSogou 从搜狗结果页提取标题/链接/摘要。
|
||
func parseSogou(page []byte) []Result {
|
||
raw := string(page)
|
||
// 命中人机校验页则明确返回空(触发上层回退),不产出伪结果。
|
||
if strings.Contains(raw, "请输入验证码") || strings.Contains(raw, "antispider") || strings.Contains(raw, "访问过于频繁") {
|
||
return nil
|
||
}
|
||
|
||
matches := sogouItemRe.FindAllStringSubmatch(raw, -1)
|
||
results := make([]Result, 0, len(matches))
|
||
for _, m := range matches {
|
||
if len(m) < 3 {
|
||
continue
|
||
}
|
||
href := m[1]
|
||
title := cleanText(m[2])
|
||
if title == "" {
|
||
continue
|
||
}
|
||
// 跳过非可访问协议(如 javascript:void(0) 的分享桩),只保留 http(s) 结果链接。
|
||
if !strings.HasPrefix(href, "http") && !strings.HasPrefix(href, "/link?") {
|
||
continue
|
||
}
|
||
// 结果链接多为搜狗跳转封装 (/link?url=...),保留源标题/摘要,
|
||
// 跳转链接可由调用方按需跟进,不影响 LLM 引用来源。
|
||
urlText := href
|
||
if strings.HasPrefix(href, "/link?") || strings.HasPrefix(href, "/weixin") {
|
||
urlText = "https://www.sogou.com" + href
|
||
}
|
||
results = append(results, Result{
|
||
Title: title,
|
||
URL: urlText,
|
||
Content: extractSogouDesc(raw),
|
||
Engine: "sogou",
|
||
})
|
||
if len(results) >= sogouMaxResult {
|
||
break
|
||
}
|
||
}
|
||
return results
|
||
}
|
||
|
||
// extractSogouDesc 提取搜狗摘要段;无法精确定位时返回空。
|
||
func extractSogouDesc(raw string) string {
|
||
m := sogouDescRe.FindStringSubmatch(raw)
|
||
if len(m) < 2 {
|
||
return ""
|
||
}
|
||
return cleanText(m[1])
|
||
}
|
||
|
||
func cleanText(s string) string {
|
||
s = anyTagRe.ReplaceAllString(s, "")
|
||
s = html.UnescapeString(s)
|
||
return strings.TrimSpace(s)
|
||
}
|
||
|
||
func randomIP() string {
|
||
b := make([]byte, 4)
|
||
_, _ = rand.Read(b)
|
||
return fmt.Sprintf("%d.%d.%d.%d", b[0], b[1], b[2], b[3])
|
||
} |