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 匹配搜狗结果条目。实际结构(实测): // //

标题 //

摘要

//

//
// 取 的 href 作 URL,h3 内文本作标题。 sogouItemRe = regexp.MustCompile(`(?s)]*href="([^"]+)"[^>]*>.*?]*class="[^"]*vr-title[^"]*"[^>]*>.*?]*>(.*?)`) // 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)[^"]*"[^>]*>(.*?)`) 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]) }