mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2025-12-11 18:45:34 +00:00
Some checks failed
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (alpine) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/amd64 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v7 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm/v8 (main) (push) Has been cancelled
ChangeDetection.io Container Build Test / Build linux/arm64 (main) (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
455 lines
16 KiB
Python
455 lines
16 KiB
Python
import pluggy
|
|
import os
|
|
import importlib
|
|
import sys
|
|
from loguru import logger
|
|
|
|
# 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
|
|
|
|
@hookspec
|
|
def register_content_fetcher(self):
|
|
"""Return a tuple of (fetcher_name, fetcher_class) for content fetcher plugins.
|
|
|
|
The fetcher_name should start with 'html_' and the fetcher_class
|
|
should inherit from changedetectionio.content_fetchers.base.Fetcher
|
|
|
|
Returns:
|
|
tuple: (str: fetcher_name, class: fetcher_class)
|
|
"""
|
|
pass
|
|
|
|
@hookspec
|
|
def fetcher_status_icon(fetcher_name):
|
|
"""Return status icon HTML attributes for a content fetcher.
|
|
|
|
Args:
|
|
fetcher_name: The name of the fetcher (e.g., 'html_webdriver', 'html_js_zyte')
|
|
|
|
Returns:
|
|
str: HTML string containing <img> tags or other status icon elements
|
|
Empty string if no custom status icon is needed
|
|
"""
|
|
pass
|
|
|
|
@hookspec
|
|
def plugin_static_path(self):
|
|
"""Return the path to the plugin's static files directory.
|
|
|
|
Returns:
|
|
str: Absolute path to the plugin's static directory, or None if no static files
|
|
"""
|
|
pass
|
|
|
|
@hookspec
|
|
def get_itemprop_availability_override(self, content, fetcher_name, fetcher_instance, url):
|
|
"""Provide custom implementation of get_itemprop_availability for a specific fetcher.
|
|
|
|
This hook allows plugins to provide their own product availability detection
|
|
when their fetcher is being used. This is called as a fallback when the built-in
|
|
method doesn't find good data.
|
|
|
|
Args:
|
|
content: The HTML/text content to parse
|
|
fetcher_name: The name of the fetcher being used (e.g., 'html_js_zyte')
|
|
fetcher_instance: The fetcher instance that generated the content
|
|
url: The URL being watched/checked
|
|
|
|
Returns:
|
|
dict or None: Dictionary with availability data:
|
|
{
|
|
'price': float or None,
|
|
'availability': str or None, # e.g., 'in stock', 'out of stock'
|
|
'currency': str or None, # e.g., 'USD', 'EUR'
|
|
}
|
|
Or None if this plugin doesn't handle this fetcher or couldn't extract data
|
|
"""
|
|
pass
|
|
|
|
@hookspec
|
|
def plugin_settings_tab(self):
|
|
"""Return settings tab information for this plugin.
|
|
|
|
This hook allows plugins to add their own settings tab to the settings page.
|
|
Settings will be saved to a separate JSON file in the datastore directory.
|
|
|
|
Returns:
|
|
dict or None: Dictionary with settings tab information:
|
|
{
|
|
'plugin_id': str, # Unique identifier (e.g., 'zyte_fetcher')
|
|
'tab_label': str, # Display name for tab (e.g., 'Zyte Fetcher')
|
|
'form_class': Form, # WTForms Form class for the settings
|
|
'template_path': str, # Optional: path to Jinja2 template (relative to plugin)
|
|
# If not provided, a default form renderer will be used
|
|
}
|
|
Or None if this plugin doesn't provide settings
|
|
"""
|
|
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
|
|
}
|
|
|
|
# Note: Removed the direct import of example_word_count_plugin as it's now in the conditions/plugins directory
|
|
|
|
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)
|
|
|
|
# Function to inject datastore into plugins that need it
|
|
def inject_datastore_into_plugins(datastore):
|
|
"""Inject the global datastore into plugins that need access to settings.
|
|
|
|
This should be called after plugins are loaded and datastore is initialized.
|
|
|
|
Args:
|
|
datastore: The global ChangeDetectionStore instance
|
|
"""
|
|
for plugin_name, plugin_obj in plugin_manager.list_name_plugin():
|
|
# Check if plugin has datastore attribute and it's not set
|
|
if hasattr(plugin_obj, 'datastore'):
|
|
if plugin_obj.datastore is None:
|
|
plugin_obj.datastore = datastore
|
|
logger.debug(f"Injected datastore into plugin: {plugin_name}")
|
|
|
|
# Function to register built-in fetchers - called later from content_fetchers/__init__.py
|
|
def register_builtin_fetchers():
|
|
"""Register built-in content fetchers as internal plugins
|
|
|
|
This is called from content_fetchers/__init__.py after all fetchers are imported
|
|
to avoid circular import issues.
|
|
"""
|
|
from changedetectionio.content_fetchers import requests, playwright, puppeteer, webdriver_selenium
|
|
|
|
# Register each built-in fetcher plugin
|
|
if hasattr(requests, 'requests_plugin'):
|
|
plugin_manager.register(requests.requests_plugin, 'builtin_requests')
|
|
|
|
if hasattr(playwright, 'playwright_plugin'):
|
|
plugin_manager.register(playwright.playwright_plugin, 'builtin_playwright')
|
|
|
|
if hasattr(puppeteer, 'puppeteer_plugin'):
|
|
plugin_manager.register(puppeteer.puppeteer_plugin, 'builtin_puppeteer')
|
|
|
|
if hasattr(webdriver_selenium, 'webdriver_selenium_plugin'):
|
|
plugin_manager.register(webdriver_selenium.webdriver_selenium_plugin, 'builtin_webdriver_selenium')
|
|
|
|
# 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 ""
|
|
|
|
def collect_fetcher_status_icons(fetcher_name):
|
|
"""Collect status icon data from all plugins
|
|
|
|
Args:
|
|
fetcher_name: The name of the fetcher (e.g., 'html_webdriver', 'html_js_zyte')
|
|
|
|
Returns:
|
|
dict or None: Icon data dictionary from first matching plugin, or None
|
|
"""
|
|
# Get status icon data from plugins
|
|
results = plugin_manager.hook.fetcher_status_icon(fetcher_name=fetcher_name)
|
|
|
|
# Return first non-None result
|
|
if results:
|
|
for result in results:
|
|
if result and isinstance(result, dict):
|
|
return result
|
|
|
|
return None
|
|
|
|
def get_itemprop_availability_from_plugin(content, fetcher_name, fetcher_instance, url):
|
|
"""Get itemprop availability data from plugins as a fallback.
|
|
|
|
This is called when the built-in get_itemprop_availability doesn't find good data.
|
|
|
|
Args:
|
|
content: The HTML/text content to parse
|
|
fetcher_name: The name of the fetcher being used (e.g., 'html_js_zyte')
|
|
fetcher_instance: The fetcher instance that generated the content
|
|
url: The URL being watched (watch.link - includes Jinja2 evaluation)
|
|
|
|
Returns:
|
|
dict or None: Availability data dictionary from first matching plugin, or None
|
|
"""
|
|
# Get availability data from plugins
|
|
results = plugin_manager.hook.get_itemprop_availability_override(
|
|
content=content,
|
|
fetcher_name=fetcher_name,
|
|
fetcher_instance=fetcher_instance,
|
|
url=url
|
|
)
|
|
|
|
# Return first non-None result with actual data
|
|
if results:
|
|
for result in results:
|
|
if result and isinstance(result, dict):
|
|
# Check if the result has any meaningful data
|
|
if result.get('price') is not None or result.get('availability'):
|
|
return result
|
|
|
|
return None
|
|
|
|
|
|
def get_active_plugins():
|
|
"""Get a list of active plugins with their descriptions.
|
|
|
|
Returns:
|
|
list: List of dictionaries with plugin information:
|
|
[
|
|
{'name': 'plugin_name', 'description': 'Plugin description'},
|
|
...
|
|
]
|
|
"""
|
|
active_plugins = []
|
|
|
|
# Get all registered plugins
|
|
for plugin_name, plugin_obj in plugin_manager.list_name_plugin():
|
|
# Skip built-in plugins (they start with 'builtin_')
|
|
if plugin_name.startswith('builtin_'):
|
|
continue
|
|
|
|
# Get plugin description if available
|
|
description = None
|
|
if hasattr(plugin_obj, '__doc__') and plugin_obj.__doc__:
|
|
description = plugin_obj.__doc__.strip().split('\n')[0] # First line only
|
|
elif hasattr(plugin_obj, 'description'):
|
|
description = plugin_obj.description
|
|
|
|
# Try to get a friendly name from the plugin
|
|
friendly_name = plugin_name
|
|
if hasattr(plugin_obj, 'name'):
|
|
friendly_name = plugin_obj.name
|
|
|
|
active_plugins.append({
|
|
'name': friendly_name,
|
|
'description': description or 'No description available'
|
|
})
|
|
|
|
return active_plugins
|
|
|
|
|
|
def get_fetcher_capabilities(watch, datastore):
|
|
"""Get capability flags for a watch's fetcher.
|
|
|
|
Args:
|
|
watch: The watch object/dict
|
|
datastore: The datastore to resolve 'system' fetcher
|
|
|
|
Returns:
|
|
dict: Dictionary with capability flags:
|
|
{
|
|
'supports_browser_steps': bool,
|
|
'supports_screenshots': bool,
|
|
'supports_xpath_element_data': bool
|
|
}
|
|
"""
|
|
# Get the fetcher name from watch
|
|
fetcher_name = watch.get('fetch_backend', 'system')
|
|
|
|
# Resolve 'system' to actual fetcher
|
|
if fetcher_name == 'system':
|
|
fetcher_name = datastore.data['settings']['application'].get('fetch_backend', 'html_requests')
|
|
|
|
# Get the fetcher class
|
|
from changedetectionio import content_fetchers
|
|
|
|
# Try to get from built-in fetchers first
|
|
if hasattr(content_fetchers, fetcher_name):
|
|
fetcher_class = getattr(content_fetchers, fetcher_name)
|
|
return {
|
|
'supports_browser_steps': getattr(fetcher_class, 'supports_browser_steps', False),
|
|
'supports_screenshots': getattr(fetcher_class, 'supports_screenshots', False),
|
|
'supports_xpath_element_data': getattr(fetcher_class, 'supports_xpath_element_data', False)
|
|
}
|
|
|
|
# Try to get from plugin-provided fetchers
|
|
# Query all plugins for registered fetchers
|
|
plugin_fetchers = plugin_manager.hook.register_content_fetcher()
|
|
for fetcher_registration in plugin_fetchers:
|
|
if fetcher_registration:
|
|
name, fetcher_class = fetcher_registration
|
|
if name == fetcher_name:
|
|
return {
|
|
'supports_browser_steps': getattr(fetcher_class, 'supports_browser_steps', False),
|
|
'supports_screenshots': getattr(fetcher_class, 'supports_screenshots', False),
|
|
'supports_xpath_element_data': getattr(fetcher_class, 'supports_xpath_element_data', False)
|
|
}
|
|
|
|
# Default: no capabilities
|
|
return {
|
|
'supports_browser_steps': False,
|
|
'supports_screenshots': False,
|
|
'supports_xpath_element_data': False
|
|
}
|
|
|
|
|
|
def get_plugin_settings_tabs():
|
|
"""Get all plugin settings tabs.
|
|
|
|
Returns:
|
|
list: List of dictionaries with plugin settings tab information:
|
|
[
|
|
{
|
|
'plugin_id': str,
|
|
'tab_label': str,
|
|
'form_class': Form,
|
|
'description': str
|
|
},
|
|
...
|
|
]
|
|
"""
|
|
tabs = []
|
|
results = plugin_manager.hook.plugin_settings_tab()
|
|
|
|
for result in results:
|
|
if result and isinstance(result, dict):
|
|
# Validate required fields
|
|
if 'plugin_id' in result and 'tab_label' in result and 'form_class' in result:
|
|
tabs.append(result)
|
|
else:
|
|
logger.warning(f"Invalid plugin settings tab spec: {result}")
|
|
|
|
return tabs
|
|
|
|
|
|
def load_plugin_settings(datastore_path, plugin_id):
|
|
"""Load settings for a specific plugin from JSON file.
|
|
|
|
Args:
|
|
datastore_path: Path to the datastore directory
|
|
plugin_id: Unique identifier for the plugin (e.g., 'zyte_fetcher')
|
|
|
|
Returns:
|
|
dict: Plugin settings, or empty dict if file doesn't exist
|
|
"""
|
|
import json
|
|
settings_file = os.path.join(datastore_path, f"{plugin_id}.json")
|
|
|
|
if not os.path.exists(settings_file):
|
|
return {}
|
|
|
|
try:
|
|
with open(settings_file, 'r', encoding='utf-8') as f:
|
|
return json.load(f)
|
|
except Exception as e:
|
|
logger.error(f"Failed to load settings for plugin '{plugin_id}': {e}")
|
|
return {}
|
|
|
|
|
|
def save_plugin_settings(datastore_path, plugin_id, settings):
|
|
"""Save settings for a specific plugin to JSON file.
|
|
|
|
Args:
|
|
datastore_path: Path to the datastore directory
|
|
plugin_id: Unique identifier for the plugin (e.g., 'zyte_fetcher')
|
|
settings: Dictionary of settings to save
|
|
|
|
Returns:
|
|
bool: True if save was successful, False otherwise
|
|
"""
|
|
import json
|
|
settings_file = os.path.join(datastore_path, f"{plugin_id}.json")
|
|
|
|
try:
|
|
with open(settings_file, 'w', encoding='utf-8') as f:
|
|
json.dump(settings, f, indent=2, ensure_ascii=False)
|
|
logger.info(f"Saved settings for plugin '{plugin_id}' to {settings_file}")
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"Failed to save settings for plugin '{plugin_id}': {e}")
|
|
return False
|
|
|
|
|
|
def get_plugin_template_paths():
|
|
"""Get list of plugin template directories for Jinja2 loader.
|
|
|
|
Returns:
|
|
list: List of absolute paths to plugin template directories
|
|
"""
|
|
template_paths = []
|
|
|
|
# Get all registered plugins
|
|
for plugin_name, plugin_obj in plugin_manager.list_name_plugin():
|
|
# Check if plugin has a templates directory
|
|
if hasattr(plugin_obj, '__file__'):
|
|
plugin_file = plugin_obj.__file__
|
|
elif hasattr(plugin_obj, '__module__'):
|
|
# Get the module file
|
|
module = sys.modules.get(plugin_obj.__module__)
|
|
if module and hasattr(module, '__file__'):
|
|
plugin_file = module.__file__
|
|
else:
|
|
continue
|
|
else:
|
|
continue
|
|
|
|
if plugin_file:
|
|
plugin_dir = os.path.dirname(os.path.abspath(plugin_file))
|
|
templates_dir = os.path.join(plugin_dir, 'templates')
|
|
if os.path.isdir(templates_dir):
|
|
template_paths.append(templates_dir)
|
|
logger.debug(f"Added plugin template path: {templates_dir}")
|
|
|
|
return template_paths |