package websearch import ( "context" "encoding/json" "fmt" "net/http" "net/url" "strings" "time" "git.yeij.top/AskaEth/Cyrene-Plugins/sdk" ) type WebSearchPlugin struct { sdk.BasePlugin client *http.Client searxngURL string } func NewWebSearchPlugin() *WebSearchPlugin { return &WebSearchPlugin{client: &http.Client{Timeout: 10 * time.Second}} } func NewWebSearchPluginWithURL(searxngURL string) *WebSearchPlugin { return &WebSearchPlugin{ client: &http.Client{Timeout: 10 * time.Second}, searxngURL: strings.TrimRight(searxngURL, "/"), } } func (p *WebSearchPlugin) Metadata() sdk.PluginMetadata { return sdk.PluginMetadata{ Name: "web_search", DisplayName: "Web Search", Version: "1.1.0", Description: "Search the internet via SearXNG (or DuckDuckGo fallback)", Category: "network", Author: sdk.PluginAuthor{Name: "Cyrene Team"}, } } func (p *WebSearchPlugin) Tools() []sdk.Tool { return []sdk.Tool{&WebSearchTool{client: p.client, searxngURL: p.searxngURL}} } type WebSearchTool struct { sdk.BaseTool client *http.Client searxngURL string } // ---- SearXNG response types ---- type searxngResponse struct { Query string `json:"query"` NumberOrResults int `json:"number_of_results"` Results []searxngResult `json:"results"` Answers []string `json:"answers"` Suggestions []string `json:"suggestions"` } type searxngResult struct { Title string `json:"title"` URL string `json:"url"` Content string `json:"content"` Engine string `json:"engine"` Score float64 `json:"score"` } // ---- DuckDuckGo response types (fallback) ---- type ddgResponse struct { Abstract string `json:"Abstract"` AbstractText string `json:"AbstractText"` Answer string `json:"Answer"` Heading string `json:"Heading"` Results []ddgTopic `json:"Results"` RelatedTopics []ddgTopic `json:"RelatedTopics"` } type ddgTopic struct { FirstURL string `json:"FirstURL"` Text string `json:"Text"` } func (t *WebSearchTool) Definition() sdk.ToolDefinition { return sdk.ToolDefinition{ ID: "web_search", Name: "web_search", DisplayName: "Web Search", Description: "Search the internet. SearXNG backend with DuckDuckGo fallback. Returns up to 5 results.", Category: "network", Complexity: sdk.ComplexitySimple, Parameters: map[string]interface{}{ "type": "object", "properties": map[string]interface{}{"query": map[string]interface{}{"type": "string"}}, "required": []string{"query"}, }, } } func (t *WebSearchTool) Validate(args map[string]interface{}) error { if _, ok := args["query"]; !ok { return fmt.Errorf("missing required parameter: query") } return nil } func (t *WebSearchTool) Execute(_ context.Context, args map[string]interface{}) (*sdk.ToolResult, error) { query, _ := args["query"].(string) if query == "" { return &sdk.ToolResult{ToolName: "web_search", Success: false, Error: "empty query"}, nil } if t.searxngURL != "" { return t.searchViaSearXNG(query) } return t.searchViaDuckDuckGo(query) } // China-accessible SearXNG engines (baidu, sogou, 360search, bing all work from China) const searxngEngines = "bing,sogou,360search,baidu" func (t *WebSearchTool) searchViaSearXNG(query string) (*sdk.ToolResult, error) { apiURL := fmt.Sprintf("%s/search?format=json&engines=%s&q=%s", t.searxngURL, searxngEngines, url.QueryEscape(query)) resp, err := t.client.Get(apiURL) if err != nil { return &sdk.ToolResult{ToolName: "web_search", Success: false, Error: fmt.Sprintf("SearXNG request failed: %v", err)}, nil } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return &sdk.ToolResult{ToolName: "web_search", Success: false, Error: fmt.Sprintf("SearXNG returned HTTP %d", resp.StatusCode)}, nil } var result searxngResponse if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return &sdk.ToolResult{ToolName: "web_search", Success: false, Error: fmt.Sprintf("SearXNG parse error: %v", err)}, nil } var out strings.Builder out.WriteString(fmt.Sprintf("搜索: %s (共%d条结果)\n\n", query, result.NumberOrResults)) // 优先显示答案(如 Wikipedia infobox) for _, answer := range result.Answers { out.WriteString(fmt.Sprintf("📌 %s\n\n", answer)) } // 搜索结果(最多5条,按score排序) count := 0 for _, r := range result.Results { if count >= 5 { break } if r.Title == "" || r.URL == "" { continue } content := cleanSnippet(r.Content) out.WriteString(fmt.Sprintf("%d. **%s**\n %s\n %s\n\n", count+1, r.Title, r.URL, content)) count++ } if out.Len() == 0 { return &sdk.ToolResult{ToolName: "web_search", Success: true, Output: fmt.Sprintf("未找到与「%s」相关的结果。", query)}, nil } return &sdk.ToolResult{ToolName: "web_search", Success: true, Output: out.String()}, nil } func (t *WebSearchTool) searchViaDuckDuckGo(query string) (*sdk.ToolResult, error) { apiURL := fmt.Sprintf("https://api.duckduckgo.com/?q=%s&format=json&no_html=1", url.QueryEscape(query)) resp, err := t.client.Get(apiURL) if err != nil { return &sdk.ToolResult{ToolName: "web_search", Success: false, Error: err.Error()}, nil } defer resp.Body.Close() var result ddgResponse if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return &sdk.ToolResult{ToolName: "web_search", Success: false, Error: err.Error()}, nil } var out strings.Builder if result.Answer != "" { out.WriteString(fmt.Sprintf("Answer: %s\n\n", result.Answer)) } if result.AbstractText != "" { text := result.AbstractText if len([]rune(text)) > 500 { text = string([]rune(text)[:500]) + "..." } out.WriteString(fmt.Sprintf("Abstract: %s\n\n", stripHTML(text))) } topics := result.Results if len(topics) == 0 { topics = result.RelatedTopics } count := 0 for _, topic := range topics { if count >= 5 { break } if topic.Text == "" { continue } out.WriteString(fmt.Sprintf("%d. %s (%s)\n", count+1, stripHTML(topic.Text), topic.FirstURL)) count++ } if out.Len() == 0 { return &sdk.ToolResult{ToolName: "web_search", Success: true, Output: "No results found for: " + query}, nil } return &sdk.ToolResult{ToolName: "web_search", Success: true, Output: out.String()}, nil } func cleanSnippet(s string) string { runes := []rune(strings.TrimSpace(s)) if len(runes) > 200 { return string(runes[:200]) + "..." } return string(runes) } func stripHTML(s string) string { result := make([]rune, 0, len([]rune(s))) inTag := false for _, r := range s { if r == '<' { inTag = true continue } if r == '>' { inTag = false continue } if !inTag { result = append(result, r) } } return strings.TrimSpace(string(result)) }