4 Commits

Author SHA1 Message Date
Markus Heiser
4295e758c0 [fix] Installation Script install fails (msgspec) (#5346)
Since #5280 has been merged, msgspec, like yaml, is a fixed part of the SearXNG
*settings framework* and therefore, like yaml, must be installed in the virtual
environment before installing SearXNG (``searx``).

The actual reason is that in SearXNG we store settings in the configuration that
are required for the installation of the ``searx`` package.  This means that
these settings (from settings.yml) are read in during the installation, and all the
necessary tools for this (pyyaml, msgspec, setuptools, etc.) must be installed
beforehand (chicken or the egg dilemma).

Related:

- https://github.com/searxng/searxng/pull/5280
- https://github.com/searxng/searxng/pull/5254

Closes: https://github.com/searxng/searxng/issues/5343

Signed-off-by: Markus Heiser <markus.heiser@darmarit.de>
2025-10-20 11:21:48 +02:00
Markus Heiser
33e798b01b [fix] TrackerPatternsDB.clean_url: don't delete query argument from new_url (#5339)
The query argument for URLs like:

- 'http://example.org?q='       --> query_str is 'q='
- 'http://example.org?/foo/bar' --> query_str is 'foo/bar'

is a *simple string* and not a key/value dict.  This string may only be removed
from the URL if one of the patterns matches.

BTW get_pretty_url(): keep such a *simple string* in the path element.

Closes: https://github.com/searxng/searxng/issues/5299

Signed-off-by: Markus Heiser <markus.heiser@darmarit.de>
2025-10-20 11:20:33 +02:00
Markus Heiser
d84ae96cf9 [build] /static 2025-10-20 10:18:33 +02:00
Markus Heiser
9371658531 [mod] typification of SearXNG: add new result type File
This PR adds a new result type: File

    Python class: searx/result_types/file.py
    Jinja template: searx/templates/simple/result_templates/file.html
    CSS (less) client/simple/src/less/result_types/file.less

Class 'File' (singular) replaces template 'files.html' (plural).  The renaming
was carried out because there is only one file (singular) in a result. Not to be
confused with the category 'files' where in multiple results can exist.

As mentioned in issue [1], the class '.category-files' was removed from the CSS
and the stylesheet was adopted in result_types/file.less (there based on the
templates and no longer based on the category).

[1] https://github.com/searxng/searxng/issues/5198

Signed-off-by: Markus Heiser <markus.heiser@darmarit.de>
2025-10-20 10:18:33 +02:00
19 changed files with 532 additions and 265 deletions

View File

@@ -0,0 +1,22 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
/*
Layout of the Files result class
*/
#main_results .result-file {
border: 1px solid var(--color-result-border);
margin: 0 @results-tablet-offset 1rem @results-tablet-offset !important;
.rounded-corners;
video {
width: 100%;
aspect-ratio: 16 / 9;
padding: 10px 0 0 0;
}
audio {
width: 100%;
padding: 10px 0 0 0;
}
}

View File

