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 `_ 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