Files
pj0235-eai_agentplatform/eai_agentplatform/backend-go/internal/search/sogou.go
T
eaiadminandClaude Code c1af86c934 feat(asr): 本地语音转写接入为一级路由 + 并行工作流合并提交
按用户指示做**一包提交**,不按工作流拆分。本提交刻意混合了多条并行线:

  · 本地 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>
2026-09-26 22:21:39 +08:00

160 lines
5.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 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])
}