123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395 |
- # SPDX-License-Identifier: AGPL-3.0-or-later
- # pylint: disable=too-few-public-methods,missing-module-docstring
- from __future__ import annotations
- __all__ = ["PluginInfo", "Plugin", "PluginStorage"]
- import abc
- import importlib
- import logging
- import pathlib
- import types
- import typing
- import warnings
- from dataclasses import dataclass, field
- import flask
- import searx
- from searx.utils import load_module
- from searx.extended_types import SXNG_Request
- from searx.result_types import Result
- if typing.TYPE_CHECKING:
- from searx.search import SearchWithPlugins
- _default = pathlib.Path(__file__).parent
- log: logging.Logger = logging.getLogger("searx.plugins")
- @dataclass
- class PluginInfo:
- """Object that holds informations about a *plugin*, these infos are shown to
- the user in the Preferences menu.
- To be able to translate the information into other languages, the text must
- be written in English and translated with :py:obj:`flask_babel.gettext`.
- """
- id: str
- """The ID-selector in HTML/CSS `#<id>`."""
- name: str
- """Name of the *plugin*."""
- description: str
- """Short description of the *answerer*."""
- preference_section: typing.Literal["general", "ui", "privacy", "query"] | None = "general"
- """Section (tab/group) in the preferences where this plugin is shown to the
- user.
- The value ``query`` is reserved for plugins that are activated via a
- *keyword* as part of a search query, see:
- - :py:obj:`PluginInfo.examples`
- - :py:obj:`Plugin.keywords`
- Those plugins are shown in the preferences in tab *Special Queries*.
- """
- examples: list[str] = field(default_factory=list)
- """List of short examples of the usage / of query terms."""
- keywords: list[str] = field(default_factory=list)
- """See :py:obj:`Plugin.keywords`"""
- class Plugin(abc.ABC):
- """Abstract base class of all Plugins."""
- id: typing.ClassVar[str]
- """The ID (suffix) in the HTML form."""
- default_on: typing.ClassVar[bool]
- """Plugin is enabled/disabled by default."""
- keywords: list[str] = []
- """Keywords in the search query that activate the plugin. The *keyword* is
- the first word in a search query. If a plugin should be executed regardless
- of the search query, the list of keywords should be empty (which is also the
- default in the base class for Plugins)."""
- log: logging.Logger
- """A logger object, is automatically initialized when calling the
- constructor (if not already set in the subclass)."""
- info: PluginInfo
- """Informations about the *plugin*, see :py:obj:`PluginInfo`."""
- def __init__(self) -> None:
- super().__init__()
- for attr in ["id", "default_on"]:
- if getattr(self, attr, None) is None:
- raise NotImplementedError(f"plugin {self} is missing attribute {attr}")
- if not self.id:
- self.id = f"{self.__class__.__module__}.{self.__class__.__name__}"
- if not getattr(self, "log", None):
- self.log = log.getChild(self.id)
- def __hash__(self) -> int:
- """The hash value is used in :py:obj:`set`, for example, when an object
- is added to the set. The hash value is also used in other contexts,
- e.g. when checking for equality to identify identical plugins from
- different sources (name collisions)."""
- return id(self)
- def __eq__(self, other):
- """py:obj:`Plugin` objects are equal if the hash values of the two
- objects are equal."""
- return hash(self) == hash(other)
- def init(self, app: flask.Flask) -> bool: # pylint: disable=unused-argument
- """Initialization of the plugin, the return value decides whether this
- plugin is active or not. Initialization only takes place once, at the
- time the WEB application is set up. The base methode always returns
- ``True``, the methode can be overwritten in the inheritances,
- - ``True`` plugin is active
- - ``False`` plugin is inactive
- """
- return True
- # pylint: disable=unused-argument
- def pre_search(self, request: SXNG_Request, search: "SearchWithPlugins") -> bool:
- """Runs BEFORE the search request and returns a boolean:
- - ``True`` to continue the search
- - ``False`` to stop the search
- """
- return True
- def on_result(self, request: SXNG_Request, search: "SearchWithPlugins", result: Result) -> bool:
- """Runs for each result of each engine and returns a boolean:
- - ``True`` to keep the result
- - ``False`` to remove the result from the result list
- The ``result`` can be modified to the needs.
- .. hint::
- If :py:obj:`Result.url` is modified, :py:obj:`Result.parsed_url` must
- be changed accordingly:
- .. code:: python
- result["parsed_url"] = urlparse(result["url"])
- """
- return True
- def post_search(self, request: SXNG_Request, search: "SearchWithPlugins") -> None | typing.Sequence[Result]:
- """Runs AFTER the search request. Can return a list of :py:obj:`Result`
- objects to be added to the final result list."""
- return
- class ModulePlugin(Plugin):
- """A wrapper class for legacy *plugins*.
- .. note::
- For internal use only!
- In a module plugin, the follwing names are mapped:
- - `module.query_keywords` --> :py:obj:`Plugin.keywords`
- - `module.plugin_id` --> :py:obj:`Plugin.id`
- - `module.logger` --> :py:obj:`Plugin.log`
- """
- _required_attrs = (("name", str), ("description", str), ("default_on", bool))
- def __init__(self, mod: types.ModuleType):
- """In case of missing attributes in the module or wrong types are given,
- a :py:obj:`TypeError` exception is raised."""
- self.module = mod
- self.id = getattr(self.module, "plugin_id", self.module.__name__)
- self.log = logging.getLogger(self.module.__name__)
- self.keywords = getattr(self.module, "query_keywords", [])
- for attr, attr_type in self._required_attrs:
- if not hasattr(self.module, attr):
- msg = f"missing attribute {attr}, cannot load plugin"
- self.log.critical(msg)
- raise TypeError(msg)
- if not isinstance(getattr(self.module, attr), attr_type):
- msg = f"attribute {attr} is not of type {attr_type}"
- self.log.critical(msg)
- raise TypeError(msg)
- self.default_on = mod.default_on
- self.info = PluginInfo(
- id=self.id,
- name=self.module.name,
- description=self.module.description,
- preference_section=getattr(self.module, "preference_section", None),
- examples=getattr(self.module, "query_examples", []),
- keywords=self.keywords,
- )
- # monkeypatch module
- self.module.logger = self.log # type: ignore
- super().__init__()
- def init(self, app: flask.Flask) -> bool:
- if not hasattr(self.module, "init"):
- return True
- return self.module.init(app)
- def pre_search(self, request: SXNG_Request, search: "SearchWithPlugins") -> bool:
- if not hasattr(self.module, "pre_search"):
- return True
- return self.module.pre_search(request, search)
- def on_result(self, request: SXNG_Request, search: "SearchWithPlugins", result: Result) -> bool:
- if not hasattr(self.module, "on_result"):
- return True
- return self.module.on_result(request, search, result)
- def post_search(self, request: SXNG_Request, search: "SearchWithPlugins") -> None | list[Result]:
- if not hasattr(self.module, "post_search"):
- return None
- return self.module.post_search(request, search)
- class PluginStorage:
- """A storage for managing the *plugins* of SearXNG."""
- plugin_list: set[Plugin]
- """The list of :py:obj:`Plugins` in this storage."""
- legacy_plugins = [
- "ahmia_filter",
- "calculator",
- "hostnames",
- "oa_doi_rewrite",
- "tor_check",
- "tracker_url_remover",
- "unit_converter",
- ]
- """Internal plugins implemented in the legacy style (as module / deprecated!)."""
- def __init__(self):
- self.plugin_list = set()
- def __iter__(self):
- yield from self.plugin_list
- def __len__(self):
- return len(self.plugin_list)
- @property
- def info(self) -> list[PluginInfo]:
- return [p.info for p in self.plugin_list]
- def load_builtins(self):
- """Load plugin modules from:
- - the python packages in :origin:`searx/plugins` and
- - the external plugins from :ref:`settings plugins`.
- """
- for f in _default.iterdir():
- if f.name.startswith("_"):
- continue
- if f.stem not in self.legacy_plugins:
- self.register_by_fqn(f"searx.plugins.{f.stem}.SXNGPlugin")
- continue
- # for backward compatibility
- mod = load_module(f.name, str(f.parent))
- self.register(ModulePlugin(mod))
- for fqn in searx.get_setting("plugins"): # type: ignore
- self.register_by_fqn(fqn)
- def register(self, plugin: Plugin):
- """Register a :py:obj:`Plugin`. In case of name collision (if two
- plugins have same ID) a :py:obj:`KeyError` exception is raised.
- """
- if plugin in self.plugin_list:
- msg = f"name collision '{plugin.id}'"
- plugin.log.critical(msg)
- raise KeyError(msg)
- self.plugin_list.add(plugin)
- plugin.log.debug("plugin has been loaded")
- def register_by_fqn(self, fqn: str):
- """Register a :py:obj:`Plugin` via its fully qualified class name (FQN).
- The FQNs of external plugins could be read from a configuration, for
- example, and registered using this method
- """
- mod_name, _, obj_name = fqn.rpartition('.')
- if not mod_name:
- # for backward compatibility
- code_obj = importlib.import_module(fqn)
- else:
- mod = importlib.import_module(mod_name)
- code_obj = getattr(mod, obj_name, None)
- if code_obj is None:
- msg = f"plugin {fqn} is not implemented"
- log.critical(msg)
- raise ValueError(msg)
- if isinstance(code_obj, types.ModuleType):
- # for backward compatibility
- warnings.warn(
- f"plugin {fqn} is implemented in a legacy module / migrate to searx.plugins.Plugin", DeprecationWarning
- )
- self.register(ModulePlugin(code_obj))
- return
- self.register(code_obj())
- def init(self, app: flask.Flask) -> None:
- """Calls the method :py:obj:`Plugin.init` of each plugin in this
- storage. Depending on its return value, the plugin is removed from
- *this* storage or not."""
- for plg in self.plugin_list.copy():
- if not plg.init(app):
- self.plugin_list.remove(plg)
- def pre_search(self, request: SXNG_Request, search: "SearchWithPlugins") -> bool:
- ret = True
- for plugin in [p for p in self.plugin_list if p.id in search.user_plugins]:
- try:
- ret = bool(plugin.pre_search(request=request, search=search))
- except Exception: # pylint: disable=broad-except
- plugin.log.exception("Exception while calling pre_search")
- continue
- if not ret:
- # skip this search on the first False from a plugin
- break
- return ret
- def on_result(self, request: SXNG_Request, search: "SearchWithPlugins", result: Result) -> bool:
- ret = True
- for plugin in [p for p in self.plugin_list if p.id in search.user_plugins]:
- try:
- ret = bool(plugin.on_result(request=request, search=search, result=result))
- except Exception: # pylint: disable=broad-except
- plugin.log.exception("Exception while calling on_result")
- continue
- if not ret:
- # ignore this result item on the first False from a plugin
- break
- return ret
- def post_search(self, request: SXNG_Request, search: "SearchWithPlugins") -> None:
- """Extend :py:obj:`search.result_container
- <searx.results.ResultContainer`> with result items from plugins listed
- in :py:obj:`search.user_plugins <SearchWithPlugins.user_plugins>`.
- """
- keyword = None
- for keyword in search.search_query.query.split():
- if keyword:
- break
- for plugin in [p for p in self.plugin_list if p.id in search.user_plugins]:
- if plugin.keywords:
- # plugin with keywords: skip plugin if no keyword match
- if keyword and keyword not in plugin.keywords:
- continue
- try:
- results = plugin.post_search(request=request, search=search) or []
- except Exception: # pylint: disable=broad-except
- plugin.log.exception("Exception while calling post_search")
- continue
- # In case of *plugins* prefix ``plugin:`` is set, see searx.result_types.Result
- search.result_container.extend(f"plugin: {plugin.id}", results)
|