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>
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/playwright-community/playwright-go"
|
||||
)
|
||||
|
||||
// browserSearchTimeout 单次浏览器搜索的整体上限。
|
||||
const browserSearchTimeout = 25 * time.Second
|
||||
|
||||
// browserSearchMaxResult 浏览器源单次最多返回结果数。
|
||||
const browserSearchMaxResult = 8
|
||||
|
||||
// BrowserSearcher 用 Playwright + 系统 Chrome 驱动真实浏览器抓取搜索结果。
|
||||
//
|
||||
// 背景:SearXNG / 纯 HTTP 抓取都会被百度和搜狗的反爬(checkSNUID、验证码、UA 检测)
|
||||
// 拦截,无法稳定取到按关键词匹配的真实结果。真实浏览器(本机 google-chrome,
|
||||
// channel="chrome")可天然通过 JS 反爬,是「通用可搜」闭环的最终兜底来源。
|
||||
//
|
||||
// 设计要点(单进程、懒加载、并发安全):
|
||||
// - 浏览器实例全局懒加载,只启动一次,进程内复用;
|
||||
// - Search 用互斥锁串行化,避免多个并发 worker 同时驱动同一浏览器导致页面串扰;
|
||||
// - 每次查询用独立 BrowserContext+Page,查询间相互隔离;
|
||||
// - 浏览器不可用时优雅失败(返回错误而非 panic),由上层回退到已有逻辑。
|
||||
//
|
||||
// 运行期依赖(新环境需先就绪):
|
||||
// - 系统必须装有 google-chrome(channel="chrome" 复用,免下载 Chromium);
|
||||
// - playwright 驱动需先就绪:上游 CDN 已失效,故用 npm 装 playwright-core@1.60.0,
|
||||
// 将 node_modules/playwright-core 复制为 ~/.cache/ms-playwright-go/1.60.0/package,
|
||||
// 并 ln -s /usr/bin/node ~/.cache/ms-playwright-go/1.60.0/node。
|
||||
// 装好即可,Search() 只调用 playwright.Run(),不做浏览器下载。
|
||||
type BrowserSearcher struct {
|
||||
startOnce sync.Once
|
||||
startErr error
|
||||
browser playwright.Browser
|
||||
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
var browserSearcher *BrowserSearcher
|
||||
|
||||
func init() {
|
||||
browserSearcher = &BrowserSearcher{}
|
||||
}
|
||||
|
||||
// GetBrowserSearcher 返回全局单例浏览器搜索器。
|
||||
func GetBrowserSearcher() *BrowserSearcher {
|
||||
return browserSearcher
|
||||
}
|
||||
|
||||
// Search 用真实浏览器抓取并解析结果,返回 []Result。
|
||||
func (b *BrowserSearcher) Search(query string) ([]Result, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
if err := b.ensureBrowser(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), browserSearchTimeout)
|
||||
defer cancel()
|
||||
|
||||
// 每查询独立 context+page,隔离 cookies 与反爬指纹。
|
||||
page, err := b.newPage(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建浏览器页失败: %w", err)
|
||||
}
|
||||
defer func() { _ = page.Close() }()
|
||||
|
||||
// 依次尝试百度、搜狗,任一成功即返回。
|
||||
if res, err := b.searchBaidu(ctx, page, query); err == nil && len(res) > 0 {
|
||||
return res, nil
|
||||
}
|
||||
if res, err := b.searchSogou(ctx, page, query); err == nil && len(res) > 0 {
|
||||
return res, nil
|
||||
}
|
||||
return nil, fmt.Errorf("浏览器搜索无结果(百度/搜狗均未命中)")
|
||||
}
|
||||
|
||||
// ensureBrowser 懒加载并启动系统 Chrome(channel=chrome,复用系统浏览器,免下载 Chromium)。
|
||||
func (b *BrowserSearcher) ensureBrowser() error {
|
||||
b.startOnce.Do(func() {
|
||||
pw, err := playwright.Run()
|
||||
if err != nil {
|
||||
b.startErr = fmt.Errorf("启动 playwright 驱动失败: %w", err)
|
||||
return
|
||||
}
|
||||
browser, err := pw.Chromium.Launch(playwright.BrowserTypeLaunchOptions{
|
||||
Channel: playwright.String("chrome"), // 使用系统已装的 google-chrome,免下载 Chromium
|
||||
Headless: playwright.Bool(true),
|
||||
Args: []string{"--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu"},
|
||||
IgnoreDefaultArgs: []string{"--enable-automation"},
|
||||
})
|
||||
if err != nil {
|
||||
b.startErr = fmt.Errorf("启动系统 Chrome 失败: %w", err)
|
||||
return
|
||||
}
|
||||
b.browser = browser
|
||||
})
|
||||
return b.startErr
|
||||
}
|
||||
|
||||
const browserUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
|
||||
|
||||
// newPage 创建隔离的浏览器页面。
|
||||
func (b *BrowserSearcher) newPage(ctx context.Context) (playwright.Page, error) {
|
||||
bctx, err := b.browser.NewContext(playwright.BrowserNewContextOptions{
|
||||
Locale: playwright.String("zh-CN"),
|
||||
UserAgent: playwright.String(browserUA),
|
||||
Viewport: &playwright.Size{Width: 1366, Height: 900},
|
||||
Permissions: []string{"geolocation"},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
page, err := bctx.NewPage()
|
||||
if err != nil {
|
||||
_ = bctx.Close()
|
||||
return nil, err
|
||||
}
|
||||
return page, nil
|
||||
}
|
||||
|
||||
// searchBaidu 抓取百度搜索结果。<h3 class="c-title"> <a href>标题</a>。
|
||||
func (b *BrowserSearcher) searchBaidu(ctx context.Context, page playwright.Page, query string) ([]Result, error) {
|
||||
if _, err := page.Goto("https://www.baidu.com/s?wd="+url.QueryEscape(query), playwright.PageGotoOptions{
|
||||
Timeout: playwright.Float(20000),
|
||||
WaitUntil: playwright.WaitUntilStateDomcontentloaded,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 等若干条结果标题元素出现。
|
||||
if _, err := page.WaitForSelector("h3", playwright.PageWaitForSelectorOptions{Timeout: playwright.Float(15000)}); err != nil {
|
||||
return nil, fmt.Errorf("百度结果未渲染: %w", err)
|
||||
}
|
||||
|
||||
items, err := page.Locator("h3").All()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var res []Result
|
||||
for _, h := range items {
|
||||
a := h.Locator("a").First()
|
||||
title, _ := a.InnerText()
|
||||
href, _ := a.GetAttribute("href")
|
||||
if strings.TrimSpace(title) == "" || href == "" {
|
||||
continue
|
||||
}
|
||||
res = append(res, Result{Title: strings.TrimSpace(title), URL: href, Content: "", Engine: "baidu_browser"})
|
||||
if len(res) >= browserSearchMaxResult {
|
||||
break
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// searchSogou 抓取搜狗搜索结果(真实浏览器可过 checkSNUID)。
|
||||
func (b *BrowserSearcher) searchSogou(ctx context.Context, page playwright.Page, query string) ([]Result, error) {
|
||||
if _, err := page.Goto("https://www.sogou.com/web?query="+url.QueryEscape(query), playwright.PageGotoOptions{
|
||||
Timeout: playwright.Float(20000),
|
||||
WaitUntil: playwright.WaitUntilStateDomcontentloaded,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := page.WaitForSelector("h3", playwright.PageWaitForSelectorOptions{Timeout: playwright.Float(15000)}); err != nil {
|
||||
return nil, fmt.Errorf("搜狗结果未渲染: %w", err)
|
||||
}
|
||||
items, err := page.Locator("h3").All()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var res []Result
|
||||
for _, h := range items {
|
||||
a := h.Locator("a").First()
|
||||
title, _ := a.InnerText()
|
||||
href, _ := a.GetAttribute("href")
|
||||
if strings.TrimSpace(title) == "" || href == "" {
|
||||
continue
|
||||
}
|
||||
res = append(res, Result{Title: strings.TrimSpace(title), URL: href, Content: "", Engine: "sogou_browser"})
|
||||
if len(res) >= browserSearchMaxResult {
|
||||
break
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Result 单个搜索结果。
|
||||
type Result struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Content string `json:"content"`
|
||||
Engine string `json:"engine"`
|
||||
}
|
||||
|
||||
// Response SearXNG /search?format=json 的标准响应(兼容旧响应结构)。
|
||||
type Response struct {
|
||||
Query string `json:"query"`
|
||||
Results []Result `json:"results"`
|
||||
}
|
||||
|
||||
// Client 搜索引擎聚合编排器。
|
||||
//
|
||||
// 持有一组按优先级排序的 Engine,Search 按顺序逐个调用,命中(成功且有结果)即返回;
|
||||
// 单个引擎失败/无结果时降级到下一引擎,全部失败再汇总根因。启用的引擎与顺序由
|
||||
// search_config.json 的 active 列表声明,替代此前写死代码里的硬编码回退链。
|
||||
type Client struct {
|
||||
engines []Engine
|
||||
}
|
||||
|
||||
// NewConfiguredClient 按配置构建聚合 Client。
|
||||
//
|
||||
// - cfg 为 nil(无配置文件)时使用默认硬编码链:SearXNG → 搜狗 HTTP → 真实浏览器,
|
||||
// 保证旧行为不因缺配置而改变。
|
||||
// - cfg 非 nil 时按 active 列表顺序构建引擎,跳过 disabled 或未知类型者。
|
||||
func NewConfiguredClient(cfg *SearchConfig) *Client {
|
||||
if cfg == nil {
|
||||
return &Client{engines: defaultEngines()}
|
||||
}
|
||||
c := &Client{}
|
||||
for _, name := range cfg.Active {
|
||||
ec, ok := cfg.Engines[name]
|
||||
if !ok || !ec.Enabled {
|
||||
continue
|
||||
}
|
||||
if e, err := engineFromConfig(name, ec); err != nil {
|
||||
// 未知类型直接跳过,不阻塞整链。
|
||||
continue
|
||||
} else {
|
||||
c.engines = append(c.engines, e)
|
||||
}
|
||||
}
|
||||
// 配置全被跳过时回退默认链,避免空组织。
|
||||
if len(c.engines) == 0 {
|
||||
c.engines = defaultEngines()
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// defaultEngines 无配置时的默认硬编码链(保持旧行为)。
|
||||
func defaultEngines() []Engine {
|
||||
return []Engine{
|
||||
&searxngEngine{client: NewSearXNGClient("")},
|
||||
&sogouEngine{client: NewSogouClient()},
|
||||
&browserEngine{searcher: GetBrowserSearcher()},
|
||||
}
|
||||
}
|
||||
|
||||
// EngineNames 返回当前生效的引擎名(有序),供诊断/展示。
|
||||
func (c *Client) EngineNames() []string {
|
||||
names := make([]string, 0, len(c.engines))
|
||||
for _, e := range c.engines {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// Search 按配置顺序逐个调用引擎,命中即返回首个有结果的引擎。
|
||||
// 全部引擎失败时聚合各引擎错误。
|
||||
func (c *Client) Search(query string) ([]Result, error) {
|
||||
var errParts []string
|
||||
for _, e := range c.engines {
|
||||
res, err := e.Search(query)
|
||||
if err == nil && len(res) > 0 {
|
||||
return res, nil
|
||||
}
|
||||
errParts = append(errParts, fmt.Sprintf("%s:%v", e.Name(), orNil(err)))
|
||||
}
|
||||
return nil, fmt.Errorf("全部搜索引擎无结果:[%s]", strings.Join(errParts, ";"))
|
||||
}
|
||||
|
||||
func orNil(err error) any {
|
||||
if err == nil {
|
||||
return "无结果"
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= n {
|
||||
return s
|
||||
}
|
||||
return string(r[:n]) + "..."
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultBaseURL 本机 SearXNG 服务默认地址(deploy/searxng docker-compose 映射)。
|
||||
DefaultBaseURL = "http://127.0.0.1:18080"
|
||||
|
||||
// EnvBaseURL 环境变量名,可覆盖 SearXNG 地址(仅无 search_config.json 时的默认链有效)。
|
||||
EnvBaseURL = "SEARXNG_BASE_URL"
|
||||
)
|
||||
|
||||
var (
|
||||
defaultClient *Client
|
||||
clientOnce sync.Once
|
||||
)
|
||||
|
||||
// GetClient 返回全局默认搜索客户端。
|
||||
//
|
||||
// 实现:懒加载,读 search_config.json 构建聚合 Client(多引擎、有序、可配置);
|
||||
// 无配置文件时回退默认链(SearXNG → 搜狗 HTTP → 真实浏览器),保证旧行为不破功。
|
||||
func GetClient() *Client {
|
||||
clientOnce.Do(func() {
|
||||
defaultClient = NewConfiguredClient(GetSearchConfig())
|
||||
})
|
||||
return defaultClient
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
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=<uuid>
|
||||
// Header: X-Acs-ApiKey: <key> 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.<region>.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
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestDtsAIUnconfiguredKey 未配置 API key 时应返回 skipDtsAIError(可被聚合 Client 降级),
|
||||
// 确保无 key 也能跑,不阻塞链路。
|
||||
func TestDtsAIUnconfiguredKey(t *testing.T) {
|
||||
// 显式覆盖一个空环境变量,保证本测试不依赖宿主 .env。
|
||||
t.Setenv("DTS_AI_API_KEY", "")
|
||||
c := NewDtsAIClient("DTS_AI_API_KEY", "cn-beijing", "", 8, 15)
|
||||
_, err := c.Search("测试")
|
||||
if err == nil {
|
||||
t.Fatal("未配置 key 时应返回 skipDtsAIError")
|
||||
}
|
||||
if _, ok := err.(skipDtsAIError); !ok {
|
||||
t.Fatalf("应返回 skipDtsAIError,实得 %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDtsAIIntegration 端到端联调 DTS-AI WebSearch(仅在配置了 DTS_AI_API_KEY 时执行,
|
||||
// 避免无 key 的 CI 环境因网络调用而失败)。
|
||||
func TestDtsAIIntegration(t *testing.T) {
|
||||
if os.Getenv("DTS_AI_API_KEY") == "" {
|
||||
t.Skip("未配置 DTS_AI_API_KEY,跳过 DTS-AI 网络联调")
|
||||
}
|
||||
c := NewDtsAIClient("DTS_AI_API_KEY", "cn-beijing", "", 5, 20)
|
||||
res, err := c.Search("外骨骼机器人 市场规模")
|
||||
if err != nil {
|
||||
t.Fatalf("DTS-AI 搜索失败: %v", err)
|
||||
}
|
||||
if len(res) == 0 {
|
||||
t.Fatal("DTS-AI 应返回至少 1 条结果")
|
||||
}
|
||||
for _, r := range res {
|
||||
if r.Title == "" || r.URL == "" {
|
||||
t.Errorf("存在空标题/空链接: %+v", r)
|
||||
}
|
||||
}
|
||||
t.Logf("DTS-AI 返回 %d 条;首条: %s -> %s", len(res), res[0].Title, res[0].URL)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package search
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Engine 一个可插拔的搜索引擎源。
|
||||
//
|
||||
// 各源(SearXNG、搜狗 HTTP、真实浏览器、付费 SerpApi 等)通过实现该接口接入聚合 Client,
|
||||
// 由 search_config.json 声明启用顺序与参数,替代此前写死在 Client.Search 里的硬编码回退链。
|
||||
type Engine interface {
|
||||
// Name 引擎唯一标识(与 search_config.json 中 engines 的 key 对应)。
|
||||
Name() string
|
||||
// Search 执行一次搜索。失败或无结果时返回 (nil, err),由聚合 Client 降级到下一引擎。
|
||||
Search(query string) ([]Result, error)
|
||||
}
|
||||
|
||||
// searxngEngine 基于 SearXNG /search?format=json 的引擎。
|
||||
type searxngEngine struct {
|
||||
client *SearXNGClient
|
||||
}
|
||||
|
||||
func (e *searxngEngine) Name() string { return "searxng" }
|
||||
func (e *searxngEngine) Search(q string) ([]Result, error) {
|
||||
return e.client.searchSearXNG(q)
|
||||
}
|
||||
|
||||
// sogouEngine 基于搜狗 HTTP 抓取的引擎。
|
||||
type sogouEngine struct {
|
||||
client *SogouClient
|
||||
}
|
||||
|
||||
func (e *sogouEngine) Name() string { return "sogou" }
|
||||
func (e *sogouEngine) Search(q string) ([]Result, error) {
|
||||
return e.client.Search(q)
|
||||
}
|
||||
|
||||
// browserEngine 基于真实浏览器(Playwright + 系统 Chrome)的引擎。
|
||||
type browserEngine struct {
|
||||
searcher *BrowserSearcher
|
||||
}
|
||||
|
||||
func (e *browserEngine) Name() string { return "browser" }
|
||||
func (e *browserEngine) Search(q string) ([]Result, error) {
|
||||
return e.searcher.Search(q)
|
||||
}
|
||||
|
||||
// serpapiEngine 基于付费 SerpApi 的引擎。
|
||||
type serpapiEngine struct {
|
||||
client *SerpApiClient
|
||||
}
|
||||
|
||||
func (e *serpapiEngine) Name() string { return "serpapi" }
|
||||
func (e *serpapiEngine) Search(q string) ([]Result, error) {
|
||||
return e.client.Search(q)
|
||||
}
|
||||
|
||||
// dtsAIEngine 基于阿里云 DTS-AI WebSearch 的引擎。
|
||||
type dtsAIEngine struct {
|
||||
client *DtsAIClient
|
||||
}
|
||||
|
||||
func (e *dtsAIEngine) Name() string { return "dtsai" }
|
||||
func (e *dtsAIEngine) Search(q string) ([]Result, error) {
|
||||
return e.client.Search(q)
|
||||
}
|
||||
|
||||
// engineFromConfig 根据一条 EngineConfig 构建对应引擎;type 未知返回 error。
|
||||
func engineFromConfig(name string, ec EngineConfig) (Engine, error) {
|
||||
switch ec.Type {
|
||||
case "searxng":
|
||||
return &searxngEngine{client: NewSearXNGClient(ec.BaseURL)}, nil
|
||||
case "sogou":
|
||||
return &sogouEngine{client: NewSogouClient()}, nil
|
||||
case "browser":
|
||||
return &browserEngine{searcher: GetBrowserSearcher()}, nil
|
||||
case "serpapi":
|
||||
return &serpapiEngine{client: NewSerpApiClient(ec.APIKeyEnv)}, nil
|
||||
case "dtsai":
|
||||
return &dtsAIEngine{client: NewDtsAIClient(ec.APIKeyEnv, ec.Region, ec.BaseURL, ec.MaxResults, ec.TimeoutSec)}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("未知搜索引擎类型 %q(引擎 %q)", ec.Type, name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// 本文件实现搜索引擎的 JSON 配置驱动。
|
||||
//
|
||||
// 背景:此前搜索源(SearXNG→搜狗HTTP→浏览器)是写死在 Client.Search 里的硬编码回退链。
|
||||
// 现在改成由 config/search_config.json 声明「启用哪些引擎、按什么顺序、各自参数」,
|
||||
// Client 按配置顺序逐个调用,命中即返回。这样新增/停用/调整引擎只需改配置,无需改代码,
|
||||
// 对齐项目「JSON 配置驱动」的约束。
|
||||
|
||||
// EngineConfig 单个搜索引擎的配置。
|
||||
type EngineConfig struct {
|
||||
Type string `json:"type"` // searxng | sogou | browser | serpapi | dtsai
|
||||
Enabled bool `json:"enabled"` // 是否启用
|
||||
BaseURL string `json:"base_url"` // searxng / html 类引擎的入口地址;dtsai 作为服务端入口(可空,按 region 推导)
|
||||
APIKeyEnv string `json:"api_key_env"` // serpapi / dtsai 的 API key 所在环境变量名
|
||||
Engine string `json:"engine"` // serpapi 具体用哪个后端引擎 google/bing/baidu
|
||||
Language string `json:"language"` // 搜索语言(zh / zh-cn / en ...)
|
||||
Region string `json:"region"` // dtsai 服务地域(cn-beijing 等)
|
||||
MaxResults int `json:"max_results"` // 单引擎最多返回条数(0 表示用默认)
|
||||
TimeoutSec int `json:"timeout_sec"` // 单次搜索超时秒数(0 表示用默认)
|
||||
Retry int `json:"retry"` // 失败重试次数(0 表示不重试)
|
||||
}
|
||||
|
||||
// SearchConfig 对外暴露的搜索引擎编排配置。
|
||||
type SearchConfig struct {
|
||||
Version int `json:"version"`
|
||||
Active []string `json:"active"` // 生效的引擎名(有序 = 尝试优先级)
|
||||
Engines map[string]EngineConfig `json:"engines"`
|
||||
}
|
||||
|
||||
const (
|
||||
// DefaultSearchConfigName 默认配置文件(放在可执行文件旁的 config/ 目录)。
|
||||
DefaultSearchConfigName = "search_config.json"
|
||||
|
||||
// EnvSearchConfig 环境变量,可覆盖配置文件完整路径。
|
||||
EnvSearchConfig = "SEARCH_CONFIG"
|
||||
)
|
||||
|
||||
var (
|
||||
searchCfgMu sync.RWMutex
|
||||
searchCfgOnce sync.Once
|
||||
searchCfgVal *SearchConfig
|
||||
)
|
||||
|
||||
// searchConfigPath 定位 search_config.json:优先 SEARCH_CONFIG 环境变量,其次可执行文件旁 config/。
|
||||
func searchConfigPath(name string) string {
|
||||
if p := os.Getenv(EnvSearchConfig); p != "" {
|
||||
return p
|
||||
}
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
exeDir := filepath.Dir(exe)
|
||||
candidates := []string{
|
||||
filepath.Join(exeDir, "config", name),
|
||||
filepath.Join(exeDir, "..", "config", name),
|
||||
}
|
||||
for _, d := range candidates {
|
||||
if fi, err := os.Stat(d); err == nil && !fi.IsDir() {
|
||||
return d
|
||||
}
|
||||
}
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// loadSearchConfig 从配置文件解析为 SearchConfig。
|
||||
func loadSearchConfig(path string) (*SearchConfig, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取 %s 失败: %w", path, err)
|
||||
}
|
||||
var cfg SearchConfig
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("解析 %s 失败: %w", path, err)
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// GetSearchConfig 返回全局搜索引擎配置(懒加载 + 缓存)。
|
||||
// 配置文件不存在时返回 nil(不报错),由调用方回退到默认硬编码链,保证旧行为不破功。
|
||||
func GetSearchConfig() *SearchConfig {
|
||||
searchCfgOnce.Do(func() {
|
||||
path := searchConfigPath(DefaultSearchConfigName)
|
||||
cfg, err := loadSearchConfig(path)
|
||||
if err != nil {
|
||||
// 无配置不是致命错误:Search() 会走默认链。
|
||||
searchCfgVal = nil
|
||||
return
|
||||
}
|
||||
searchCfgVal = cfg
|
||||
})
|
||||
return searchCfgVal
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SearXNGClient 单引擎客户端:查询自建 SearXNG 的 /search?format=json。
|
||||
type SearXNGClient struct {
|
||||
baseURL string
|
||||
language string
|
||||
maxResults int
|
||||
timeout time.Duration
|
||||
hc *http.Client
|
||||
}
|
||||
|
||||
// NewSearXNGClient 创建 SearXNG 单引擎客户端。baseURL 形如 http://127.0.0.1:18080。
|
||||
// 传空串时使用默认本地网关地址。
|
||||
func NewSearXNGClient(baseURL string) *SearXNGClient {
|
||||
if baseURL == "" {
|
||||
baseURL = DefaultBaseURL
|
||||
}
|
||||
return &SearXNGClient{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
language: "zh",
|
||||
maxResults: 8,
|
||||
timeout: 25 * time.Second,
|
||||
hc: &http.Client{Timeout: 25 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// searchSearXNG 单次 SearXNG 查询(不含兜底)。
|
||||
func (c *SearXNGClient) searchSearXNG(query string) ([]Result, error) {
|
||||
params := url.Values{}
|
||||
params.Set("q", query)
|
||||
params.Set("format", "json")
|
||||
params.Set("language", c.language)
|
||||
params.Set("safesearch", "0")
|
||||
searchURL := c.baseURL + "/search?" + params.Encode()
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, searchURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("构建搜索请求失败: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SearXNG 不可达: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取搜索响应失败: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("SearXNG 返回 %d: %s", resp.StatusCode, truncate(string(body), 200))
|
||||
}
|
||||
|
||||
var out Response
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
return nil, fmt.Errorf("搜索响应解析失败: %w", err)
|
||||
}
|
||||
if len(out.Results) > c.maxResults {
|
||||
out.Results = out.Results[:c.maxResults]
|
||||
}
|
||||
return out.Results, nil
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
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])
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseSogouRejectsCaptcha(t *testing.T) {
|
||||
if got := parseSogou([]byte(`<html>请输入验证码</html>`)); got != nil {
|
||||
t.Fatalf("验证码页应返回 nil,实得 %d 条", len(got))
|
||||
}
|
||||
if got := parseSogou([]byte(`<html>antispider</html>`)); got != nil {
|
||||
t.Fatalf("反爬页应返回 nil,实得 %d 条", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseSogouFixture 用真实抓取的搜狗结果页验证解析器提取标题/链接/摘要。
|
||||
func TestParseSogouFixture(t *testing.T) {
|
||||
fixture := filepath.Join("testdata", "sogou_result.html")
|
||||
// 编译前若 fixture 不存在则跳过(保持 go test 在无网络/无 fixture 时可过)。
|
||||
if _, err := os.Stat(fixture); err != nil {
|
||||
t.Skip("testdata/sogou_result.html 不存在,跳过 fixture 断言")
|
||||
}
|
||||
raw, _ := os.ReadFile(fixture)
|
||||
got := parseSogou(raw)
|
||||
if len(got) == 0 {
|
||||
t.Fatal("fixture 应解析出至少 1 条结果")
|
||||
}
|
||||
for _, r := range got {
|
||||
if strings.TrimSpace(r.Title) == "" {
|
||||
t.Errorf("存在空标题: %+v", r)
|
||||
}
|
||||
if !strings.HasPrefix(r.URL, "http") {
|
||||
t.Errorf("URL 异常: %q", r.URL)
|
||||
}
|
||||
}
|
||||
t.Logf("解析 %d 条;首条: %s -> %s", len(got), got[0].Title, got[0].URL)
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user