package search import ( "bytes" "crypto/rand" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "os" "strings" "time" ) // DtsAIClient 调用阿里云 DTS-AI 服务(DtsAI, Version 2026-04-01)的 WebSearch 获取搜索结果。 // // 背景:SerpApi 是境外付费源,个别企业网络或跨境场景下连通性/合规受限。阿里云 DTS-AI // 是部署在国内(cn-beijing)的官方搜索服务,服务器端完成检索,返回结构化 JSON、无验证码, // 公测期间免费。可作为「通用可搜」闭环在国内的稳定补充源。 // // 认证:DTS-AI 支持两种凭据,本实现采用官方推荐的 API Key 方式——通过请求头 // `X-Acs-ApiKey` 透传,无需计算 OpenAPI 签名,便于轻量集成。key 从环境变量 // `DTS_AI_API_KEY` 读取(可在 search_config.json 的 api_key_env 覆盖)。未配置 key 时 // Search 返回 (nil, skipDtsAIError) 让上层回退到下一引擎,保证无 key 也能跑。 // // 调用方式(curl 实测对等): // // POST https://dtsai.cn-beijing.aliyuncs.com?Action=WebSearch&Version=2026-04-01&SignatureNonce= // Header: X-Acs-ApiKey: Content-Type: application/json // Body: {"RegionId":"cn-beijing","Query":"...","MaxResults":N} type DtsAIClient struct { apiKey string region string endpoint string maxResults int timeout time.Duration hc *http.Client } // skipDtsAIError 表示未配置 DTS-AI API key,调用方应跳过本源继续回退。 type skipDtsAIError struct{} func (skipDtsAIError) Error() string { return "DTS-AI 未配置 API key,跳过该源" } // NewDtsAIClient 创建 DTS-AI 客户端。 // - apiKeyEnv: 读取 API key 的环境变量名(空串时默认 DTS_AI_API_KEY) // - region: 地域 ID(默认 cn-beijing,官方另支持 ap-southeast-1) // - endpoint: 服务入口;推荐 dtsai..aliyuncs.com,空串时按 region 推导 // - maxResults: 单次最多返回条数(官方默认 10,最大 50) // - timeoutSec: 单次请求超时秒数 func NewDtsAIClient(apiKeyEnv, region, endpoint string, maxResults, timeoutSec int) *DtsAIClient { envName := "DTS_AI_API_KEY" if apiKeyEnv != "" { envName = apiKeyEnv } if region == "" { region = "cn-beijing" } if endpoint == "" { endpoint = fmt.Sprintf("https://dtsai.%s.aliyuncs.com", region) } if maxResults <= 0 { maxResults = 10 } if timeoutSec <= 0 { timeoutSec = 15 } c := &DtsAIClient{ apiKey: strings.TrimSpace(os.Getenv(envName)), region: region, endpoint: strings.TrimRight(endpoint, "/"), maxResults: maxResults, } c.hc = &http.Client{Timeout: time.Duration(timeoutSec) * time.Second} return c } // randNonce 生成请求签名随机数(SignatureNonce),保持请求唯一性。 func randNonce() string { b := make([]byte, 16) if _, err := rand.Read(b); err != nil { return fmt.Sprintf("%d", time.Now().UnixNano()) } return hex.EncodeToString(b) } // dtsAIRequest DTS-AI WebSearch 请求体(对齐 cargo 各字段部分可选)。 type dtsAIRequest struct { RegionId string `json:"RegionId"` Query string `json:"Query"` MaxResults int `json:"MaxResults"` } // dtsAIItem 单个搜索结果。 type dtsAIItem struct { Title string `json:"Title"` Url string `json:"Url"` Snippet string `json:"Snippet"` } // dtsAIResponse DTS-AI WebSearch 响应体。 type dtsAIResponse struct { RequestId string `json:"RequestId"` Query string `json:"Query"` HttpStatusCode int `json:"HttpStatusCode"` TotalResults int `json:"TotalResults"` Success bool `json:"Success"` ErrorMessage string `json:"ErrorMessage"` Code string `json:"Code"` SearchResult []dtsAIItem `json:"SearchResult"` } // Search 调用 DTS-AI WebSearch 搜索。未配置 key 时返回 skipDtsAIError。 func (c *DtsAIClient) Search(query string) ([]Result, error) { if c.apiKey == "" { return nil, skipDtsAIError{} } payload, err := json.Marshal(dtsAIRequest{ RegionId: c.region, Query: query, MaxResults: c.maxResults, }) if err != nil { return nil, fmt.Errorf("构建 DTS-AI 请求体失败: %w", err) } endpoint := fmt.Sprintf("%s?Action=WebSearch&Version=2026-04-01&SignatureNonce=%s", c.endpoint, randNonce()) req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(payload)) if err != nil { return nil, fmt.Errorf("构建 DTS-AI 请求失败: %w", err) } req.Header.Set("X-Acs-ApiKey", c.apiKey) req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") resp, err := c.hc.Do(req) if err != nil { return nil, fmt.Errorf("DTS-AI 不可达: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("读取 DTS-AI 响应失败: %w", err) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("DTS-AI 返回 %d: %s", resp.StatusCode, truncate(string(body), 200)) } var out dtsAIResponse if err := json.Unmarshal(body, &out); err != nil { return nil, fmt.Errorf("DTS-AI 响应解析失败: %w", err) } if !out.Success { if out.ErrorMessage != "" { return nil, fmt.Errorf("DTS-AI 业务错误: %s", out.ErrorMessage) } if out.Code != "" { return nil, fmt.Errorf("DTS-AI 业务错误 code=%s", out.Code) } return nil, fmt.Errorf("DTS-AI 业务错误: Success=false") } var res []Result for _, r := range out.SearchResult { title := strings.TrimSpace(r.Title) link := strings.TrimSpace(r.Url) if title == "" || link == "" { continue } res = append(res, Result{ Title: title, URL: link, Content: strings.TrimSpace(r.Snippet), Engine: "dtsai", }) if len(res) >= c.maxResults { break } } return res, nil }