mirror of
https://github.com/parchlinuxB/Gitee.git
synced 2025-02-23 18:35:43 -05:00
395 lines
13 KiB
Python
395 lines
13 KiB
Python
|
# 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)
|