summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/dev/engines/online/exaapi.rst8
-rw-r--r--searx/engines/exaapi.py167
-rw-r--r--searx/settings.yml7
3 files changed, 182 insertions, 0 deletions
diff --git a/docs/dev/engines/online/exaapi.rst b/docs/dev/engines/online/exaapi.rst
new file mode 100644
index 000000000..5b58bf7d6
--- /dev/null
+++ b/docs/dev/engines/online/exaapi.rst
@@ -0,0 +1,8 @@
+.. _exaapi engine:
+
+==============
+Exa API Engine
+==============
+
+.. automodule:: searx.engines.exaapi
+ :members:
diff --git a/searx/engines/exaapi.py b/searx/engines/exaapi.py
new file mode 100644
index 000000000..81092f117
--- /dev/null
+++ b/searx/engines/exaapi.py
@@ -0,0 +1,167 @@
+# SPDX-License-Identifier: AGPL-3.0-or-later
+"""Engine to search using the official `Exa Search API`_. Exa is a search engine for AI agents.
+
+.. _Exa Search API: https://exa.ai/docs/reference/search
+
+Configuration
+=============
+
+The engine has the following mandatory setting:
+
+- :py:obj:`api_key`
+
+You can obtain an API key from the `API Key section <https://dashboard.exa.ai/api-keys>`_ in the Exa dashboard.
+
+Optional settings are:
+
+- :py:obj:`results_per_page`
+- :py:obj:`search_type`
+- :py:obj:`content_mode`
+- :py:obj:`content_max_characters`
+
+.. code:: yaml
+
+ - name: exaapi
+ engine: exaapi
+ api_key: "..."
+ results_per_page: 10
+ search_type: auto
+ content_mode: highlights
+ inactive: false
+
+The API supports SafeSearch and region-aware results.
+"""
+
+import typing as t
+
+from dateutil import parser
+
+from searx.exceptions import SearxEngineAPIException
+from searx.result_types import EngineResults
+from searx.utils import html_to_text
+
+if t.TYPE_CHECKING:
+ from searx.extended_types import SXNG_Response
+ from searx.search.processors import OnlineParams
+
+
+SearchType = t.Literal["fast", "auto", "instant", "deep", "deep-lite", "deep-reasoning"]
+ContentMode = t.Literal["highlights", "text"]
+
+about = {
+ "website": "https://exa.ai",
+ "wikidata_id": None,
+ "official_api_documentation": "https://exa.ai/docs/reference/search",
+ "use_official_api": True,
+ "require_api_key": True,
+ "results": "JSON",
+}
+
+api_key: str = ""
+"""API key for Exa Search API (required)."""
+
+categories = ["general", "web"]
+safesearch = True
+
+base_url = "https://api.exa.ai/search"
+results_per_page: int = 10
+"""Maximum number of results per request. Value must be between 1 and 100, default is 10."""
+
+search_type: SearchType = "auto"
+"""Search type. Default is auto, see documentation for more information."""
+
+content_mode: ContentMode = "highlights"
+"""Content to request from the API: ``highlights`` (excerpts) or ``text`` (page text)."""
+
+content_max_characters: int = 500
+"""Maximum characters for the requested content."""
+
+
+def init(_):
+ if not api_key:
+ raise SearxEngineAPIException("No API key provided")
+ if not 1 <= results_per_page <= 100:
+ raise ValueError("results_per_page must be between 1 and 100")
+ if search_type not in t.get_args(SearchType):
+ raise ValueError(f"Unsupported search type: {search_type}")
+ if content_mode not in t.get_args(ContentMode):
+ raise ValueError(f"Unsupported content mode: {content_mode}")
+ if content_max_characters < 1:
+ raise ValueError("content_max_characters must be at least 1")
+
+
+def _contents_payload() -> dict[str, t.Any]:
+ if content_mode == "text":
+ return {"text": {"maxCharacters": content_max_characters, "stripLinks": True}}
+ return {"highlights": {"maxCharacters": content_max_characters}}
+
+
+def _extract_content(result: dict[str, t.Any]) -> str:
+ if content_mode == "text":
+ return html_to_text(result.get("text") or "")
+ return html_to_text(" ".join(result.get("highlights") or []))
+
+
+def request(query: str, params: "OnlineParams") -> None:
+ """Create the API request."""
+ body: dict[str, t.Any] = {
+ "query": query,
+ "type": search_type,
+ "numResults": results_per_page,
+ "contents": _contents_payload(),
+ }
+
+ # Apply SafeSearch if enabled
+ if params["safesearch"]:
+ body["moderation"] = True
+
+ # Apply region-aware results if specified
+ locale_parts = params["searxng_locale"].split("-")
+ region = locale_parts[-1]
+ if len(locale_parts) > 1:
+ body["userLocation"] = region.upper()
+
+ params["url"] = base_url
+ params["method"] = "POST"
+ params["headers"]["x-api-key"] = api_key
+ params["json"] = body
+
+
+def _extract_published_date(value: str | None):
+ """Extract and parse the published date from the API response.
+
+ Args:
+ value: Raw date string from the API
+
+ Returns:
+ Parsed datetime object or None if parsing fails
+ """
+ if not value:
+ return None
+ try:
+ return parser.parse(value)
+ except (parser.ParserError, TypeError, OverflowError):
+ return None
+
+
+def response(resp: "SXNG_Response") -> EngineResults:
+ """Process the API response and return results."""
+ res = EngineResults()
+
+ for result in resp.json().get("results", []):
+ url = result.get("url")
+ if not url:
+ continue
+
+ res.add(
+ res.types.MainResult(
+ url=url,
+ title=html_to_text(result.get("title") or url),
+ content=_extract_content(result),
+ thumbnail=result.get("image") or "",
+ publishedDate=_extract_published_date(result.get("publishedDate")),
+ author=result.get("author") or "",
+ )
+ )
+
+ return res
diff --git a/searx/settings.yml b/searx/settings.yml
index 0491bcc8e..b2c8cbcf3 100644
--- a/searx/settings.yml
+++ b/searx/settings.yml
@@ -3296,6 +3296,13 @@ engines:
# brave_category: goggles
# Goggles: # required! This should be a URL ending in .goggle
+ - name: exaapi
+ engine: exaapi
+ api_key: ""
+ search_type: auto
+ content_mode: highlights
+ inactive: true
+
- name: lib.rs
shortcut: lrs
engine: lib_rs