From 5cb4cb2bc524d120b0c1a58b556abf576fb39a06 Mon Sep 17 00:00:00 2001 From: Bnyro Date: Mon, 13 Jul 2026 14:49:43 +0200 Subject: [PATCH] [fix] qwant: engine blocked with captcha Qwant now requires a `datadome` cookie that it returns in the first search response as a `Set-Cookie`. This cookie has to be sent for all requests, otherwise they will be blocked. This means that now, the first search request is blocked (results in CAPTCHA), and only the subsequent searches work (same happens on the Qwant website for me). However, I don't think it's worth repeating the same search request multiple times very quickly because that also makes us more suspicious. --- searx/engines/qwant.py | 34 +++++++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/searx/engines/qwant.py b/searx/engines/qwant.py index 1c690146c..88216e885 100644 --- a/searx/engines/qwant.py +++ b/searx/engines/qwant.py @@ -51,6 +51,7 @@ from urllib.parse import urlencode import babel from flask_babel import gettext # pyright: ignore[reportUnknownVariableType] +from searx.enginelib import EngineCache from searx.enginelib.traits import EngineTraits from searx.exceptions import ( SearxEngineAccessDeniedException, @@ -105,9 +106,19 @@ qwant_news_locales = [ ] # fmt: on +base_url = "https://www.qwant.com" api_url = "https://api.qwant.com/v3/search/" """URL of Qwant's API (JSON)""" +CACHE: EngineCache +"""Cache for storing the ``datadome`` cookie.""" + + +def setup(engine_settings: dict[str, t.Any]) -> bool: + global CACHE # pylint: disable=global-statement + CACHE = EngineCache(engine_settings["name"]) + return True + def request(query: str, params: "OnlineParams") -> None: """Qwant search request""" @@ -129,24 +140,29 @@ def request(query: str, params: "OnlineParams") -> None: "tgp": test_group_value, "device": "desktop", "safesearch": params["safesearch"], - "display": True, - "llm": True, + # True would be encoded to "True", instead of "true", which makes the request + # easier to detect and block + "displayed": "true", + "llm": "true", } - # shuffle query parameters to be harder to fingerprint - args = list(args.items()) - random.shuffle(args) - args = dict(args) - params["raise_for_httperror"] = False params["url"] = f"{api_url}{qwant_categ}?{urlencode(args)}" + params["cookies"]["datadome"] = CACHE.get("datadome") + params["headers"].update({"Accept": "application/json", "Referer": f"{base_url}/", "Origin": base_url}) + def response(resp: "SXNG_Response") -> EngineResults: """Parse results from Qwant's API""" # pylint: disable=too-many-locals, too-many-branches, too-many-statements + # cache datadome cookie - changes on each request + datadome = resp.cookies.get("datadome") + if datadome: + CACHE.set("datadome", datadome) + res = EngineResults() # Try to load JSON result @@ -163,8 +179,8 @@ def response(resp: "SXNG_Response") -> EngineResults: error_code = data.get("error_code") if error_code == 24: raise SearxEngineTooManyRequestsException() - if search_results.get("data", {}).get("error_data", {}).get("captchaUrl") is not None: - raise SearxEngineCaptchaException() + if search_results.get("url") is not None: + raise SearxEngineCaptchaException(suspended_time=0) if resp.status_code == 403: raise SearxEngineAccessDeniedException() msg = ",".join(data.get("message", ["unknown"]))