mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2025-11-02 07:37:25 +00:00
Work on plugins in general
This commit is contained in:
@@ -233,6 +233,9 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
|
||||
|
||||
# Only works reliably with Playwright
|
||||
|
||||
# Import the global plugin system
|
||||
from changedetectionio.pluggy_interface import collect_ui_edit_stats_extras
|
||||
|
||||
template_args = {
|
||||
'available_processors': processors.available_processors(),
|
||||
'available_timezones': sorted(available_timezones()),
|
||||
@@ -246,11 +249,12 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe
|
||||
'has_extra_headers_file': len(datastore.get_all_headers_in_textfile_for_watch(uuid=uuid)) > 0,
|
||||
'has_special_tag_options': _watch_has_tag_options_set(watch=watch),
|
||||
'jq_support': jq_support,
|
||||
'lev_info': levenshtein_ratio_recent_history(watch),
|
||||
#'lev_info': levenshtein_ratio_recent_history(watch),
|
||||
'playwright_enabled': os.getenv('PLAYWRIGHT_DRIVER_URL', False),
|
||||
'settings_application': datastore.data['settings']['application'],
|
||||
'system_has_playwright_configured': os.getenv('PLAYWRIGHT_DRIVER_URL'),
|
||||
'system_has_webdriver_configured': os.getenv('WEBDRIVER_URL'),
|
||||
'ui_edit_stats_extras': collect_ui_edit_stats_extras(watch),
|
||||
'visual_selector_data_ready': datastore.visualselector_data_is_ready(watch_uuid=uuid),
|
||||
'timezone_default_config': datastore.data['settings']['application'].get('timezone'),
|
||||
'using_global_webdriver_wait': not default['webdriver_delay'],
|
||||
|
||||
@@ -132,3 +132,18 @@ for plugin in plugin_manager.get_plugins():
|
||||
if isinstance(new_field_choices, list):
|
||||
field_choices.extend(new_field_choices)
|
||||
|
||||
def collect_ui_edit_stats_extras(watch):
|
||||
"""Collect and combine HTML content from all plugins that implement ui_edit_stats_extras"""
|
||||
extras_content = []
|
||||
|
||||
for plugin in plugin_manager.get_plugins():
|
||||
try:
|
||||
content = plugin.ui_edit_stats_extras(watch=watch)
|
||||
if content:
|
||||
extras_content.append(content)
|
||||
except Exception as e:
|
||||
# Skip plugins that don't implement the hook or have errors
|
||||
pass
|
||||
|
||||
return "\n".join(extras_content) if extras_content else ""
|
||||
|
||||
|
||||
@@ -33,6 +33,11 @@ class ConditionsSpec:
|
||||
def add_data(current_watch_uuid, application_datastruct, ephemeral_data):
|
||||
"""Add to the datadict"""
|
||||
pass
|
||||
|
||||
@hookspec
|
||||
def ui_edit_stats_extras(watch):
|
||||
"""Return HTML content to add to the stats tab in the edit view"""
|
||||
pass
|
||||
|
||||
# ✅ Set up Pluggy Plugin Manager
|
||||
plugin_manager = pluggy.PluginManager(PLUGIN_NAMESPACE)
|
||||
|
||||
@@ -1,44 +1,107 @@
|
||||
import pluggy
|
||||
from loguru import logger
|
||||
|
||||
hookimpl = pluggy.HookimplMarker("changedetectionio_conditions")
|
||||
# Support both plugin systems
|
||||
conditions_hookimpl = pluggy.HookimplMarker("changedetectionio_conditions")
|
||||
global_hookimpl = pluggy.HookimplMarker("changedetectionio")
|
||||
|
||||
def levenshtein_ratio_recent_history(watch, incoming_text):
|
||||
def levenshtein_ratio_recent_history(watch, incoming_text=None):
|
||||
try:
|
||||
from Levenshtein import ratio, distance
|
||||
k = list(watch.history.keys())
|
||||
if len(k) >= 2:
|
||||
a = watch.get_history_snapshot(timestamp=k[0])
|
||||
b = incoming_text
|
||||
distance = distance(a, b)
|
||||
return distance
|
||||
# When called from ui_edit_stats_extras, we don't have incoming_text
|
||||
if incoming_text is None:
|
||||
a = watch.get_history_snapshot(timestamp=k[-2]) # Previous snapshot
|
||||
b = watch.get_history_snapshot(timestamp=k[-1]) # Latest snapshot
|
||||
else:
|
||||
a = watch.get_history_snapshot(timestamp=k[0])
|
||||
b = incoming_text
|
||||
|
||||
distance_value = distance(a, b)
|
||||
ratio_value = ratio(a, b)
|
||||
return {
|
||||
'distance': distance_value,
|
||||
'ratio': ratio_value,
|
||||
'percent_similar': round(ratio_value * 100, 2)
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning("Unable to calc similarity", e)
|
||||
logger.warning(f"Unable to calc similarity: {str(e)}")
|
||||
|
||||
return ''
|
||||
|
||||
@hookimpl
|
||||
@conditions_hookimpl
|
||||
def register_operators():
|
||||
pass
|
||||
|
||||
@hookimpl
|
||||
@conditions_hookimpl
|
||||
def register_operator_choices():
|
||||
pass
|
||||
|
||||
|
||||
@hookimpl
|
||||
@conditions_hookimpl
|
||||
def register_field_choices():
|
||||
return [
|
||||
("levenshtein_ratio", "Levenshtein text difference distance/similarity"),
|
||||
]
|
||||
|
||||
@hookimpl
|
||||
@conditions_hookimpl
|
||||
def add_data(current_watch_uuid, application_datastruct, ephemeral_data):
|
||||
|
||||
res = {}
|
||||
watch = application_datastruct['watching'].get(current_watch_uuid)
|
||||
if watch and 'text' in ephemeral_data:
|
||||
# This is slightly misleading, it's extracting a PRICE not a Number..
|
||||
res['levenshtein_ratio'] = levenshtein_ratio_recent_history(watch, ephemeral_data['text'])
|
||||
lev_data = levenshtein_ratio_recent_history(watch, ephemeral_data['text'])
|
||||
if isinstance(lev_data, dict):
|
||||
res['levenshtein_ratio'] = lev_data['distance']
|
||||
res['levenshtein_similarity'] = lev_data['percent_similar']
|
||||
else:
|
||||
res['levenshtein_ratio'] = lev_data
|
||||
|
||||
return res
|
||||
|
||||
@conditions_hookimpl
|
||||
def ui_edit_stats_extras(watch):
|
||||
"""Add Levenshtein stats to the UI through conditions plugin system"""
|
||||
return _generate_stats_html(watch)
|
||||
|
||||
def _generate_stats_html(watch):
|
||||
"""Generate the HTML for Levenshtein stats - shared by both plugin systems"""
|
||||
if len(watch.history.keys()) < 2:
|
||||
return "<p>Not enough history to calculate Levenshtein metrics</p>"
|
||||
|
||||
try:
|
||||
lev_data = levenshtein_ratio_recent_history(watch)
|
||||
if not lev_data or not isinstance(lev_data, dict):
|
||||
return "<p>Unable to calculate Levenshtein metrics</p>"
|
||||
|
||||
html = f"""
|
||||
<div class="levenshtein-stats">
|
||||
<h4>Levenshtein Text Similarity Details</h4>
|
||||
<table class="pure-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Raw distance (edits needed)</td>
|
||||
<td>{lev_data['distance']}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Similarity ratio</td>
|
||||
<td>{lev_data['ratio']:.4f}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Percent similar</td>
|
||||
<td>{lev_data['percent_similar']}%</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p style="font-size: 80%;">Levenshtein metrics compare the last two snapshots, measuring how many character edits are needed to transform one into the other.</p>
|
||||
</div>
|
||||
"""
|
||||
return html
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating Levenshtein UI extras: {str(e)}")
|
||||
return "<p>Error calculating Levenshtein metrics</p>"
|
||||
|
||||
@global_hookimpl
|
||||
def ui_edit_stats_extras(watch):
|
||||
"""Add Levenshtein stats to the UI using the global plugin system"""
|
||||
return _generate_stats_html(watch)
|
||||
|
||||
87
changedetectionio/pluggy_interface.py
Normal file
87
changedetectionio/pluggy_interface.py
Normal file
@@ -0,0 +1,87 @@
|
||||
import pluggy
|
||||
import os
|
||||
import importlib
|
||||
import sys
|
||||
|
||||
# Global plugin namespace for changedetection.io
|
||||
PLUGIN_NAMESPACE = "changedetectionio"
|
||||
|
||||
hookspec = pluggy.HookspecMarker(PLUGIN_NAMESPACE)
|
||||
hookimpl = pluggy.HookimplMarker(PLUGIN_NAMESPACE)
|
||||
|
||||
|
||||
class ChangeDetectionSpec:
|
||||
"""Hook specifications for extending changedetection.io functionality."""
|
||||
|
||||
@hookspec
|
||||
def ui_edit_stats_extras(watch):
|
||||
"""Return HTML content to add to the stats tab in the edit view.
|
||||
|
||||
Args:
|
||||
watch: The watch object being edited
|
||||
|
||||
Returns:
|
||||
str: HTML content to be inserted in the stats tab
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
# Set up Plugin Manager
|
||||
plugin_manager = pluggy.PluginManager(PLUGIN_NAMESPACE)
|
||||
|
||||
# Register hookspecs
|
||||
plugin_manager.add_hookspecs(ChangeDetectionSpec)
|
||||
|
||||
# Load plugins from subdirectories
|
||||
def load_plugins_from_directories():
|
||||
# Dictionary of directories to scan for plugins
|
||||
plugin_dirs = {
|
||||
'conditions': os.path.join(os.path.dirname(__file__), 'conditions', 'plugins'),
|
||||
# Add more plugin directories here as needed
|
||||
}
|
||||
|
||||
# Also load plugins from the root directory (for example plugins)
|
||||
try:
|
||||
import example_word_count_plugin
|
||||
plugin_manager.register(example_word_count_plugin, "example_word_count_plugin")
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
for dir_name, dir_path in plugin_dirs.items():
|
||||
if not os.path.exists(dir_path):
|
||||
continue
|
||||
|
||||
# Get all Python files (excluding __init__.py)
|
||||
for filename in os.listdir(dir_path):
|
||||
if filename.endswith(".py") and filename != "__init__.py":
|
||||
module_name = filename[:-3] # Remove .py extension
|
||||
module_path = f"changedetectionio.{dir_name}.plugins.{module_name}"
|
||||
|
||||
try:
|
||||
module = importlib.import_module(module_path)
|
||||
# Register the plugin with pluggy
|
||||
plugin_manager.register(module, module_name)
|
||||
except (ImportError, AttributeError) as e:
|
||||
print(f"Error loading plugin {module_name}: {e}")
|
||||
|
||||
# Load plugins
|
||||
load_plugins_from_directories()
|
||||
|
||||
# Discover installed plugins from external packages (if any)
|
||||
plugin_manager.load_setuptools_entrypoints(PLUGIN_NAMESPACE)
|
||||
|
||||
# Helper function to collect UI stats extras from all plugins
|
||||
def collect_ui_edit_stats_extras(watch):
|
||||
"""Collect and combine HTML content from all plugins that implement ui_edit_stats_extras"""
|
||||
extras_content = []
|
||||
|
||||
# Get all plugins that implement the ui_edit_stats_extras hook
|
||||
results = plugin_manager.hook.ui_edit_stats_extras(watch=watch)
|
||||
|
||||
# If we have results, add them to our content
|
||||
if results:
|
||||
for result in results:
|
||||
if result: # Skip empty results
|
||||
extras_content.append(result)
|
||||
|
||||
return "\n".join(extras_content) if extras_content else ""
|
||||
@@ -453,10 +453,12 @@ Math: {{ 1 + 1 }}") }}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h4>Text similarity</h4>
|
||||
<p><strong>Levenshtein Distance</strong> - Last 2 snapshots: {{ lev_info }}</p>
|
||||
<p style="max-width: 80%; font-size: 80%"><strong>Levenshtein Distance</strong> Calculates the minimum number of insertions, deletions, and substitutions required to change one text into the other.</p>
|
||||
|
||||
{% if ui_edit_stats_extras %}
|
||||
<div class="plugin-stats-extras">
|
||||
{{ ui_edit_stats_extras|safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if watch.history_n %}
|
||||
<p>
|
||||
<a href="{{url_for('ui.ui_edit.watch_get_latest_html', uuid=uuid)}}" class="pure-button button-small">Download latest HTML snapshot</a>
|
||||
|
||||
Reference in New Issue
Block a user