From ef2dccfdc88d5accdf159cfa1872f0e3de0b3636 Mon Sep 17 00:00:00 2001 From: Pawansingh3889 Date: Wed, 2 Sep 2026 17:13:43 +0530 Subject: [PATCH] feat(tools): add SearchApiTool for searchapi.io SearchApi fronts many engines behind one endpoint, so a single tool covers Google web search, news, scholar and jobs, plus Bing, YouTube and Baidu, by setting the engine parameter. The response is the engine's own JSON, trimmed before an agent sees it: inline data: URIs are dropped because SearchApi returns favicons and thumbnails as base64 strings that can each run to tens of kilobytes, long strings are truncated, and every *_results list is capped at n_results. The key is sent in the Authorization header rather than the query string, so it stays out of request logs and out of the request_url SearchApi echoes back in search_metadata. A failed request raises with the API's own message, while a 200 carrying an error message, which is how SearchApi reports a page with no results, is passed through so the agent can read why. --- docs/docs.json | 4 + .../ar/tools/search-research/overview.mdx | 4 + .../tools/search-research/searchapitool.mdx | 74 +++++++ .../en/tools/search-research/overview.mdx | 4 + .../tools/search-research/searchapitool.mdx | 74 +++++++ .../ko/tools/search-research/overview.mdx | 4 + .../tools/search-research/searchapitool.mdx | 74 +++++++ .../pt-BR/tools/search-research/overview.mdx | 4 + .../tools/search-research/searchapitool.mdx | 74 +++++++ lib/crewai-tools/src/crewai_tools/__init__.py | 2 + .../src/crewai_tools/tools/__init__.py | 2 + .../tools/searchapi_tool/README.md | 81 ++++++++ .../tools/searchapi_tool/__init__.py | 0 .../tools/searchapi_tool/searchapi_tool.py | 188 +++++++++++++++++ .../tests/tools/searchapi_tool_test.py | 193 ++++++++++++++++++ lib/crewai-tools/tool.specs.json | 157 ++++++++++++++ 16 files changed, 939 insertions(+) create mode 100644 docs/edge/ar/tools/search-research/searchapitool.mdx create mode 100644 docs/edge/en/tools/search-research/searchapitool.mdx create mode 100644 docs/edge/ko/tools/search-research/searchapitool.mdx create mode 100644 docs/edge/pt-BR/tools/search-research/searchapitool.mdx create mode 100644 lib/crewai-tools/src/crewai_tools/tools/searchapi_tool/README.md create mode 100644 lib/crewai-tools/src/crewai_tools/tools/searchapi_tool/__init__.py create mode 100644 lib/crewai-tools/src/crewai_tools/tools/searchapi_tool/searchapi_tool.py create mode 100644 lib/crewai-tools/tests/tools/searchapi_tool_test.py diff --git a/docs/docs.json b/docs/docs.json index 22a2c963fe..ef874720e9 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -259,6 +259,7 @@ "pages": [ "edge/en/tools/search-research/overview", "edge/en/tools/search-research/serperdevtool", + "edge/en/tools/search-research/searchapitool", "edge/en/tools/search-research/bravesearchtool", "edge/en/tools/search-research/exasearchtool", "edge/en/tools/search-research/linkupsearchtool", @@ -13912,6 +13913,7 @@ "pages": [ "edge/pt-BR/tools/search-research/overview", "edge/pt-BR/tools/search-research/serperdevtool", + "edge/pt-BR/tools/search-research/searchapitool", "edge/pt-BR/tools/search-research/bravesearchtool", "edge/pt-BR/tools/search-research/exasearchtool", "edge/pt-BR/tools/search-research/linkupsearchtool", @@ -26630,6 +26632,7 @@ "pages": [ "edge/ko/tools/search-research/overview", "edge/ko/tools/search-research/serperdevtool", + "edge/ko/tools/search-research/searchapitool", "edge/ko/tools/search-research/bravesearchtool", "edge/ko/tools/search-research/exasearchtool", "edge/ko/tools/search-research/linkupsearchtool", @@ -39777,6 +39780,7 @@ "pages": [ "edge/ar/tools/search-research/overview", "edge/ar/tools/search-research/serperdevtool", + "edge/ar/tools/search-research/searchapitool", "edge/ar/tools/search-research/bravesearchtool", "edge/ar/tools/search-research/exasearchtool", "edge/ar/tools/search-research/linkupsearchtool", diff --git a/docs/edge/ar/tools/search-research/overview.mdx b/docs/edge/ar/tools/search-research/overview.mdx index 6b73d12b5d..4d57d32b12 100644 --- a/docs/edge/ar/tools/search-research/overview.mdx +++ b/docs/edge/ar/tools/search-research/overview.mdx @@ -14,6 +14,10 @@ mode: "wide" تكامل مع Google search API لقدرات بحث شاملة على الويب. + + عدة محركات بحث خلف نقطة نهاية واحدة: بحث Google على الويب والأخبار والأبحاث الأكاديمية والوظائف، إضافة إلى Bing و YouTube وغيرها. + + بحث يركز على الخصوصية مع فهرس بحث Brave المستقل. diff --git a/docs/edge/ar/tools/search-research/searchapitool.mdx b/docs/edge/ar/tools/search-research/searchapitool.mdx new file mode 100644 index 0000000000..6f10ea92b0 --- /dev/null +++ b/docs/edge/ar/tools/search-research/searchapitool.mdx @@ -0,0 +1,74 @@ +--- +title: بحث SearchApi +description: أداة `SearchApiTool` تبحث في الإنترنت عبر SearchApi، الذي يجمع عدة محركات بحث خلف نقطة نهاية واحدة. +icon: magnifying-glass +mode: "wide" +--- + +# `SearchApiTool` + +## الوصف + +أداة `SearchApiTool` تبحث في الإنترنت عبر [SearchApi](https://www.searchapi.io). نقطة نهاية واحدة تجمع عدة محركات، تُختار بالمعامل `engine`، لذا تغطي أداة واحدة بحث Google على الويب والأخبار والأبحاث الأكاديمية والوظائف، إضافة إلى Bing و YouTube و Baidu وبقية [المحركات المدعومة](https://www.searchapi.io/docs). تُرجع الأداة JSON الخاص بالمحرك نفسه، لذا تتطابق النتائج مع توثيق ذلك المحرك. + +يحدث أمران للاستجابة قبل أن يراها الـ Agent: + +- تُحذف روابط `data:` المضمّنة. يُرجع SearchApi الأيقونات والصور المصغّرة كسلاسل base64، وقد تبلغ الواحدة منها عشرات الكيلوبايتات من السياق الذي لا يعني شيئًا للـ Agent. +- تُقتطع السلاسل الطويلة عند `max_string_length`، وتُحدَّد كل قائمة `*_results` بعدد `n_results`. + +## التثبيت + +1. **تثبيت الحزمة**: تأكد من تثبيت حزمة `crewai[tools]` في بيئة Python لديك. +2. **الحصول على مفتاح API**: احصل على مفتاح SearchApi من https://www.searchapi.io (تتوفر خطة مجانية). +3. **إعداد البيئة**: احفظ المفتاح في متغير بيئة باسم `SEARCHAPI_API_KEY`. + +```shell +pip install 'crewai[tools]' +``` + +يُرسَل المفتاح في ترويسة `Authorization` بدلًا من سلسلة الاستعلام، لذا يبقى خارج سجلات الطلبات وخارج `request_url` الذي يعيده SearchApi في `search_metadata`. + +## مثال + +```python Code +from crewai_tools import SearchApiTool + +# Initialize the tool for internet searching capabilities +tool = SearchApiTool() +``` + +## المعاملات + +- **engine**: محرك SearchApi المطلوب، مثل `google` أو `google_news` أو `google_scholar` أو `google_jobs` أو `bing` أو `youtube` أو `baidu`. القيمة الافتراضية `google`. ويمكن تمريره مع كل استدعاء. +- **n_results**: الحد الأقصى لطول كل قائمة `*_results` في الاستجابة. القيمة الافتراضية `10`. +- **country**: اختياري. بلد البحث، يُرسَل باسم `gl` (مثل `uk`). +- **locale**: اختياري. لغة الواجهة، تُرسَل باسم `hl` (مثل `en`). +- **location**: اختياري. الموقع المعياري للبحث، مثل `London,England`. +- **max_string_length**: أطول سلسلة تبقى كما هي في الاستجابة. القيمة الافتراضية `1000`. +- **timeout**: مهلة الطلب بالثواني. القيمة الافتراضية `30`. +- **api_key**: اختياري. مفتاح SearchApi الخاص بك. وإن لم يُحدَّد، يُقرأ من متغير البيئة `SEARCHAPI_API_KEY`. +- **search_url**: نقطة النهاية التي تُستدعى. القيمة الافتراضية `https://www.searchapi.io/api/v1/search`. + +## مثال مع المعاملات + +```python Code +from crewai_tools import SearchApiTool + +# Recent news, localized to the UK +news_tool = SearchApiTool( + engine="google_news", + country="uk", + locale="en", + location="London,England", + n_results=5, +) + +# Academic search through the same tool +scholar_tool = SearchApiTool(engine="google_scholar") +``` + +## الأخطاء + +يؤدي فشل الطلب إلى إطلاق `RuntimeError` يحمل رسالة SearchApi نفسها، مثل `SearchApi request failed (HTTP 401): Invalid API key.`. + +أما البحث الناجح الذي لم يجد شيئًا فليس خطأً: يُرجع SearchApi استجابة HTTP 200 مع رسالة `error` مثل `"Google didn't return any results."`، وتمرّر الأداة هذه الرسالة كما هي ليقرأ الـ Agent سبب خلو الصفحة. diff --git a/docs/edge/en/tools/search-research/overview.mdx b/docs/edge/en/tools/search-research/overview.mdx index 6fdd8410c4..9d73c9599d 100644 --- a/docs/edge/en/tools/search-research/overview.mdx +++ b/docs/edge/en/tools/search-research/overview.mdx @@ -14,6 +14,10 @@ These tools enable your agents to search the web, research topics, and find info Google search API integration for comprehensive web search capabilities. + + Many search engines behind one endpoint: Google web, news, scholar and jobs, plus Bing, YouTube and more. + + Privacy-focused search with Brave's independent search index. diff --git a/docs/edge/en/tools/search-research/searchapitool.mdx b/docs/edge/en/tools/search-research/searchapitool.mdx new file mode 100644 index 0000000000..77ce8bcfe1 --- /dev/null +++ b/docs/edge/en/tools/search-research/searchapitool.mdx @@ -0,0 +1,74 @@ +--- +title: SearchApi Search +description: The `SearchApiTool` searches the internet through SearchApi, which fronts many search engines behind one endpoint. +icon: magnifying-glass +mode: "wide" +--- + +# `SearchApiTool` + +## Description + +The `SearchApiTool` searches the internet through [SearchApi](https://www.searchapi.io). One endpoint fronts many engines, chosen with the `engine` parameter, so a single tool covers Google web search, news, scholar and jobs, plus Bing, YouTube, Baidu and the rest of the [supported engines](https://www.searchapi.io/docs). The tool returns the engine's own JSON, so results line up with the engine's documentation. + +Two things happen to the response before an agent sees it: + +- Inline `data:` URIs are dropped. SearchApi returns favicons and thumbnails as base64 strings, and a single one can run to tens of kilobytes of context that means nothing to an agent. +- Long strings are truncated at `max_string_length`, and every `*_results` list is capped at `n_results`. + +## Installation + +1. **Package Installation**: Confirm that the `crewai[tools]` package is installed in your Python environment. +2. **API Key Acquisition**: Get a SearchApi key at https://www.searchapi.io (free tier available). +3. **Environment Configuration**: Store the key in an environment variable named `SEARCHAPI_API_KEY`. + +```shell +pip install 'crewai[tools]' +``` + +The key is sent in the `Authorization` header rather than the query string, so it stays out of request logs and out of the `request_url` SearchApi echoes back in `search_metadata`. + +## Example + +```python Code +from crewai_tools import SearchApiTool + +# Initialize the tool for internet searching capabilities +tool = SearchApiTool() +``` + +## Parameters + +- **engine**: The SearchApi engine to query, such as `google`, `google_news`, `google_scholar`, `google_jobs`, `bing`, `youtube` or `baidu`. Default is `google`. Can also be passed per call. +- **n_results**: Cap on the length of each `*_results` list in the response. Default is `10`. +- **country**: Optional. Country of the search, sent as `gl` (for example `uk`). +- **locale**: Optional. Interface language, sent as `hl` (for example `en`). +- **location**: Optional. Canonical location of the search, for example `London,England`. +- **max_string_length**: Longest string kept intact in the response. Default is `1000`. +- **timeout**: Request timeout in seconds. Default is `30`. +- **api_key**: Optional. Your SearchApi key. Falls back to the `SEARCHAPI_API_KEY` environment variable. +- **search_url**: The endpoint to call. Default is `https://www.searchapi.io/api/v1/search`. + +## Example with Parameters + +```python Code +from crewai_tools import SearchApiTool + +# Recent news, localized to the UK +news_tool = SearchApiTool( + engine="google_news", + country="uk", + locale="en", + location="London,England", + n_results=5, +) + +# Academic search through the same tool +scholar_tool = SearchApiTool(engine="google_scholar") +``` + +## Errors + +A failed request raises a `RuntimeError` carrying SearchApi's own message, for example `SearchApi request failed (HTTP 401): Invalid API key.`. + +A successful search that found nothing is not an error: SearchApi returns HTTP 200 with an `error` message such as `"Google didn't return any results."`, and the tool passes that through so the agent can read why the page was empty. diff --git a/docs/edge/ko/tools/search-research/overview.mdx b/docs/edge/ko/tools/search-research/overview.mdx index ff59c38971..d565e32c10 100644 --- a/docs/edge/ko/tools/search-research/overview.mdx +++ b/docs/edge/ko/tools/search-research/overview.mdx @@ -14,6 +14,10 @@ mode: "wide" 종합적인 웹 검색 기능을 위한 Google 검색 API 통합. + + 하나의 엔드포인트로 여러 검색 엔진을 제공합니다: Google 웹, 뉴스, 학술, 채용 정보에 더해 Bing, YouTube 등. + + Brave의 독립적인 검색 인덱스를 활용한 프라이버시 중심의 검색. diff --git a/docs/edge/ko/tools/search-research/searchapitool.mdx b/docs/edge/ko/tools/search-research/searchapitool.mdx new file mode 100644 index 0000000000..bc0a5471aa --- /dev/null +++ b/docs/edge/ko/tools/search-research/searchapitool.mdx @@ -0,0 +1,74 @@ +--- +title: SearchApi 검색 +description: SearchApiTool은(는) 여러 검색 엔진을 하나의 엔드포인트로 제공하는 SearchApi를 통해 인터넷을 검색합니다. +icon: magnifying-glass +mode: "wide" +--- + +# `SearchApiTool` + +## 설명 + +`SearchApiTool`은 [SearchApi](https://www.searchapi.io)를 통해 인터넷을 검색합니다. 하나의 엔드포인트가 여러 엔진을 제공하며 `engine` 파라미터로 선택하므로, 단일 도구로 Google 웹 검색, 뉴스, 학술, 채용 정보는 물론 Bing, YouTube, Baidu 및 나머지 [지원 엔진](https://www.searchapi.io/docs)까지 다룰 수 있습니다. 이 도구는 엔진의 JSON을 그대로 반환하므로 결과는 해당 엔진의 문서와 일치합니다. + +에이전트가 응답을 보기 전에 두 가지 처리가 이루어집니다: + +- 인라인 `data:` URI가 제거됩니다. SearchApi는 파비콘과 썸네일을 base64 문자열로 반환하는데, 하나만으로도 수십 킬로바이트에 이를 수 있으며 에이전트에게는 아무런 의미가 없습니다. +- 긴 문자열은 `max_string_length`에서 잘리고, 모든 `*_results` 목록은 `n_results` 개수로 제한됩니다. + +## 설치 + +1. **패키지 설치**: Python 환경에 `crewai[tools]` 패키지가 설치되어 있는지 확인하세요. +2. **API 키 발급**: https://www.searchapi.io 에서 SearchApi 키를 발급받으세요(무료 티어 제공). +3. **환경 설정**: 발급받은 키를 `SEARCHAPI_API_KEY`라는 환경 변수에 저장하세요. + +```shell +pip install 'crewai[tools]' +``` + +키는 쿼리 문자열이 아니라 `Authorization` 헤더로 전송되므로, 요청 로그와 SearchApi가 `search_metadata`에 되돌려주는 `request_url`에 노출되지 않습니다. + +## 예제 + +```python Code +from crewai_tools import SearchApiTool + +# Initialize the tool for internet searching capabilities +tool = SearchApiTool() +``` + +## 파라미터 + +- **engine**: 호출할 SearchApi 엔진입니다. 예: `google`, `google_news`, `google_scholar`, `google_jobs`, `bing`, `youtube`, `baidu`. 기본값은 `google`입니다. 호출할 때마다 전달할 수도 있습니다. +- **n_results**: 응답에서 각 `*_results` 목록의 최대 길이입니다. 기본값은 `10`입니다. +- **country**: 선택 사항. 검색 대상 국가이며 `gl`로 전송됩니다(예: `uk`). +- **locale**: 선택 사항. 인터페이스 언어이며 `hl`로 전송됩니다(예: `en`). +- **location**: 선택 사항. 검색의 표준 위치입니다(예: `London,England`). +- **max_string_length**: 응답에서 그대로 유지되는 문자열의 최대 길이입니다. 기본값은 `1000`입니다. +- **timeout**: 요청 타임아웃(초)입니다. 기본값은 `30`입니다. +- **api_key**: 선택 사항. SearchApi 키입니다. 지정하지 않으면 `SEARCHAPI_API_KEY` 환경 변수를 사용합니다. +- **search_url**: 호출할 엔드포인트입니다. 기본값은 `https://www.searchapi.io/api/v1/search`입니다. + +## 파라미터를 사용한 예제 + +```python Code +from crewai_tools import SearchApiTool + +# Recent news, localized to the UK +news_tool = SearchApiTool( + engine="google_news", + country="uk", + locale="en", + location="London,England", + n_results=5, +) + +# Academic search through the same tool +scholar_tool = SearchApiTool(engine="google_scholar") +``` + +## 오류 + +요청이 실패하면 SearchApi의 메시지를 담은 `RuntimeError`가 발생합니다. 예: `SearchApi request failed (HTTP 401): Invalid API key.` + +검색은 성공했지만 결과가 없는 경우는 오류가 아닙니다. SearchApi는 HTTP 200과 함께 `"Google didn't return any results."` 같은 `error` 메시지를 반환하며, 이 도구는 에이전트가 그 이유를 읽을 수 있도록 해당 메시지를 그대로 전달합니다. diff --git a/docs/edge/pt-BR/tools/search-research/overview.mdx b/docs/edge/pt-BR/tools/search-research/overview.mdx index bece4353eb..ab97a08485 100644 --- a/docs/edge/pt-BR/tools/search-research/overview.mdx +++ b/docs/edge/pt-BR/tools/search-research/overview.mdx @@ -14,6 +14,10 @@ Essas ferramentas permitem que seus agentes pesquisem na web, explorem tópicos Integração com a API de busca do Google para capacidades abrangentes de pesquisa na web. + + Vários motores de busca em um único endpoint: web, notícias, artigos acadêmicos e vagas do Google, além de Bing, YouTube e outros. + + Pesquisa voltada para privacidade com o índice independente de busca do Brave. diff --git a/docs/edge/pt-BR/tools/search-research/searchapitool.mdx b/docs/edge/pt-BR/tools/search-research/searchapitool.mdx new file mode 100644 index 0000000000..3dad234ebe --- /dev/null +++ b/docs/edge/pt-BR/tools/search-research/searchapitool.mdx @@ -0,0 +1,74 @@ +--- +title: Pesquisa SearchApi +description: O `SearchApiTool` pesquisa na internet através do SearchApi, que reúne vários motores de busca em um único endpoint. +icon: magnifying-glass +mode: "wide" +--- + +# `SearchApiTool` + +## Descrição + +O `SearchApiTool` pesquisa na internet através do [SearchApi](https://www.searchapi.io). Um único endpoint reúne vários motores, escolhidos com o parâmetro `engine`, de modo que uma só ferramenta cobre a busca web do Google, notícias, artigos acadêmicos e vagas, além de Bing, YouTube, Baidu e os demais [motores suportados](https://www.searchapi.io/docs). A ferramenta retorna o JSON do próprio motor, então os resultados correspondem à documentação dele. + +Duas coisas acontecem com a resposta antes que um agente a veja: + +- URIs `data:` embutidas são descartadas. O SearchApi retorna favicons e miniaturas como strings base64, e uma única delas pode chegar a dezenas de kilobytes de contexto que não significam nada para um agente. +- Strings longas são truncadas em `max_string_length`, e cada lista `*_results` é limitada a `n_results`. + +## Instalação + +1. **Instalação do pacote**: confirme que o pacote `crewai[tools]` está instalado no seu ambiente Python. +2. **Obtenção da chave de API**: obtenha uma chave do SearchApi em https://www.searchapi.io (há um plano gratuito). +3. **Configuração do ambiente**: guarde a chave em uma variável de ambiente chamada `SEARCHAPI_API_KEY`. + +```shell +pip install 'crewai[tools]' +``` + +A chave é enviada no cabeçalho `Authorization` em vez da query string, portanto não aparece nos logs de requisição nem no `request_url` que o SearchApi devolve em `search_metadata`. + +## Exemplo + +```python Code +from crewai_tools import SearchApiTool + +# Initialize the tool for internet searching capabilities +tool = SearchApiTool() +``` + +## Parâmetros + +- **engine**: o motor do SearchApi a consultar, como `google`, `google_news`, `google_scholar`, `google_jobs`, `bing`, `youtube` ou `baidu`. O padrão é `google`. Também pode ser passado a cada chamada. +- **n_results**: limite do tamanho de cada lista `*_results` na resposta. O padrão é `10`. +- **country**: opcional. País da pesquisa, enviado como `gl` (por exemplo, `uk`). +- **locale**: opcional. Idioma da interface, enviado como `hl` (por exemplo, `en`). +- **location**: opcional. Localização canônica da pesquisa, por exemplo `London,England`. +- **max_string_length**: maior string mantida intacta na resposta. O padrão é `1000`. +- **timeout**: tempo limite da requisição em segundos. O padrão é `30`. +- **api_key**: opcional. Sua chave do SearchApi. Na ausência dela, usa a variável de ambiente `SEARCHAPI_API_KEY`. +- **search_url**: o endpoint chamado. O padrão é `https://www.searchapi.io/api/v1/search`. + +## Exemplo com parâmetros + +```python Code +from crewai_tools import SearchApiTool + +# Recent news, localized to the UK +news_tool = SearchApiTool( + engine="google_news", + country="uk", + locale="en", + location="London,England", + n_results=5, +) + +# Academic search through the same tool +scholar_tool = SearchApiTool(engine="google_scholar") +``` + +## Erros + +Uma requisição malsucedida levanta um `RuntimeError` com a mensagem do próprio SearchApi, por exemplo `SearchApi request failed (HTTP 401): Invalid API key.`. + +Uma pesquisa bem-sucedida que não encontrou nada não é um erro: o SearchApi retorna HTTP 200 com uma mensagem `error` como `"Google didn't return any results."`, e a ferramenta repassa isso para que o agente possa ler por que a página veio vazia. diff --git a/lib/crewai-tools/src/crewai_tools/__init__.py b/lib/crewai-tools/src/crewai_tools/__init__.py index 2db6be89bc..5eba03badc 100644 --- a/lib/crewai-tools/src/crewai_tools/__init__.py +++ b/lib/crewai-tools/src/crewai_tools/__init__.py @@ -161,6 +161,7 @@ from crewai_tools.tools.scrapfly_scrape_website_tool.scrapfly_scrape_website_tool import ( ScrapflyScrapeWebsiteTool, ) +from crewai_tools.tools.searchapi_tool.searchapi_tool import SearchApiTool from crewai_tools.tools.selenium_scraping_tool.selenium_scraping_tool import ( SeleniumScrapingTool, ) @@ -308,6 +309,7 @@ "ScrapegraphScrapeTool", "ScrapegraphScrapeToolSchema", "ScrapflyScrapeWebsiteTool", + "SearchApiTool", "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 2653490f76..8eb0ec6da9 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/__init__.py +++ b/lib/crewai-tools/src/crewai_tools/tools/__init__.py @@ -149,6 +149,7 @@ from crewai_tools.tools.scrapfly_scrape_website_tool.scrapfly_scrape_website_tool import ( ScrapflyScrapeWebsiteTool, ) +from crewai_tools.tools.searchapi_tool.searchapi_tool import SearchApiTool from crewai_tools.tools.selenium_scraping_tool.selenium_scraping_tool import ( SeleniumScrapingTool, ) @@ -290,6 +291,7 @@ "ScrapegraphScrapeTool", "ScrapegraphScrapeToolSchema", "ScrapflyScrapeWebsiteTool", + "SearchApiTool", "SeleniumScrapingTool", "SerpApiGoogleSearchTool", "SerpApiGoogleShoppingTool", 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..50658a9fa6 --- /dev/null +++ b/lib/crewai-tools/src/crewai_tools/tools/searchapi_tool/README.md @@ -0,0 +1,81 @@ +# SearchApi Tool + +## Description + +The `SearchApiTool` searches the internet through [SearchApi](https://www.searchapi.io). SearchApi puts many engines behind one endpoint, so a single tool covers Google web search, news, scholar, jobs, Bing, YouTube, Baidu and the rest of the [supported engines](https://www.searchapi.io/docs) by changing the `engine` argument. The tool returns the engine's own JSON, so results line up with the engine's documentation. + +Two things happen to the response before an agent sees it: + +- Inline `data:` URIs are dropped. SearchApi returns favicons and thumbnails as base64 strings, and a single one can run to tens of kilobytes of context that means nothing to an agent. +- Long strings are truncated at `max_string_length`, and every `*_results` list is capped at `n_results`. + +## Installation + +No extra package is needed: + +```shell +uv add 'crewai[tools]' +``` + +## Environment Variables + +Set your SearchApi key: + +```bash +export SEARCHAPI_API_KEY='your_searchapi_key' +``` + +The key is sent in the `Authorization` header rather than the query string, so it stays out of request logs and out of the `request_url` SearchApi echoes back in `search_metadata`. + +## Example + +```python +from crewai import Agent, Crew, Task +from crewai_tools import SearchApiTool + +search_tool = SearchApiTool() + +researcher = Agent( + role="Market Researcher", + goal="Find what people are saying about a company right now", + backstory="An analyst who checks the record before forming a view.", + tools=[search_tool], + verbose=True, +) + +research_task = Task( + description="Search for recent coverage of CrewAI.", + expected_output="A short report on what the search returned.", + agent=researcher, +) + +crew = Crew(agents=[researcher], tasks=[research_task], verbose=True) +result = crew.kickoff() +print(result) +``` + +Point the same tool at a different engine: + +```python +news_tool = SearchApiTool(engine="google_news") +scholar_tool = SearchApiTool(engine="google_scholar", n_results=5) +uk_tool = SearchApiTool(country="uk", locale="en", location="London,England") +``` + +## Arguments + +- `engine` (str, optional): The SearchApi engine to query, such as `"google"`, `"google_news"`, `"google_scholar"`, `"google_jobs"`, `"bing"`, `"youtube"` or `"baidu"`. Defaults to `"google"`. Can also be passed per call. +- `n_results` (int, optional): Cap on the length of each `*_results` list in the response. Defaults to `10`. +- `country` (str, optional): Country of the search, sent as `gl` (for example `"uk"`). Defaults to `None`. +- `locale` (str, optional): Interface language, sent as `hl` (for example `"en"`). Defaults to `None`. +- `location` (str, optional): Canonical location of the search, for example `"London,England"`. Defaults to `None`. +- `max_string_length` (int, optional): Longest string kept intact in the response. Defaults to `1000`. +- `timeout` (int, optional): Request timeout in seconds. Defaults to `30`. +- `api_key` (str, optional): Your SearchApi key. Falls back to the `SEARCHAPI_API_KEY` environment variable. +- `search_url` (str, optional): The endpoint to call. Defaults to `https://www.searchapi.io/api/v1/search`. + +## Errors + +A failed request raises a `RuntimeError` carrying SearchApi's own message, for example `SearchApi request failed (HTTP 401): Invalid API key.`. + +A successful search that found nothing is not an error: SearchApi returns HTTP 200 with an `error` message such as `"Google didn't return any results."`, and the tool passes that through so the agent can read why the page was empty. 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_tool.py b/lib/crewai-tools/src/crewai_tools/tools/searchapi_tool/searchapi_tool.py new file mode 100644 index 0000000000..01acdb78dd --- /dev/null +++ b/lib/crewai-tools/src/crewai_tools/tools/searchapi_tool/searchapi_tool.py @@ -0,0 +1,188 @@ +"""Search the web through SearchApi (https://www.searchapi.io).""" + +import json +import os +from typing import Any + +from crewai.tools import BaseTool, EnvVar +from pydantic import BaseModel, Field +import requests + + +DEFAULT_SEARCH_URL = "https://www.searchapi.io/api/v1/search" + +_RESULT_LIST_SUFFIX = "_results" + + +def _is_data_uri(value: Any) -> bool: + """Return True for an inline ``data:`` URI, which is never useful to an agent.""" + return isinstance(value, str) and value.startswith("data:") + + +def _sanitize(value: Any, max_string_length: int) -> Any: + """Drop inline data URIs and truncate long strings, recursively. + + SearchApi ships favicons and thumbnails as ``data:image/png;base64,...`` + strings on most result items. They mean nothing to an agent and each one + can run to tens of kilobytes, so they are dropped rather than truncated. + + Args: + value: Any part of a decoded JSON response. + max_string_length: Longest string kept intact; longer ones are cut. + + Returns: + The same structure with data URIs removed and long strings truncated. + """ + if isinstance(value, dict): + return { + key: _sanitize(item, max_string_length) + for key, item in value.items() + if not _is_data_uri(item) + } + if isinstance(value, list): + return [ + _sanitize(item, max_string_length) + for item in value + if not _is_data_uri(item) + ] + if isinstance(value, str) and len(value) > max_string_length: + return value[:max_string_length] + "..." + return value + + +def _error_detail(response: requests.Response) -> str: + """Pull SearchApi's error message out of a failed response. + + Args: + response: A response whose status code is not a success. + + Returns: + The API's own error message, or the start of the raw body when the + response is not the documented ``{"error": ...}`` shape. + """ + try: + body = response.json() + except ValueError: + return response.text[:500] + error = body.get("error") if isinstance(body, dict) else None + if isinstance(error, str): + return error + return json.dumps(body)[:500] + + +class SearchApiToolSchema(BaseModel): + """Input for SearchApiTool.""" + + search_query: str = Field( + ..., description="Mandatory search query you want to use to search the internet" + ) + + +class SearchApiTool(BaseTool): + """Search the internet through SearchApi's unified search endpoint. + + One endpoint fronts many engines, selected with ``engine``, so the same + tool covers web search, news, scholar, jobs and the rest. The response is + the engine's own JSON, so its shape follows the engine's documentation at + https://www.searchapi.io/docs. + """ + + name: str = "Search the internet with SearchApi" + description: str = ( + "A tool that can be used to search the internet with a search_query using " + "SearchApi. One endpoint fronts many engines, set with 'engine': 'google' " + "(default), 'google_news', 'google_scholar', 'google_jobs', 'bing', " + "'youtube', 'baidu' and others. Returns the engine's structured JSON results." + ) + args_schema: type[BaseModel] = SearchApiToolSchema + search_url: str = DEFAULT_SEARCH_URL + engine: str = "google" + n_results: int = 10 + country: str | None = None + locale: str | None = None + location: str | None = None + max_string_length: int = 1000 + timeout: int = 30 + api_key: str | None = None + env_vars: list[EnvVar] = Field( + default_factory=lambda: [ + EnvVar( + name="SEARCHAPI_API_KEY", + description="API key for SearchApi", + required=True, + ), + ] + ) + + def _run(self, **kwargs: Any) -> dict[str, Any]: + """Execute the search operation. + + Args: + **kwargs: ``search_query`` (or ``query``) to search for, and an + optional ``engine`` overriding the configured one for this call. + + Returns: + The engine's JSON response, with data URIs dropped, long strings + truncated and result lists capped at ``n_results``. A response that + carries an ``error`` message instead of results, which is how + SearchApi reports a page with nothing on it, is passed through so + the agent can read why. + + Raises: + ValueError: No search query was given, or no API key is configured. + RuntimeError: SearchApi answered with an error status. + """ + search_query: str | None = kwargs.get("search_query") or kwargs.get("query") + if not search_query: + raise ValueError("search_query is required") + + api_key = self.api_key or os.getenv("SEARCHAPI_API_KEY") + if not api_key: + raise ValueError( + "SEARCHAPI_API_KEY environment variable is required for SearchApiTool" + ) + + params: dict[str, Any] = { + "engine": kwargs.get("engine", self.engine), + "q": search_query, + } + if self.country: + params["gl"] = self.country + if self.locale: + params["hl"] = self.locale + if self.location: + params["location"] = self.location + + # The key travels in the Authorization header rather than the query + # string, so it stays out of request logs and out of the request_url + # SearchApi echoes back in search_metadata. + response = requests.get( + self.search_url, + headers={"Authorization": f"Bearer {api_key}"}, + params=params, + timeout=self.timeout, + ) + if not response.ok: + raise RuntimeError( + f"SearchApi request failed (HTTP {response.status_code}): " + f"{_error_detail(response)}" + ) + + return self._format_results(response.json()) + + def _format_results(self, results: dict[str, Any]) -> dict[str, Any]: + """Cap result lists and strip payload that only burns context. + + Args: + results: The decoded JSON body of a successful search. + + Returns: + The same body with every ``*_results`` list capped at + ``n_results`` and every value sanitized. + """ + formatted: dict[str, Any] = {} + for key, value in results.items(): + if key.endswith(_RESULT_LIST_SUFFIX) and isinstance(value, list): + value = value[: self.n_results] + formatted[key] = _sanitize(value, self.max_string_length) + return formatted 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..78a68312a0 --- /dev/null +++ b/lib/crewai-tools/tests/tools/searchapi_tool_test.py @@ -0,0 +1,193 @@ +import os +from unittest.mock import MagicMock, patch + +import pytest +import requests as requests_lib + +from crewai_tools.tools.searchapi_tool.searchapi_tool import SearchApiTool + + +def _mock_response( + status_code: int = 200, + json_data: dict | None = None, + text: str = "", +) -> MagicMock: + """Build a ``requests.Response``-like mock with the attributes ``_run`` uses.""" + resp = MagicMock(spec=requests_lib.Response) + resp.status_code = status_code + resp.ok = 200 <= status_code < 400 + resp.text = text or (str(json_data) if json_data else "") + resp.json.return_value = json_data if json_data is not None else {} + return resp + + +@pytest.fixture(autouse=True) +def _searchapi_env(): + with patch.dict(os.environ, {"SEARCHAPI_API_KEY": "test-api-key"}): + yield + + +@pytest.fixture +def tool(): + return SearchApiTool() + + +def test_default_attributes(tool): + assert tool.search_url == "https://www.searchapi.io/api/v1/search" + assert tool.engine == "google" + assert tool.n_results == 10 + assert tool.env_vars[0].name == "SEARCHAPI_API_KEY" + + +def test_missing_query_raises(tool): + with pytest.raises(ValueError, match="search_query is required"): + tool._run() + + +def test_missing_api_key_raises(): + with patch.dict(os.environ, {}, clear=True): + with pytest.raises(ValueError, match="SEARCHAPI_API_KEY"): + SearchApiTool()._run(search_query="test") + + +@patch("crewai_tools.tools.searchapi_tool.searchapi_tool.requests.get") +def test_key_is_sent_as_bearer_header_not_query_param(mock_get, tool): + """The key must stay out of the query string, which SearchApi echoes back.""" + mock_get.return_value = _mock_response(json_data={"organic_results": []}) + + tool._run(search_query="crewai") + + kwargs = mock_get.call_args.kwargs + assert kwargs["headers"]["Authorization"] == "Bearer test-api-key" + assert "api_key" not in kwargs["params"] + + +@patch("crewai_tools.tools.searchapi_tool.searchapi_tool.requests.get") +def test_explicit_api_key_takes_precedence_over_env(mock_get): + mock_get.return_value = _mock_response(json_data={}) + + SearchApiTool(api_key="explicit-key")._run(search_query="crewai") + + assert mock_get.call_args.kwargs["headers"]["Authorization"] == "Bearer explicit-key" + + +@patch("crewai_tools.tools.searchapi_tool.searchapi_tool.requests.get") +def test_localization_params_are_only_sent_when_set(mock_get): + mock_get.return_value = _mock_response(json_data={}) + + SearchApiTool(country="uk", locale="en", location="London,England")._run( + search_query="fish" + ) + + params = mock_get.call_args.kwargs["params"] + assert params == { + "engine": "google", + "q": "fish", + "gl": "uk", + "hl": "en", + "location": "London,England", + } + + +@patch("crewai_tools.tools.searchapi_tool.searchapi_tool.requests.get") +def test_engine_can_be_overridden_per_call(mock_get, tool): + mock_get.return_value = _mock_response(json_data={}) + + tool._run(search_query="crewai", engine="google_news") + + assert mock_get.call_args.kwargs["params"]["engine"] == "google_news" + + +@patch("crewai_tools.tools.searchapi_tool.searchapi_tool.requests.get") +def test_query_alias_is_accepted(mock_get, tool): + mock_get.return_value = _mock_response(json_data={}) + + tool._run(query="crewai") + + assert mock_get.call_args.kwargs["params"]["q"] == "crewai" + + +@patch("crewai_tools.tools.searchapi_tool.searchapi_tool.requests.get") +def test_result_lists_are_capped_at_n_results(mock_get): + mock_get.return_value = _mock_response( + json_data={ + "organic_results": [{"position": i} for i in range(10)], + "related_questions": [{"question": f"q{i}"} for i in range(10)], + } + ) + + result = SearchApiTool(n_results=3)._run(search_query="crewai") + + assert len(result["organic_results"]) == 3 + # Only lists named *_results are capped; everything else passes through. + assert len(result["related_questions"]) == 10 + + +@patch("crewai_tools.tools.searchapi_tool.searchapi_tool.requests.get") +def test_data_uris_are_dropped_from_results(mock_get, tool): + mock_get.return_value = _mock_response( + json_data={ + "organic_results": [ + { + "title": "CrewAI", + "link": "https://crewai.com", + "favicon": "data:image/png;base64," + "A" * 20000, + } + ], + "inline_images": ["data:image/png;base64,AAAA", "https://img.co/a.png"], + } + ) + + result = tool._run(search_query="crewai") + + assert result["organic_results"][0] == { + "title": "CrewAI", + "link": "https://crewai.com", + } + assert result["inline_images"] == ["https://img.co/a.png"] + + +@patch("crewai_tools.tools.searchapi_tool.searchapi_tool.requests.get") +def test_long_strings_are_truncated(mock_get): + mock_get.return_value = _mock_response( + json_data={"organic_results": [{"snippet": "x" * 500}]} + ) + + result = SearchApiTool(max_string_length=100)._run(search_query="crewai") + + assert result["organic_results"][0]["snippet"] == "x" * 100 + "..." + + +@patch("crewai_tools.tools.searchapi_tool.searchapi_tool.requests.get") +def test_empty_result_page_is_returned_not_raised(mock_get, tool): + """A 200 carrying `error` is SearchApi reporting no results, not a failure.""" + mock_get.return_value = _mock_response( + json_data={ + "search_metadata": {"id": "search_1", "status": "Success"}, + "error": "Google didn't return any results.", + } + ) + + result = tool._run(search_query="asdkjhaskdjh") + + assert result["error"] == "Google didn't return any results." + + +@patch("crewai_tools.tools.searchapi_tool.searchapi_tool.requests.get") +def test_error_status_raises_with_api_message(mock_get, tool): + mock_get.return_value = _mock_response( + status_code=401, json_data={"error": "Invalid API key."} + ) + + with pytest.raises(RuntimeError, match="HTTP 401.*Invalid API key."): + tool._run(search_query="crewai") + + +@patch("crewai_tools.tools.searchapi_tool.searchapi_tool.requests.get") +def test_non_json_error_body_falls_back_to_text(mock_get, tool): + resp = _mock_response(status_code=502, text="Bad gateway") + resp.json.side_effect = ValueError("not json") + mock_get.return_value = resp + + with pytest.raises(RuntimeError, match="HTTP 502.*Bad gateway"): + tool._run(search_query="crewai") diff --git a/lib/crewai-tools/tool.specs.json b/lib/crewai-tools/tool.specs.json index 2e540f8c13..4fea757151 100644 --- a/lib/crewai-tools/tool.specs.json +++ b/lib/crewai-tools/tool.specs.json @@ -21130,6 +21130,163 @@ "type": "object" } }, + { + "description": "A tool that can be used to search the internet with a search_query using SearchApi. One endpoint fronts many engines, set with 'engine': 'google' (default), 'google_news', 'google_scholar', 'google_jobs', 'bing', 'youtube', 'baidu' and others. Returns the engine's structured JSON results.", + "env_vars": [ + { + "default": null, + "description": "API key for SearchApi", + "name": "SEARCHAPI_API_KEY", + "required": true + } + ], + "humanized_name": "Search the internet with SearchApi", + "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" + }, + "ToolFailurePolicy": { + "description": "How an agent reacts when one of its tools reports a failure.", + "enum": [ + "ignore", + "warn", + "raise" + ], + "title": "ToolFailurePolicy", + "type": "string" + } + }, + "description": "Search the internet through SearchApi's unified search endpoint.\n\nOne endpoint fronts many engines, selected with ``engine``, so the same\ntool covers web search, news, scholar, jobs and the rest. The response is\nthe engine's own JSON, so its shape follows the engine's documentation at\nhttps://www.searchapi.io/docs.", + "properties": { + "api_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Api Key" + }, + "country": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Country" + }, + "engine": { + "default": "google", + "title": "Engine", + "type": "string" + }, + "locale": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Locale" + }, + "location": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Location" + }, + "max_string_length": { + "default": 1000, + "title": "Max String Length", + "type": "integer" + }, + "n_results": { + "default": 10, + "title": "N Results", + "type": "integer" + }, + "search_url": { + "default": "https://www.searchapi.io/api/v1/search", + "title": "Search Url", + "type": "string" + }, + "timeout": { + "default": 30, + "title": "Timeout", + "type": "integer" + } + }, + "required": [], + "title": "SearchApiTool", + "type": "object" + }, + "name": "SearchApiTool", + "package_dependencies": [], + "run_params_schema": { + "description": "Input for SearchApiTool.", + "properties": { + "search_query": { + "description": "Mandatory search query you want to use to search the internet", + "title": "Search Query", + "type": "string" + } + }, + "required": [ + "search_query" + ], + "title": "SearchApiToolSchema", + "type": "object" + } + }, { "description": "A tool that can be used to read a website content.", "env_vars": [],