@@ -163,7 +163,6 @@ article[data-vim-selected].category-videos,
article[data-vim-selected].category-news, article[data-vim-selected].category-news,
article[data-vim-selected].category-map, article[data-vim-selected].category-map,
article[data-vim-selected].category-music, article[data-vim-selected].category-music,
article[data-vim-selected].category-files,
article[data-vim-selected].category-social { article[data-vim-selected].category-social {
border: 1px solid var(--color-result-vim-arrow); border: 1px solid var(--color-result-vim-arrow);
.rounded-corners; .rounded-corners;
@@ -387,7 +386,6 @@ article[data-vim-selected].category-social {
.category-news, .category-news,
.category-map, .category-map,
.category-music, .category-music,
.category-files,
.category-social { .category-social {
border: 1px solid var(--color-result-border); border: 1px solid var(--color-result-border);
margin: 0 @results-tablet-offset 1rem @results-tablet-offset !important; margin: 0 @results-tablet-offset 1rem @results-tablet-offset !important;
@@ -1168,3 +1166,4 @@ pre code {
@import "result_types/keyvalue.less"; @import "result_types/keyvalue.less";
@import "result_types/code.less"; @import "result_types/code.less";
@import "result_types/paper.less"; @import "result_types/paper.less";
@import "result_types/file.less";

View File

@@ -0,0 +1,7 @@
.. _result_types.file:
============
File Results
============
.. automodule:: searx.result_types.file

View File

@@ -17,6 +17,7 @@ following types have been implemented so far ..
main/keyvalue main/keyvalue
main/code main/code
main/paper main/paper
main/file
The :ref:`LegacyResult <LegacyResult>` is used internally for the results that The :ref:`LegacyResult <LegacyResult>` is used internally for the results that
have not yet been typed. The templates can be used as orientation until the have not yet been typed. The templates can be used as orientation until the
@@ -28,5 +29,4 @@ final typing is complete.
- :ref:`template torrent` - :ref:`template torrent`
- :ref:`template map` - :ref:`template map`
- :ref:`template packages` - :ref:`template packages`
- :ref:`template files`
- :ref:`template products` - :ref:`template products`

View File

@@ -60,7 +60,7 @@ Fields used in the template :origin:`macro result_sub_header
publishedDate : :py:obj:`datetime.datetime` publishedDate : :py:obj:`datetime.datetime`
The date on which the object was published. The date on which the object was published.
length: :py:obj:`time.struct_time` length: :py:obj:`datetime.timedelta`
Playing duration in seconds. Playing duration in seconds.
views: :py:class:`str` views: :py:class:`str`
@@ -469,38 +469,6 @@ links : :py:class:`dict`
Additional links in the form of ``{'link_name': 'http://example.com'}`` Additional links in the form of ``{'link_name': 'http://example.com'}``
.. _template files:
``files.html``
--------------
Displays result fields from:
- :ref:`macro result_header` and
- :ref:`macro result_sub_header`
Additional fields used in the :origin:`code.html
<searx/templates/simple/result_templates/files.html>`:
filename, size, time: :py:class:`str`
Filename, Filesize and Date of the file.
mtype : ``audio`` | ``video`` | :py:class:`str`
Mimetype type of the file.
subtype : :py:class:`str`
Mimetype / subtype of the file.
abstract : :py:class:`str`
Abstract of the file.
author : :py:class:`str`
Name of the author of the file
embedded : :py:class:`str`
URL of an embedded media type (``audio`` or ``video``) / is collapsible.
.. _template products: .. _template products:
``products.html`` ``products.html``

View File

@@ -1,5 +1,6 @@
# SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-License-Identifier: AGPL-3.0-or-later
"""Simple implementation to store TrackerPatterns data in a SQL database.""" """Simple implementation to store TrackerPatterns data in a SQL database."""
# pylint: disable=too-many-branches
import typing as t import typing as t
@@ -119,6 +120,12 @@ class TrackerPatternsDB:
for rule in self.rules(): for rule in self.rules():
query_str: str = parsed_new_url.query
if not query_str:
# There are no more query arguments in the parsed_new_url on
# which rules can be applied, stop iterating over the rules.
break
if not re.match(rule[self.Fields.url_regexp], new_url): if not re.match(rule[self.Fields.url_regexp], new_url):
# no match / ignore pattern # no match / ignore pattern
continue continue
@@ -136,18 +143,32 @@ class TrackerPatternsDB:
# overlapping urlPattern like ".*" # overlapping urlPattern like ".*"
continue continue
# remove tracker arguments from the url-query part
query_args: list[tuple[str, str]] = list(parse_qsl(parsed_new_url.query)) query_args: list[tuple[str, str]] = list(parse_qsl(parsed_new_url.query))
if query_args:
# remove tracker arguments from the url-query part
for name, val in query_args.copy():
# remove URL arguments
for pattern in rule[self.Fields.del_args]:
if re.match(pattern, name):
log.debug(
"TRACKER_PATTERNS: %s remove tracker arg: %s='%s'", parsed_new_url.netloc, name, val
)
query_args.remove((name, val))
for name, val in query_args.copy(): parsed_new_url = parsed_new_url._replace(query=urlencode(query_args))
# remove URL arguments new_url = urlunparse(parsed_new_url)
else:
# The query argument for URLs like:
# - 'http://example.org?q=' --> query_str is 'q=' and query_args is []
# - 'http://example.org?/foo/bar' --> query_str is 'foo/bar' and query_args is []
# is a simple string and not a key/value dict.
for pattern in rule[self.Fields.del_args]: for pattern in rule[self.Fields.del_args]:
if re.match(pattern, name): if re.match(pattern, query_str):
log.debug("TRACKER_PATTERNS: %s remove tracker arg: %s='%s'", parsed_new_url.netloc, name, val) log.debug("TRACKER_PATTERNS: %s remove tracker arg: '%s'", parsed_new_url.netloc, query_str)
query_args.remove((name, val)) parsed_new_url = parsed_new_url._replace(query="")
new_url = urlunparse(parsed_new_url)
parsed_new_url = parsed_new_url._replace(query=urlencode(query_args)) break
new_url = urlunparse(parsed_new_url)
if new_url != url: if new_url != url:
return new_url return new_url

View File

@@ -13,23 +13,12 @@ Configuration
You must configure the following settings: You must configure the following settings:
``base_url``: - :py:obj:`base_url`
Location where recoll-webui can be reached. - :py:obj:`mount_prefix`
- :py:obj:`dl_prefix`
- :py:obj:`search_dir`
``mount_prefix``: Example scenario:
Location where the file hierarchy is mounted on your *local* filesystem.
``dl_prefix``:
Location where the file hierarchy as indexed by recoll can be reached.
``search_dir``:
Part of the indexed file hierarchy to be search, if empty the full domain is
searched.
Example
=======
Scenario:
#. Recoll indexes a local filesystem mounted in ``/export/documents/reference``, #. Recoll indexes a local filesystem mounted in ``/export/documents/reference``,
#. the Recoll search interface can be reached at https://recoll.example.org/ and #. the Recoll search interface can be reached at https://recoll.example.org/ and
@@ -37,107 +26,128 @@ Scenario:
.. code:: yaml .. code:: yaml
base_url: https://recoll.example.org/ base_url: https://recoll.example.org
mount_prefix: /export/documents mount_prefix: /export/documents
dl_prefix: https://download.example.org dl_prefix: https://download.example.org
search_dir: '' search_dir: ""
Implementations Implementations
=============== ===============
""" """
import typing as t
from datetime import date, timedelta from datetime import date, timedelta
from json import loads
from urllib.parse import urlencode, quote from urllib.parse import urlencode, quote
# about from searx.result_types import EngineResults
if t.TYPE_CHECKING:
from searx.extended_types import SXNG_Response
from searx.search.processors import OnlineParams
about = { about = {
"website": None, "website": None,
"wikidata_id": 'Q15735774', "wikidata_id": "Q15735774",
"official_api_documentation": 'https://www.lesbonscomptes.com/recoll/', "official_api_documentation": "https://www.lesbonscomptes.com/recoll/",
"use_official_api": True, "use_official_api": True,
"require_api_key": False, "require_api_key": False,
"results": 'JSON', "results": "JSON",
} }
# engine dependent config
paging = True paging = True
time_range_support = True time_range_support = True
# parameters from settings.yml base_url: str = ""
base_url = None """Location where recoll-webui can be reached."""
search_dir = ''
mount_prefix = None
dl_prefix = None
# embedded mount_prefix: str = ""
embedded_url = '<{ttype} controls height="166px" ' + 'src="{url}" type="{mtype}"></{ttype}>' """Location where the file hierarchy is mounted on your *local* filesystem."""
dl_prefix: str = ""
"""Location where the file hierarchy as indexed by recoll can be reached."""
search_dir: str = ""
"""Part of the indexed file hierarchy to be search, if empty the full domain is
searched."""
_s2i: dict[str | None, int] = {"day": 1, "week": 7, "month": 30, "year": 365}
# helper functions def setup(engine_settings: dict[str, t.Any]) -> bool:
def get_time_range(time_range): """Initialization of the Recoll engine, checks if the mandatory values are
sw = {'day': 1, 'week': 7, 'month': 30, 'year': 365} # pylint: disable=invalid-name configured.
"""
missing: list[str] = []
for cfg_name in ["base_url", "mount_prefix", "dl_prefix"]:
if not engine_settings.get(cfg_name):
missing.append(cfg_name)
if missing:
logger.error("missing recoll configuration: %s", missing)
return False
offset = sw.get(time_range, 0) if engine_settings["base_url"].endswith("/"):
engine_settings["base_url"] = engine_settings["base_url"][:-1]
return True
def search_after(time_range: str | None) -> str:
offset = _s2i.get(time_range, 0)
if not offset: if not offset:
return '' return ""
return (date.today() - timedelta(days=offset)).isoformat() return (date.today() - timedelta(days=offset)).isoformat()
# do search-request def request(query: str, params: "OnlineParams") -> None:
def request(query, params): args = {
search_after = get_time_range(params['time_range']) "query": query,
search_url = base_url + 'json?{query}&highlight=0' "page": params["pageno"],
params['url'] = search_url.format( "after": search_after(params["time_range"]),
query=urlencode({'query': query, 'page': params['pageno'], 'after': search_after, 'dir': search_dir}) "dir": search_dir,
) "highlight": 0,
}
return params params["url"] = f"{base_url}/json?{urlencode(args)}"
# get response from search-request def response(resp: "SXNG_Response") -> EngineResults:
def response(resp):
results = []
response_json = loads(resp.text) res = EngineResults()
json_data = resp.json()
if not response_json: if not json_data:
return [] return res
for result in response_json.get('results', []): for result in json_data.get("results", []):
title = result['label']
url = result['url'].replace('file://' + mount_prefix, dl_prefix)
content = '{}'.format(result['snippet'])
# append result url = result.get("url", "").replace("file://" + mount_prefix, dl_prefix)
item = {'url': url, 'title': title, 'content': content, 'template': 'files.html'}
if result['size']: mtype = subtype = result.get("mime", "")
item['size'] = int(result['size']) if mtype:
mtype, subtype = (mtype.split("/", 1) + [""])[:2]
for parameter in ['filename', 'abstract', 'author', 'mtype', 'time']:
if result[parameter]:
item[parameter] = result[parameter]
# facilitate preview support for known mime types # facilitate preview support for known mime types
if 'mtype' in result and '/' in result['mtype']: thumbnail = embedded = ""
(mtype, subtype) = result['mtype'].split('/') if mtype in ["audio", "video"]:
item['mtype'] = mtype embedded_url = '<{ttype} controls height="166px" ' + 'src="{url}" type="{mtype}"></{ttype}>'
item['subtype'] = subtype embedded = embedded_url.format(ttype=mtype, url=quote(url.encode("utf8"), "/:"), mtype=result["mtype"])
if mtype in ["image"] and subtype in ["bmp", "gif", "jpeg", "png"]:
thumbnail = url
if mtype in ['audio', 'video']: res.add(
item['embedded'] = embedded_url.format( res.types.File(
ttype=mtype, url=quote(url.encode('utf8'), '/:'), mtype=result['mtype'] title=result.get("label", ""),
) url=url,
content=result.get("snippet", ""),
if mtype in ['image'] and subtype in ['bmp', 'gif', 'jpeg', 'png']: size=result.get("size", ""),
item['thumbnail'] = url filename=result.get("filename", ""),
abstract=result.get("abstract", ""),
results.append(item) author=result.get("author", ""),
mtype=mtype,
if 'nres' in response_json: subtype=subtype,
results.append({'number_of_results': response_json['nres']}) time=result.get("time", ""),
embedded=embedded,
return results thumbnail=thumbnail,
)
)
return res

View File

@@ -1,102 +1,208 @@
# SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-License-Identifier: AGPL-3.0-or-later
"""Wikimedia Commons (images)""" """`Wikimedia Commons`_ is a collection of more than 120 millions freely usable
media files to which anyone can contribute.
This engine uses the `MediaWiki query API`_, with which engines can be configured
for searching images, videos, audio, and other files in the Wikimedia.
.. _MediaWiki query API: https://commons.wikimedia.org/w/api.php?action=help&modules=query
.. _Wikimedia Commons: https://commons.wikimedia.org/
Configuration
=============
The engine has the following additional settings:
.. code:: yaml
- name: wikicommons.images
engine: wikicommons
wc_search_type: image
- name: wikicommons.videos
engine: wikicommons
wc_search_type: video
- name: wikicommons.audio
engine: wikicommons
wc_search_type: audio
- name: wikicommons.files
engine: wikicommons
wc_search_type: file
Implementations
===============
"""
import typing as t
import datetime import datetime
import pathlib
from urllib.parse import urlencode from urllib.parse import urlencode, unquote
from searx.utils import html_to_text, humanize_bytes from searx.utils import html_to_text, humanize_bytes
from searx.result_types import EngineResults
if t.TYPE_CHECKING:
from searx.extended_types import SXNG_Response
from searx.search.processors import OnlineParams
# about
about = { about = {
"website": 'https://commons.wikimedia.org/', "website": "https://commons.wikimedia.org/",
"wikidata_id": 'Q565', "wikidata_id": "Q565",
"official_api_documentation": 'https://commons.wikimedia.org/w/api.php', "official_api_documentation": "https://commons.wikimedia.org/w/api.php",
"use_official_api": True, "use_official_api": True,
"require_api_key": False, "require_api_key": False,
"results": 'JSON', "results": "JSON",
} }
categories = ['images']
search_type = 'images'
base_url = "https://commons.wikimedia.org" categories: list[str] = []
search_prefix = (
'?action=query'
'&format=json'
'&generator=search'
'&gsrnamespace=6'
'&gsrprop=snippet'
'&prop=info|imageinfo'
'&iiprop=url|size|mime'
'&iiurlheight=180' # needed for the thumb url
)
paging = True paging = True
number_of_results = 10 number_of_results = 10
search_types = { wc_api_url = "https://commons.wikimedia.org/w/api.php"
'images': 'bitmap|drawing', wc_search_type: str = ""
'videos': 'video',
'audio': 'audio', SEARCH_TYPES: dict[str, str] = {
'files': 'multimedia|office|archive|3d', "image": "bitmap|drawing",
"video": "video",
"audio": "audio",
"file": "multimedia|office|archive|3d",
} }
# FileType = t.Literal["bitmap", "drawing", "video", "audio", "multimedia", "office", "archive", "3d"]
# FILE_TYPES = list(t.get_args(FileType))
def request(query, params): def setup(engine_settings: dict[str, t.Any]) -> bool:
language = 'en' """Initialization of the Wikimedia engine, checks if the value configured in
if params['language'] != 'all': :py:obj:`wc_search_type` is valid."""
language = params['language'].split('-')[0]
if search_type not in search_types: if engine_settings.get("wc_search_type") not in SEARCH_TYPES:
raise ValueError(f"Unsupported search type: {search_type}") logger.error(
"wc_search_type: %s isn't a valid file type (%s)",
engine_settings.get("wc_search_type"),
",".join(SEARCH_TYPES.keys()),
)
return False
return True
filetype = search_types[search_type]
def request(query: str, params: "OnlineParams") -> None:
uselang: str = "en"
if params["searxng_locale"] != "all":
uselang = params["searxng_locale"].split("-")[0]
filetype = SEARCH_TYPES[wc_search_type]
args = { args = {
'uselang': language, # https://commons.wikimedia.org/w/api.php
'gsrlimit': number_of_results, "format": "json",
'gsroffset': number_of_results * (params["pageno"] - 1), "uselang": uselang,
'gsrsearch': f"filetype:{filetype} {query}", "action": "query",
# https://commons.wikimedia.org/w/api.php?action=help&modules=query
"prop": "info|imageinfo",
# generator (gsr optins) https://commons.wikimedia.org/w/api.php?action=help&modules=query%2Bsearch
"generator": "search",
"gsrnamespace": "6", # https://www.mediawiki.org/wiki/Help:Namespaces#Renaming_namespaces
"gsrprop": "snippet",
"gsrlimit": number_of_results,
"gsroffset": number_of_results * (params["pageno"] - 1),
"gsrsearch": f"filetype:{filetype} {query}",
# imageinfo: https://commons.wikimedia.org/w/api.php?action=help&modules=query%2Bimageinfo
"iiprop": "url|size|mime",
"iiurlheight": "180", # needed for the thumb url
} }
params["url"] = f"{wc_api_url}?{urlencode(args, safe=':|')}"
params["url"] = f"{base_url}/w/api.php{search_prefix}&{urlencode(args, safe=':|')}"
return params
def response(resp): def response(resp: "SXNG_Response") -> EngineResults:
results = []
json = resp.json()
if not json.get("query", {}).get("pages"): res = EngineResults()
return results json_data = resp.json()
for item in json["query"]["pages"].values(): pages = json_data.get("query", {}).get("pages", {}).values()
for item in pages:
if not item.get("imageinfo", []):
continue
imageinfo = item["imageinfo"][0] imageinfo = item["imageinfo"][0]
title = item["title"].replace("File:", "").rsplit('.', 1)[0]
result = {
'url': imageinfo["descriptionurl"],
'title': title,
'content': html_to_text(item["snippet"]),
}
if search_type == "images": title: str = item["title"].replace("File:", "").rsplit(".", 1)[0]
result['template'] = 'images.html' content = html_to_text(item["snippet"])
result['img_src'] = imageinfo["url"]
result['thumbnail_src'] = imageinfo["thumburl"]
result['resolution'] = f'{imageinfo["width"]} x {imageinfo["height"]}'
else:
result['thumbnail'] = imageinfo["thumburl"]
if search_type == "videos": url: str = imageinfo["descriptionurl"]
result['template'] = 'videos.html' media_url: str = imageinfo["url"]
if imageinfo.get('duration'): mimetype: str = imageinfo["mime"]
result['length'] = datetime.timedelta(seconds=int(imageinfo['duration'])) thumbnail: str = imageinfo["thumburl"]
result['iframe_src'] = imageinfo['url'] size = imageinfo.get("size")
elif search_type == "files": if size:
result['template'] = 'files.html' size = humanize_bytes(size)
result['metadata'] = imageinfo['mime']
result['size'] = humanize_bytes(imageinfo['size'])
elif search_type == "audio":
result['iframe_src'] = imageinfo['url']
results.append(result) duration = None
seconds: str = imageinfo.get("duration")
if seconds:
try:
duration = datetime.timedelta(seconds=int(seconds))
except OverflowError:
pass
return results if wc_search_type == "file":
res.add(
res.types.File(
title=title,
url=url,
content=content,
size=size,
mimetype=mimetype,
filename=unquote(pathlib.Path(media_url).name),
embedded=media_url,
thumbnail=thumbnail,
)
)
continue
if wc_search_type == "image":
res.add(
res.types.LegacyResult(
template="images.html",
title=title,
url=url,
content=content,
img_src=imageinfo["url"],
thumbnail_src=thumbnail,
resolution=f"{imageinfo['width']} x {imageinfo['height']}",
img_format=imageinfo["mime"],
filesize=size,
)
)
continue
if wc_search_type == "video":
res.add(
res.types.LegacyResult(
template="videos.html",
title=title,
url=url,
content=content,
iframe_src=media_url,
length=duration,
)
)
continue
if wc_search_type == "audio":
res.add(
res.types.MainResult(
template="default.html",
title=title,
url=url,
content=content,
audio_src=media_url,
length=duration,
)
)
continue
return res

View File

@@ -23,6 +23,7 @@ __all__ = [
"WeatherAnswer", "WeatherAnswer",
"Code", "Code",
"Paper", "Paper",
"File",
] ]
import typing as t import typing as t
@@ -33,6 +34,7 @@ from .answer import AnswerSet, Answer, Translations, WeatherAnswer
from .keyvalue import KeyValue from .keyvalue import KeyValue
from .code import Code from .code import Code
from .paper import Paper from .paper import Paper
from .file import File
class ResultList(list[Result | LegacyResult], abc.ABC): class ResultList(list[Result | LegacyResult], abc.ABC):
@@ -47,6 +49,7 @@ class ResultList(list[Result | LegacyResult], abc.ABC):
KeyValue = KeyValue KeyValue = KeyValue
Code = Code Code = Code
Paper = Paper Paper = Paper
File = File
MainResult = MainResult MainResult = MainResult
Result = Result Result = Result
Translations = Translations Translations = Translations

View File

@@ -27,7 +27,6 @@ import typing as t
import re import re
import urllib.parse import urllib.parse
import warnings import warnings
import time
import datetime import datetime
from collections.abc import Callable from collections.abc import Callable
@@ -236,13 +235,6 @@ class Result(msgspec.Struct, kw_only=True):
url: str | None = None url: str | None = None
"""A link related to this *result*""" """A link related to this *result*"""
template: str = "default.html"
"""Name of the template used to render the result.
By default :origin:`result_templates/default.html
<searx/templates/simple/result_templates/default.html>` is used.
"""
engine: str | None = "" engine: str | None = ""
"""Name of the engine *this* result comes from. In case of *plugins* a """Name of the engine *this* result comes from. In case of *plugins* a
prefix ``plugin:`` is set, in case of *answerer* prefix ``answerer:`` is prefix ``plugin:`` is set, in case of *answerer* prefix ``answerer:`` is
@@ -350,6 +342,13 @@ class Result(msgspec.Struct, kw_only=True):
class MainResult(Result): # pylint: disable=missing-class-docstring class MainResult(Result): # pylint: disable=missing-class-docstring
"""Base class of all result types displayed in :ref:`area main results`.""" """Base class of all result types displayed in :ref:`area main results`."""
template: str = "default.html"
"""Name of the template used to render the result.
By default :origin:`result_templates/default.html
<searx/templates/simple/result_templates/default.html>` is used.
"""
title: str = "" title: str = ""
"""Link title of the result item.""" """Link title of the result item."""
@@ -359,6 +358,12 @@ class MainResult(Result): # pylint: disable=missing-class-docstring
img_src: str = "" img_src: str = ""
"""URL of a image that is displayed in the result item.""" """URL of a image that is displayed in the result item."""
iframe_src: str = ""
"""URL of an embedded ``<iframe>`` / the frame is collapsible."""
audio_src: str = ""
"""URL of an embedded ``<audio controls>``."""
thumbnail: str = "" thumbnail: str = ""
"""URL of a thumbnail that is displayed in the result item.""" """URL of a thumbnail that is displayed in the result item."""
@@ -372,7 +377,7 @@ class MainResult(Result): # pylint: disable=missing-class-docstring
completely eliminated. completely eliminated.
""" """
length: time.struct_time | None = None length: datetime.timedelta | None = None
"""Playing duration in seconds.""" """Playing duration in seconds."""
views: str = "" views: str = ""

View File

@@ -0,0 +1,94 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""
Typification of the *file* results. Results of this type are rendered in
the :origin:`file.html <searx/templates/simple/result_templates/file.html>`
template.
----
.. autoclass:: File
:members:
:show-inheritance:
"""
# pylint: disable=too-few-public-methods
__all__ = ["File"]
import typing as t
import mimetypes
from ._base import MainResult
@t.final
class File(MainResult, kw_only=True):
"""Class for results of type *file*"""
template: str = "file.html"
filename: str = ""
"""Name of the file."""
size: str = ""
"""Size of bytes in human readable notation (``MB`` for 1024 * 1024 Bytes
file size.)"""
time: str = ""
"""Indication of a time, such as the date of the last modification or the
date of creation. This is a simple string, the *date* of which can be freely
chosen according to the context."""
mimetype: str = ""
"""Mimetype/Subtype of the file. For ``audio`` and ``video``, a URL can be
passed in the :py:obj:`File.embedded` field to embed the referenced media in
the result. If no value is specified, the MIME type is determined from
``self.filename`` or, alternatively, from ``self.embedded`` (if either of
the two values is set)."""
abstract: str = ""
"""Abstract of the file."""
author: str = ""
"""Author of the file."""
embedded: str = ""
"""URL of an embedded media type (audio or video) / is collapsible."""
mtype: str = ""
"""Used for displaying :py:obj:`File.embedded`. Its value is automatically
populated from the base type of :py:obj:`File.mimetype`, and can be
explicitly set to enforce e.g. ``audio`` or ``video`` when mimetype is
something like "application/ogg" but its know the content is for example a
video."""
subtype: str = ""
"""Used for displaying :py:obj:`File.embedded`. Its value is automatically
populated from the subtype type of :py:obj:`File.mimetype`, and can be
explicitly set to enforce a subtype for the :py:obj:`File.embedded`
element."""
def __post_init__(self):
super().__post_init__()
if not self.mtype or not self.subtype:
fn = self.filename or self.embedded
if not self.mimetype and fn:
self.mimetype = mimetypes.guess_type(fn, strict=False)[0] or ""
mtype, subtype = (self.mimetype.split("/", 1) + [""])[:2]
if not self.mtype:
# I don't know why, but the ogg video stream is not displayed,
# may https://github.com/videojs/video.js can help?
if self.embedded.endswith(".ogv"):
self.mtype = "video"
elif self.embedded.endswith(".oga"):
self.mtype = "audio"
else:
self.mtype = mtype
if not self.subtype:
self.subtype = subtype

View File

@@ -2298,31 +2298,27 @@ engines:
- name: wikicommons.images - name: wikicommons.images
engine: wikicommons engine: wikicommons
shortcut: wc shortcut: wci
categories: images categories: images
search_type: images wc_search_type: image
number_of_results: 10
- name: wikicommons.videos - name: wikicommons.videos
engine: wikicommons engine: wikicommons
shortcut: wcv shortcut: wcv
categories: videos categories: videos
search_type: videos wc_search_type: video
number_of_results: 10
- name: wikicommons.audio - name: wikicommons.audio
engine: wikicommons engine: wikicommons
shortcut: wca shortcut: wca
categories: music categories: music
search_type: audio wc_search_type: audio
number_of_results: 10
- name: wikicommons.files - name: wikicommons.files
engine: wikicommons engine: wikicommons
shortcut: wcf shortcut: wcf
categories: files categories: files
search_type: files wc_search_type: file
number_of_results: 10
- name: wolframalpha - name: wolframalpha
shortcut: wa shortcut: wa

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -3,7 +3,7 @@
{{ result_header(result, favicons, image_proxify) -}} {{ result_header(result, favicons, image_proxify) -}}
{{- result_sub_header(result) -}} {{- result_sub_header(result) -}}
{% if result.iframe_src -%} {% if result.iframe_src -%}
<p class="altlink"><a class="btn-collapse collapsed media-loader disabled_if_nojs" data-target="#result-media-{{ index }}" data-btn-text-collapsed="{{ _('show media') }}" data-btn-text-not-collapsed="{{ _('hide media') }}">{{ icon('music-note') }} {{ _('show media') }}</a></p> <p class="altlink"><a class="btn-collapse collapsed media-loader disabled_if_nojs" data-target="#result-media-{{ index }}" data-btn-text-collapsed="{{ _('show media') }}" data-btn-text-not-collapsed="{{ _('hide media') }}">{{ icon('play') }} {{ _('show media') }}</a></p>
{%- endif %} {%- endif %}
{%- if result.content %} {%- if result.content %}
<p class="content"> <p class="content">

View File

@@ -0,0 +1,74 @@
{% from "simple/macros.html" import result_header, result_sub_header, result_sub_footer, result_footer, result_link with context %}
{% from "simple/icons.html" import icon_small %}
{{ result_header(result, favicons, image_proxify) }}
{{ result_sub_header(result) }}
{% if result.abstract %}
<p class="abstract">{{ result.abstract|safe }}</p>
{% endif -%}
{%- if result.content %}
<p class="content">{{ result.content|safe }}</p>
{% endif -%}
<div class="attributes">
{% if result.author %}
<div>
<span>{{ _("Author") }}:</span>
<span>{{ result.author }}</span>
</div>
{% endif %}
{% if result.filename %}
<div>
<span>{{ _("Filename") }}:</span>
<span>{{ result.filename }}</span>
</div>
{% endif %}
{% if result.size %}
<div>
<span>{{ _("Filesize") }}:</span>
<span>{{ result.size }}</span>
</div>
{% endif %}
{% if result.time %}
<div>
<span>{{ _("Date") }}:</span>
<span>{{ result.time }}</span>
</div>
{% endif %}
{% if result.mimetype %}
<div>
<span>{{ _("Type") }}:</span>
<span>{{ result.mimetype }}</span>
</div>
{% endif %}
</div>
{% if result.embedded %}
{% if result.mtype in ("audio", "video") %}
<p class="altlink">
<a class="btn-collapse collapsed media-loader disabled_if_nojs"
data-target="#result-media-{{ index }}"
data-btn-text-collapsed="{{ _("show media") }}"
data-btn-text-not-collapsed="{{ _("hide media") }}"
>
{{ _("show media") }}
</a>
</p>
<div id="result-media-{{ index }}" class="embedded-{{ result.mtype }} invisible">
<{{ result.mtype }} controls preload="metadata" {% if result.thumbnail %}poster="{{ result.thumbnail }}" {% endif %}>
<source src="{{result.embedded}}" type="{{ result.mtype }}/{{ result.subtype }}">
</{{ result.mtype }}>
</div>
{% else %}
<p class="altlink">
<a href="{{result.embedded }}" target="_blank" rel="noopener noreferrer" download>
{{ _("Download") }}
</a>
</p>
{% endif %}
{% endif %}
{{ result_sub_footer(result) }}
{{ result_footer(result) }}

View File

@@ -1,45 +0,0 @@
{% from 'simple/macros.html' import result_header, result_sub_header, result_sub_footer, result_footer, result_link with context %}
{% from 'simple/icons.html' import icon_small %}
{{- result_header(result, favicons, image_proxify) -}}
{{- result_sub_header(result) -}}
{%- if result.embedded -%}
<small> &bull; <a class="text-info btn-collapse collapsed cursor-pointer media-loader disabled_if_nojs" data-toggle="collapse" data-target="#result-media-{{ index }}" data-btn-text-collapsed="{{ _('show media') }}" data-btn-text-not-collapsed="{{ _('hide media') }}">
{%- if result.mtype == 'audio' %}{{ icon_small('musical-notes') -}}
{%- elif result.mtype == 'video' %} {{ icon_small('play') -}}
{%- endif %} {{ _('show media') }}</a></small>
{%- endif -%}
{%- if result.embedded -%}
<div id="result-media-{{ index }}" class="collapse invisible">
{{- result.embedded|safe -}}
</div>
{%- endif -%}
{%- if result.abstract %}<p class="result-content result-abstract">{{ result.abstract|safe }}</p>{% endif -%}
{%- if result.img_src -%}
<div class="container-fluid">
<div class="row">
<img src="{{ image_proxify(result.img_src) }}" alt="{{ result.title|striptags }}" title="{{ result.title|striptags }}" style="width: auto; max-height: 60px; min-height: 60px;" class="col-xs-2 col-sm-4 col-md-4 result-content">
{%- if result.content %}<p class="result-content col-xs-8 col-sm-8 col-md-8">{{ result.content|safe }}</p>{% endif -%}
</div>
</div>
{%- else -%}
{%- if result.content %}<p class="result-content">{{ result.content|safe }}</p>{% endif -%}
{%- endif -%}
<table class="result-metadata result-content">
{%- if result.author %}<tr><td>{{ _('Author') }}</td><td>{{ result.author|safe }}</td></tr>{% endif -%}
{%- if result.filename %}<tr><td>{{ _('Filename') }}</td><td>{{ result.filename|safe }}</td></tr>{% endif -%}
{%- if result.size %}<tr><td>{{ _('Filesize') }}</td><td>{{ result.size|safe }}</td></tr>{%- endif -%}
{%- if result.time %}<tr><td>{{ _('Date') }}</td><td>{{ result.time|safe }}</td></tr>{% endif -%}
{%- if result.mtype %}<tr><td>{{ _('Type') }}</td><td>{{ result.mtype|safe }}/{{ result.subtype|safe }}</td></tr>{% endif -%}
</table>
{{ result_footer(result) }}

View File

@@ -356,6 +356,12 @@ def get_pretty_url(parsed_url: urllib.parse.ParseResult):
path = parsed_url.path path = parsed_url.path
path = path[:-1] if len(path) > 0 and path[-1] == '/' else path path = path[:-1] if len(path) > 0 and path[-1] == '/' else path
path = unquote(path.replace("/", " ")) path = unquote(path.replace("/", " "))
# Keep the query argument for URLs like:
# - 'http://example.org?/foo/bar' --> parsed_url.query is 'foo/bar'
query_args: list[tuple[str, str]] = list(urllib.parse.parse_qsl(parsed_url.query))
if not query_args and parsed_url.query:
path += (" .." if len(parsed_url.query) > 24 else " ") + parsed_url.query[-24:]
return [parsed_url.scheme + "://" + parsed_url.netloc, path] return [parsed_url.scheme + "://" + parsed_url.netloc, path]

View File

@@ -492,6 +492,7 @@ pip install -U pip
pip install -U setuptools pip install -U setuptools
pip install -U wheel pip install -U wheel
pip install -U pyyaml pip install -U pyyaml
pip install -U msgspec
cd ${SEARXNG_SRC} cd ${SEARXNG_SRC}
pip install --use-pep517 --no-build-isolation -e . pip install --use-pep517 --no-build-isolation -e .
EOF EOF