diff --git a/components/tool/duckduckgo/README.md b/components/tool/duckduckgo/README.md
new file mode 100644
index 0000000..c69ceae
--- /dev/null
+++ b/components/tool/duckduckgo/README.md
@@ -0,0 +1,98 @@
+# DuckDuckGo Search Tool
+
+English | [简体中文](README_zh.md)
+
+A DuckDuckGo search tool implementation for [Eino](https://github.com/cloudwego/eino) that implements the `InvokableTool` interface. This enables seamless integration with Eino's ChatModel interaction system and `ToolsNode` for enhanced search capabilities.
+
+## Features
+
+- Implements `github.com/cloudwego/eino/components/tool.InvokableTool`
+- Easy integration with Eino's tool system
+- Configurable search parameters
+
+## Installation
+
+```bash
+go get github.com/cloudwego/eino-ext/components/tool/duckduckgo
+```
+
+## Quick Start
+
+```go
+package main
+
+import (
+ "context"
+ "log"
+
+ "github.com/cloudwego/eino-ext/components/tool/duckduckgo"
+ "github.com/cloudwego/eino-ext/components/tool/duckduckgo/ddgsearch"
+ "github.com/cloudwego/eino/components/tool"
+)
+
+func main() {
+ // Create tool config
+ cfg := &duckduckgo.Config{ // All of these parameters are default values, for demonstration purposes only
+ ToolName: "duckduckgo_search",
+ ToolDesc: "search web for information by duckduckgo",
+ Region: ddgsearch.RegionWT,
+ Retries: 3,
+ Timeout: 10,
+ MaxResults: 10,
+ }
+
+ // Create the search tool
+ searchTool, err := duckduckgo.NewTool(context.Background(), cfg)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ // Use with Eino's ToolsNode
+ tools := []tool.BaseTool{searchTool}
+ // ... configure and use with ToolsNode
+}
+```
+
+## Configuration
+
+The tool can be configured using the `Config` struct:
+
+```go
+type Config struct {
+ ToolName string // Tool name for LLM interaction (default: "duckduckgo_search")
+ ToolDesc string // Tool description (default: "search web for information by duckduckgo")
+ Region ddgsearch.Region // Search region (default: "wt-wt" of no specified region)
+ Retries int // Number of retries (default: 3)
+ Timeout int // Max timeout in seconds (default: 10)
+ MaxResults int // Maximum results per search (default: 10)
+ Proxy string // Optional proxy URL
+}
+```
+
+## Search
+
+### Request Schema
+```go
+type SearchRequest struct {
+ Query string `json:"query" jsonschema_description:"The query to search the web for"`
+ Page int `json:"page" jsonschema_description:"The page number to search for, default: 1"`
+}
+```
+
+### Response Schema
+```go
+type SearchResponse struct {
+ Results []SearchResult `json:"results" jsonschema_description:"The results of the search"`
+}
+
+type SearchResult struct {
+ Title string `json:"title" jsonschema_description:"The title of the search result"`
+ Description string `json:"description" jsonschema_description:"The description of the search result"`
+ Link string `json:"link" jsonschema_description:"The link of the search result"`
+}
+```
+
+## For More Details
+
+- [DuckDuckGo Search Library Documentation](ddgsearch/README.md)
+- [Eino Documentation](https://github.com/cloudwego/eino)
diff --git a/components/tool/duckduckgo/README_zh.md b/components/tool/duckduckgo/README_zh.md
new file mode 100644
index 0000000..808c8c6
--- /dev/null
+++ b/components/tool/duckduckgo/README_zh.md
@@ -0,0 +1,98 @@
+# DuckDuckGo 搜索工具
+
+[English](README.md) | 简体中文
+
+这是一个为 [Eino](https://github.com/cloudwego/eino) 实现的 DuckDuckGo 搜索工具。该工具实现了 `InvokableTool` 接口,可以与 Eino 的 ChatModel 交互系统和 `ToolsNode` 无缝集成。
+
+## 特性
+
+- 实现了 `github.com/cloudwego/eino/components/tool.InvokableTool` 接口
+- 易于与 Eino 工具系统集成
+- 可配置的搜索参数
+
+## 安装
+
+```bash
+go get github.com/cloudwego/eino-ext/components/tool/duckduckgo
+```
+
+## 快速开始
+
+```go
+package main
+
+import (
+ "context"
+ "log"
+
+ "github.com/cloudwego/eino-ext/components/tool/duckduckgo"
+ "github.com/cloudwego/eino-ext/components/tool/duckduckgo/ddgsearch"
+ "github.com/cloudwego/eino/components/tool"
+)
+
+func main() {
+ // 创建工具配置
+ cfg := &duckduckgo.Config{ // 下面所有这些参数都是默认值,仅作用法展示
+ ToolName: "duckduckgo_search",
+ ToolDesc: "search web for information by duckduckgo",
+ Region: ddgsearch.RegionWT,
+ Retries: 3,
+ Timeout: 10,
+ MaxResults: 10,
+ }
+
+ // 创建搜索工具
+ searchTool, err := duckduckgo.NewTool(context.Background(), cfg)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ // 与 Eino 的 ToolsNode 一起使用
+ tools := []tool.BaseTool{searchTool}
+ // ... 配置并使用 ToolsNode
+}
+```
+
+## 配置
+
+工具可以通过 `Config` 结构体进行配置:
+
+```go
+type Config struct {
+ ToolName string // 用于 LLM 交互的工具名称(默认:"duckduckgo_search")
+ ToolDesc string // 工具描述(默认:"search web for information by duckduckgo")
+ Region ddgsearch.Region // 搜索地区(默认:"wt-wt")
+ Retries int // 重试次数(默认:3)
+ Timeout int // 最大超时时间(秒)(默认:10)
+ MaxResults int // 每次搜索的最大结果数(默认:10)
+ Proxy string // 可选的代理 URL
+}
+```
+
+## Search
+
+### 请求 Schema
+```go
+type SearchRequest struct {
+ Query string `json:"query" jsonschema_description:"要搜索的查询内容"`
+ Page int `json:"page" jsonschema_description:"要搜索的页码,默认:1"`
+}
+```
+
+### 响应 Schema
+```go
+type SearchResponse struct {
+ Results []SearchResult `json:"results" jsonschema_description:"搜索结果列��"`
+}
+
+type SearchResult struct {
+ Title string `json:"title" jsonschema_description:"搜索结果的标题"`
+ Description string `json:"description" jsonschema_description:"搜索结果的描述"`
+ Link string `json:"link" jsonschema_description:"搜索结果的链接"`
+}
+```
+
+## 更多详情
+
+- [DuckDuckGo 搜索库文档](ddgsearch/README_zh.md)
+- [Eino 文档](https://github.com/cloudwego/eino)
\ No newline at end of file
diff --git a/components/tool/duckduckgo/ddgsearch/README.md b/components/tool/duckduckgo/ddgsearch/README.md
new file mode 100644
index 0000000..cddd72f
--- /dev/null
+++ b/components/tool/duckduckgo/ddgsearch/README.md
@@ -0,0 +1,149 @@
+# DDGSearch
+
+English | [简体中文](README_zh.md)
+
+A native Go library for DuckDuckGo search functionality. This library provides a simple and efficient way to perform searches using DuckDuckGo's search engine.
+
+## Why DuckDuckGo?
+
+DuckDuckGo offers several advantages:
+- **No Authentication Required**: Unlike other search engines, DuckDuckGo's API can be used without any API keys or authentication
+- Privacy-focused search results
+- No rate limiting for reasonable usage
+- Support for multiple regions and languages
+- Clean and relevant search results
+
+## Features
+
+- Clean and idiomatic Go implementation
+- Comprehensive error handling
+- Configurable search parameters
+- In-memory caching with TTL
+- Support for:
+ - Multiple regions (us-en, uk-en, de-de, etc.)
+ - Safe search levels (strict, moderate, off)
+ - Time-based filtering (day, week, month, year)
+ - Result pagination
+ - Custom HTTP headers
+ - Proxy configuration
+
+## Installation
+
+```bash
+go get github.com/cloudwego/eino-ext/components/tool/duckduckgo
+```
+
+## Quick Start
+
+```go
+package main
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "time"
+
+ "github.com/cloudwego/eino-ext/components/tool/duckduckgo/ddgsearch"
+)
+
+func main() {
+ // Create a new client with configuration
+ cfg := &ddgsearch.Config{
+ Timeout: 30 * time.Second,
+ MaxRetries: 3,
+ Cache: true,
+ }
+ client, err := ddgsearch.New(cfg)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ // Configure search parameters
+ params := &ddgsearch.SearchParams{
+ Query: "what is golang",
+ Region: ddgsearch.RegionUSEN,
+ SafeSearch: ddgsearch.SafeSearchModerate,
+ TimeRange: ddgsearch.TimeRangeMonth,
+ MaxResults: 10,
+ }
+
+ // Perform search
+ response, err := client.Search(context.Background(), params)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ // Print results
+ for i, result := range response.Results {
+ fmt.Printf("%d. %s\n URL: %s\n Description: %s\n\n",
+ i+1, result.Title, result.URL, result.Description)
+ }
+}
+```
+
+## Advanced Usage
+
+### Configuration
+
+```go
+// Create client with custom configuration
+cfg := &ddgsearch.Config{
+ Timeout: 20 * time.Second,
+ MaxRetries: 3,
+ Proxy: "http://proxy:8080",
+ Cache: true,
+ Headers: map[string]string{
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
+ },
+}
+client, err := ddgsearch.New(cfg)
+```
+
+### Search Parameters
+
+```go
+params := &ddgsearch.SearchParams{
+ Query: "golang tutorial", // Search query
+ Region: ddgsearch.RegionUS, // Region for results (us-en, uk-en, etc.)
+ SafeSearch: ddgsearch.SafeSearchModerate, // Safe search level
+ TimeRange: ddgsearch.TimeRangeWeek, // Time filter
+ MaxResults: 10, // Maximum results to return
+}
+```
+
+Available regions:
+- RegionUS (United States)
+- RegionUK (United Kingdom)
+- RegionDE (Germany)
+- RegionFR (France)
+- RegionJP (Japan)
+- RegionCN (China)
+- RegionRU (Russia)
+
+Safe search levels:
+- SafeSearchStrict
+- SafeSearchModerate
+- SafeSearchOff
+
+Time range options:
+- TimeRangeDay
+- TimeRangeWeek
+- TimeRangeMonth
+- TimeRangeYear
+
+### Proxy Support
+
+```go
+// HTTP proxy
+cfg := &ddgsearch.Config{
+ Proxy: "http://proxy:8080",
+}
+client, err := ddgsearch.New(cfg)
+
+// SOCKS5 proxy
+cfg := &ddgsearch.Config{
+ Proxy: "socks5://proxy:1080",
+}
+client, err := ddgsearch.New(cfg)
+```
diff --git a/components/tool/duckduckgo/ddgsearch/README_zh.md b/components/tool/duckduckgo/ddgsearch/README_zh.md
new file mode 100644
index 0000000..92f0127
--- /dev/null
+++ b/components/tool/duckduckgo/ddgsearch/README_zh.md
@@ -0,0 +1,149 @@
+# DDGSearch
+
+[English](README.md) | 简体中文
+
+一个用于 DuckDuckGo 搜索功能的原生 Go 库。该库提供了一种简单高效的方式来使用 DuckDuckGo 搜索引擎进行搜索。
+
+## 为什么选择 DuckDuckGo?
+
+DuckDuckGo 提供了以下优势:
+- **无需认证**:与其他搜索引擎不同,DuckDuckGo 的 API 无需任何 API 密钥或认证
+- 注重隐私的搜索结果
+- 合理使用范围内无速率限制
+- 支持多个地区和语言
+- 干净且相关的搜索结果
+
+## 特性
+
+- 简洁且地道的 Go 实现
+- 全面的错误处理
+- 可配置的搜索参数
+- 带 TTL 的内存缓存
+- 支持:
+ - 多个地区(us-en、uk-en、de-de 等)
+ - 安全搜索级别(严格、适中、关闭)
+ - 基于时间的过滤(天、周、月、年)
+ - 结果分页
+ - 自定义 HTTP 头
+ - 代理配置
+
+## 安装
+
+```bash
+go get github.com/cloudwego/eino-ext/components/tool/duckduckgo
+```
+
+## 快速开始
+
+```go
+package main
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "time"
+
+ "github.com/cloudwego/eino-ext/components/tool/duckduckgo/ddgsearch"
+)
+
+func main() {
+ // 创建带配置的新客户端
+ cfg := &ddgsearch.Config{
+ Timeout: 30 * time.Second,
+ MaxRetries: 3,
+ Cache: true,
+ }
+ client, err := ddgsearch.New(cfg)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ // 配置搜索参数
+ params := &ddgsearch.SearchParams{
+ Query: "what is golang",
+ Region: ddgsearch.RegionUSEN,
+ SafeSearch: ddgsearch.SafeSearchModerate,
+ TimeRange: ddgsearch.TimeRangeMonth,
+ MaxResults: 10,
+ }
+
+ // 执行搜索
+ response, err := client.Search(context.Background(), params)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ // 打印结果
+ for i, result := range response.Results {
+ fmt.Printf("%d. %s\n URL: %s\n Description: %s\n\n",
+ i+1, result.Title, result.URL, result.Description)
+ }
+}
+```
+
+## 高级用法
+
+### 配置
+
+```go
+// 使用自定义配置创建客户端
+cfg := &ddgsearch.Config{
+ Timeout: 20 * time.Second,
+ MaxRetries: 3,
+ Proxy: "http://proxy:8080",
+ Cache: true,
+ Headers: map[string]string{
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
+ },
+}
+client, err := ddgsearch.New(cfg)
+```
+
+### 搜索参���
+
+```go
+params := &ddgsearch.SearchParams{
+ Query: "golang tutorial", // 搜索查询
+ Region: ddgsearch.RegionUS, // 结果地区(us-en、uk-en 等)
+ SafeSearch: ddgsearch.SafeSearchModerate, // 安全搜索级别
+ TimeRange: ddgsearch.TimeRangeWeek, // 时间过滤器
+ MaxResults: 10, // 返回的最大结果数
+}
+```
+
+可用地区:
+- RegionUS(美国)
+- RegionUK(英国)
+- RegionDE(德国)
+- RegionFR(法国)
+- RegionJP(日本)
+- RegionCN(中国)
+- RegionRU(俄罗斯)
+
+安全搜索级别:
+- SafeSearchStrict(严格)
+- SafeSearchModerate(适中)
+- SafeSearchOff(关闭)
+
+时间范围选项:
+- TimeRangeDay(天)
+- TimeRangeWeek(周)
+- TimeRangeMonth(月)
+- TimeRangeYear(年)
+
+### 代理支持
+
+```go
+// HTTP 代理
+cfg := &ddgsearch.Config{
+ Proxy: "http://proxy:8080",
+}
+client, err := ddgsearch.New(cfg)
+
+// SOCKS5 代理
+cfg := &ddgsearch.Config{
+ Proxy: "socks5://proxy:1080",
+}
+client, err := ddgsearch.New(cfg)
+```
\ No newline at end of file
diff --git a/components/tool/duckduckgo/ddgsearch/cache.go b/components/tool/duckduckgo/ddgsearch/cache.go
new file mode 100644
index 0000000..9b7d3f8
--- /dev/null
+++ b/components/tool/duckduckgo/ddgsearch/cache.go
@@ -0,0 +1,80 @@
+/*
+ * Copyright 2024 CloudWeGo Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package ddgsearch
+
+import (
+ "sync"
+ "time"
+)
+
+// cache implements a simple in-memory cache with expiration
+type cache struct {
+ mu sync.RWMutex
+ items map[string]*cacheItem
+ maxAge time.Duration
+}
+
+type cacheItem struct {
+ value interface{}
+ expiration time.Time
+}
+
+func newCache(maxAge time.Duration) *cache {
+ return &cache{
+ items: make(map[string]*cacheItem),
+ maxAge: maxAge,
+ }
+}
+
+func (c *cache) get(key string) (interface{}, bool) {
+ c.mu.RLock()
+ defer c.mu.RUnlock()
+
+ item, exists := c.items[key]
+ if !exists {
+ return nil, false
+ }
+
+ if time.Now().After(item.expiration) {
+ delete(c.items, key)
+ return nil, false
+ }
+
+ return item.value, true
+}
+
+func (c *cache) set(key string, value interface{}) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ c.items[key] = &cacheItem{
+ value: value,
+ expiration: time.Now().Add(c.maxAge),
+ }
+}
+
+func (c *cache) delete(key string) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ delete(c.items, key)
+}
+
+func (c *cache) clear() {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.items = make(map[string]*cacheItem)
+}
diff --git a/components/tool/duckduckgo/ddgsearch/client.go b/components/tool/duckduckgo/ddgsearch/client.go
new file mode 100644
index 0000000..ac52199
--- /dev/null
+++ b/components/tool/duckduckgo/ddgsearch/client.go
@@ -0,0 +1,230 @@
+/*
+ * Copyright 2024 CloudWeGo Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package ddgsearch
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "time"
+)
+
+// DDGS represents the DuckDuckGo search client.
+// It handles all search-related operations including request configuration,
+// caching, and result parsing.
+//
+// Use New() to create a new instance with proper configuration.
+type DDGS struct {
+ client *http.Client
+ headers map[string]string
+ proxy string
+ timeout time.Duration
+ cache *cache
+ config *Config
+}
+
+// Config configures the DDGS client behavior.
+// All fields are optional and will use sensible defaults if not provided.
+type Config struct {
+ // Headers specifies custom HTTP headers to be sent with each request.
+ // Common headers like "User-Agent" can be set here.
+ // Example:
+ // Headers: map[string]string{
+ // "User-Agent": "MyApp/1.0",
+ // "Accept-Language": "en-US",
+ // }
+ Headers map[string]string
+
+ // Proxy specifies the proxy server URL for all requests.
+ // Supports HTTP, HTTPS, and SOCKS5 proxies.
+ // Example values:
+ // - "http://proxy.example.com:8080"
+ // - "socks5://localhost:1080"
+ // - "tb" (special alias for Tor Browser)
+ Proxy string
+
+ // Timeout specifies the maximum duration for a single request.
+ // Default is 30 seconds if not specified.
+ // Example: 5 * time.Second
+ Timeout time.Duration
+
+ // Cache enables in-memory caching of search results.
+ // When enabled, identical search requests will return cached results
+ // for improved performance. Cache entries expire after 5 minutes.
+ Cache bool
+
+ // MaxRetries specifies the maximum number of retry attempts for failed requests.
+ // Default is 3.
+ MaxRetries int
+}
+
+// New creates a new DDGS client with the given configuration
+func New(cfg *Config) (*DDGS, error) {
+ if cfg == nil {
+ cfg = &Config{
+ Headers: make(map[string]string),
+ Timeout: 30 * time.Second,
+ MaxRetries: 3,
+ }
+ }
+
+ if cfg.Timeout == 0 {
+ cfg.Timeout = 30 * time.Second
+ }
+
+ if cfg.MaxRetries == 0 {
+ cfg.MaxRetries = 3
+ }
+
+ d := &DDGS{
+ client: &http.Client{Timeout: cfg.Timeout},
+ headers: cfg.Headers,
+ proxy: cfg.Proxy,
+ timeout: cfg.Timeout,
+ config: cfg,
+ }
+
+ // Configure proxy if specified
+ if cfg.Proxy != "" {
+ proxyURL, err := url.Parse(cfg.Proxy)
+ if err != nil {
+ return nil, fmt.Errorf("invalid proxy URL: %w", err)
+ }
+
+ // Validate proxy scheme
+ switch proxyURL.Scheme {
+ case "http", "https", "socks5":
+ d.client.Transport = &http.Transport{
+ Proxy: http.ProxyURL(proxyURL),
+ }
+ default:
+ return nil, fmt.Errorf("unsupported proxy scheme: %s", proxyURL.Scheme)
+ }
+ }
+
+ if cfg.Cache {
+ d.cache = newCache(5 * time.Minute) // 5 minutes cache expiration
+ }
+
+ return d, nil
+}
+
+// sendRequestWithRetry sends the request with retry
+func (d *DDGS) sendRequestWithRetry(ctx context.Context, req *http.Request, params *SearchParams) (*SearchResponse, error) {
+ var resp *http.Response
+ var err error
+ var attempt int
+
+ for attempt = 0; attempt <= d.config.MaxRetries; attempt++ {
+ // Check context cancellation
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+
+ resp, err = d.client.Do(req)
+ if err != nil {
+ if attempt == d.config.MaxRetries {
+ return nil, fmt.Errorf("failed to send request after retries: %w", err)
+ }
+ time.Sleep(time.Second) // Simple fixed 1 second delay between retries
+ continue
+ }
+
+ // Check for rate limit response
+ if resp.StatusCode == http.StatusTooManyRequests {
+ if attempt == d.config.MaxRetries {
+ return nil, ErrRateLimit
+ }
+ time.Sleep(time.Second)
+ continue
+ }
+
+ break
+ }
+
+ defer resp.Body.Close()
+
+ // Read response body
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+
+ // Parse search response
+ response, err := parseSearchResponse(body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse search results: %w", err)
+ }
+
+ // Check for no results
+ if len(response.Results) == 0 {
+ return nil, ErrNoResults
+ }
+
+ // Apply max results limit if specified
+ if params.MaxResults > 0 && len(response.Results) > params.MaxResults {
+ response.Results = response.Results[:params.MaxResults]
+ }
+
+ return response, nil
+}
+
+// getVQD retrieves the VQD token required for search requests
+func (d *DDGS) getVQD(ctx context.Context, query string) (string, error) {
+ endpoint := "https://duckduckgo.com"
+
+ // Create request with query parameter
+ req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
+ if err != nil {
+ return "", fmt.Errorf("failed to create request: %w", err)
+ }
+
+ // Add query parameter
+ q := req.URL.Query()
+ q.Set("q", query)
+ req.URL.RawQuery = q.Encode()
+
+ // Set headers
+ for k, v := range d.headers {
+ req.Header.Set(k, v)
+ }
+
+ // Set default User-Agent if not provided
+ if _, ok := req.Header["User-Agent"]; !ok {
+ req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
+ }
+
+ resp, err := d.client.Do(req)
+ if err != nil {
+ return "", fmt.Errorf("failed to send request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return "", fmt.Errorf("failed to read response: %w", err)
+ }
+
+ vqd := extractVQDToken(string(body))
+ if vqd == "" {
+ return "", fmt.Errorf("failed to extract VQD token")
+ }
+
+ return vqd, nil
+}
diff --git a/components/tool/duckduckgo/ddgsearch/client_test.go b/components/tool/duckduckgo/ddgsearch/client_test.go
new file mode 100644
index 0000000..5bf5527
--- /dev/null
+++ b/components/tool/duckduckgo/ddgsearch/client_test.go
@@ -0,0 +1,248 @@
+/*
+ * Copyright 2024 CloudWeGo Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package ddgsearch
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+)
+
+func TestNew(t *testing.T) {
+ tests := []struct {
+ name string
+ cfg *Config
+ wantErr bool
+ }{
+ {
+ name: "nil config",
+ cfg: nil,
+ wantErr: false,
+ },
+ {
+ name: "valid config",
+ cfg: &Config{
+ Headers: map[string]string{"User-Agent": "test"},
+ Timeout: 5 * time.Second,
+ },
+ wantErr: false,
+ },
+ {
+ name: "invalid proxy",
+ cfg: &Config{
+ Proxy: "invalid://proxy",
+ },
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ client, err := New(tt.cfg)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("New() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ if !tt.wantErr && client == nil {
+ t.Error("New() returned nil client")
+ }
+ })
+ }
+}
+
+func TestDDGS_Search(t *testing.T) {
+ // Create a test server
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/":
+ // Return VQD token
+ w.Write([]byte(``))
+ case "/d.js":
+ // Return search results
+ w.Write([]byte(`{
+ "results": [
+ {"t": "Test Result 1", "u": "http://example.com/1", "a": "Description 1"},
+ {"t": "Test Result 2", "u": "http://example.com/2", "a": "Description 2"}
+ ],
+ "noResults": false
+ }`))
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ // Create client with test server URL
+ client, err := New(&Config{
+ Headers: map[string]string{"User-Agent": "test"},
+ Timeout: 5 * time.Second,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Set test endpoints
+ searchURL = server.URL + "/d.js"
+
+ tests := []struct {
+ name string
+ params *SearchParams
+ wantCount int
+ wantErr bool
+ wantErrMsg string
+ }{
+ {
+ name: "valid search",
+ params: &SearchParams{
+ Query: "test",
+ MaxResults: 2,
+ },
+ wantCount: 2,
+ wantErr: false,
+ },
+ {
+ name: "empty query",
+ params: &SearchParams{},
+ wantCount: 0,
+ wantErr: true,
+ },
+ {
+ name: "nil params",
+ params: nil,
+ wantCount: 0,
+ wantErr: true,
+ },
+ {
+ name: "max results limit",
+ params: &SearchParams{
+ Query: "test",
+ MaxResults: 1,
+ },
+ wantCount: 1,
+ wantErr: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ results, err := client.Search(context.Background(), tt.params)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("Search() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ if !tt.wantErr {
+ if results == nil {
+ t.Error("Search() returned nil results when error not expected")
+ return
+ }
+ if len(results.Results) != tt.wantCount {
+ t.Errorf("Search() got %d results, want %d", len(results.Results), tt.wantCount)
+ }
+ }
+ })
+ }
+}
+
+func TestDDGS_SearchCache(t *testing.T) {
+ requestCount := 0
+
+ // Create a test server that counts requests
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ requestCount++
+ switch r.URL.Path {
+ case "/":
+ // Return VQD token
+ w.Write([]byte(``))
+ case "/d.js":
+ // Return search results
+ w.Write([]byte(`{
+ "results": [
+ {"t": "Test Result 1", "u": "http://example.com/1", "a": "Description 1"},
+ {"t": "Test Result 2", "u": "http://example.com/2", "a": "Description 2"}
+ ],
+ "noResults": false
+ }`))
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ // Create client with cache enabled
+ client, err := New(&Config{
+ Headers: map[string]string{"User-Agent": "test"},
+ Timeout: 5 * time.Second,
+ Cache: true,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Set test endpoints
+ searchURL = server.URL + "/d.js"
+
+ // First search request
+ params := &SearchParams{
+ Query: "test query",
+ MaxResults: 2,
+ }
+ response, err := client.Search(context.Background(), params)
+ if err != nil {
+ t.Fatalf("First search failed: %v", err)
+ }
+
+ results1 := response.Results
+ if len(results1) != 2 {
+ t.Errorf("Expected 2 results, got %d", len(results1))
+ }
+ initialRequests := requestCount
+
+ // Second search request with same parameters (should use cache)
+ response2, err := client.Search(context.Background(), params)
+ if err != nil {
+ t.Fatalf("Second search failed: %v", err)
+ }
+ results2 := response2.Results
+ if len(results2) != 2 {
+ t.Errorf("Expected 2 results, got %d", len(results2))
+ }
+
+ // Check if request count remained the same (indicating cache hit)
+ if requestCount != initialRequests {
+ t.Errorf("Cache not working: expected %d requests, got %d", initialRequests, requestCount)
+ }
+
+ // Verify results are identical
+ for i := range results1 {
+ if results1[i].URL != results2[i].URL {
+ t.Errorf("Cache returned different results: expected %v, got %v", results1[i], results2[i])
+ }
+ }
+
+ // Different query (should not use cache)
+ params.Query = "different query"
+ _, err = client.Search(context.Background(), params)
+ if err != nil {
+ t.Fatalf("Third search failed: %v", err)
+ }
+ if requestCount <= initialRequests {
+ t.Error("Cache incorrectly used for different query")
+ }
+
+}
diff --git a/components/tool/duckduckgo/ddgsearch/doc.go b/components/tool/duckduckgo/ddgsearch/doc.go
new file mode 100644
index 0000000..ee35e33
--- /dev/null
+++ b/components/tool/duckduckgo/ddgsearch/doc.go
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2024 CloudWeGo Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// Package ddgsearch provides a Go client for DuckDuckGo search API.
+//
+// Example usage:
+//
+// cfg := &ddgsearch.Config{
+// Headers: map[string]string{
+// "User-Agent": "MyApp/1.0",
+// },
+// Timeout: 10 * time.Second,
+// Cache: true,
+// MaxRetries: 3,
+// }
+//
+// client, err := ddgsearch.New(cfg)
+// if err != nil {
+// log.Fatal(err)
+// }
+//
+// params := &ddgsearch.SearchParams{
+// Query: "golang programming",
+// Region: ddgsearch.RegionUS,
+// SafeSearch: ddgsearch.SafeSearchModerate,
+// TimeRange: ddgsearch.TimeRangeYear,
+// MaxResults: 10,
+// }
+//
+// results, err := client.Search(context.Background(), params)
+// if err != nil {
+// log.Fatal(err)
+// }
+//
+// for _, result := range results {
+// fmt.Printf("Title: %s\nURL: %s\nDescription: %s\n\n",
+// result.Title, result.URL, result.Description)
+// }
+package ddgsearch
diff --git a/components/tool/duckduckgo/ddgsearch/errors.go b/components/tool/duckduckgo/ddgsearch/errors.go
new file mode 100644
index 0000000..8550377
--- /dev/null
+++ b/components/tool/duckduckgo/ddgsearch/errors.go
@@ -0,0 +1,132 @@
+/*
+ * Copyright 2024 CloudWeGo Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package ddgsearch
+
+import (
+ "errors"
+ "fmt"
+)
+
+// SearchError represents an error that occurred during a search operation.
+// It wraps the original error and provides additional context.
+type SearchError struct {
+ Message string // Human readable error message
+ Err error // Original error
+}
+
+func (e *SearchError) Error() string {
+ if e.Err != nil {
+ return fmt.Sprintf("%s: %v", e.Message, e.Err)
+ }
+ return e.Message
+}
+
+func (e *SearchError) Unwrap() error {
+ return e.Err
+}
+
+// NewSearchError creates a new SearchError with the given message and error.
+func NewSearchError(message string, err error) error {
+ return &SearchError{Message: message, Err: err}
+}
+
+// Common errors returned by the library
+var (
+ // ErrRateLimit is returned when DuckDuckGo rate limits the request.
+ // This typically happens when too many requests are made in a short period.
+ ErrRateLimit = &SearchError{Message: "rate limit exceeded"}
+
+ // ErrTimeout is returned when a request to DuckDuckGo times out.
+ // This can happen due to network issues or server-side delays.
+ ErrTimeout = &SearchError{Message: "request timeout"}
+
+ // ErrNoResults is returned when the search yields no results.
+ // This can happen with very specific queries or when DuckDuckGo has no matching content.
+ ErrNoResults = &SearchError{Message: "no results found"}
+
+ // ErrInvalidResponse is returned when the response from DuckDuckGo cannot be parsed.
+ // This can happen if the API changes or returns an unexpected format.
+ ErrInvalidResponse = &SearchError{Message: "invalid response from DuckDuckGo"}
+
+ // ErrInvalidRegion is returned when an unsupported region code is provided.
+ // Use one of the predefined Region constants.
+ ErrInvalidRegion = &SearchError{Message: "invalid region code"}
+
+ // ErrInvalidSafeSearch is returned when an unsupported safe search level is provided.
+ // Use one of the predefined SafeSearch constants.
+ ErrInvalidSafeSearch = &SearchError{Message: "invalid safe search level"}
+
+ // ErrInvalidTimeRange is returned when an unsupported time range is provided.
+ // Use one of the predefined TimeRange constants.
+ ErrInvalidTimeRange = &SearchError{Message: "invalid time range"}
+)
+
+// IsRateLimitErr checks if the error is a rate limit error.
+//
+// Example:
+//
+// if IsRateLimitErr(err) {
+// time.Sleep(time.Second * 5)
+// // retry request
+// }
+func IsRateLimitErr(err error) bool {
+ var searchErr *SearchError
+ if err == nil {
+ return false
+ }
+ if ok := errors.As(err, &searchErr); ok {
+ return searchErr == ErrRateLimit
+ }
+ return false
+}
+
+// IsTimeoutErr checks if the error is a timeout error.
+//
+// Example:
+//
+// if IsTimeoutErr(err) {
+// // increase timeout and retry
+// client.SetTimeout(30 * time.Second)
+// }
+func IsTimeoutErr(err error) bool {
+ var searchErr *SearchError
+ if err == nil {
+ return false
+ }
+ if ok := errors.As(err, &searchErr); ok {
+ return searchErr == ErrTimeout
+ }
+ return false
+}
+
+// IsNoResultsErr checks if the error indicates no results were found.
+//
+// Example:
+//
+// if IsNoResultsErr(err) {
+// fmt.Println("No results found, try different keywords")
+// }
+func IsNoResultsErr(err error) bool {
+ var searchErr *SearchError
+ if err == nil {
+ return false
+ }
+ if ok := errors.As(err, &searchErr); ok {
+ return searchErr == ErrNoResults
+ }
+ return false
+}
diff --git a/components/tool/duckduckgo/ddgsearch/search_news.go b/components/tool/duckduckgo/ddgsearch/search_news.go
new file mode 100644
index 0000000..12b8fb0
--- /dev/null
+++ b/components/tool/duckduckgo/ddgsearch/search_news.go
@@ -0,0 +1,192 @@
+/*
+ * Copyright 2024 CloudWeGo Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package ddgsearch
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "time"
+)
+
+// News performs a DuckDuckGo news search with the given parameters.
+func (d *DDGS) News(ctx context.Context, params *NewsParams) (*NewsResponse, error) {
+ if params.Query == "" {
+ return nil, fmt.Errorf("query is required")
+ }
+
+ // Get vqd token
+ vqd, err := d.getVQD(ctx, params.Query)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get vqd: %w", err)
+ }
+
+ // Prepare safe search parameter
+ safeSearchMap := map[SafeSearch]string{
+ SafeSearchStrict: "1",
+ SafeSearchModerate: "-1",
+ SafeSearchOff: "-2",
+ }
+
+ // Build query parameters
+ queryParams := url.Values{
+ "l": {string(params.Region)},
+ "o": {"json"},
+ "noamp": {"1"},
+ "q": {params.Query},
+ "vqd": {vqd},
+ "p": {safeSearchMap[params.SafeSearch]},
+ "t": {"n"}, // Ensure we're requesting news
+ }
+
+ if params.TimeRange != "" {
+ queryParams.Set("df", string(params.TimeRange))
+ }
+
+ maxResults := params.MaxResults
+ if maxResults <= 0 || maxResults > 120 {
+ maxResults = 30 // Default to first page
+ }
+
+ var allResults []NewsResult
+ seenURLs := make(map[string]bool)
+
+ // Fetch results in batches of 30
+ for offset := 0; offset < maxResults; offset += 30 {
+ queryParams.Set("s", fmt.Sprintf("%d", offset))
+
+ // Create request
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, newsURL, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ // Set query parameters
+ req.URL.RawQuery = queryParams.Encode()
+
+ // Set headers
+ for k, v := range d.headers {
+ req.Header.Set(k, v)
+ }
+
+ // Ensure we have a User-Agent header
+ if req.Header.Get("User-Agent") == "" {
+ req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
+ }
+
+ // Set additional required headers
+ req.Header.Set("Accept", "application/json")
+ req.Header.Set("Accept-Language", "en-US,en;q=0.9")
+ req.Header.Set("Referer", "https://duckduckgo.com/")
+ req.Header.Set("Authority", "duckduckgo.com")
+ req.Header.Set("Sec-Fetch-Dest", "empty")
+ req.Header.Set("Sec-Fetch-Mode", "cors")
+ req.Header.Set("Sec-Fetch-Site", "same-origin")
+
+ // Send request with retry
+ var resp *http.Response
+ var lastErr error
+ for retries := 0; retries < 3; retries++ {
+ if retries > 0 {
+ time.Sleep(time.Second * time.Duration(retries))
+ }
+
+ resp, lastErr = d.client.Do(req)
+ if lastErr == nil && resp.StatusCode == http.StatusOK {
+ break
+ }
+ if resp != nil {
+ resp.Body.Close()
+ }
+ }
+ if lastErr != nil {
+ return nil, fmt.Errorf("failed to send request after retries: %w", lastErr)
+ }
+ if resp == nil {
+ return nil, fmt.Errorf("no response received after retries")
+ }
+ defer resp.Body.Close()
+
+ // Check response status
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(resp.Body)
+ return nil, fmt.Errorf("unexpected status code: %d, body: %s", resp.StatusCode, string(body))
+ }
+
+ // Read response body
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response: %w", err)
+ }
+
+ // Debug the response
+ if len(body) == 0 {
+ return nil, fmt.Errorf("empty response body")
+ }
+
+ // Try to parse the response
+ var raw rawNewsResponse
+ if err := json.Unmarshal(body, &raw); err != nil {
+ // Try to get more information about the response
+ bodyStr := truncateString(string(body), 200)
+ return nil, fmt.Errorf("failed to parse news response (status: %d, body: %s): %w",
+ resp.StatusCode, bodyStr, err)
+ }
+
+ // Process results
+ for _, r := range raw.Results {
+ if !seenURLs[r.URL] {
+ seenURLs[r.URL] = true
+
+ // Convert Unix timestamp to ISO8601
+ date := time.Unix(r.Date, 0).UTC().Format(time.RFC3339)
+
+ result := NewsResult{
+ Date: date,
+ Title: r.Title,
+ Body: r.Excerpt,
+ URL: normalizeURL(r.URL),
+ Image: normalizeURL(r.Image),
+ Source: r.Source,
+ }
+ allResults = append(allResults, result)
+ }
+ }
+
+ // If we got less than 30 results, there are no more to fetch
+ if len(raw.Results) < 30 {
+ break
+ }
+ }
+
+ // If we got no results at all, return an error with more context
+ if len(allResults) == 0 {
+ return nil, fmt.Errorf("no news results found for query: %s", params.Query)
+ }
+
+ // Trim results to max requested
+ if len(allResults) > maxResults {
+ allResults = allResults[:maxResults]
+ }
+
+ return &NewsResponse{
+ Results: allResults,
+ }, nil
+}
diff --git a/components/tool/duckduckgo/ddgsearch/search_news_test.go b/components/tool/duckduckgo/ddgsearch/search_news_test.go
new file mode 100644
index 0000000..3ce6f5d
--- /dev/null
+++ b/components/tool/duckduckgo/ddgsearch/search_news_test.go
@@ -0,0 +1,203 @@
+/*
+ * Copyright 2024 CloudWeGo Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package ddgsearch
+
+import (
+ "context"
+ "fmt"
+ "testing"
+ "time"
+)
+
+func TestDDGS_News(t *testing.T) {
+ // Create a client with custom configuration for testing
+ cfg := &Config{
+ Headers: map[string]string{
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
+ },
+ Timeout: 30 * time.Second,
+ MaxRetries: 3,
+ }
+
+ client, err := New(cfg)
+ if err != nil {
+ t.Fatalf("Failed to create client: %v", err)
+ }
+
+ // Create context with timeout
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ tests := []struct {
+ name string
+ params *NewsParams
+ want func(*NewsResponse) error
+ wantErr bool
+ }{
+ {
+ name: "basic_search",
+ params: &NewsParams{
+ Query: "technology news",
+ Region: RegionUS,
+ SafeSearch: SafeSearchModerate,
+ MaxResults: 5,
+ },
+ want: func(resp *NewsResponse) error {
+ if len(resp.Results) == 0 {
+ return fmt.Errorf("expected results, got none")
+ }
+ for i, r := range resp.Results {
+ if r.Title == "" {
+ return fmt.Errorf("result %d has empty title", i)
+ }
+ if r.URL == "" {
+ return fmt.Errorf("result %d has empty URL", i)
+ }
+ if r.Source == "" {
+ return fmt.Errorf("result %d has empty source", i)
+ }
+ if r.Date == "" {
+ return fmt.Errorf("result %d has empty date", i)
+ }
+ }
+ return nil
+ },
+ wantErr: false,
+ },
+ {
+ name: "empty_query",
+ params: &NewsParams{
+ Query: "",
+ },
+ want: nil,
+ wantErr: true,
+ },
+ {
+ name: "search_with_max_results",
+ params: &NewsParams{
+ Query: "technology",
+ Region: RegionUS,
+ SafeSearch: SafeSearchModerate,
+ MaxResults: 2,
+ },
+ want: func(resp *NewsResponse) error {
+ if len(resp.Results) > 2 {
+ return fmt.Errorf("expected at most 2 results, got %d", len(resp.Results))
+ }
+ // Print sample results for debugging
+ t.Logf("Sample results for %q:", "search with max results")
+ for i, r := range resp.Results {
+ t.Logf(" %d. %s (%s) - %s", i+1, r.Title, r.Source, r.Date)
+ }
+ return nil
+ },
+ wantErr: false,
+ },
+ {
+ name: "search_with_time_range",
+ params: &NewsParams{
+ Query: "artificial intelligence",
+ Region: RegionUS,
+ SafeSearch: SafeSearchModerate,
+ TimeRange: TimeRangeDay,
+ MaxResults: 3,
+ },
+ want: func(resp *NewsResponse) error {
+ if len(resp.Results) == 0 {
+ return fmt.Errorf("expected results, got none")
+ }
+ // Verify date is within the last day
+ now := time.Now()
+ for i, r := range resp.Results {
+ date, err := time.Parse(time.RFC3339, r.Date)
+ if err != nil {
+ return fmt.Errorf("result %d has invalid date format: %s", i, r.Date)
+ }
+ if now.Sub(date) > 24*time.Hour {
+ return fmt.Errorf("result %d date %s is older than 24 hours", i, r.Date)
+ }
+ }
+ return nil
+ },
+ wantErr: false,
+ },
+ {
+ name: "search_with_region",
+ params: &NewsParams{
+ Query: "news",
+ Region: RegionJP,
+ SafeSearch: SafeSearchModerate,
+ MaxResults: 3,
+ },
+ want: func(resp *NewsResponse) error {
+ if len(resp.Results) == 0 {
+ return fmt.Errorf("expected results, got none")
+ }
+ return nil
+ },
+ wantErr: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ var resp *NewsResponse
+ var err error
+
+ // Retry logic for flaky tests
+ for retries := 0; retries < 3; retries++ {
+ if retries > 0 {
+ t.Logf("Retry %d for %s", retries, tt.name)
+ time.Sleep(time.Second * time.Duration(retries))
+ }
+
+ resp, err = client.News(ctx, tt.params)
+
+ if (err != nil) == tt.wantErr {
+ break // Test passed
+ }
+
+ if err != nil {
+ t.Logf("Retry %d failed: %v", retries+1, err)
+ continue
+ }
+
+ if tt.want != nil {
+ if err := tt.want(resp); err == nil {
+ break // Test passed
+ } else {
+ t.Logf("Retry %d failed: %v", retries+1, err)
+ }
+ }
+ }
+
+ if (err != nil) != tt.wantErr {
+ t.Errorf("DDGS.News() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+
+ if !tt.wantErr && tt.want != nil {
+ if err := tt.want(resp); err != nil {
+ t.Errorf("DDGS.News() validation failed: %v", err)
+ }
+ }
+ })
+
+ // Add delay between tests to avoid rate limiting
+ time.Sleep(time.Second)
+ }
+}
diff --git a/components/tool/duckduckgo/ddgsearch/search_text.go b/components/tool/duckduckgo/ddgsearch/search_text.go
new file mode 100644
index 0000000..19d3308
--- /dev/null
+++ b/components/tool/duckduckgo/ddgsearch/search_text.go
@@ -0,0 +1,190 @@
+/*
+ * Copyright 2024 CloudWeGo Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package ddgsearch
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strconv"
+)
+
+// Search performs a search with the given parameters
+func (d *DDGS) Search(ctx context.Context, params *SearchParams) (*SearchResponse, error) {
+ if params == nil {
+ return nil, fmt.Errorf("search params cannot be nil")
+ }
+
+ if params.Query == "" {
+ return nil, fmt.Errorf("search query cannot be empty")
+ }
+
+ // Generate cache key if caching is enabled
+ if d.cache != nil {
+ params.cacheKey = params.getCacheKey()
+
+ // Try to get from cache
+ if cached, ok := d.cache.get(params.cacheKey); ok {
+ if response, ok := cached.(*SearchResponse); ok {
+ return response, nil
+ }
+ }
+ }
+
+ // Get VQD token
+ vqd, err := d.getVQD(ctx, params.Query)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get vqd token: %w", err)
+ }
+
+ // Build search URL using SearchParams method
+ searchURL := params.buildSearchURL(vqd)
+
+ // Create request
+ req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+
+ // Send request with retry
+ response, err := d.sendRequestWithRetry(ctx, req, params)
+ if err != nil {
+ return nil, err
+ }
+
+ // Cache the response if caching is enabled
+ if d.cache != nil && params.cacheKey != "" {
+ d.cache.set(params.cacheKey, response)
+ }
+
+ // max results
+ if params.MaxResults > 0 && len(response.Results) > params.MaxResults {
+ response.Results = response.Results[:params.MaxResults]
+ }
+
+ return response, nil
+}
+
+// validate checks if the search parameters are valid
+func (p *SearchParams) validate() error {
+ if p.Query == "" {
+ return NewSearchError("search query cannot be empty", nil)
+ }
+ if p.Page < 0 {
+ return NewSearchError("page number cannot be negative", nil)
+ }
+ if p.MaxResults < 0 {
+ return NewSearchError("max results cannot be negative", nil)
+ }
+ return nil
+}
+
+// buildSearchURL constructs the search URL with all necessary parameters
+func (p *SearchParams) buildSearchURL(vqd string) string {
+ // Use test endpoint if available
+ endpoint := searchURL
+
+ // Initialize URL parameters
+ params := url.Values{}
+
+ // Main search parameters
+ params.Set("q", p.Query) // The search query text
+ params.Set("vqd", vqd) // Verification query ID, required by DuckDuckGo
+
+ // Regional and language settings
+ params.Set("kl", string(p.Region)) // Knowledge location - affects result relevance by region
+ params.Set("l", string(p.Region))
+
+ // Search behavior flags
+ params.Set("ss", "1") // Show snippets in results
+ params.Set("sp", "1") // Show preference cookies
+ params.Set("sc", "1") // Show category headers
+ params.Set("o", "json") // Output format (JSON)
+
+ // Optional parameters
+ if p.SafeSearch != "" {
+ params.Set("p", string(p.SafeSearch)) // Safe search level (strict/moderate/off)
+ }
+ if p.TimeRange != "" {
+ params.Set("df", string(p.TimeRange)) // Date filter for results (d/w/m/y)
+ }
+ if p.Page > 1 {
+ // Skip page results
+ // Example: page 2 of max 10 results starts at result 10, page 3 at result 20, etc.
+ pageSize := p.MaxResults
+ if pageSize == 0 {
+ pageSize = 10 // default page size
+ }
+ params.Set("s", strconv.Itoa((p.Page-1)*pageSize))
+ }
+
+ // Construct final URL
+ return endpoint + "?" + params.Encode()
+}
+
+// getCacheKey generates a unique cache key for the search parameters
+func (p *SearchParams) getCacheKey() string {
+ // Use url.Values to consistently encode parameters
+ v := url.Values{}
+ v.Set("q", p.Query)
+ v.Set("r", string(p.Region))
+ v.Set("s", string(p.SafeSearch))
+ v.Set("t", string(p.TimeRange))
+ v.Set("p", strconv.Itoa(p.Page))
+
+ return v.Encode()
+}
+
+// parseSearchResponse parses the search response from DuckDuckGo
+func parseSearchResponse(body []byte) (*SearchResponse, error) {
+ var response struct {
+ Results []struct {
+ Title string `json:"t"`
+ URL string `json:"u"`
+ Description string `json:"a"`
+ } `json:"results"`
+ NoResults bool `json:"noResults"`
+ }
+
+ err := json.Unmarshal(body, &response)
+ if err != nil {
+ return nil, fmt.Errorf("failed to unmarshal response: %w", err)
+ }
+
+ if response.NoResults {
+ return &SearchResponse{}, nil
+ }
+
+ results := make([]SearchResult, 0, len(response.Results))
+ for _, r := range response.Results {
+ if r.Description == "" && r.URL == "" && r.Title == "" {
+ continue
+ }
+
+ results = append(results, SearchResult{
+ Title: r.Title,
+ URL: r.URL,
+ Description: r.Description,
+ })
+ }
+
+ return &SearchResponse{
+ Results: results,
+ }, nil
+}
diff --git a/components/tool/duckduckgo/ddgsearch/types.go b/components/tool/duckduckgo/ddgsearch/types.go
new file mode 100644
index 0000000..162f946
--- /dev/null
+++ b/components/tool/duckduckgo/ddgsearch/types.go
@@ -0,0 +1,229 @@
+/*
+ * Copyright 2024 CloudWeGo Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package ddgsearch
+
+// Common constants
+var (
+ baseURL = "https://duckduckgo.com"
+ searchURL = "https://links.duckduckgo.com/d.js"
+ newsURL = "https://duckduckgo.com/news.js"
+)
+
+// Region represents a geographical region for search results.
+// Different regions may return different search results based on local relevance.
+// others can be found at: https://duckduckgo.com/duckduckgo-help-pages/settings/params/
+type Region string
+
+// Available regions for DuckDuckGo search
+const (
+ // RegionWT represents World region (No specific region, default)
+ RegionWT Region = "wt-wt"
+ // RegionUS represents United States region
+ RegionUS Region = "us-en"
+ // RegionUK represents United Kingdom region
+ RegionUK Region = "uk-en"
+ // RegionDE represents Germany region
+ RegionDE Region = "de-de"
+ // RegionFR represents France region
+ RegionFR Region = "fr-fr"
+ // RegionJP represents Japan region
+ RegionJP Region = "jp-jp"
+ // RegionCN represents China region
+ RegionCN Region = "cn-zh"
+ // RegionRU represents Russia region
+ RegionRU Region = "ru-ru"
+)
+
+// SafeSearch represents the safe search level for filtering explicit content.
+type SafeSearch string
+
+const (
+ // SafeSearchStrict enables strict filtering of explicit content
+ SafeSearchStrict SafeSearch = "strict"
+ // SafeSearchModerate enables moderate filtering of explicit content
+ SafeSearchModerate SafeSearch = "moderate"
+ // SafeSearchOff disables filtering of explicit content
+ SafeSearchOff SafeSearch = "off"
+)
+
+// TimeRange represents the time range for search results.
+type TimeRange string
+
+const (
+ // TimeRangeDay limits results to the past day
+ TimeRangeDay TimeRange = "d"
+ // TimeRangeWeek limits results to the past week
+ TimeRangeWeek TimeRange = "w"
+ // TimeRangeMonth limits results to the past month
+ TimeRangeMonth TimeRange = "m"
+ // TimeRangeYear limits results to the past year
+ TimeRangeYear TimeRange = "y"
+ // TimeRangeAll includes results from all time periods
+ TimeRangeAll TimeRange = ""
+)
+
+// NewsParams configures the news search behavior.
+type NewsParams struct {
+ // Query is the search term or phrase
+ Query string `json:"query"`
+
+ // Region specifies the geographical region for results
+ // Use one of the Region constants (e.g., RegionUS, RegionUK)
+ Region Region `json:"region"`
+
+ // SafeSearch controls filtering of explicit content
+ // Use one of the SafeSearch constants
+ SafeSearch SafeSearch `json:"safe_search"`
+
+ // TimeRange limits results to a specific time period
+ // Use one of the TimeRange constants
+ TimeRange TimeRange `json:"time_range"`
+
+ // MaxResults limits the number of results returned.
+ // Set to 0 for no limit. Note that:
+ // 1. DuckDuckGo API typically returns 10 results per page
+ // 2. This parameter only truncates results when the API returns more results than MaxResults
+ // 3. To get more results, use NextPage() to paginate through results
+ MaxResults int `json:"max_results"`
+}
+
+// SearchParams configures the search behavior.
+// Example usage:
+//
+// params := &SearchParams{
+// Query: "golang tutorials", // Required
+// Region: RegionCN, // Optional, defaults to RegionCN
+// SafeSearch: SafeSearchModerate, // Optional, defaults to Moderate
+// TimeRange: TimeRangeMonth, // Optional, defaults to All
+// Page: 1, // Optional, defaults to 1
+// MaxResults: 10, // Optional, defaults to all results
+// }
+//
+// see more at: https://duckduckgo.com/duckduckgo-help-pages/settings/params/
+type SearchParams struct {
+ // Query is the search term or phrase
+ Query string `json:"query"`
+
+ // Region specifies the geographical region for results
+ // Use one of the Region constants (e.g., RegionUS, RegionUK)
+ Region Region `json:"region"`
+
+ // SafeSearch controls filtering of explicit content
+ // Use one of the SafeSearch constants
+ SafeSearch SafeSearch `json:"safe_search"`
+
+ // TimeRange limits results to a specific time period
+ // Use one of the TimeRange constants
+ TimeRange TimeRange `json:"time_range"`
+
+ // Page specifies which page of results to return
+ // Starts from 1
+ Page int `json:"page"`
+
+ // MaxResults limits the number of results returned.
+ // Set to 0 for no limit. Note that:
+ // 1. DuckDuckGo API typically returns 10 results per page
+ // 2. This parameter only truncates results when the API returns more results than MaxResults
+ // 3. To get more results, use NextPage() to paginate through results
+ MaxResults int `json:"max_results"`
+
+ // cacheKey is used internally for caching search results
+ cacheKey string `json:"-"`
+}
+
+// NextPage returns a new SearchParams with the page number incremented.
+// This is useful for paginating through search results.
+//
+// Example usage:
+//
+// params := &SearchParams{
+// Query: "golang",
+// MaxResults: 10,
+// }
+//
+// // Get first page results
+// results1, err := client.Search(ctx, params)
+// if err != nil {
+// return err
+// }
+//
+// // Get next page results
+// nextParams := params.NextPage()
+// results2, err := client.Search(ctx, nextParams)
+func (p *SearchParams) NextPage() *SearchParams {
+ return &SearchParams{
+ Query: p.Query,
+ Region: p.Region,
+ SafeSearch: p.SafeSearch,
+ TimeRange: p.TimeRange,
+ MaxResults: p.MaxResults,
+ Page: p.Page + 1,
+ }
+}
+
+// SearchResult represents a single search result.
+// Contains the title, URL, and description of the result.
+type SearchResult struct {
+ // Title is the title of the search result
+ Title string `json:"t"`
+
+ // URL is the web address of the result
+ URL string `json:"u"`
+
+ // Description is a brief summary of the result content
+ Description string `json:"a"`
+}
+
+// SearchResponse represents the complete response from a search request.
+type SearchResponse struct {
+ // Results contains the list of search results
+ Results []SearchResult `json:"results"`
+
+ // NoResults indicates whether the search returned any results
+ NoResults bool `json:"noResults"`
+}
+
+// NewsResult represents a single news search result from DuckDuckGo.
+type NewsResult struct {
+ Date string `json:"date"` // ISO8601 formatted date
+ Title string `json:"title"` // News article title
+ Body string `json:"body"` // News article excerpt/summary
+ URL string `json:"url"` // Article URL
+ Image string `json:"image"` // URL of the article's image
+ Source string `json:"source"` // News source name
+}
+
+// NewsResponse represents the complete response from a news search request.
+type NewsResponse struct {
+ Results []NewsResult `json:"results"` // List of news results
+}
+
+// rawNewsResponse represents the raw response from DuckDuckGo news API.
+type rawNewsResponse struct {
+ Results []struct {
+ Date int64 `json:"date"` // Unix timestamp
+ Title string `json:"title"` // Article title
+ Excerpt string `json:"excerpt"` // Article excerpt
+ URL string `json:"url"` // Article URL
+ Image string `json:"image"` // Image URL
+ Source string `json:"source"` // Source name
+ } `json:"results"`
+ Query string `json:"query"` // The search query
+ QueryEncoded string `json:"queryEncoded"` // URL encoded query
+ ResponseType string `json:"response_type"` // Type of response
+ QueryCategory string `json:"query_category"` // Category of query
+}
diff --git a/components/tool/duckduckgo/ddgsearch/utils.go b/components/tool/duckduckgo/ddgsearch/utils.go
new file mode 100644
index 0000000..ea7f816
--- /dev/null
+++ b/components/tool/duckduckgo/ddgsearch/utils.go
@@ -0,0 +1,99 @@
+/*
+ * Copyright 2024 CloudWeGo Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package ddgsearch
+
+import (
+ "fmt"
+ "net/url"
+ "regexp"
+ "strings"
+)
+
+// extractVQD extracts the VQD token from the DuckDuckGo response
+func extractVQD(body []byte, query string) (string, error) {
+ content := string(body)
+
+ // Try to find vqd in JavaScript code
+ re := regexp.MustCompile(`vqd=["']([^"']+)["']`)
+ matches := re.FindStringSubmatch(content)
+ if len(matches) > 1 {
+ return matches[1], nil
+ }
+
+ // Try to find vqd in meta tags
+ re = regexp.MustCompile(`]+content=["']([^"']+)["'][^>]+name=["']vqd["']`)
+ matches = re.FindStringSubmatch(content)
+ if len(matches) > 1 {
+ return matches[1], nil
+ }
+
+ // Try to find vqd in any context
+ re = regexp.MustCompile(`vqd=([^&"']+)`)
+ matches = re.FindStringSubmatch(content)
+ if len(matches) > 1 {
+ return matches[1], nil
+ }
+
+ return "", fmt.Errorf("could not extract vqd for keywords: %s", query)
+}
+
+// extractVQDToken extracts the VQD token from the HTML response
+func extractVQDToken(html string) string {
+ re := regexp.MustCompile(`vqd=["']([^"']+)["']`)
+ matches := re.FindStringSubmatch(html)
+ if len(matches) > 1 {
+ return matches[1]
+ }
+ return ""
+}
+
+// normalizeURL removes AMP and tracking parameters from URLs.
+func normalizeURL(u string) string {
+ if u == "" {
+ return ""
+ }
+
+ // Remove AMP
+ u = strings.ReplaceAll(u, "/amp/", "/")
+ u = strings.ReplaceAll(u, "?amp=1", "")
+ u = strings.ReplaceAll(u, "&=1", "")
+
+ // Parse URL
+ parsed, err := url.Parse(u)
+ if err != nil {
+ return u
+ }
+
+ // Remove tracking parameters
+ q := parsed.Query()
+ for k := range q {
+ if strings.Contains(strings.ToLower(k), "utm_") {
+ q.Del(k)
+ }
+ }
+ parsed.RawQuery = q.Encode()
+
+ return parsed.String()
+}
+
+// truncateString truncates a string to a maximum length.
+func truncateString(s string, maxLength int) string {
+ if len(s) <= maxLength {
+ return s
+ }
+ return s[:maxLength] + "..."
+}
diff --git a/components/tool/duckduckgo/ddgsearch/utils_test.go b/components/tool/duckduckgo/ddgsearch/utils_test.go
new file mode 100644
index 0000000..b94fbcf
--- /dev/null
+++ b/components/tool/duckduckgo/ddgsearch/utils_test.go
@@ -0,0 +1,73 @@
+/*
+ * Copyright 2024 CloudWeGo Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package ddgsearch
+
+import (
+ "testing"
+)
+
+func TestExtractVQD(t *testing.T) {
+ tests := []struct {
+ name string
+ html []byte
+ keywords string
+ want string
+ wantErr bool
+ }{
+ {
+ name: "valid vqd double quotes",
+ html: []byte(``),
+ keywords: "test",
+ want: "123456",
+ wantErr: false,
+ },
+ {
+ name: "valid vqd single quotes",
+ html: []byte(``),
+ keywords: "test",
+ want: "789012",
+ wantErr: false,
+ },
+ {
+ name: "valid vqd with ampersand",
+ html: []byte(``),
+ keywords: "test",
+ want: "345678",
+ wantErr: false,
+ },
+ {
+ name: "no vqd",
+ html: []byte(``),
+ keywords: "test",
+ want: "",
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := extractVQD(tt.html, tt.keywords)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("extractVQD() error = %v, wantErr %v", err, tt.wantErr)
+ return
+ }
+ if got != tt.want {
+ t.Errorf("extractVQD() = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
diff --git a/components/tool/duckduckgo/example/main.go b/components/tool/duckduckgo/example/main.go
new file mode 100644
index 0000000..83db584
--- /dev/null
+++ b/components/tool/duckduckgo/example/main.go
@@ -0,0 +1,81 @@
+/*
+ * Copyright 2024 CloudWeGo Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log"
+
+ "github.com/cloudwego/eino-ext/components/tool/duckduckgo"
+ "github.com/cloudwego/eino-ext/components/tool/duckduckgo/ddgsearch"
+)
+
+func main() {
+ ctx := context.Background()
+
+ // Create configuration
+ config := &duckduckgo.Config{
+ MaxResults: 3, // Limit to return 3 results
+ Region: ddgsearch.RegionCN,
+ DDGConfig: &ddgsearch.Config{
+ Timeout: 10,
+ Cache: true,
+ MaxRetries: 5,
+ },
+ }
+
+ // Create search client
+ tool, err := duckduckgo.NewTool(ctx, config)
+ if err != nil {
+ log.Fatal("Failed to create tool:", err)
+ }
+
+ // Create search request
+ searchReq := &duckduckgo.SearchRequest{
+ Query: "Golang programming development",
+ Page: 1,
+ }
+
+ jsonReq, err := json.Marshal(searchReq)
+ if err != nil {
+ log.Fatal("Failed to marshal search request:", err)
+ }
+
+ // Execute search
+ resp, err := tool.InvokableRun(ctx, string(jsonReq))
+ if err != nil {
+ log.Fatal("Search failed:", err)
+ }
+
+ var searchResp duckduckgo.SearchResponse
+ if err := json.Unmarshal([]byte(resp), &searchResp); err != nil {
+ log.Fatal("Failed to unmarshal search response:", err)
+ }
+
+ // Print results
+ fmt.Println("Search Results:")
+ fmt.Println("==============")
+ for i, result := range searchResp.Results {
+ fmt.Printf("\n%d. Title: %s\n", i+1, result.Title)
+ fmt.Printf(" Link: %s\n", result.Link)
+ fmt.Printf(" Description: %s\n", result.Description)
+ }
+ fmt.Println("")
+ fmt.Println("==============")
+}
diff --git a/components/tool/duckduckgo/go.mod b/components/tool/duckduckgo/go.mod
new file mode 100644
index 0000000..b661345
--- /dev/null
+++ b/components/tool/duckduckgo/go.mod
@@ -0,0 +1,48 @@
+module github.com/cloudwego/eino-ext/components/tool/duckduckgo
+
+go 1.18
+
+require (
+ github.com/bytedance/mockey v1.2.13
+ github.com/cloudwego/eino v0.3.0
+ github.com/stretchr/testify v1.9.0
+)
+
+require (
+ github.com/bytedance/sonic v1.12.2 // indirect
+ github.com/bytedance/sonic/loader v0.2.0 // indirect
+ github.com/cloudwego/base64x v0.1.4 // indirect
+ github.com/cloudwego/iasm v0.2.0 // indirect
+ github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/getkin/kin-openapi v0.118.0 // indirect
+ github.com/go-openapi/jsonpointer v0.19.5 // indirect
+ github.com/go-openapi/swag v0.19.5 // indirect
+ github.com/goph/emperror v0.17.2 // indirect
+ github.com/gopherjs/gopherjs v1.17.2 // indirect
+ github.com/invopop/yaml v0.1.0 // indirect
+ github.com/josharian/intern v1.0.0 // indirect
+ github.com/json-iterator/go v1.1.12 // indirect
+ github.com/jtolds/gls v4.20.0+incompatible // indirect
+ github.com/klauspost/cpuid/v2 v2.0.9 // indirect
+ github.com/mailru/easyjson v0.7.7 // indirect
+ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
+ github.com/modern-go/reflect2 v1.0.2 // indirect
+ github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
+ github.com/nikolalohinski/gonja v1.5.3 // indirect
+ github.com/pelletier/go-toml/v2 v2.0.9 // indirect
+ github.com/perimeterx/marshmallow v1.1.4 // indirect
+ github.com/pkg/errors v0.9.1 // indirect
+ github.com/pmezard/go-difflib v1.0.0 // indirect
+ github.com/sirupsen/logrus v1.9.3 // indirect
+ github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f // indirect
+ github.com/smarty/assertions v1.15.0 // indirect
+ github.com/smartystreets/goconvey v1.8.1 // indirect
+ github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
+ github.com/yargevad/filepathx v1.0.0 // indirect
+ golang.org/x/arch v0.11.0 // indirect
+ golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 // indirect
+ golang.org/x/sys v0.26.0 // indirect
+ gopkg.in/yaml.v2 v2.4.0 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+)
diff --git a/components/tool/duckduckgo/go.sum b/components/tool/duckduckgo/go.sum
new file mode 100644
index 0000000..505f1c8
--- /dev/null
+++ b/components/tool/duckduckgo/go.sum
@@ -0,0 +1,151 @@
+github.com/airbrake/gobrake v3.6.1+incompatible/go.mod h1:wM4gu3Cn0W0K7GUuVWnlXZU11AGBXMILnrdOU8Kn00o=
+github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA=
+github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
+github.com/bugsnag/bugsnag-go v1.4.0/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8=
+github.com/bugsnag/panicwrap v1.2.0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE=
+github.com/bytedance/mockey v1.2.13 h1:jokWZAm/pUEbD939Rhznz615MKUCZNuvCFQlJ2+ntoo=
+github.com/bytedance/mockey v1.2.13/go.mod h1:1BPHF9sol5R1ud/+0VEHGQq/+i2lN+GTsr3O2Q9IENY=
+github.com/bytedance/sonic v1.12.2 h1:oaMFuRTpMHYLpCntGca65YWt5ny+wAceDERTkT2L9lg=
+github.com/bytedance/sonic v1.12.2/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk=
+github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
+github.com/bytedance/sonic/loader v0.2.0 h1:zNprn+lsIP06C/IqCHs3gPQIvnvpKbbxyXQP1iU4kWM=
+github.com/bytedance/sonic/loader v0.2.0/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
+github.com/certifi/gocertifi v0.0.0-20190105021004-abcd57078448/go.mod h1:GJKEexRPVJrBSOjoqN5VNOIKJ5Q3RViH6eu3puDRwx4=
+github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
+github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
+github.com/cloudwego/eino v0.3.0 h1:Xp/zqvyyskRn0obOUvH0Rj/INZwq68z9vvTjXOsNhLw=
+github.com/cloudwego/eino v0.3.0/go.mod h1:+kmJimGEcKuSI6OKhet7kBedkm1WUZS3H1QRazxgWUo=
+github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
+github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
+github.com/getkin/kin-openapi v0.118.0 h1:z43njxPmJ7TaPpMSCQb7PN0dEYno4tyBPQcrFdHoLuM=
+github.com/getkin/kin-openapi v0.118.0/go.mod h1:l5e9PaFUo9fyLJCPGQeXI2ML8c3P8BHOEV2VaAVf/pc=
+github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ=
+github.com/go-check/check v0.0.0-20180628173108-788fd7840127 h1:0gkP6mzaMqkmpcJYCFOLkIBwI7xFExG03bbkOkCvUPI=
+github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
+github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
+github.com/go-openapi/swag v0.19.5 h1:lTz6Ys4CmqqCQmZPBlbQENR1/GucA2bzYTE12Pw4tFY=
+github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
+github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
+github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
+github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
+github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/goph/emperror v0.17.2 h1:yLapQcmEsO0ipe9p5TaN22djm3OFV/TfM/fcYP0/J18=
+github.com/goph/emperror v0.17.2/go.mod h1:+ZbQ+fUNO/6FNiUo0ujtMjhgad9Xa6fQL9KhH4LNHic=
+github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
+github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=
+github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
+github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
+github.com/invopop/yaml v0.1.0 h1:YW3WGUoJEXYfzWBjn00zIlrw7brGVD0fUKRYDPAPhrc=
+github.com/invopop/yaml v0.1.0/go.mod h1:2XuRLgs/ouIrW3XNzuNj7J3Nvu/Dig5MXvbCEdiBN3Q=
+github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
+github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
+github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
+github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
+github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
+github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8=
+github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
+github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
+github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
+github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
+github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
+github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
+github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
+github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
+github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU=
+github.com/mattn/go-isatty v0.0.8 h1:HLtExJ+uU2HOZ+wI0Tt5DtUDrx8yhUqDcp7fYERX4CE=
+github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
+github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
+github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
+github.com/nikolalohinski/gonja v1.5.3 h1:GsA+EEaZDZPGJ8JtpeGN78jidhOlxeJROpqMT9fTj9c=
+github.com/nikolalohinski/gonja v1.5.3/go.mod h1:RmjwxNiXAEqcq1HeK5SSMmqFJvKOfTfXhkJv6YBtPa4=
+github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
+github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
+github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
+github.com/pelletier/go-toml/v2 v2.0.9 h1:uH2qQXheeefCCkuBBSLi7jCiSmj3VRh2+Goq2N7Xxu0=
+github.com/pelletier/go-toml/v2 v2.0.9/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
+github.com/perimeterx/marshmallow v1.1.4 h1:pZLDH9RjlLGGorbXhcaQLhfuV0pFMNfPO55FuFkxqLw=
+github.com/perimeterx/marshmallow v1.1.4/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
+github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rollbar/rollbar-go v1.0.2/go.mod h1:AcFs5f0I+c71bpHlXNNDbOWJiKwjFDtISeXco0L5PKQ=
+github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
+github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
+github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
+github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f h1:Z2cODYsUxQPofhpYRMQVwWz4yUVpHF+vPi+eUdruUYI=
+github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f/go.mod h1:JqzWyvTuI2X4+9wOHmKSQCYxybB/8j6Ko43qVmXDuZg=
+github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY=
+github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec=
+github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY=
+github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
+github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
+github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
+github.com/ugorji/go v1.2.7 h1:qYhyWUUd6WbiM+C6JZAUkIJt/1WrjzNHY9+KCIjVqTo=
+github.com/ugorji/go v1.2.7/go.mod h1:nF9osbDWLy6bDVv/Rtoh6QgnvNDpmCalQV5urGCCS6M=
+github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0=
+github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
+github.com/x-cray/logrus-prefixed-formatter v0.5.2 h1:00txxvfBM9muc0jiLIEAkAcIMJzfthRT6usrui8uGmg=
+github.com/yargevad/filepathx v1.0.0 h1:SYcT+N3tYGi+NvazubCNlvgIPbzAk7i7y2dwg3I5FYc=
+github.com/yargevad/filepathx v1.0.0/go.mod h1:BprfX/gpYNJHJfc35GjRRpVcwWXS89gGulUIU5tK3tA=
+golang.org/x/arch v0.11.0 h1:KXV8WWKCXm6tRpLirl2szsO5j/oOODwZf4hATmGVNs4=
+golang.org/x/arch v0.11.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
+golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
+golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA=
+golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 h1:MGwJjxBy0HJshjDNfLsYO8xppfqWlA5ZT9OhtUUhTNw=
+golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc=
+golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
+golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/term v0.10.0 h1:3R7pNqamzBraeqj/Tj8qt1aQ2HpmlC+Cx/qL/7hn4/c=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
+gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
+gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
+gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
diff --git a/components/tool/duckduckgo/search.go b/components/tool/duckduckgo/search.go
new file mode 100644
index 0000000..7c5af08
--- /dev/null
+++ b/components/tool/duckduckgo/search.go
@@ -0,0 +1,167 @@
+/*
+ * Copyright 2024 CloudWeGo Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package duckduckgo
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/cloudwego/eino-ext/components/tool/duckduckgo/ddgsearch"
+
+ "github.com/cloudwego/eino/components/tool"
+ "github.com/cloudwego/eino/components/tool/utils"
+)
+
+type Config struct {
+ ToolName string `json:"tool_name"` // default: duckduckgo_search
+ ToolDesc string `json:"tool_desc"` // default: "search web for information by duckduckgo"
+
+ Region ddgsearch.Region `json:"region"` // default: "wt-wt"
+ MaxResults int `json:"max_results"` // default: 10
+ SafeSearch ddgsearch.SafeSearch `json:"safe_search"` // default: ddgsearch.SafeSearchModerate
+ TimeRange ddgsearch.TimeRange `json:"time_range"` // default: ddgsearch.TimeRangeAll
+
+ DDGConfig *ddgsearch.Config `json:"ddg_config"`
+}
+
+func NewTool(ctx context.Context, config *Config) (tool.InvokableTool, error) {
+ ddgs, err := newDDGS(ctx, config)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create ddg search tool: %w", err)
+ }
+
+ searchTool, err := utils.InferTool(config.ToolName, config.ToolDesc, ddgs.Search)
+ if err != nil {
+ return nil, fmt.Errorf("failed to infer tool: %w", err)
+ }
+
+ return searchTool, nil
+}
+
+// validate validates the configuration and sets default values if not provided.
+func (conf *Config) validate() error {
+ if conf == nil {
+ return fmt.Errorf("config is nil")
+ }
+
+ if conf.ToolName == "" {
+ conf.ToolName = "duckduckgo_search"
+ }
+
+ if conf.ToolDesc == "" {
+ conf.ToolDesc = "search web for information by duckduckgo"
+ }
+
+ if conf.Region == "" {
+ conf.Region = ddgsearch.RegionWT
+ }
+
+ if conf.MaxResults <= 0 {
+ conf.MaxResults = 10
+ }
+
+ if conf.SafeSearch == "" {
+ conf.SafeSearch = ddgsearch.SafeSearchOff
+ }
+
+ if conf.TimeRange == "" {
+ conf.TimeRange = ddgsearch.TimeRangeAll
+ }
+
+ if conf.DDGConfig == nil {
+ conf.DDGConfig = &ddgsearch.Config{}
+ }
+
+ if conf.DDGConfig.Timeout == 0 {
+ conf.DDGConfig.Timeout = 30 * time.Second
+ }
+
+ if conf.DDGConfig.MaxRetries == 0 {
+ conf.DDGConfig.MaxRetries = 3
+ }
+
+ return nil
+}
+
+func newDDGS(_ context.Context, config *Config) (*ddgs, error) {
+ if config == nil {
+ config = &Config{}
+ }
+
+ if err := config.validate(); err != nil {
+ return nil, err
+ }
+
+ ddg, err := ddgsearch.New(config.DDGConfig)
+ if err != nil {
+ return nil, err
+ }
+
+ return &ddgs{
+ config: config,
+ ddg: ddg,
+ }, nil
+}
+
+type ddgs struct {
+ config *Config
+ ddg *ddgsearch.DDGS
+}
+
+type SearchRequest struct {
+ Query string `json:"query" jsonschema_description:"The query to search the web for"`
+ Page int `json:"page" jsonschema_description:"The page number to search for, default: 1"`
+}
+
+type SearchResult struct {
+ Title string `json:"title" jsonschema_description:"The title of the search result"`
+ Description string `json:"description" jsonschema_description:"The description of the search result"`
+ Link string `json:"link" jsonschema_description:"The link of the search result"`
+}
+
+type SearchResponse struct {
+ Results []*SearchResult `json:"results" jsonschema_description:"The results of the search"`
+}
+
+func (d *ddgs) Search(ctx context.Context, request *SearchRequest) (*SearchResponse, error) {
+ results, err := d.ddg.Search(ctx, &ddgsearch.SearchParams{
+ Query: request.Query,
+ Region: ddgsearch.Region(d.config.Region),
+ MaxResults: d.config.MaxResults,
+ Page: request.Page,
+ SafeSearch: d.config.SafeSearch,
+ TimeRange: d.config.TimeRange,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ searchResponse := &SearchResponse{
+ Results: make([]*SearchResult, len(results.Results)),
+ }
+
+ for i, result := range results.Results {
+ searchResponse.Results[i] = &SearchResult{
+ Title: result.Title,
+ Description: result.Description,
+ Link: result.URL,
+ }
+ }
+
+ return searchResponse, nil
+}
diff --git a/components/tool/duckduckgo/search_test.go b/components/tool/duckduckgo/search_test.go
new file mode 100644
index 0000000..682a8e0
--- /dev/null
+++ b/components/tool/duckduckgo/search_test.go
@@ -0,0 +1,269 @@
+/*
+ * Copyright 2024 CloudWeGo Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package duckduckgo
+
+import (
+ "context"
+ "fmt"
+ "testing"
+ "time"
+
+ "github.com/bytedance/mockey"
+ "github.com/stretchr/testify/assert"
+
+ "github.com/cloudwego/eino-ext/components/tool/duckduckgo/ddgsearch"
+)
+
+func MockDDGS() *mockey.Mocker {
+ return mockey.Mock((*ddgsearch.DDGS).Search).To(func(ctx context.Context, request *ddgsearch.SearchParams) (*ddgsearch.SearchResponse, error) {
+ if request == nil {
+ return nil, fmt.Errorf("request is nil")
+ }
+ if request.Query == "" {
+ return nil, fmt.Errorf("query is empty")
+ }
+ fmt.Println("mocked ddgs.Search", request)
+ return &ddgsearch.SearchResponse{Results: []ddgsearch.SearchResult{
+ {
+ Title: "test title",
+ Description: "test description",
+ URL: "test link",
+ },
+ {
+ Title: "test title 2",
+ Description: "test description 2",
+ URL: "test link 2",
+ },
+ }}, nil
+ }).Build()
+}
+
+func TestConfig_Validate(t *testing.T) {
+ tests := []struct {
+ name string
+ config *Config
+ wantErr bool
+ }{
+ {
+ name: "nil config",
+ config: nil,
+ wantErr: true,
+ },
+ {
+ name: "empty config",
+ config: &Config{},
+ wantErr: false,
+ },
+ {
+ name: "valid config",
+ config: &Config{
+ ToolName: "custom_ddg",
+ ToolDesc: "custom description",
+ Region: "us-en",
+ MaxResults: 20,
+ DDGConfig: &ddgsearch.Config{
+ Timeout: 20,
+ Proxy: "http://proxy.example.com",
+ Cache: true,
+ MaxRetries: 5,
+ },
+ },
+ wantErr: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := tt.config.validate()
+ if tt.wantErr {
+ assert.Error(t, err)
+ return
+ }
+ assert.NoError(t, err)
+
+ if tt.config.ToolName == "" {
+ assert.Equal(t, "duckduckgo_search", tt.config.ToolName)
+ }
+ if tt.config.ToolDesc == "" {
+ assert.Equal(t, "search web for information by duckduckgo", tt.config.ToolDesc)
+ }
+ if tt.config.Region == "" {
+ assert.Equal(t, "zh-CN", tt.config.Region)
+ }
+ if tt.config.MaxResults <= 0 {
+ assert.Equal(t, 10, tt.config.MaxResults)
+ }
+ })
+ }
+}
+
+func TestNewDDGS(t *testing.T) {
+ tests := []struct {
+ name string
+ config *Config
+ wantErr bool
+ }{
+ {
+ name: "nil config",
+ config: nil,
+ wantErr: false,
+ },
+ {
+ name: "valid config",
+ config: &Config{
+ DDGConfig: &ddgsearch.Config{
+ Timeout: 20,
+ Proxy: "http://proxy.example.com",
+ Cache: true,
+ MaxRetries: 5,
+ },
+ },
+ wantErr: false,
+ },
+ {
+ name: "invalid timeout",
+ config: &Config{
+ DDGConfig: &ddgsearch.Config{
+ Timeout: -1,
+ },
+ },
+ wantErr: false, // won't error because Validate() fixes it
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ctx := context.Background()
+ ddgs, err := newDDGS(ctx, tt.config)
+ if tt.wantErr {
+ assert.Error(t, err)
+ return
+ }
+ mocker := MockDDGS()
+ assert.NoError(t, err)
+ assert.NotNil(t, ddgs)
+ assert.NotNil(t, ddgs.config)
+ assert.NotNil(t, ddgs.ddg)
+ mocker.UnPatch()
+ })
+ }
+}
+
+func TestDDGS_Search(t *testing.T) {
+ ctx := context.Background()
+ config := &Config{
+ Region: "zh-CN",
+ MaxResults: 5,
+ }
+
+ ddgs, err := newDDGS(ctx, config)
+ assert.NoError(t, err)
+ mocker := MockDDGS()
+ defer mocker.UnPatch()
+
+ tests := []struct {
+ name string
+ request *SearchRequest
+ wantErr bool
+ }{
+ {
+ name: "basic search",
+ request: &SearchRequest{
+ Query: "golang testing",
+ Page: 1,
+ },
+ wantErr: false,
+ },
+ {
+ name: "empty query",
+ request: &SearchRequest{
+ Query: "",
+ Page: 1,
+ },
+ wantErr: true,
+ },
+ {
+ name: "page number handling",
+ request: &SearchRequest{
+ Query: "test query",
+ Page: 0,
+ },
+ wantErr: false,
+ },
+ {
+ name: "search with max results",
+ request: &SearchRequest{
+ Query: "popular technology news",
+ Page: 1,
+ },
+ wantErr: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+
+ resp, err := ddgs.Search(ctx, tt.request)
+
+ if err != nil {
+ if err.Error() == "no results found" {
+ // This is an acceptable case for any search
+ t.Logf("no results found for query: %s", tt.request.Query)
+ return
+ }
+ if tt.wantErr {
+ assert.Error(t, err)
+ return
+ }
+ t.Errorf("unexpected error: %v", err)
+ return
+ }
+
+ if tt.wantErr {
+ t.Error("expected error but got none")
+ return
+ }
+
+ assert.NotNil(t, resp)
+ assert.NotNil(t, resp.Results)
+ if len(resp.Results) > 0 {
+ result := resp.Results[0]
+ assert.NotEmpty(t, result.Title)
+ assert.NotEmpty(t, result.Link)
+ }
+
+ for _, result := range resp.Results {
+ fmt.Printf("title: %s, description: %s, link: %s\n", result.Title, result.Description, result.Link)
+ }
+ })
+ }
+}
+
+func TestNewTool(t *testing.T) {
+ ctx := context.Background()
+ config := &Config{
+ DDGConfig: &ddgsearch.Config{
+ Timeout: 10,
+ MaxRetries: 5,
+ },
+ }
+ tool, err := NewTool(ctx, config)
+ assert.NoError(t, err)
+ assert.NotNil(t, tool)
+}