package search import ( "encoding/json" "fmt" "io" "net/http" "net/url" "os" "strings" "time" ) // SerpApiClient 调用付费的 SerpApi(https://serpapi.com)获取搜索结果。 // // 背景:SearXNG / 搜狗 HTTP / 真实浏览器都会被百度和搜狗对数据中心 IP 的反爬拦截, // 无法稳定取得按关键词匹配的真实结果。SerpApi 是商业搜索 API,服务端负责抓取 // (本机只连 serpapi.com,连通性好),返回结构化 JSON,无验证码,是「通用可搜」 // 闭环的稳定主源。 // // 配置:API key 从环境变量 SERPAPI_API_KEY 读取。未配置 key 时 Search 返回 // (nil, errSkipSerp) 让上层回退到既有的 SearXNG→搜狗→浏览器链,保证无 key 也能跑。 // // 设计说明:engine 用 google(SerpApi 默认),检索在 SerpApi 服务端完成,不受本机 // 出口 IP 反爬影响;hl=zh-cn 让中文结果更相关。 type SerpApiClient struct { apiKey string maxResults int timeout time.Duration hc *http.Client } // errSkipSerp 表示未配置 SerpApi key,调用方应跳过本源继续回退。 type skipSerpError struct{} func (skipSerpError) Error() string { return "SerpApi 未配置 API key,跳过该源" } // NewSerpApiClient 创建 SerpApi 客户端;key 从指定环境变量读取(空串时默认 SERPAPI_API_KEY)。 func NewSerpApiClient(apiKeyEnv ...string) *SerpApiClient { envName := "SERPAPI_API_KEY" if len(apiKeyEnv) > 0 && apiKeyEnv[0] != "" { envName = apiKeyEnv[0] } c := &SerpApiClient{ apiKey: strings.TrimSpace(os.Getenv(envName)), maxResults: 8, timeout: 15 * time.Second, } c.hc = &http.Client{Timeout: c.timeout} return c } // serpAPIURL SerpApi 搜索端点。 const serpAPIURL = "https://serpapi.com/search.json" // serpAPIResponse SerpApi /search.json 的有机结果片段。 type serpAPIResponse struct { OrganicResults []struct { Title string `json:"title"` Link string `json:"link"` Snippet string `json:"snippet"` } `json:"organic_results"` Error string `json:"error"` } // Search 调用 SerpApi 搜索。未配置 key 时返回 skipSerpError。 func (c *SerpApiClient) Search(query string) ([]Result, error) { if c.apiKey == "" { return nil, skipSerpError{} } params := url.Values{} params.Set("q", query) params.Set("engine", "google") params.Set("hl", "zh-cn") params.Set("num", fmt.Sprintf("%d", c.maxResults)) params.Set("api_key", c.apiKey) req, err := http.NewRequest(http.MethodGet, serpAPIURL+"?"+params.Encode(), nil) if err != nil { return nil, fmt.Errorf("构建 SerpApi 请求失败: %w", err) } req.Header.Set("Accept", "application/json") resp, err := c.hc.Do(req) if err != nil { return nil, fmt.Errorf("SerpApi 不可达: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("读取 SerpApi 响应失败: %w", err) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("SerpApi 返回 %d: %s", resp.StatusCode, truncate(string(body), 200)) } var out serpAPIResponse if err := json.Unmarshal(body, &out); err != nil { return nil, fmt.Errorf("SerpApi 响应解析失败: %w", err) } if out.Error != "" { return nil, fmt.Errorf("SerpApi 业务错误: %s", out.Error) } var res []Result for _, r := range out.OrganicResults { title := strings.TrimSpace(r.Title) link := strings.TrimSpace(r.Link) if title == "" || link == "" { continue } res = append(res, Result{ Title: title, URL: link, Content: strings.TrimSpace(r.Snippet), Engine: "serpapi_google", }) if len(res) >= c.maxResults { break } } return res, nil }