diff --git a/lib/crewai-tools/src/crewai_tools/__init__.py b/lib/crewai-tools/src/crewai_tools/__init__.py index cb71de1d64..c7db7e1a73 100644 --- a/lib/crewai-tools/src/crewai_tools/__init__.py +++ b/lib/crewai-tools/src/crewai_tools/__init__.py @@ -160,6 +160,9 @@ from crewai_tools.tools.selenium_scraping_tool.selenium_scraping_tool import ( SeleniumScrapingTool, ) +from crewai_tools.tools.searchapi_tool.searchapi_search_tool import ( + SearchApiSearchTool, +) from crewai_tools.tools.serpapi_tool.serpapi_google_search_tool import ( SerpApiGoogleSearchTool, ) @@ -300,6 +303,7 @@ "ScrapegraphScrapeTool", "ScrapegraphScrapeToolSchema", "ScrapflyScrapeWebsiteTool", + "SearchApiSearchTool", "SeleniumScrapingTool", "SerpApiGoogleSearchTool", "SerpApiGoogleShoppingTool", diff --git a/lib/crewai-tools/src/crewai_tools/tools/__init__.py b/lib/crewai-tools/src/crewai_tools/tools/__init__.py index 18bf4e5638..40e2a6d07c 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/__init__.py +++ b/lib/crewai-tools/src/crewai_tools/tools/__init__.py @@ -148,6 +148,9 @@ from crewai_tools.tools.selenium_scraping_tool.selenium_scraping_tool import ( SeleniumScrapingTool, ) +from crewai_tools.tools.searchapi_tool.searchapi_search_tool import ( + SearchApiSearchTool, +) from crewai_tools.tools.serpapi_tool.serpapi_google_search_tool import ( SerpApiGoogleSearchTool, ) @@ -283,6 +286,7 @@ "ScrapegraphScrapeToolSchema", "ScrapflyScrapeWebsiteTool", "SeleniumScrapingTool", + "SearchApiSearchTool", "SerpApiGoogleSearchTool", "SerpApiGoogleShoppingTool", "SerperDevTool", diff --git a/lib/crewai-tools/src/crewai_tools/tools/searchapi_tool/README.md b/lib/crewai-tools/src/crewai_tools/tools/searchapi_tool/README.md new file mode 100644 index 0000000000..183c3aca8a --- /dev/null +++ b/lib/crewai-tools/src/crewai_tools/tools/searchapi_tool/README.md @@ -0,0 +1,84 @@ +# SearchApi Tool + +## Description +[SearchApi](https://www.searchapi.io) is a real-time SERP API that delivers structured data from 100+ search engines and sources. A single tool class supports multiple engines — no need for separate tool classes per search type. + +To use this tool, set `SEARCHAPI_API_KEY` in your environment. Get your API key at [searchapi.io](https://www.searchapi.io). + +## Installation +```shell +pip install 'crewai[tools]' +``` + +## Supported Engines + +| Engine | Description | +|--------|-------------| +| `google` | Google web search (default) | +| `google_news` | Google News | +| `google_shopping` | Google Shopping | +| `google_jobs` | Google Jobs | +| `youtube` | YouTube video search | +| `bing` | Bing web search | +| `baidu` | Baidu search | + +## Usage + +### Google Search (default) +```python +from crewai_tools import SearchApiSearchTool + +tool = SearchApiSearchTool() +``` + +### Google News +```python +from crewai_tools import SearchApiSearchTool + +tool = SearchApiSearchTool(engine="google_news") +``` + +### Google Shopping +```python +from crewai_tools import SearchApiSearchTool + +tool = SearchApiSearchTool(engine="google_shopping") +``` + +### YouTube Search +```python +from crewai_tools import SearchApiSearchTool + +tool = SearchApiSearchTool(engine="youtube") +``` + +### With Location, Country and Language +```python +from crewai_tools import SearchApiSearchTool + +tool = SearchApiSearchTool( + engine="google", + n_results=5, + country="us", + language="en", +) + +# Location can also be passed per-query at runtime +result = tool.run(search_query="coffee shops", location="San Francisco") +``` + +## Configuration + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `engine` | `str` | `"google"` | Search engine to use | +| `n_results` | `int` | `10` | Number of results to return | +| `country` | `str \| None` | `None` | Country code (e.g., `"us"`, `"uk"`) | +| `language` | `str \| None` | `None` | Language code (e.g., `"en"`, `"es"`) | + +## Runtime Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `search_query` | `str` | Yes | The search query to execute | +| `location` | `str \| None` | No | Location for the search (e.g., `"New York"`) | diff --git a/lib/crewai-tools/src/crewai_tools/tools/searchapi_tool/__init__.py b/lib/crewai-tools/src/crewai_tools/tools/searchapi_tool/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lib/crewai-tools/src/crewai_tools/tools/searchapi_tool/searchapi_search_tool.py b/lib/crewai-tools/src/crewai_tools/tools/searchapi_tool/searchapi_search_tool.py new file mode 100644 index 0000000000..8e59fc3136 --- /dev/null +++ b/lib/crewai-tools/src/crewai_tools/tools/searchapi_tool/searchapi_search_tool.py @@ -0,0 +1,129 @@ +"""SearchApi search tool for CrewAI agents.""" + +import logging +import os +from typing import Any + +from crewai.tools import BaseTool, EnvVar +from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator +import requests + + +logger = logging.getLogger(__name__) + +BASE_URL = "https://www.searchapi.io/api/v1/search" + +SUPPORTED_ENGINES = [ + "google", + "google_news", + "google_shopping", + "google_jobs", + "youtube", + "bing", + "baidu", +] + + +class SearchApiSearchToolSchema(BaseModel): + """Input schema for SearchApi search tool.""" + + search_query: str = Field( + ..., description="Mandatory search query to perform the search." + ) + location: str | None = Field( + None, description="Location to perform the search from (e.g., 'New York')." + ) + + +class SearchApiSearchTool(BaseTool): + """Search the internet using SearchApi. + + Supports multiple engines including Google, Google News, Google Shopping, + Google Jobs, YouTube, Bing, and Baidu. Configure the engine at initialization. + """ + + model_config = ConfigDict( + arbitrary_types_allowed=True, validate_assignment=True, frozen=False + ) + name: str = "SearchApi Search" + description: str = ( + "A tool that searches the internet using SearchApi. " + "Supports multiple engines: google, google_news, google_shopping, " + "google_jobs, youtube, bing, and baidu." + ) + args_schema: type[BaseModel] = SearchApiSearchToolSchema + package_dependencies: list[str] = Field(default_factory=lambda: ["requests"]) + env_vars: list[EnvVar] = Field( + default_factory=lambda: [ + EnvVar( + name="SEARCHAPI_API_KEY", + description="API key for SearchApi (https://www.searchapi.io)", + required=True, + ), + ] + ) + + engine: str = "google" + n_results: int = 10 + country: str | None = None + language: str | None = None + + _api_key: str | None = PrivateAttr(default=None) + + @field_validator("engine") + @classmethod + def validate_engine(cls, v: str) -> str: + """Validate the engine is supported.""" + if v not in SUPPORTED_ENGINES: + raise ValueError( + f"Invalid engine: {v}. Must be one of: {', '.join(SUPPORTED_ENGINES)}" + ) + return v + + def __init__(self, **kwargs: Any) -> None: + """Initialize the SearchApi tool and validate configuration.""" + super().__init__(**kwargs) + api_key = os.getenv("SEARCHAPI_API_KEY") + if not api_key: + raise ValueError( + "Missing SEARCHAPI_API_KEY. Get your key at https://www.searchapi.io" + ) + self._api_key = api_key + + def _run(self, **kwargs: Any) -> Any: + """Execute a search query against the configured SearchApi engine.""" + search_query: str | None = kwargs.get("search_query") or kwargs.get("query") + if not search_query: + raise ValueError("search_query is required") + + params: dict[str, Any] = { + "engine": self.engine, + "q": search_query, + "num": self.n_results, + } + + location = kwargs.get("location") + if location: + params["location"] = location + if self.country: + params["gl"] = self.country + if self.language: + params["hl"] = self.language + + headers = {"Authorization": f"Bearer {self._api_key}"} + + try: + response = requests.get( + BASE_URL, params=params, headers=headers, timeout=30 + ) + response.raise_for_status() + results: dict[str, Any] = response.json() + except requests.RequestException as e: + error_msg = f"An error occurred while performing the search: {e!s}" + logger.error(error_msg) + return error_msg + + for key in ["search_metadata", "search_parameters", "pagination"]: + results.pop(key, None) + + return results diff --git a/lib/crewai-tools/tests/tools/searchapi_tool_test.py b/lib/crewai-tools/tests/tools/searchapi_tool_test.py new file mode 100644 index 0000000000..22f939478b --- /dev/null +++ b/lib/crewai-tools/tests/tools/searchapi_tool_test.py @@ -0,0 +1,286 @@ +"""Tests for SearchApiSearchTool.""" + +import os +from unittest.mock import MagicMock, patch + +import pytest +import requests + +from crewai_tools.tools.searchapi_tool.searchapi_search_tool import ( + SearchApiSearchTool, +) + + +@pytest.fixture(autouse=True) +def mock_searchapi_api_key(): + with patch.dict(os.environ, {"SEARCHAPI_API_KEY": "test_key"}): + yield + + +class TestInitialization: + """Test tool initialization and configuration.""" + + def test_default_initialization(self): + tool = SearchApiSearchTool() + assert tool.name == "SearchApi Search" + assert tool.engine == "google" + assert tool.n_results == 10 + assert tool.country is None + assert tool.language is None + + def test_custom_engine(self): + tool = SearchApiSearchTool(engine="google_news") + assert tool.engine == "google_news" + + def test_custom_parameters(self): + tool = SearchApiSearchTool( + engine="youtube", + n_results=5, + country="us", + language="en", + ) + assert tool.engine == "youtube" + assert tool.n_results == 5 + assert tool.country == "us" + assert tool.language == "en" + + def test_invalid_engine_raises(self): + with pytest.raises(ValueError, match="Invalid engine"): + SearchApiSearchTool(engine="invalid_engine") + + def test_missing_api_key_raises(self): + with patch.dict(os.environ, {}, clear=True): + with pytest.raises(ValueError, match="Missing SEARCHAPI_API_KEY"): + SearchApiSearchTool() + + def test_api_key_not_serialized(self): + """The API key must never leak via model serialization.""" + tool = SearchApiSearchTool() + dumped = tool.model_dump(mode="json") + assert "test_key" not in str(dumped) + assert "_api_key" not in dumped + + def test_all_supported_engines(self): + engines = [ + "google", + "google_news", + "google_shopping", + "google_jobs", + "youtube", + "bing", + "baidu", + ] + for engine in engines: + tool = SearchApiSearchTool(engine=engine) + assert tool.engine == engine + + +class TestSearchExecution: + """Test the _run method with mocked HTTP requests.""" + + @patch( + "crewai_tools.tools.searchapi_tool.searchapi_search_tool.requests.get" + ) + def test_google_search(self, mock_get): + mock_response = MagicMock() + mock_response.json.return_value = { + "search_metadata": {"id": "abc"}, + "search_parameters": {"q": "test"}, + "pagination": {"next": "..."}, + "organic_results": [ + {"title": "Result 1", "link": "http://r1.com", "snippet": "Snippet 1"}, + {"title": "Result 2", "link": "http://r2.com", "snippet": "Snippet 2"}, + ], + } + mock_response.raise_for_status.return_value = None + mock_get.return_value = mock_response + + tool = SearchApiSearchTool() + result = tool._run(search_query="best electric cars") + + assert "organic_results" in result + assert len(result["organic_results"]) == 2 + assert "search_metadata" not in result + assert "search_parameters" not in result + assert "pagination" not in result + + @patch( + "crewai_tools.tools.searchapi_tool.searchapi_search_tool.requests.get" + ) + def test_google_news_search(self, mock_get): + mock_response = MagicMock() + mock_response.json.return_value = { + "search_metadata": {"id": "def"}, + "news_results": [ + {"title": "News 1", "link": "http://n1.com", "date": "2026-01-01"}, + ], + } + mock_response.raise_for_status.return_value = None + mock_get.return_value = mock_response + + tool = SearchApiSearchTool(engine="google_news") + result = tool._run(search_query="AI news") + + assert "news_results" in result + assert "search_metadata" not in result + + @patch( + "crewai_tools.tools.searchapi_tool.searchapi_search_tool.requests.get" + ) + def test_google_shopping_search(self, mock_get): + mock_response = MagicMock() + mock_response.json.return_value = { + "search_metadata": {"id": "ghi"}, + "shopping_results": [ + {"title": "Product 1", "price": "$99", "link": "http://p1.com"}, + ], + } + mock_response.raise_for_status.return_value = None + mock_get.return_value = mock_response + + tool = SearchApiSearchTool(engine="google_shopping") + result = tool._run(search_query="wireless headphones") + + assert "shopping_results" in result + assert "search_metadata" not in result + + @patch( + "crewai_tools.tools.searchapi_tool.searchapi_search_tool.requests.get" + ) + def test_youtube_search(self, mock_get): + mock_response = MagicMock() + mock_response.json.return_value = { + "search_metadata": {"id": "jkl"}, + "video_results": [ + {"title": "Video 1", "link": "http://yt.com/1"}, + ], + } + mock_response.raise_for_status.return_value = None + mock_get.return_value = mock_response + + tool = SearchApiSearchTool(engine="youtube") + result = tool._run(search_query="python tutorial") + + assert "video_results" in result + assert "search_metadata" not in result + + +class TestRequestConstruction: + """Test that requests are constructed correctly.""" + + @patch( + "crewai_tools.tools.searchapi_tool.searchapi_search_tool.requests.get" + ) + def test_request_params(self, mock_get): + mock_response = MagicMock() + mock_response.json.return_value = {} + mock_response.raise_for_status.return_value = None + mock_get.return_value = mock_response + + tool = SearchApiSearchTool(engine="google", n_results=5) + tool._run(search_query="test query") + + _, kwargs = mock_get.call_args + assert kwargs["params"]["engine"] == "google" + assert kwargs["params"]["q"] == "test query" + assert kwargs["params"]["num"] == 5 + assert kwargs["headers"]["Authorization"] == "Bearer test_key" + + @patch( + "crewai_tools.tools.searchapi_tool.searchapi_search_tool.requests.get" + ) + def test_request_with_location(self, mock_get): + mock_response = MagicMock() + mock_response.json.return_value = {} + mock_response.raise_for_status.return_value = None + mock_get.return_value = mock_response + + tool = SearchApiSearchTool() + tool._run(search_query="coffee shops", location="San Francisco") + + _, kwargs = mock_get.call_args + assert kwargs["params"]["location"] == "San Francisco" + + @patch( + "crewai_tools.tools.searchapi_tool.searchapi_search_tool.requests.get" + ) + def test_request_with_country_and_language(self, mock_get): + mock_response = MagicMock() + mock_response.json.return_value = {} + mock_response.raise_for_status.return_value = None + mock_get.return_value = mock_response + + tool = SearchApiSearchTool(country="us", language="en") + tool._run(search_query="test") + + _, kwargs = mock_get.call_args + assert kwargs["params"]["gl"] == "us" + assert kwargs["params"]["hl"] == "en" + + @patch( + "crewai_tools.tools.searchapi_tool.searchapi_search_tool.requests.get" + ) + def test_location_not_sent_when_none(self, mock_get): + mock_response = MagicMock() + mock_response.json.return_value = {} + mock_response.raise_for_status.return_value = None + mock_get.return_value = mock_response + + tool = SearchApiSearchTool() + tool._run(search_query="test") + + _, kwargs = mock_get.call_args + assert "location" not in kwargs["params"] + assert "gl" not in kwargs["params"] + assert "hl" not in kwargs["params"] + + +class TestErrorHandling: + """Test error handling for various failure modes.""" + + @patch( + "crewai_tools.tools.searchapi_tool.searchapi_search_tool.requests.get" + ) + def test_timeout_returns_message(self, mock_get): + mock_get.side_effect = requests.Timeout("connection timed out") + + tool = SearchApiSearchTool() + result = tool._run(search_query="anything") + + assert isinstance(result, str) + assert "error occurred" in result.lower() + + @patch( + "crewai_tools.tools.searchapi_tool.searchapi_search_tool.requests.get" + ) + def test_connection_error_returns_message(self, mock_get): + mock_get.side_effect = requests.ConnectionError("failed to connect") + + tool = SearchApiSearchTool() + result = tool._run(search_query="anything") + + assert isinstance(result, str) + assert "error occurred" in result.lower() + + @patch( + "crewai_tools.tools.searchapi_tool.searchapi_search_tool.requests.get" + ) + def test_http_error_returns_message(self, mock_get): + mock_response = MagicMock() + mock_response.raise_for_status.side_effect = requests.HTTPError("401 Unauthorized") + mock_get.return_value = mock_response + + tool = SearchApiSearchTool() + result = tool._run(search_query="anything") + + assert isinstance(result, str) + assert "error occurred" in result.lower() + + def test_missing_search_query_raises(self): + tool = SearchApiSearchTool() + with pytest.raises(ValueError, match="search_query is required"): + tool._run() + + +if __name__ == "__main__": + pytest.main([__file__]) diff --git a/lib/crewai-tools/tool.specs.json b/lib/crewai-tools/tool.specs.json index 795fa932c4..c67981a418 100644 --- a/lib/crewai-tools/tool.specs.json +++ b/lib/crewai-tools/tool.specs.json @@ -20304,6 +20304,128 @@ "type": "object" } }, + { + "description": "A tool that searches the internet using SearchApi. Supports multiple engines: google, google_news, google_shopping, google_jobs, youtube, bing, and baidu.", + "env_vars": [ + { + "default": null, + "description": "API key for SearchApi (https://www.searchapi.io)", + "name": "SEARCHAPI_API_KEY", + "required": true + } + ], + "humanized_name": "SearchApi Search", + "init_params_schema": { + "$defs": { + "EnvVar": { + "properties": { + "default": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Default" + }, + "description": { + "title": "Description", + "type": "string" + }, + "name": { + "title": "Name", + "type": "string" + }, + "required": { + "default": true, + "title": "Required", + "type": "boolean" + } + }, + "required": [ + "name", + "description" + ], + "title": "EnvVar", + "type": "object" + } + }, + "properties": { + "engine": { + "default": "google", + "title": "Engine", + "type": "string" + }, + "n_results": { + "default": 10, + "title": "N Results", + "type": "integer" + }, + "country": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Country" + }, + "language": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Language" + } + }, + "required": [], + "title": "SearchApiSearchTool", + "type": "object" + }, + "name": "SearchApiSearchTool", + "package_dependencies": [ + "requests" + ], + "run_params_schema": { + "description": "Input schema for SearchApi search tool.", + "properties": { + "location": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Location to perform the search from (e.g., 'New York').", + "title": "Location" + }, + "search_query": { + "description": "Mandatory search query to perform the search.", + "title": "Search Query", + "type": "string" + } + }, + "required": [ + "search_query" + ], + "title": "SearchApiSearchToolSchema", + "type": "object" + } + }, { "description": "A tool to perform to perform a Google search with a search_query.", "env_vars": [