_core.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427
  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", "PluginStorage"]
  5. import abc
  6. import importlib
  7. import logging
  8. import pathlib
  9. import types
  10. import typing
  11. import warnings
  12. from dataclasses import dataclass, field
  13. import flask
  14. import searx
  15. from searx.utils import load_module
  16. from searx.extended_types import SXNG_Request
  17. from searx.result_types import Result
  18. if typing.TYPE_CHECKING:
  19. from searx.search import SearchWithPlugins
  20. _default = pathlib.Path(__file__).parent
  21. log: logging.Logger = logging.getLogger("searx.plugins")
  22. @dataclass
  23. class PluginInfo:
  24. """Object that holds informations about a *plugin*, these infos are shown to
  25. the user in the Preferences menu.
  26. To be able to translate the information into other languages, the text must
  27. be written in English and translated with :py:obj:`flask_babel.gettext`.
  28. """
  29. id: str
  30. """The ID-selector in HTML/CSS `#<id>`."""
  31. name: str
  32. """Name of the *plugin*."""
  33. description: str
  34. """Short description of the *answerer*."""
  35. preference_section: typing.Literal["general", "ui", "privacy", "query"] | None = "general"
  36. """Section (tab/group) in the preferences where this plugin is shown to the
  37. user.
  38. The value ``query`` is reserved for plugins that are activated via a
  39. *keyword* as part of a search query, see:
  40. - :py:obj:`PluginInfo.examples`
  41. - :py:obj:`Plugin.keywords`
  42. Those plugins are shown in the preferences in tab *Special Queries*.
  43. """
  44. examples: list[str] = field(default_factory=list)
  45. """List of short examples of the usage / of query terms."""
  46. keywords: list[str] = field(default_factory=list)
  47. """See :py:obj:`Plugin.keywords`"""
  48. class Plugin(abc.ABC):
  49. """Abstract base class of all Plugins."""
  50. id: str = ""
  51. """The ID (suffix) in the HTML form."""
  52. default_on: bool = False
  53. """Plugin is enabled/disabled by default."""
  54. keywords: list[str] = []
  55. """Keywords in the search query that activate the plugin. The *keyword* is
  56. the first word in a search query. If a plugin should be executed regardless
  57. of the search query, the list of keywords should be empty (which is also the
  58. default in the base class for Plugins)."""
  59. log: logging.Logger
  60. """A logger object, is automatically initialized when calling the
  61. constructor (if not already set in the subclass)."""
  62. info: PluginInfo
  63. """Informations about the *plugin*, see :py:obj:`PluginInfo`."""
  64. fqn: str = ""
  65. def __init__(self) -> None:
  66. super().__init__()
  67. if not self.fqn:
  68. self.fqn = self.__class__.__mro__[0].__module__
  69. for attr in ["id", "default_on"]:
  70. if getattr(self, attr, None) is None:
  71. raise NotImplementedError(f"plugin {self} is missing attribute {attr}")
  72. if not self.id:
  73. self.id = f"{self.__class__.__module__}.{self.__class__.__name__}"
  74. if not getattr(self, "log", None):
  75. self.log = log.getChild(self.id)
  76. def __hash__(self) -> int:
  77. """The hash value is used in :py:obj:`set`, for example, when an object
  78. is added to the set. The hash value is also used in other contexts,
  79. e.g. when checking for equality to identify identical plugins from
  80. different sources (name collisions)."""
  81. return id(self)
  82. def __eq__(self, other):
  83. """py:obj:`Plugin` objects are equal if the hash values of the two
  84. objects are equal."""
  85. return hash(self) == hash(other)
  86. def init(self, app: flask.Flask) -> bool: # pylint: disable=unused-argument
  87. """Initialization of the plugin, the return value decides whether this
  88. plugin is active or not. Initialization only takes place once, at the
  89. time the WEB application is set up. The base methode always returns
  90. ``True``, the methode can be overwritten in the inheritances,
  91. - ``True`` plugin is active
  92. - ``False`` plugin is inactive
  93. """
  94. return True
  95. # pylint: disable=unused-argument
  96. def pre_search(self, request: SXNG_Request, search: "SearchWithPlugins") -> bool:
  97. """Runs BEFORE the search request and returns a boolean:
  98. - ``True`` to continue the search
  99. - ``False`` to stop the search
  100. """
  101. return True
  102. def on_result(self, request: SXNG_Request, search: "SearchWithPlugins", result: Result) -> bool:
  103. """Runs for each result of each engine and returns a boolean:
  104. - ``True`` to keep the result
  105. - ``False`` to remove the result from the result list
  106. The ``result`` can be modified to the needs.
  107. .. hint::
  108. If :py:obj:`Result.url` is modified, :py:obj:`Result.parsed_url` must
  109. be changed accordingly:
  110. .. code:: python
  111. result["parsed_url"] = urlparse(result["url"])
  112. """
  113. return True
  114. def post_search(self, request: SXNG_Request, search: "SearchWithPlugins") -> None | typing.Sequence[Result]:
  115. """Runs AFTER the search request. Can return a list of :py:obj:`Result`
  116. objects to be added to the final result list."""
  117. return
  118. class ModulePlugin(Plugin):
  119. """A wrapper class for legacy *plugins*.
  120. .. note::
  121. For internal use only!
  122. In a module plugin, the follwing names are mapped:
  123. - `module.query_keywords` --> :py:obj:`Plugin.keywords`
  124. - `module.plugin_id` --> :py:obj:`Plugin.id`
  125. - `module.logger` --> :py:obj:`Plugin.log`
  126. """
  127. _required_attrs = (("name", str), ("description", str), ("default_on", bool))
  128. def __init__(self, mod: types.ModuleType, fqn: str):
  129. """In case of missing attributes in the module or wrong types are given,
  130. a :py:obj:`TypeError` exception is raised."""
  131. self.fqn = fqn
  132. self.module = mod
  133. self.id = getattr(self.module, "plugin_id", self.module.__name__)
  134. self.log = logging.getLogger(self.module.__name__)
  135. self.keywords = getattr(self.module, "query_keywords", [])
  136. for attr, attr_type in self._required_attrs:
  137. if not hasattr(self.module, attr):
  138. msg = f"missing attribute {attr}, cannot load plugin"
  139. self.log.critical(msg)
  140. raise TypeError(msg)
  141. if not isinstance(getattr(self.module, attr), attr_type):
  142. msg = f"attribute {attr} is not of type {attr_type}"
  143. self.log.critical(msg)
  144. raise TypeError(msg)
  145. self.default_on = mod.default_on
  146. self.info = PluginInfo(
  147. id=self.id,
  148. name=self.module.name,
  149. description=self.module.description,
  150. preference_section=getattr(self.module, "preference_section", None),
  151. examples=getattr(self.module, "query_examples", []),
  152. keywords=self.keywords,
  153. )
  154. # monkeypatch module
  155. self.module.logger = self.log # type: ignore
  156. super().__init__()
  157. def init(self, app: flask.Flask) -> bool:
  158. if not hasattr(self.module, "init"):
  159. return True
  160. return self.module.init(app)
  161. def pre_search(self, request: SXNG_Request, search: "SearchWithPlugins") -> bool:
  162. if not hasattr(self.module, "pre_search"):
  163. return True
  164. return self.module.pre_search(request, search)
  165. def on_result(self, request: SXNG_Request, search: "SearchWithPlugins", result: Result) -> bool:
  166. if not hasattr(self.module, "on_result"):
  167. return True
  168. return self.module.on_result(request, search, result)
  169. def post_search(self, request: SXNG_Request, search: "SearchWithPlugins") -> None | list[Result]:
  170. if not hasattr(self.module, "post_search"):
  171. return None
  172. return self.module.post_search(request, search)
  173. class PluginStorage:
  174. """A storage for managing the *plugins* of SearXNG."""
  175. plugin_list: set[Plugin]
  176. """The list of :py:obj:`Plugins` in this storage."""
  177. legacy_plugins = [
  178. "ahmia_filter",
  179. "calculator",
  180. "hostnames",
  181. "oa_doi_rewrite",
  182. "tor_check",
  183. "tracker_url_remover",
  184. "unit_converter",
  185. ]
  186. """Internal plugins implemented in the legacy style (as module / deprecated!)."""
  187. def __init__(self):
  188. self.plugin_list = set()
  189. def __iter__(self):
  190. yield from self.plugin_list
  191. def __len__(self):
  192. return len(self.plugin_list)
  193. @property
  194. def info(self) -> list[PluginInfo]:
  195. return [p.info for p in self.plugin_list]
  196. def load_builtins(self):
  197. """Load plugin modules from:
  198. - the python packages in :origin:`searx/plugins` and
  199. - the external plugins from :ref:`settings plugins`.
  200. """
  201. for f in _default.iterdir():
  202. if f.name.startswith("_"):
  203. continue
  204. if f.stem not in self.legacy_plugins:
  205. self.register_by_fqn(f"searx.plugins.{f.stem}.SXNGPlugin")
  206. continue
  207. # for backward compatibility
  208. mod = load_module(f.name, str(f.parent))
  209. self.register(ModulePlugin(mod, f"searx.plugins.{f.stem}"))
  210. for fqn in searx.get_setting("plugins"): # type: ignore
  211. self.register_by_fqn(fqn)
  212. def register(self, plugin: Plugin):
  213. """Register a :py:obj:`Plugin`. In case of name collision (if two
  214. plugins have same ID) a :py:obj:`KeyError` exception is raised.
  215. """
  216. if plugin in self.plugin_list:
  217. msg = f"name collision '{plugin.id}'"
  218. plugin.log.critical(msg)
  219. raise KeyError(msg)
  220. if not plugin.fqn.startswith("searx.plugins."):
  221. self.plugin_list.add(plugin)
  222. plugin.log.debug("plugin has been registered")
  223. return
  224. # backward compatibility for the enabled_plugins setting
  225. # https://docs.searxng.org/admin/settings/settings_plugins.html#enabled-plugins-internal
  226. en_plgs: list[str] | None = searx.get_setting("enabled_plugins") # type:ignore
  227. if en_plgs is None:
  228. # enabled_plugins not listed in the /etc/searxng/settings.yml:
  229. # check default_on before register ..
  230. if plugin.default_on:
  231. self.plugin_list.add(plugin)
  232. plugin.log.debug("builtin plugin has been registered by SearXNG's defaults")
  233. return
  234. plugin.log.debug("builtin plugin is not registered by SearXNG's defaults")
  235. return
  236. if plugin.info.name not in en_plgs:
  237. # enabled_plugins listed in the /etc/searxng/settings.yml,
  238. # but this plugin is not listed in:
  239. plugin.log.debug("builtin plugin is not registered by maintainer's settings")
  240. return
  241. # if the plugin is in enabled_plugins, then it is on by default.
  242. plugin.default_on = True
  243. self.plugin_list.add(plugin)
  244. plugin.log.debug("builtin plugin is registered by maintainer's settings")
  245. def register_by_fqn(self, fqn: str):
  246. """Register a :py:obj:`Plugin` via its fully qualified class name (FQN).
  247. The FQNs of external plugins could be read from a configuration, for
  248. example, and registered using this method
  249. """
  250. mod_name, _, obj_name = fqn.rpartition('.')
  251. if not mod_name:
  252. # for backward compatibility
  253. code_obj = importlib.import_module(fqn)
  254. else:
  255. mod = importlib.import_module(mod_name)
  256. code_obj = getattr(mod, obj_name, None)
  257. if code_obj is None:
  258. msg = f"plugin {fqn} is not implemented"
  259. log.critical(msg)
  260. raise ValueError(msg)
  261. if isinstance(code_obj, types.ModuleType):
  262. # for backward compatibility
  263. warnings.warn(
  264. f"plugin {fqn} is implemented in a legacy module / migrate to searx.plugins.Plugin", DeprecationWarning
  265. )
  266. self.register(ModulePlugin(code_obj, fqn))
  267. return
  268. self.register(code_obj())
  269. def init(self, app: flask.Flask) -> None:
  270. """Calls the method :py:obj:`Plugin.init` of each plugin in this
  271. storage. Depending on its return value, the plugin is removed from
  272. *this* storage or not."""
  273. for plg in self.plugin_list.copy():
  274. if not plg.init(app):
  275. self.plugin_list.remove(plg)
  276. def pre_search(self, request: SXNG_Request, search: "SearchWithPlugins") -> bool:
  277. ret = True
  278. for plugin in [p for p in self.plugin_list if p.id in search.user_plugins]:
  279. try:
  280. ret = bool(plugin.pre_search(request=request, search=search))
  281. except Exception: # pylint: disable=broad-except
  282. plugin.log.exception("Exception while calling pre_search")
  283. continue
  284. if not ret:
  285. # skip this search on the first False from a plugin
  286. break
  287. return ret
  288. def on_result(self, request: SXNG_Request, search: "SearchWithPlugins", result: Result) -> bool:
  289. ret = True
  290. for plugin in [p for p in self.plugin_list if p.id in search.user_plugins]:
  291. try:
  292. ret = bool(plugin.on_result(request=request, search=search, result=result))
  293. except Exception: # pylint: disable=broad-except
  294. plugin.log.exception("Exception while calling on_result")
  295. continue
  296. if not ret:
  297. # ignore this result item on the first False from a plugin
  298. break
  299. return ret
  300. def post_search(self, request: SXNG_Request, search: "SearchWithPlugins") -> None:
  301. """Extend :py:obj:`search.result_container
  302. <searx.results.ResultContainer`> with result items from plugins listed
  303. in :py:obj:`search.user_plugins <SearchWithPlugins.user_plugins>`.
  304. """
  305. keyword = None
  306. for keyword in search.search_query.query.split():
  307. if keyword:
  308. break
  309. for plugin in [p for p in self.plugin_list if p.id in search.user_plugins]:
  310. if plugin.keywords:
  311. # plugin with keywords: skip plugin if no keyword match
  312. if keyword and keyword not in plugin.keywords:
  313. continue
  314. try:
  315. results = plugin.post_search(request=request, search=search) or []
  316. except Exception: # pylint: disable=broad-except
  317. plugin.log.exception("Exception while calling post_search")
  318. continue
  319. # In case of *plugins* prefix ``plugin:`` is set, see searx.result_types.Result
  320. search.result_container.extend(f"plugin: {plugin.id}", results)