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:
eaiadmin
2026-09-26 22:21:39 +08:00
co-authored by Claude Code
parent e73169df50
commit c1af86c934
192 changed files with 18046 additions and 392 deletions
@@ -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