3 Commits
Author SHA1 Message Date
Bnyro e831fc2a1c [fix] categories: rename stock_images category to stock images (#6760)
Category ``stock_images`` is referenced to as ``stock images`` everywhere else,
so SXNG didn't find any translation for ``stock_images``.
2026-09-19 15:01:12 +02:00
WookieeOnCrack c0014f015e [mod] brave - modernize response handling (#6742) 2026-09-19 13:04:37 +02:00
Bnyro fe5bdd1bf2 [feat] magnific: solve captcha to bypass botblocking
Magnific now employs a very simple to solve CAPTCHA before
all API requests (essentially summing up some numbers).

Python script for testing:
```python
import re
from lxml import html
import requests

headers = {
    'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:156.0) Gecko/20100101 Firefox/156.0',
    'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
    'Accept-Language': 'en-US,en;q=0.9',
    'Sec-GPC': '1',
    'Connection': 'keep-alive',
    'Upgrade-Insecure-Requests': '1',
    'Sec-Fetch-Dest': 'document',
    'Sec-Fetch-Mode': 'navigate',
    'Sec-Fetch-Site': 'same-origin',
    'Priority': 'u=0, i',
    'Pragma': 'no-cache',
    'Cache-Control': 'no-cache',
}

resp = requests.get(
    "https://www.magnific.com/api/regular/search?term=tree&filters%5Bai-generated%5D%5Bexcluded%5D=1&page=1&locale=en&filters%5Blicense%5D=free",
    headers=headers,
)
cookies = resp.cookies
print(resp.text)

_NUMBER_RE = re.compile("\d+")
_BM_VERIFY = re.compile(r"\"bm-verify\":\s*\"(.*?)\"")

doc = html.fromstring(resp.text)
script = doc.xpath("//script")[0].text
numbers = [int(m) for m in _NUMBER_RE.findall(script)]
bm_verify = _BM_VERIFY.search(resp.text)
if not bm_verify:
    exit(1)
bm_verify = bm_verify.group(1)
print("bm-verify: " + bm_verify)

solution = sum(numbers)
print(f"sum({numbers}) = {solution}")

resp = requests.post(
    "https://www.magnific.com/_sec/verify?provider=interstitial",
    json={"bm-verify": bm_verify, "pow": solution},
    headers=headers,
    cookies=cookies,
)

resp = requests.get(
    "https://www.magnific.com/api/regular/search?term=bird&filters%5Bai-generated%5D%5Bexcluded%5D=1&page=1&locale=en&filters%5Blicense%5D=free",
    headers=headers,
    cookies=resp.cookies,
)
print(resp.text)
```
2026-09-19 11:31:00 +02:00
11 changed files with 205 additions and 170 deletions
+1 -1
View File
@@ -33,7 +33,7 @@ about = {
base_url = "https://500px.com"
api_url = "https://api-neo.500px.com"
categories = ["stock_images"]
categories = ["stock images"]
paging = True
results_per_page = 30
+138 -157
View File
@@ -119,25 +119,20 @@ Implementations
import json
import typing as t
from urllib.parse import (
urlencode,
urlparse,
)
from collections.abc import Callable
from urllib.parse import urlencode
from dateutil import parser
from searx import locales
from searx import locales, logger
from searx.enginelib.traits import EngineTraits
from searx.exceptions import SearxEngineResponseException
from searx.result_types import EngineResults, MainResult
from searx.result_types.image import Image
from searx.utils import html_to_text, js_obj_str_to_json_str, js_obj_str_to_python
if t.TYPE_CHECKING:
from searx.extended_types import SXNG_Response
from searx.result_types import EngineResults
from searx.utils import (
eval_xpath_getindex,
eval_xpath_list,
extract_text,
get_embedded_stream_url,
js_obj_str_to_json_str,
js_obj_str_to_python,
)
about = {
"website": "https://search.brave.com/",
@@ -261,155 +256,143 @@ def extract_json_data(text: str) -> dict[str, t.Any]:
return data
def response(resp: SXNG_Response) -> EngineResults:
def response(resp: "SXNG_Response") -> EngineResults:
# delegate the response to the appropriate parser based on search type
if brave_category in ("search", "goggles"):
return _parse_search(resp)
match brave_category:
case "search" | "goggles":
return _parse_results(_parse_search_result, resp)
case "news":
return _parse_results(_parse_news_result, resp)
case "images":
return _parse_results(_parse_image_result, resp)
case "videos":
return _parse_results(_parse_video_result, resp)
case _:
raise ValueError(f"Unsupported brave category: {brave_category}") # pyright: ignore[reportUnreachable]
if brave_category in ("news"):
return _parse_news(resp)
def _parse_search_result(result: dict[str, t.Any]) -> MainResult:
thumbnail: dict[str, t.Any] = result.get("thumbnail", {})
return MainResult(
template="default.html",
title=result.get("title", ""),
content=html_to_text(result.get("description", "")),
url=result.get("url", ""),
publishedDate=_extract_published_date(result.get("page_age")),
pubdate=result.get("age", ""),
thumbnail=thumbnail.get("src", "") if thumbnail and not thumbnail.get("logo") else "",
)
def _parse_secondary_items(json_data: dict[str, t.Any], results: EngineResults):
# video results utilize same schema as video search -> re-use _parse_video_result
body_resp: dict[str, t.Any] = _get_response_data(json_data)
videos_resp: dict[str, t.Any] = body_resp.get("videos", {})
if videos_resp and "results" in videos_resp:
for result in videos_resp.get("results", []):
results.add(_parse_video_result(result))
# related queries -> suggestion
query: dict[str, t.Any] = body_resp.get("query", {})
if query and "related_queries" in query:
for x in query.get("related_queries", []):
suggestion = " ".join(val[1] for val in x)
results.add(results.types.LegacyResult(suggestion=suggestion))
def _parse_news_result(result: dict[str, t.Any]) -> MainResult:
thumbnail: dict[str, t.Any] = result.get("thumbnail", {})
return MainResult(
title=result.get("title", ""),
content=result.get("description", ""),
url=result.get("url"),
publishedDate=_extract_published_date(result.get("age")),
pubdate=result.get("age", ""),
thumbnail=thumbnail.get("src", "") if thumbnail else "",
)
def _parse_image_result(result: dict[str, t.Any]) -> Image:
properties: dict[str, t.Any] = result.get("properties", {})
thumbnail: dict[str, t.Any] = result.get("thumbnail", {})
width, height = properties.get("width"), properties.get("height")
return Image(
title=result.get("title", ""),
url=result.get("url"),
img_src=properties.get("url", ""),
thumbnail_src=thumbnail.get("src", "") if thumbnail else "",
source=result.get("source", ""),
resolution=f"{width}x{height}" if width and height else "",
)
def _parse_video_result(result: dict[str, t.Any]) -> MainResult:
video: dict[str, t.Any] = result.get("video", {})
thumbnail: dict[str, t.Any] = result.get("thumbnail", {})
return MainResult(
template="videos.html",
title=result.get("title", ""),
url=result.get("url"),
content=result.get("description", ""),
length=video.get("duration"),
publishedDate=_extract_published_date(result.get("age")),
pubdate=result.get("age", ""),
views=video.get("views", ""),
thumbnail=thumbnail.get("src", "") if thumbnail else "",
)
def _get_response_data(json_data: dict[str, t.Any], category: str | None = None) -> dict[str, t.Any]:
"""Navigate the Brave embedded JSON to the category-specific response object."""
# Brave’s structure is mostly consistent but has a couple of quirks:
# - most categories live under data[1].data.body.response.<category>
# - news omits the intermediate "body" key
try:
data: dict[str, t.Any] = json_data["data"][1]["data"]
if data.get("noResults"): # Boolean Value
return {}
if category == "news":
return data["response"]["news"]
body_resp = data["body"]["response"]
if category in ("search", "goggles"):
return body_resp["web"]
# images / videos / secondary items
return body_resp
except (KeyError, IndexError, TypeError) as e:
raise SearxEngineResponseException(f"Unexpected Brave JSON structure for category {category!r}") from e
def _parse_results(parse_func: Callable[..., MainResult | Image], resp: "SXNG_Response") -> EngineResults:
"""Extract json data and loop through result list
The suppled :py.obj:`parse_func` parses individual result items
General search / goggle search relies on :py.obj:`_parse_secondary_items` for mixed result-types"""
# Example script source containing the data:
#
# kit.start(app, element, {
# node_ids: [0, 19],
# data: [{type:"data",data: .... ["q","goggles_id"],route:1,url:1}}]
# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
results = EngineResults()
json_data: dict[str, t.Any] = extract_json_data(resp.text)
json_resp: dict[str, t.Any] = json_data["data"][1]["data"]["body"]["response"]
json_resp: dict[str, t.Any] = _get_response_data(json_data, brave_category)
if not json_resp:
# if _get_response_data returns {} - indicates it was parsed successfully but had "noResults" = True
return results
if brave_category == "images":
return _parse_images(json_resp)
if brave_category == "videos":
return _parse_videos(json_resp)
json_results: list[dict[str, t.Any]] = json_resp["results"]
for result in json_results:
results.add(parse_func(result))
raise ValueError(f"Unsupported brave category: {brave_category}")
# general search / goggle might have secondary items
if brave_category in ("search", "goggles"):
_parse_secondary_items(json_data, results)
def _parse_search(resp: SXNG_Response) -> EngineResults:
res = EngineResults()
dom = resp.html()
for result in eval_xpath_list(dom, "//div[contains(@class, 'snippet ')]"):
url: str | None = eval_xpath_getindex(result, ".//a/@href", 0, default=None)
title_tag = eval_xpath_getindex(result, ".//div[contains(@class, 'title')]", 0, default=None)
if url is None or title_tag is None or not urlparse(url).netloc: # partial url likely means it's an ad
continue
content: str = ""
pub_date = None
# there are other classes like 'site-name-content' we don't want to match,
# however only using contains(@class, 'content') would e.g. also match `site-name-content`
# thus, we explicitly also require the spaces as class separator
_content = eval_xpath_getindex(
result,
".//div[contains(concat(' ', @class, ' '), ' content ')]",
0,
default="",
)
if len(_content):
content = extract_text(_content) # type: ignore
_pub_date = extract_text(
eval_xpath_getindex(_content, ".//span[contains(@class, 't-secondary')]", 0, default="")
)
if _pub_date:
pub_date = _extract_published_date(_pub_date)
content = content.lstrip(_pub_date).strip("- \n\t")
thumbnail: str = eval_xpath_getindex(result, ".//a[contains(@class, 'thumbnail')]//img/@src", 0, default="")
item = res.types.LegacyResult(
template="default.html",
url=url,
title=extract_text(title_tag),
content=content,
publishedDate=pub_date,
thumbnail=thumbnail,
)
res.add(item)
video_tag = eval_xpath_getindex(
result,
".//div[contains(@class, 'video-snippet') and @data-macro='video']",
0,
default=[],
)
if len(video_tag):
# In my tests a video tag in the WEB search was most often not a
# video, except the ones from youtube ..
iframe_src = get_embedded_stream_url(url)
if iframe_src:
item["iframe_src"] = iframe_src
item["template"] = "videos.html"
for suggestion in eval_xpath_list(dom, "//a[contains(@class, 'related-query')]"):
res.append(res.types.LegacyResult({"suggestion": extract_text(suggestion)}))
return res
def _parse_news(resp: SXNG_Response) -> EngineResults:
res = EngineResults()
dom = resp.html()
for result in eval_xpath_list(dom, "//div[@data-type='news']"):
url = eval_xpath_getindex(result, ".//a/@href", 0, default=None)
if url is None:
continue
title = eval_xpath_list(result, ".//div[contains(@class, 'title')]")
content = eval_xpath_list(result, ".//div[contains(@class, 'description')]")
thumbnail = eval_xpath_getindex(result, ".//a[contains(@class, 'thumbnail')]//img/@src", 0, default="")
item = res.types.LegacyResult(
template="default.html",
url=url,
title=extract_text(title),
thumbnail=thumbnail,
content=extract_text(content),
)
res.add(item)
return res
def _parse_images(json_resp: dict[str, t.Any]) -> EngineResults:
res = EngineResults()
for result in json_resp["results"]:
item = res.types.LegacyResult(
template="images.html",
url=result["url"],
title=result["title"],
source=result["source"],
img_src=result["properties"]["url"],
thumbnail_src=result["thumbnail"]["src"],
)
res.add(item)
return res
def _parse_videos(json_resp: dict[str, t.Any]) -> EngineResults:
res = EngineResults()
for result in json_resp["results"]:
item = res.types.LegacyResult(
template="videos.html",
url=result["url"],
title=result["title"],
content=result["description"],
length=result["video"]["duration"],
duration=result["video"]["duration"],
publishedDate=_extract_published_date(result["age"]),
)
if result["thumbnail"] is not None:
item["thumbnail"] = result["thumbnail"]["src"]
res.add(item)
return res
return results
def fetch_traits(engine_traits: EngineTraits):
@@ -447,10 +430,9 @@ def fetch_traits(engine_traits: EngineTraits):
# silently ignore unknown languages
continue
conflict = engine_traits.custom["ui_lang"].get(sxng_tag) # type: ignore
if conflict:
if conflict := engine_traits.custom["ui_lang"].get(sxng_tag):
if conflict != ui_lang:
print("CONFLICT: babel %s --> %s, %s" % (sxng_tag, conflict, ui_lang))
print(f"CONFLICT: babel {sxng_tag} --> {conflict}, {ui_lang}")
continue
engine_traits.custom["ui_lang"][sxng_tag] = ui_lang
@@ -477,15 +459,14 @@ def fetch_traits(engine_traits: EngineTraits):
for lang_tag in babel.languages.get_official_languages(country_tag, de_facto=True):
lang_tag = lang_map.get(lang_tag, lang_tag)
try:
sxng_tag = region_tag(babel.Locale.parse("%s_%s" % (lang_tag, country_tag.upper())))
sxng_tag = region_tag(babel.Locale.parse(f"{lang_tag}_{country_tag.upper()}"))
except babel.UnknownLocaleError:
# silently ignore unknown languages
continue
# print("%-20s: %s <-- %s" % (v["label"], country_tag, sxng_tag))
conflict = engine_traits.regions.get(sxng_tag)
if conflict:
if conflict != country_tag:
print("CONFLICT: babel %s --> %s, %s" % (sxng_tag, conflict, country_tag))
if conflict and conflict != country_tag:
print(f"CONFLICT: babel {sxng_tag} --> {conflict}, {country_tag}")
continue
engine_traits.regions[sxng_tag] = country_tag
+1 -1
View File
@@ -18,7 +18,7 @@ about = {
"results": 'JSON',
}
categories = ["stock_images"]
categories = ["stock images"]
page_size = 15
paging = True
+55 -1
View File
@@ -4,11 +4,16 @@
.. _Magnific: https://www.magnific.com
"""
import re
from urllib.parse import urlencode
import typing as t
from searx.enginelib import EngineCache
from searx.exceptions import SearxEngineAPIException
from searx.network import get, post
from searx.result_types import EngineResults
from searx.utils import eval_xpath_getindex, extract_text
if t.TYPE_CHECKING:
from searx.extended_types import SXNG_Response
@@ -26,7 +31,7 @@ about = {
base_url = "https://www.magnific.com"
categories = ["images"]
categories = ["stock images"]
paging = True
free_images_only = True
@@ -34,6 +39,47 @@ free_images_only = True
Whether to only load images that may be used for free, without a Magnific account.
"""
# regular expressions for solving the captcha
_NUMBER_RE = re.compile(r"\d+")
_BM_VERIFY_RE = re.compile(r"\"bm-verify\":\s*\"(.*?)\"")
_CAPTCHA_COOKIE_NAME = "ak_bmsc"
CACHE: EngineCache
"""Cache for storing the cookie to bypass botblocking, obtained by solving a CAPTCHA."""
def setup(engine_settings: dict[str, t.Any]):
global CACHE # pylint: disable=global-statement
CACHE = EngineCache(engine_settings["name"])
def _solve_captcha(original_resp: "SXNG_Response") -> "SXNG_Response":
"""Solves the Magnific CAPTCHA by summing all numbers in the response.
E.g., the relevant part of the script that contains the numbers could look like
``var i = 1789729970; var j = i + Number("1947" + "43160");``."""
doc = original_resp.html()
script = extract_text(eval_xpath_getindex(doc, "//script", 0)) or ""
bm_verify = _BM_VERIFY_RE.search(original_resp.text)
if not bm_verify:
raise SearxEngineAPIException("failed to extract bm verify token")
bm_verify = bm_verify.group(1)
# challenge is just adding a bunch of number together (some are strings, some are ints)
numbers = [int(m) for m in _NUMBER_RE.findall(script)]
solution = sum(numbers)
challenge_resp = post(
f"{base_url}/_sec/verify?provider=interstitial",
json={"bm-verify": bm_verify, "pow": solution},
cookies=original_resp.cookies,
)
resp = get(original_resp.url, headers=original_resp.search_params["headers"], cookies=challenge_resp.cookies)
# cookie is valid for 2 hours
CACHE.set(_CAPTCHA_COOKIE_NAME, challenge_resp.cookies[_CAPTCHA_COOKIE_NAME], expire=7200)
return resp
def request(query: str, params: "OnlineParams") -> None:
args = {"term": query, "filters[ai-generated][excluded]": 1, "page": params["pageno"], "locale": "en"}
@@ -41,10 +87,18 @@ def request(query: str, params: "OnlineParams") -> None:
args["filters[license]"] = "free"
params["headers"]["Referer"] = f"{base_url}/search"
# cookie that is obtained after solving the CAPTCHA
if cookie := CACHE.get(_CAPTCHA_COOKIE_NAME):
params["cookies"][_CAPTCHA_COOKIE_NAME] = cookie
params["url"] = f"{base_url}/api/regular/search?{urlencode(args)}"
def response(resp: "SXNG_Response"):
if resp.headers["content-type"] == "text/html":
resp = _solve_captcha(resp)
res = EngineResults()
result: dict[str, t.Any] # TBH: dict[str, t.Any]
+1 -1
View File
@@ -26,7 +26,7 @@ about = {
}
base_url = "https://www.pexels.com"
categories = ["stock_images"]
categories = ["stock images"]
api_key = "H2jk9uKnhRmL6WPwh89zBezWvr"
"""
+1 -1
View File
@@ -26,7 +26,7 @@ about = {
base_url = "https://picjumbo.com"
categories = ["stock_images"]
categories = ["stock images"]
paging = True
+1 -1
View File
@@ -25,7 +25,7 @@ about = {
base_url = "https://www.shopify.com"
categories = ["stock_images"]
categories = ["stock images"]
paging = True
+1 -1
View File
@@ -25,7 +25,7 @@ about = {
base_url = "https://stocksnap.io"
cdn_url = "https://cdn.stocksnap.io"
categories = ["stock_images"]
categories = ["stock images"]
paging = True
+1 -1
View File
@@ -18,7 +18,7 @@ about = {
base_url = 'https://unsplash.com/'
search_url = base_url + 'napi/search/photos?'
categories = ["stock_images"]
categories = ["stock images"]
page_size = 20
paging = True
+1 -1
View File
@@ -17,7 +17,7 @@ about = {
}
# engine dependent config
categories = ["stock_images"]
categories = ["stock images"]
paging = False
# search-url
+2 -2
View File
@@ -280,7 +280,7 @@ plugins:
categories_as_tabs:
general:
images:
# stock_images:
# stock images:
videos:
news:
map:
@@ -1932,7 +1932,7 @@ engines:
- name: pixabay images
engine: pixabay
pixabay_type: images
categories: stock_images
categories: stock images
shortcut: pixi
disabled: true