5 Commits

Author SHA1 Message Date
ArsenBilov
ef9a188cc8 [feat] engines: add paid yandex search api (#6349) 2026-08-13 18:16:56 +02:00
Bnyro
cdfdaa5a88 [mod] dogpile: set to inactive by default 2026-08-12 16:14:58 +02:00
Bnyro
5638231358 [fix] dogpile: access denied due to missing origin header 2026-08-12 16:13:15 +02:00
vojkovic
1c3bb1e88f [mod] defaults: GET method and ddg autocomplete 2026-08-12 19:05:02 +08:00
Ivan Gabaldon
54613defc7 Revert "[upd] pypi: Bump the minor group across 1 directory with 3 updates (#…" (#6515)
This reverts commit e8e710e42a.
2026-08-12 01:34:08 +02:00
15 changed files with 263 additions and 28 deletions

View File

@@ -8,7 +8,7 @@
search: search:
safe_search: 0 safe_search: 0
autocomplete: "" autocomplete: "duckduckgo"
favicon_resolver: "" favicon_resolver: ""
default_lang: "" default_lang: ""
ban_time_on_fail: 5 ban_time_on_fail: 5
@@ -32,7 +32,7 @@
- ``2``: Strict - ``2``: Strict
``autocomplete``: ``autocomplete``:
Existing autocomplete backends, leave blank to turn it off. Existing autocomplete backends, set blank to turn it off.
- ``360search`` - ``360search``
- ``baidu`` - ``baidu``

View File

@@ -14,7 +14,7 @@
limiter: false limiter: false
public_instance: false public_instance: false
image_proxy: false image_proxy: false
method: "POST" method: "GET"
default_http_headers: default_http_headers:
X-Content-Type-Options : nosniff X-Content-Type-Options : nosniff
X-Download-Options : noopen X-Download-Options : noopen
@@ -58,8 +58,8 @@
``method`` : ``GET`` | ``POST`` ``method`` : ``GET`` | ``POST``
HTTP method. By defaults ``POST`` is used / The ``POST`` method has the HTTP method. By default, ``GET`` is used / The ``POST`` method has the
advantage with some WEB browsers that the history is not easy to read, but advantage with some browsers that the history is not saved, but
there are also various disadvantages that sometimes **severely restrict the there are also various disadvantages that sometimes **severely restrict the
ease of use for the end user** (e.g. back button to jump back to the previous ease of use for the end user** (e.g. back button to jump back to the previous
search page and drag & drop of search term to new tabs do not work as search page and drag & drop of search term to new tabs do not work as

View File

@@ -0,0 +1,8 @@
.. _yandex api engine:
=================
Yandex Search API
=================
.. automodule:: searx.engines.yandex_api
:members:

View File

@@ -43,9 +43,10 @@ def init(_):
def request(query: str, params: "OnlineParams"): def request(query: str, params: "OnlineParams"):
params["url"] = f"{base_url}/api/{dogpile_categ}" params["url"] = f"{base_url}/api/{dogpile_categ}"
params["headers"]["Origin"] = base_url
params["method"] = "POST" params["method"] = "POST"
params["json"] = {"q": query, "qadf": safe_search_map[params["safesearch"]], "page": params["pageno"]} params["json"] = {"q": query, "qadf": safe_search_map[params["safesearch"]], "page": params["pageno"]}
return params
def response(resp: "SXNG_Response"): def response(resp: "SXNG_Response"):

217
searx/engines/yandex_api.py Normal file
View File

@@ -0,0 +1,217 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Yandex Search API (the official **paid** `Yandex Search API v2`_).
Unlike the :origin:`yandex <searx/engines/yandex.py>` engine (which scrapes the
public HTML interface and is prone to CAPTCHA blocking), this engine talks to
the official, paid Yandex Cloud Search API. It requires a Yandex Cloud account,
a *folder id* and an *API key*.
The API answers with a Base64-encoded XML document, which is decoded and
parsed here.
Configuration
=============
The engine is inactive by default because it needs credentials. To enable it,
set ``inactive: false`` and add your ``api_key`` and ``yandex_folder_id`` to
:origin:`searx/settings.yml`:
.. code:: yaml
- name: yandex api
engine: yandex_api
shortcut: yda
categories: [general, web]
inactive: false
api_key: "" # Yandex Cloud API key (``Api-Key``)
yandex_folder_id: "" # Yandex Cloud folder id
# optional, see below:
yandex_default_language: en
.. _Yandex Search API v2:
https://aistudio.yandex.ru/docs/en/search-api/api-ref/WebSearch/search.html
"""
import math
import typing as t
from base64 import b64decode
from lxml import etree
from searx.exceptions import SearxEngineAPIException
from searx.result_types import EngineResults
from searx.utils import extract_text
if t.TYPE_CHECKING:
from searx.extended_types import SXNG_Response
from searx.search.processors import OnlineParams
about = {
"website": "https://yandex.cloud/en/services/search-api",
"wikidata_id": "Q5281",
"official_api_documentation": "https://aistudio.yandex.ru/docs/en/search-api/api-ref/WebSearch/search.html",
"use_official_api": True,
"require_api_key": True,
"results": "XML",
}
# Engine configuration
categories = ["general", "web"]
paging = True
safesearch = True
# Credentials, overwritten via settings.yml
api_key: str = ""
"""Yandex Cloud API key, passed as ``Authorization: Api-Key <api_key>``."""
yandex_folder_id: str = ""
"""Yandex Cloud folder id the API key belongs to."""
# Search tuning, overwritten via settings.yml
yandex_default_language: str = "en"
"""Default query language. It selects the Yandex search domain (e.g. yandex.ru
for ``ru``, yandex.com for ``en``) and the language of the search-result
notifications, but only as a fallback -- a request whose own locale matches
:py:obj:`language_map` overrides it. Must be one of its keys: ``ru``, ``be``,
``kk``, ``uk``, ``tr`` or ``en``."""
region: str = ""
"""Optional Yandex `region id`.
Only meaningful together with ``SEARCH_TYPE_RU``.
__ https://aistudio.yandex.ru/docs/en/search-api/reference/regions.html
"""
page_size: int = 10
"""Number of results requested per page."""
base_url = "https://searchapi.api.cloud.yandex.net/v2/web/search"
# searxng safesearch level -> Yandex familyMode
safesearch_map = {
0: "FAMILY_MODE_NONE",
1: "FAMILY_MODE_MODERATE",
2: "FAMILY_MODE_STRICT",
}
# Map a query language to a (search_type, l10n) pair. It drives both the
# per-request override (when the query's locale matches) and the
# ``yandex_default_language`` default.
language_map = {
"ru": ("SEARCH_TYPE_RU", "LOCALIZATION_RU"),
"be": ("SEARCH_TYPE_BE", "LOCALIZATION_BE"),
"kk": ("SEARCH_TYPE_KK", "LOCALIZATION_KK"),
"uk": ("SEARCH_TYPE_RU", "LOCALIZATION_UK"),
"tr": ("SEARCH_TYPE_TR", "LOCALIZATION_TR"),
"en": ("SEARCH_TYPE_COM", "LOCALIZATION_EN"),
# Uzbek ('uz') is intentionally omitted: Yandex offers SEARCH_TYPE_UZ but no
# matching LOCALIZATION_UZ, so such queries fall back to the defaults above.
}
def setup(_):
"""Validate credentials and paging limits when the engine is loaded."""
if not api_key or not yandex_folder_id:
raise SearxEngineAPIException("missing 'api_key' and/or 'yandex_folder_id' in engine settings")
if not 1 <= page_size <= 100:
raise SearxEngineAPIException("'page_size' must be in the range 1..100 (Yandex 'groupsOnPage')")
if yandex_default_language not in language_map:
raise SearxEngineAPIException(f"'yandex_default_language' must be one of {sorted(language_map)}")
def request(query: str, params: "OnlineParams"):
# Yandex returns at most 250 results for a query.
max_page = math.ceil(250 / page_size)
if params["pageno"] > max_page:
params["url"] = None
return
if len(query) > 400:
# Yandex rejects a 'queryText' longer than 400 characters; decline the
# request gracefully instead of provoking an API error.
params["url"] = None
return
lang = params["searxng_locale"].split("-")[0].lower()
req_search_type, req_l10n = language_map.get(lang, language_map[yandex_default_language])
body: dict[str, t.Any] = {
"query": {
"searchType": req_search_type,
"queryText": query,
"familyMode": safesearch_map[params["safesearch"]],
# the API uses a 0-based page index
"page": str(params["pageno"] - 1),
},
"groupSpec": {
"groupMode": "GROUP_MODE_FLAT",
"groupsOnPage": str(page_size),
"docsInGroup": "1",
},
"l10n": req_l10n,
"folderId": yandex_folder_id,
"responseFormat": "FORMAT_XML",
}
# Yandex accepts a 'region' only together with the Russian search type.
if region and req_search_type == "SEARCH_TYPE_RU":
body["region"] = region
params["method"] = "POST"
params["url"] = base_url
params["headers"]["Authorization"] = f"Api-Key {api_key}"
params["headers"]["Content-Type"] = "application/json"
params["json"] = body
def _raw_xml(resp: "SXNG_Response") -> bytes:
"""Extract and Base64-decode the XML payload out of the JSON envelope.
The synchronous ``/v2/web/search`` endpoint returns ``{"rawData":
"<base64>"}`` on success; HTTP errors are raised upstream via
``raise_for_httperror``.
"""
data: dict[str, t.Any] = resp.json()
raw_data = data.get("rawData")
if raw_data is None:
raise SearxEngineAPIException("Yandex Search API: no 'rawData' in response")
return b64decode(raw_data)
def response(resp: "SXNG_Response") -> EngineResults:
res = EngineResults()
dom = etree.fromstring(_raw_xml(resp)) # pylint: disable=c-extension-no-member
# An <error> inside <response> signals an application error. Code 15 simply
# means "nothing was found" and must not raise.
error = dom.find(".//response/error")
if error is not None:
if error.get("code") == "15":
return res
raise SearxEngineAPIException(f"Yandex Search API error {error.get('code')}: {error.text}")
for doc in dom.iterfind(".//doc"):
url = extract_text(doc.find("url"), allow_none=True)
title = extract_text(doc.find("title"), allow_none=True)
if not url or not title:
continue
content = extract_text(doc.find("headline"), allow_none=True)
if not content:
passages = doc.find("passages")
if passages is not None:
content = " ".join(extract_text(p) or "" for p in passages.iterfind("passage")).strip()
res.add(
res.types.MainResult(
url=url,
title=title,
content=content or "",
)
)
return res

View File

@@ -41,9 +41,9 @@ search:
# Filter results. 0: None, 1: Moderate, 2: Strict # Filter results. 0: None, 1: Moderate, 2: Strict
safe_search: 0 safe_search: 0
# Existing autocomplete backends: "360search", "baidu", "bing", "brave", "dbpedia", "duckduckgo", "google", # Existing autocomplete backends: "360search", "baidu", "bing", "brave", "dbpedia", "duckduckgo", "google",
# "kagi", "yandex", "privacywall", "mwmbl", "naver", "seznam", "sogou", "startpage", "swisscows", "quark", # "yandex", "privacywall", "mwmbl", "naver", "seznam", "sogou", "startpage", "swisscows", "quark", "qwant",
# "qwant", "wikipedia" - leave blank to turn it off by default. # "wikipedia" - set blank to turn it off by default.
autocomplete: "" autocomplete: "duckduckgo"
# minimun characters to type before autocompleter starts # minimun characters to type before autocompleter starts
autocomplete_min: 4 autocomplete_min: 4
# backend for the favicon near URL in search results. # backend for the favicon near URL in search results.
@@ -107,11 +107,10 @@ server:
image_proxy: false image_proxy: false
# 1.0 and 1.1 are supported # 1.0 and 1.1 are supported
http_protocol_version: "1.0" http_protocol_version: "1.0"
# POST queries are "more secure!" but are also the source of hard-to-locate # POST keeps search queries out of the browsers history, but causes UX issues.
# annoyances, which is why GET may be better for end users and their browsers.
# see https://github.com/searxng/searxng/pull/3619 # see https://github.com/searxng/searxng/pull/3619
# Is overwritten by ${SEARXNG_METHOD} # Is overwritten by ${SEARXNG_METHOD}
method: "POST" method: "GET"
default_http_headers: default_http_headers:
X-Content-Type-Options: nosniff X-Content-Type-Options: nosniff
X-Download-Options: noopen X-Download-Options: noopen
@@ -853,28 +852,28 @@ engines:
shortcut: dog shortcut: dog
dogpile_categ: search dogpile_categ: search
categories: general categories: general
disabled: true inactive: true
- name: dogpile images - name: dogpile images
engine: dogpile engine: dogpile
shortcut: dogi shortcut: dogi
dogpile_categ: images dogpile_categ: images
categories: images categories: images
disabled: true inactive: true
- name: dogpile videos - name: dogpile videos
engine: dogpile engine: dogpile
shortcut: dogv shortcut: dogv
dogpile_categ: videos dogpile_categ: videos
categories: videos categories: videos
disabled: true inactive: true
- name: dogpile news - name: dogpile news
engine: dogpile engine: dogpile
shortcut: dogn shortcut: dogn
dogpile_categ: news dogpile_categ: news
categories: news categories: news
disabled: true inactive: true
# duckduckgo uses html.duckduckgo.com, # duckduckgo uses html.duckduckgo.com,
# duckduckgo web uses duckduckgo.com # duckduckgo web uses duckduckgo.com
@@ -2585,6 +2584,16 @@ engines:
shortcut: ydi shortcut: ydi
disabled: true disabled: true
- name: yandex api
engine: yandex_api
categories: [general, web]
shortcut: yda
inactive: true
# api_key: ""
# folder_id: ""
# search_type: SEARCH_TYPE_COM
# l10n: LOCALIZATION_EN
- name: yandex music - name: yandex music
engine: yandex_music engine: yandex_music
network: yandex network: yandex

View File

@@ -192,7 +192,7 @@ SCHEMA: dict[str, t.Any] = {
'brand': SettingsBrand, 'brand': SettingsBrand,
'search': { 'search': {
'safe_search': SettingsValue((0, 1, 2), 0), 'safe_search': SettingsValue((0, 1, 2), 0),
'autocomplete': SettingsValue(str, ''), 'autocomplete': SettingsValue(str, 'duckduckgo'),
'autocomplete_min': SettingsValue(int, 4), 'autocomplete_min': SettingsValue(int, 4),
'favicon_resolver': SettingsValue(str, ''), 'favicon_resolver': SettingsValue(str, ''),
'default_lang': SettingsValue(tuple(SXNG_LOCALE_TAGS + ['']), ''), 'default_lang': SettingsValue(tuple(SXNG_LOCALE_TAGS + ['']), ''),
@@ -219,7 +219,7 @@ SCHEMA: dict[str, t.Any] = {
'base_url': SettingsValue((False, str), False, 'SEARXNG_BASE_URL'), 'base_url': SettingsValue((False, str), False, 'SEARXNG_BASE_URL'),
'image_proxy': SettingsValue(bool, False, 'SEARXNG_IMAGE_PROXY'), 'image_proxy': SettingsValue(bool, False, 'SEARXNG_IMAGE_PROXY'),
'http_protocol_version': SettingsValue(('1.0', '1.1'), '1.0'), 'http_protocol_version': SettingsValue(('1.0', '1.1'), '1.0'),
'method': SettingsValue(('POST', 'GET'), 'POST', 'SEARXNG_METHOD'), 'method': SettingsValue(('POST', 'GET'), 'GET', 'SEARXNG_METHOD'),
'default_http_headers': SettingsValue(dict, {}), 'default_http_headers': SettingsValue(dict, {}),
}, },
# redis is deprecated .. # redis is deprecated ..

View File

@@ -4,7 +4,7 @@
<div class="wrapper"> <div class="wrapper">
{%- for output_type in search_formats -%} {%- for output_type in search_formats -%}
<div class="left"> <div class="left">
<form method="{{ method or 'POST' }}" action="{{ url_for('search') }}"> <form method="{{ method }}" action="{{ url_for('search') }}">
<input type="hidden" name="q" value="{{ q|e }}"> <input type="hidden" name="q" value="{{ q|e }}">
{%- for category in selected_categories -%} {%- for category in selected_categories -%}
<input type="hidden" name="category_{{ category }}" value="1"> <input type="hidden" name="category_{{ category }}" value="1">

View File

@@ -2,7 +2,7 @@
<h4 id="corrections-title">{{ _('Try searching for:') }}</h4> <h4 id="corrections-title">{{ _('Try searching for:') }}</h4>
{% for correction in corrections %} {% for correction in corrections %}
<div class="left"> <div class="left">
<form method="{{ method or 'POST' }}" action="{{ url_for('search') }}" role="navigation"> <form method="{{ method }}" action="{{ url_for('search') }}" role="navigation">
{% for category in selected_categories %} {% for category in selected_categories %}
<input type="hidden" name="category_{{ category }}" value="1"> <input type="hidden" name="category_{{ category }}" value="1">
{% endfor %} {% endfor %}

View File

@@ -31,7 +31,7 @@
<div> <div>
<h3><bdi>{{ topic.name }}</bdi></h3> <h3><bdi>{{ topic.name }}</bdi></h3>
{%- for suggestion in topic.suggestions -%} {%- for suggestion in topic.suggestions -%}
<form method="{{ method or 'POST' }}" action="{{ url_for('search') }}"> <form method="{{ method }}" action="{{ url_for('search') }}">
<input type="hidden" name="q" value="{{ suggestion }}"> <input type="hidden" name="q" value="{{ suggestion }}">
<input type="hidden" name="time_range" value="{{ time_range }}"> <input type="hidden" name="time_range" value="{{ time_range }}">
<input type="hidden" name="language" value="{{ current_language }}"> <input type="hidden" name="language" value="{{ current_language }}">

View File

@@ -3,7 +3,7 @@
<summary class="title" id="suggestions-title">{{ _('Suggestions') }}</summary> <summary class="title" id="suggestions-title">{{ _('Suggestions') }}</summary>
<ul class="wrapper"> <ul class="wrapper">
{%- for suggestion in suggestions -%} {%- for suggestion in suggestions -%}
<li><form method="{{ method or 'POST' }}" action="{{ url_for('search') }}"> <li><form method="{{ method }}" action="{{ url_for('search') }}">
<input type="hidden" name="q" value="{{ suggestion.url }}"> <input type="hidden" name="q" value="{{ suggestion.url }}">
{%- for category in selected_categories -%} {%- for category in selected_categories -%}
<input type="hidden" name="category_{{ category }}" value="1"> <input type="hidden" name="category_{{ category }}" value="1">

View File

@@ -75,7 +75,7 @@
{% if paging %} {% if paging %}
<nav id="pagination" role="navigation"> <nav id="pagination" role="navigation">
{% if pageno > 1 %} {% if pageno > 1 %}
<form method="{{ method or 'POST' }}" action="{{ url_for('search') }}" class="previous_page"> <form method="{{ method }}" action="{{ url_for('search') }}" class="previous_page">
<div class="{% if rtl %}right{% else %}left{% endif %}"> <div class="{% if rtl %}right{% else %}left{% endif %}">
<input type="hidden" name="q" value="{{ q|e }}" > <input type="hidden" name="q" value="{{ q|e }}" >
{% for category in selected_categories %} {% for category in selected_categories %}
@@ -93,7 +93,7 @@
</form> </form>
{% endif %} {% endif %}
{%- if results | count > 0 -%} {%- if results | count > 0 -%}
<form method="{{ method or 'POST' }}" action="{{ url_for('search') }}" class="next_page"> <form method="{{ method }}" action="{{ url_for('search') }}" class="next_page">
<div class="{% if rtl %}left{% else %}right{% endif %}"> <div class="{% if rtl %}left{% else %}right{% endif %}">
<input type="hidden" name="q" value="{{ q|e }}" > <input type="hidden" name="q" value="{{ q|e }}" >
{% for category in selected_categories %} {% for category in selected_categories %}
@@ -119,7 +119,7 @@
<div class="numbered_pagination"> <div class="numbered_pagination">
{% for x in range(pstart, pend) %} {% for x in range(pstart, pend) %}
<form method="{{ method or 'POST' }}" action="{{ url_for('search') }}" class="page_number"> <form method="{{ method }}" action="{{ url_for('search') }}" class="page_number">
<input type="hidden" name="q" value="{{ q|e }}" > <input type="hidden" name="q" value="{{ q|e }}" >
{% for category in selected_categories %} {% for category in selected_categories %}
<input type="hidden" name="category_{{ category }}" value="1" > <input type="hidden" name="category_{{ category }}" value="1" >

View File

@@ -1,4 +1,4 @@
<form id="search" method="{{ method or 'POST' }}" action="{{ url_for('search') }}" role="search"> <form id="search" method="{{ method }}" action="{{ url_for('search') }}" role="search">
<div id="search_header"> <div id="search_header">
<a id="search_logo" href="{{ url_for('index') }}" tabindex="0" title="{{ _('Display the front page') }}"> <a id="search_logo" href="{{ url_for('index') }}" tabindex="0" title="{{ _('Display the front page') }}">
<span hidden>SearXNG</span> <span hidden>SearXNG</span>

View File

@@ -1,4 +1,4 @@
<form id="search" method="{{ method or 'POST' }}" action="{{ url_for('search') }}" role="search"> <form id="search" method="{{ method }}" action="{{ url_for('search') }}" role="search">
<div id="search_header"> <div id="search_header">
<div id="search_view"> <div id="search_view">
<div class="search_box"> <div class="search_box">

View File

@@ -1195,7 +1195,7 @@ def opensearch():
method = 'GET' method = 'GET'
if method not in ('POST', 'GET'): if method not in ('POST', 'GET'):
method = 'POST' method = 'GET'
ret = render('opensearch.xml', opensearch_method=method, autocomplete=autocomplete) ret = render('opensearch.xml', opensearch_method=method, autocomplete=autocomplete)
resp = Response(response=ret, status=200, mimetype="application/opensearchdescription+xml") resp = Response(response=ret, status=200, mimetype="application/opensearchdescription+xml")