_core.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304
  1. # SPDX-License-Identifier: AGPL-3.0-or-later
  2. # pylint: disable=too-few-public-methods,missing-module-docstring
  3. from __future__ import annotations
  4. __all__ = ["PluginInfo", "Plugin", "PluginCfg", "PluginStorage"]
  5. import abc
  6. import importlib
  7. import inspect
  8. import logging
  9. import re
  10. import typing
  11. from dataclasses import dataclass, field
  12. from searx.extended_types import SXNG_Request
  13. from searx.result_types import Result
  14. if typing.TYPE_CHECKING:
  15. from searx.search import SearchWithPlugins
  16. import flask
  17. log: logging.Logger = logging.getLogger("searx.plugins")
  18. @dataclass
  19. class PluginInfo:
  20. """Object that holds informations about a *plugin*, these infos are shown to
  21. the user in the Preferences menu.
  22. To be able to translate the information into other languages, the text must
  23. be written in English and translated with :py:obj:`flask_babel.gettext`.
  24. """
  25. id: str
  26. """The ID-selector in HTML/CSS `#<id>`."""
  27. name: str
  28. """Name of the *plugin*."""
  29. description: str
  30. """Short description of the *answerer*."""
  31. preference_section: typing.Literal["general", "ui", "privacy", "query"] | None = "general"
  32. """Section (tab/group) in the preferences where this plugin is shown to the
  33. user.
  34. The value ``query`` is reserved for plugins that are activated via a
  35. *keyword* as part of a search query, see:
  36. - :py:obj:`PluginInfo.examples`
  37. - :py:obj:`Plugin.keywords`
  38. Those plugins are shown in the preferences in tab *Special Queries*.
  39. """
  40. examples: list[str] = field(default_factory=list)
  41. """List of short examples of the usage / of query terms."""
  42. keywords: list[str] = field(default_factory=list)
  43. """See :py:obj:`Plugin.keywords`"""
  44. ID_REGXP = re.compile("[a-z][a-z0-9].*")
  45. class Plugin(abc.ABC):
  46. """Abstract base class of all Plugins."""
  47. id: str = ""
  48. """The ID (suffix) in the HTML form."""
  49. active: typing.ClassVar[bool]
  50. """Plugin is enabled/disabled by default (:py:obj:`PluginCfg.active`)."""
  51. keywords: list[str] = []
  52. """Keywords in the search query that activate the plugin. The *keyword* is
  53. the first word in a search query. If a plugin should be executed regardless
  54. of the search query, the list of keywords should be empty (which is also the
  55. default in the base class for Plugins)."""
  56. log: logging.Logger
  57. """A logger object, is automatically initialized when calling the
  58. constructor (if not already set in the subclass)."""
  59. info: PluginInfo
  60. """Informations about the *plugin*, see :py:obj:`PluginInfo`."""
  61. fqn: str = ""
  62. def __init__(self, plg_cfg: PluginCfg) -> None:
  63. super().__init__()
  64. if not self.fqn:
  65. self.fqn = self.__class__.__mro__[0].__module__
  66. # names from the configuration
  67. for n, v in plg_cfg.__dict__.items():
  68. setattr(self, n, v)
  69. # names that must be set by the plugin implementation
  70. for attr in [
  71. "id",
  72. ]:
  73. if getattr(self, attr, None) is None:
  74. raise NotImplementedError(f"plugin {self} is missing attribute {attr}")
  75. if not ID_REGXP.match(self.id):
  76. raise ValueError(f"plugin ID {self.id} contains invalid character (use lowercase ASCII)")
  77. if not getattr(self, "log", None):
  78. pkg_name = inspect.getmodule(self.__class__).__package__ # type: ignore
  79. self.log = logging.getLogger(f"{pkg_name}.{self.id}")
  80. def __hash__(self) -> int:
  81. """The hash value is used in :py:obj:`set`, for example, when an object
  82. is added to the set. The hash value is also used in other contexts,
  83. e.g. when checking for equality to identify identical plugins from
  84. different sources (name collisions)."""
  85. return id(self)
  86. def __eq__(self, other):
  87. """py:obj:`Plugin` objects are equal if the hash values of the two
  88. objects are equal."""
  89. return hash(self) == hash(other)
  90. def init(self, app: "flask.Flask") -> bool: # pylint: disable=unused-argument
  91. """Initialization of the plugin, the return value decides whether this
  92. plugin is active or not. Initialization only takes place once, at the
  93. time the WEB application is set up. The base methode always returns
  94. ``True``, the methode can be overwritten in the inheritances,
  95. - ``True`` plugin is active
  96. - ``False`` plugin is inactive
  97. """
  98. return True
  99. # pylint: disable=unused-argument
  100. def pre_search(self, request: SXNG_Request, search: "SearchWithPlugins") -> bool:
  101. """Runs BEFORE the search request and returns a boolean:
  102. - ``True`` to continue the search
  103. - ``False`` to stop the search
  104. """
  105. return True
  106. def on_result(self, request: SXNG_Request, search: "SearchWithPlugins", result: Result) -> bool:
  107. """Runs for each result of each engine and returns a boolean:
  108. - ``True`` to keep the result
  109. - ``False`` to remove the result from the result list
  110. The ``result`` can be modified to the needs.
  111. .. hint::
  112. If :py:obj:`Result.url <searx.result_types._base.Result.url>` is modified,
  113. :py:obj:`Result.parsed_url <searx.result_types._base.Result.parsed_url>` must
  114. be changed accordingly:
  115. .. code:: python
  116. result["parsed_url"] = urlparse(result["url"])
  117. """
  118. return True
  119. def post_search(self, request: SXNG_Request, search: "SearchWithPlugins") -> None | typing.Sequence[Result]:
  120. """Runs AFTER the search request. Can return a list of
  121. :py:obj:`Result <searx.result_types._base.Result>` objects to be added to the
  122. final result list."""
  123. return
  124. @dataclass
  125. class PluginCfg:
  126. """Settings of a plugin.
  127. .. code:: yaml
  128. mypackage.mymodule.MyPlugin:
  129. active: true
  130. """
  131. active: bool = False
  132. """Plugin is active by default and the user can *opt-out* in the preferences."""
  133. class PluginStorage:
  134. """A storage for managing the *plugins* of SearXNG."""
  135. plugin_list: set[Plugin]
  136. """The list of :py:obj:`Plugins` in this storage."""
  137. def __init__(self):
  138. self.plugin_list = set()
  139. def __iter__(self):
  140. yield from self.plugin_list
  141. def __len__(self):
  142. return len(self.plugin_list)
  143. @property
  144. def info(self) -> list[PluginInfo]:
  145. return [p.info for p in self.plugin_list]
  146. def load_settings(self, cfg: dict[str, dict]):
  147. """Load plugins configured in SearXNG's settings :ref:`settings
  148. plugins`."""
  149. for fqn, plg_settings in cfg.items():
  150. cls = None
  151. mod_name, cls_name = fqn.rsplit('.', 1)
  152. try:
  153. mod = importlib.import_module(mod_name)
  154. cls = getattr(mod, cls_name, None)
  155. except Exception as exc: # pylint: disable=broad-exception-caught
  156. log.exception(exc)
  157. if cls is None:
  158. msg = f"plugin {fqn} is not implemented"
  159. raise ValueError(msg)
  160. plg = cls(PluginCfg(**plg_settings))
  161. self.register(plg)
  162. def register(self, plugin: Plugin):
  163. """Register a :py:obj:`Plugin`. In case of name collision (if two
  164. plugins have same ID) a :py:obj:`KeyError` exception is raised.
  165. """
  166. if plugin in [p.id for p in self.plugin_list]:
  167. msg = f"name collision '{plugin.id}'"
  168. plugin.log.critical(msg)
  169. raise KeyError(msg)
  170. self.plugin_list.add(plugin)
  171. plugin.log.debug("plugin has been loaded")
  172. def init(self, app: "flask.Flask") -> None:
  173. """Calls the method :py:obj:`Plugin.init` of each plugin in this
  174. storage. Depending on its return value, the plugin is removed from
  175. *this* storage or not."""
  176. for plg in self.plugin_list.copy():
  177. if not plg.init(app):
  178. self.plugin_list.remove(plg)
  179. def pre_search(self, request: SXNG_Request, search: "SearchWithPlugins") -> bool:
  180. ret = True
  181. for plugin in [p for p in self.plugin_list if p.id in search.user_plugins]:
  182. try:
  183. ret = bool(plugin.pre_search(request=request, search=search))
  184. except Exception: # pylint: disable=broad-except
  185. plugin.log.exception("Exception while calling pre_search")
  186. continue
  187. if not ret:
  188. # skip this search on the first False from a plugin
  189. break
  190. return ret
  191. def on_result(self, request: SXNG_Request, search: "SearchWithPlugins", result: Result) -> bool:
  192. ret = True
  193. for plugin in [p for p in self.plugin_list if p.id in search.user_plugins]:
  194. try:
  195. ret = bool(plugin.on_result(request=request, search=search, result=result))
  196. except Exception: # pylint: disable=broad-except
  197. plugin.log.exception("Exception while calling on_result")
  198. continue
  199. if not ret:
  200. # ignore this result item on the first False from a plugin
  201. break
  202. return ret
  203. def post_search(self, request: SXNG_Request, search: "SearchWithPlugins") -> None:
  204. """Extend :py:obj:`search.result_container
  205. <searx.results.ResultContainer`> with result items from plugins listed
  206. in :py:obj:`search.user_plugins <SearchWithPlugins.user_plugins>`.
  207. """
  208. keyword = None
  209. for keyword in search.search_query.query.split():
  210. if keyword:
  211. break
  212. for plugin in [p for p in self.plugin_list if p.id in search.user_plugins]:
  213. if plugin.keywords:
  214. # plugin with keywords: skip plugin if no keyword match
  215. if keyword and keyword not in plugin.keywords:
  216. continue
  217. try:
  218. results = plugin.post_search(request=request, search=search) or []
  219. except Exception: # pylint: disable=broad-except
  220. plugin.log.exception("Exception while calling post_search")
  221. continue
  222. # In case of *plugins* prefix ``plugin:`` is set, see searx.result_types.Result
  223. search.result_container.extend(f"plugin: {plugin.id}", results)