{%- endif -%}
{%- endif -%}
- {%- if watch['processor'] and watch['processor'] in processor_badge_texts -%}
- {{ processor_badge_texts[watch['processor']] }}
- {%- endif -%}
+
{%- for watch_tag_uuid, watch_tag in datastore.get_all_tags_for_watch(watch['uuid']).items() -%}
{{ watch_tag.title }}
{%- endfor -%}
diff --git a/changedetectionio/forms.py b/changedetectionio/forms.py
index 9ffd111fee6..8343dd1af15 100644
--- a/changedetectionio/forms.py
+++ b/changedetectionio/forms.py
@@ -730,7 +730,7 @@ class quickWatchForm(Form):
url = fields.URLField(_l('URL'), validators=[validateURL()])
tags = StringTagUUID(_l('Group tag'), validators=[validators.Optional()])
watch_submit_button = SubmitField(_l('Watch'), render_kw={"class": "pure-button pure-button-primary"})
- processor = RadioField(_l('Processor'), choices=lambda: processors.available_processors(), default="text_json_diff")
+ processor = RadioField(_l('Processor'), choices=lambda: processors.available_processors(), default=processors.get_default_processor)
edit_and_watch_submit_button = SubmitField(_l('Edit > Watch'), render_kw={"class": "pure-button pure-button-primary"})
@@ -749,7 +749,7 @@ def __init__(self, formdata=None, obj=None, prefix="", data=None, meta=None, **k
notification_format = SelectField(_l('Notification format'), choices=list(valid_notification_formats.items()))
notification_title = StringField(_l('Notification Title'), default='ChangeDetection.io Notification - {{ watch_url }}', validators=[validators.Optional(), ValidateJinja2Template()])
notification_urls = StringListField(_l('Notification URL List'), validators=[validators.Optional(), ValidateAppRiseServers(), ValidateJinja2Template()])
- processor = RadioField( label=_l("Processor - What do you want to achieve?"), choices=lambda: processors.available_processors(), default="text_json_diff")
+ processor = RadioField( label=_l("Processor - What do you want to achieve?"), choices=lambda: processors.available_processors(), default=processors.get_default_processor)
scheduler_timezone_default = StringField(_l("Default timezone for watch check scheduler"), render_kw={"list": "timezones"}, validators=[validateTimeZoneName()])
webdriver_delay = IntegerField(_l('Wait seconds before extracting text'), validators=[validators.Optional(), validators.NumberRange(min=1, message=_l("Should contain one or more seconds"))])
@@ -763,7 +763,7 @@ def __init__(self, formdata=None, obj=None, prefix="", data=None, meta=None, **k
class importForm(Form):
- processor = RadioField(_l('Processor'), choices=lambda: processors.available_processors(), default="text_json_diff")
+ processor = RadioField(_l('Processor'), choices=lambda: processors.available_processors(), default=processors.get_default_processor)
urls = TextAreaField(_l('URLs'))
xlsx_file = FileField(_l('Upload .xlsx file'), validators=[FileAllowed(['xlsx'], _l('Must be .xlsx file!'))])
file_mapping = SelectField(_l('File mapping'), [validators.DataRequired()], choices={('wachete', 'Wachete mapping'), ('custom','Custom mapping')})
diff --git a/changedetectionio/pluggy_interface.py b/changedetectionio/pluggy_interface.py
index 08dba213f6b..49647b14158 100644
--- a/changedetectionio/pluggy_interface.py
+++ b/changedetectionio/pluggy_interface.py
@@ -105,6 +105,30 @@ def plugin_settings_tab(self):
"""
pass
+ @hookspec
+ def register_processor(self):
+ """Register an external processor plugin.
+
+ External packages can implement this hook to register custom processors
+ that will be discovered alongside built-in processors.
+
+ Returns:
+ dict or None: Dictionary with processor information:
+ {
+ 'processor_name': str, # Machine name (e.g., 'osint_recon')
+ 'processor_module': module, # Module containing processor.py
+ 'processor_class': class, # The perform_site_check class
+ 'metadata': { # Optional metadata
+ 'name': str, # Display name
+ 'description': str, # Description
+ 'processor_weight': int,# Sort weight (lower = higher priority)
+ 'list_badge_text': str, # Badge text for UI
+ }
+ }
+ Return None if this plugin doesn't provide a processor
+ """
+ pass
+
# Set up Plugin Manager
plugin_manager = pluggy.PluginManager(PLUGIN_NAMESPACE)
diff --git a/changedetectionio/processors/__init__.py b/changedetectionio/processors/__init__.py
index 29828a04b83..5fedc0b5381 100644
--- a/changedetectionio/processors/__init__.py
+++ b/changedetectionio/processors/__init__.py
@@ -17,9 +17,11 @@ def find_sub_packages(package_name):
return [name for _, name, is_pkg in pkgutil.iter_modules(package.__path__) if is_pkg]
+@lru_cache(maxsize=1)
def find_processors():
"""
Find all subclasses of DifferenceDetectionProcessor in the specified package.
+ Results are cached to avoid repeated discovery.
:param package_name: The name of the package to scan for processor modules.
:return: A list of (module, class) tuples.
@@ -46,6 +48,23 @@ def find_processors():
except (ModuleNotFoundError, ImportError) as e:
logger.warning(f"Failed to import module {module_name}: {e} (find_processors())")
+ # Discover plugin processors via pluggy
+ try:
+ from changedetectionio.pluggy_interface import plugin_manager
+ plugin_results = plugin_manager.hook.register_processor()
+
+ for result in plugin_results:
+ if result and isinstance(result, dict):
+ processor_module = result.get('processor_module')
+ processor_name = result.get('processor_name')
+
+ if processor_module and processor_name:
+ processors.append((processor_module, processor_name))
+ plugin_path = getattr(processor_module, '__file__', 'unknown location')
+ logger.info(f"Registered plugin processor: {processor_name} from {plugin_path}")
+ except Exception as e:
+ logger.warning(f"Error loading plugin processors: {e}")
+
return processors
@@ -97,54 +116,137 @@ def find_processor_module(processor_name):
return None
+def get_processor_module(processor_name):
+ """
+ Get the actual processor module (with perform_site_check class) by name.
+ Works for both built-in and plugin processors.
+
+ Args:
+ processor_name: Processor machine name (e.g., 'text_json_diff', 'osint_recon')
+
+ Returns:
+ module: The processor module containing perform_site_check, or None if not found
+ """
+ processor_classes = find_processors()
+ processor_tuple = next((tpl for tpl in processor_classes if tpl[1] == processor_name), None)
+
+ if processor_tuple:
+ # Return the actual processor module (first element of tuple)
+ return processor_tuple[0]
+
+ return None
+
+
+def get_processor_submodule(processor_name, submodule_name):
+ """
+ Get an optional submodule from a processor (e.g., 'difference', 'extract', 'preview').
+ Works for both built-in and plugin processors.
+
+ Args:
+ processor_name: Processor machine name (e.g., 'text_json_diff', 'osint_recon')
+ submodule_name: Name of the submodule (e.g., 'difference', 'extract', 'preview')
+
+ Returns:
+ module: The submodule if it exists, or None if not found
+ """
+ processor_classes = find_processors()
+ processor_tuple = next((tpl for tpl in processor_classes if tpl[1] == processor_name), None)
+
+ if not processor_tuple:
+ return None
+
+ processor_module = processor_tuple[0]
+ parent_module = get_parent_module(processor_module)
+
+ if not parent_module:
+ return None
+
+ # Try to import the submodule
+ try:
+ # For built-in processors: changedetectionio.processors.text_json_diff.difference
+ # For plugin processors: changedetectionio_osint.difference
+ parent_module_name = parent_module.__name__
+ submodule_full_name = f"{parent_module_name}.{submodule_name}"
+ return importlib.import_module(submodule_full_name)
+ except (ModuleNotFoundError, ImportError):
+ return None
+
+
+@lru_cache(maxsize=1)
+def get_plugin_processor_metadata():
+ """Get metadata from plugin processors."""
+ metadata = {}
+ try:
+ from changedetectionio.pluggy_interface import plugin_manager
+ plugin_results = plugin_manager.hook.register_processor()
+
+ for result in plugin_results:
+ if result and isinstance(result, dict):
+ processor_name = result.get('processor_name')
+ meta = result.get('metadata', {})
+ if processor_name:
+ metadata[processor_name] = meta
+ except Exception as e:
+ logger.warning(f"Error getting plugin processor metadata: {e}")
+ return metadata
+
+
def available_processors():
"""
Get a list of processors by name and description for the UI elements.
- Can be filtered via ALLOWED_PROCESSORS environment variable (comma-separated list).
+ Can be filtered via DISABLED_PROCESSORS environment variable (comma-separated list).
:return: A list :)
"""
processor_classes = find_processors()
- # Check if ALLOWED_PROCESSORS env var is set
- # For now we disable it, need to make a deploy with lots of new code and this will be an overload
- allowed_processors_env = os.getenv('ALLOWED_PROCESSORS', 'text_json_diff, restock_diff').strip()
- allowed_processors = None
- if allowed_processors_env:
+ # Check if DISABLED_PROCESSORS env var is set
+ disabled_processors_env = os.getenv('DISABLED_PROCESSORS', 'image_ssim_diff').strip()
+ disabled_processors = []
+ if disabled_processors_env:
# Parse comma-separated list and strip whitespace
- allowed_processors = [p.strip() for p in allowed_processors_env.split(',') if p.strip()]
- logger.info(f"ALLOWED_PROCESSORS set, filtering to: {allowed_processors}")
+ disabled_processors = [p.strip() for p in disabled_processors_env.split(',') if p.strip()]
+ logger.info(f"DISABLED_PROCESSORS set, disabling: {disabled_processors}")
available = []
+ plugin_metadata = get_plugin_processor_metadata()
+
for module, sub_package_name in processor_classes:
- # Filter by allowed processors if set
- if allowed_processors and sub_package_name not in allowed_processors:
- logger.debug(f"Skipping processor '{sub_package_name}' (not in ALLOWED_PROCESSORS)")
+ # Skip disabled processors
+ if sub_package_name in disabled_processors:
+ logger.debug(f"Skipping processor '{sub_package_name}' (in DISABLED_PROCESSORS)")
continue
- # Try to get the 'name' attribute from the processor module first
- if hasattr(module, 'name'):
- description = gettext(module.name)
+ # Check if this is a plugin processor
+ if sub_package_name in plugin_metadata:
+ meta = plugin_metadata[sub_package_name]
+ description = gettext(meta.get('name', sub_package_name))
+ # Plugin processors start from weight 10 to separate them from built-in processors
+ weight = 100 + meta.get('processor_weight', 0)
else:
- # Fall back to processor_description from parent module's __init__.py
- parent_module = get_parent_module(module)
- if parent_module and hasattr(parent_module, 'processor_description'):
- description = gettext(parent_module.processor_description)
+ # Try to get the 'name' attribute from the processor module first
+ if hasattr(module, 'name'):
+ description = gettext(module.name)
else:
- # Final fallback to a readable name
- description = sub_package_name.replace('_', ' ').title()
-
- # Get weight for sorting (lower weight = higher in list)
- weight = 0 # Default weight for processors without explicit weight
-
- # Check processor module itself first
- if hasattr(module, 'processor_weight'):
- weight = module.processor_weight
- else:
- # Fall back to parent module (package __init__.py)
- parent_module = get_parent_module(module)
- if parent_module and hasattr(parent_module, 'processor_weight'):
- weight = parent_module.processor_weight
+ # Fall back to processor_description from parent module's __init__.py
+ parent_module = get_parent_module(module)
+ if parent_module and hasattr(parent_module, 'processor_description'):
+ description = gettext(parent_module.processor_description)
+ else:
+ # Final fallback to a readable name
+ description = sub_package_name.replace('_', ' ').title()
+
+ # Get weight for sorting (lower weight = higher in list)
+ weight = 0 # Default weight for processors without explicit weight
+
+ # Check processor module itself first
+ if hasattr(module, 'processor_weight'):
+ weight = module.processor_weight
+ else:
+ # Fall back to parent module (package __init__.py)
+ parent_module = get_parent_module(module)
+ if parent_module and hasattr(parent_module, 'processor_weight'):
+ weight = parent_module.processor_weight
available.append((sub_package_name, description, weight))
@@ -155,6 +257,20 @@ def available_processors():
return [(name, desc) for name, desc, weight in available]
+def get_default_processor():
+ """
+ Get the default processor to use when none is specified.
+ Returns the first available processor based on weight (lowest weight = highest priority).
+ This ensures forms auto-select a valid processor even when DISABLED_PROCESSORS filters the list.
+
+ :return: The processor name string (e.g., 'text_json_diff')
+ """
+ available = available_processors()
+ if available:
+ return available[0][0] # Return the processor name from first tuple
+ return 'text_json_diff' # Fallback if somehow no processors are available
+
+
def get_processor_badge_texts():
"""
Get a dictionary mapping processor names to their list_badge_text values.
@@ -279,3 +395,76 @@ def get_processor_badge_css():
return '\n\n'.join(css_rules)
+
+def save_processor_config(datastore, watch_uuid, config_data):
+ """
+ Save processor-specific configuration to JSON file.
+
+ This is a shared helper function used by both the UI edit form and API endpoints
+ to consistently handle processor configuration storage.
+
+ Args:
+ datastore: The application datastore instance
+ watch_uuid: UUID of the watch
+ config_data: Dictionary of configuration data to save (with processor_config_* prefix removed)
+
+ Returns:
+ bool: True if saved successfully, False otherwise
+ """
+ if not config_data:
+ return True
+
+ try:
+ from changedetectionio.processors.base import difference_detection_processor
+
+ # Get processor name from watch
+ watch = datastore.data['watching'].get(watch_uuid)
+ if not watch:
+ logger.error(f"Cannot save processor config: watch {watch_uuid} not found")
+ return False
+
+ processor_name = watch.get('processor', 'text_json_diff')
+
+ # Create a processor instance to access config methods
+ processor_instance = difference_detection_processor(datastore, watch_uuid)
+
+ # Use processor name as filename so each processor keeps its own config
+ config_filename = f'{processor_name}.json'
+ processor_instance.update_extra_watch_config(config_filename, config_data)
+
+ logger.debug(f"Saved processor config to {config_filename}: {config_data}")
+ return True
+
+ except Exception as e:
+ logger.error(f"Failed to save processor config: {e}")
+ return False
+
+
+def extract_processor_config_from_form_data(form_data):
+ """
+ Extract processor_config_* fields from form data and return separate dicts.
+
+ This is a shared helper function used by both the UI edit form and API endpoints
+ to consistently handle processor configuration extraction.
+
+ IMPORTANT: This function modifies form_data in-place by removing processor_config_* fields.
+
+ Args:
+ form_data: Dictionary of form data (will be modified in-place)
+
+ Returns:
+ dict: Dictionary of processor config data (with processor_config_* prefix removed)
+ """
+ processor_config_data = {}
+
+ # Use list() to create a copy of keys since we're modifying the dict
+ for field_name in list(form_data.keys()):
+ if field_name.startswith('processor_config_'):
+ config_key = field_name.replace('processor_config_', '')
+ # Save all values (including empty strings) to allow explicit clearing of settings
+ processor_config_data[config_key] = form_data[field_name]
+ # Remove from form_data to prevent it from reaching datastore
+ del form_data[field_name]
+
+ return processor_config_data
+
diff --git a/changedetectionio/processors/image_ssim_diff/__init__.py b/changedetectionio/processors/image_ssim_diff/__init__.py
index 128dfc119ac..15072e00a69 100644
--- a/changedetectionio/processors/image_ssim_diff/__init__.py
+++ b/changedetectionio/processors/image_ssim_diff/__init__.py
@@ -12,6 +12,13 @@
processor_name = "image_ssim_diff"
processor_weight = 2 # Lower weight = appears at top, heavier weight = appears lower (bottom)
+# Processor capabilities
+supports_visual_selector = True
+supports_browser_steps = True
+supports_text_filters_and_triggers = False
+supports_text_filters_and_triggers_elements = False
+supports_request_type = True
+
PROCESSOR_CONFIG_NAME = f"{Path(__file__).parent.name}.json"
# Subprocess timeout settings
diff --git a/changedetectionio/processors/restock_diff/__init__.py b/changedetectionio/processors/restock_diff/__init__.py
index 3d472beece0..28ecd62d74d 100644
--- a/changedetectionio/processors/restock_diff/__init__.py
+++ b/changedetectionio/processors/restock_diff/__init__.py
@@ -4,6 +4,13 @@
from typing import Union
import re
+# Processor capabilities
+supports_visual_selector = True
+supports_browser_steps = True
+supports_text_filters_and_triggers = True
+supports_text_filters_and_triggers_elements = True
+supports_request_type = True
+
class Restock(dict):
def parse_currency(self, raw_value: str) -> Union[float, None]:
diff --git a/changedetectionio/processors/text_json_diff/__init__.py b/changedetectionio/processors/text_json_diff/__init__.py
index c1ed82aae69..db8cca313de 100644
--- a/changedetectionio/processors/text_json_diff/__init__.py
+++ b/changedetectionio/processors/text_json_diff/__init__.py
@@ -1,6 +1,12 @@
-
from loguru import logger
+# Processor capabilities
+supports_visual_selector = True
+supports_browser_steps = True
+supports_text_filters_and_triggers = True
+supports_text_filters_and_triggers_elements = True
+supports_request_type = True
+
def _task(watch, update_handler):
diff --git a/changedetectionio/tests/plugins/test_processor.py b/changedetectionio/tests/plugins/test_processor.py
new file mode 100644
index 00000000000..8f5302b8f5e
--- /dev/null
+++ b/changedetectionio/tests/plugins/test_processor.py
@@ -0,0 +1,41 @@
+import time
+
+from flask import url_for
+
+from changedetectionio.tests.util import wait_for_all_checks
+
+
+def test_check_plugin_processor(client, live_server, measure_memory_usage, datastore_path):
+ # requires os-int intelligence plugin installed (first basic one we test with)
+
+ res = client.get(url_for("watchlist.index"))
+ assert b'OSINT Reconnaissance' in res.data, "Must have the OSINT plugin installed at test time"
+ assert b'' in res.data, "But the first text_json_diff processor should always be selected by default in quick watch form"
+
+ res = client.post(
+ url_for("ui.ui_views.form_quick_watch_add"),
+ data={"url": 'http://127.0.0.1', "tags": '', 'processor': 'osint_recon'},
+ follow_redirects=True
+ )
+ assert b"Watch added" in res.data
+ client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
+
+ wait_for_all_checks(client)
+
+ res = client.get(
+ url_for("ui.ui_preview.preview_page", uuid="first"),
+ follow_redirects=True
+ )
+
+ assert b'Target: http://127.0.0.1' in res.data
+ assert b'DNSKEY Records' in res.data
+ wait_for_all_checks(client)
+
+
+ # Now change it to something that doesnt exist
+ uuid = next(iter(live_server.app.config['DATASTORE'].data['watching']))
+ live_server.app.config['DATASTORE'].data['watching'][uuid]['processor'] = "now_missing"
+ client.get(url_for("ui.form_watch_checknow"), follow_redirects=True)
+ wait_for_all_checks(client)
+ res = client.get(url_for("watchlist.index"))
+ assert b"Exception: Processor module" in res.data and b'now_missing' in res.data, f'Should register that the plugin is missing for {uuid}'
diff --git a/changedetectionio/worker.py b/changedetectionio/worker.py
index e5046c5ef22..f731c694314 100644
--- a/changedetectionio/worker.py
+++ b/changedetectionio/worker.py
@@ -142,11 +142,14 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
processor = watch.get('processor', 'text_json_diff')
# Init a new 'difference_detection_processor'
- try:
- processor_module = importlib.import_module(f"changedetectionio.processors.{processor}.processor")
- except ModuleNotFoundError as e:
- print(f"Processor module '{processor}' not found.")
- raise e
+ # Use get_processor_module() to support both built-in and plugin processors
+ from changedetectionio.processors import get_processor_module
+ processor_module = get_processor_module(processor)
+
+ if not processor_module:
+ error_msg = f"Processor module '{processor}' not found."
+ logger.error(error_msg)
+ raise ModuleNotFoundError(error_msg)
update_handler = processor_module.perform_site_check(datastore=datastore,
watch_uuid=uuid)
@@ -365,8 +368,10 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
logger.error(f"Exception (BrowserStepsInUnsupportedFetcher) reached processing watch UUID: {uuid}")
except Exception as e:
+ import traceback
logger.error(f"Worker {worker_id} exception processing watch UUID: {uuid}")
logger.error(str(e))
+ logger.error(traceback.format_exc())
datastore.update_watch(uuid=uuid, update_obj={'last_error': "Exception: " + str(e)})
process_changedetection_results = False
@@ -385,8 +390,8 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
if not datastore.data['watching'].get(uuid):
continue
- logger.debug(f"Processing watch UUID: {uuid} - xpath_data length returned {len(update_handler.xpath_data) if update_handler.xpath_data else 'empty.'}")
- if process_changedetection_results:
+ logger.debug(f"Processing watch UUID: {uuid} - xpath_data length returned {len(update_handler.xpath_data) if update_handler and update_handler.xpath_data else 'empty.'}")
+ if update_handler and process_changedetection_results:
try:
datastore.update_watch(uuid=uuid, update_obj=update_obj)
@@ -436,44 +441,44 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
# Always record attempt count
count = watch.get('check_count', 0) + 1
-
- # Always record page title (used in notifications, and can change even when the content is the same)
- if update_obj.get('content-type') and 'html' in update_obj.get('content-type'):
+ if update_handler: # Could be none or empty if the processor was not found
+ # Always record page title (used in notifications, and can change even when the content is the same)
+ if update_obj.get('content-type') and 'html' in update_obj.get('content-type'):
+ try:
+ page_title = html_tools.extract_title(data=update_handler.fetcher.content)
+ if page_title:
+ page_title = page_title.strip()[:2000]
+ logger.debug(f"UUID: {uuid} Page is '{page_title}'")
+ datastore.update_watch(uuid=uuid, update_obj={'page_title': page_title})
+ except Exception as e:
+ logger.warning(f"UUID: {uuid} Exception when extracting - {str(e)}")
+
+ # Record server header
try:
- page_title = html_tools.extract_title(data=update_handler.fetcher.content)
- if page_title:
- page_title = page_title.strip()[:2000]
- logger.debug(f"UUID: {uuid} Page is '{page_title}'")
- datastore.update_watch(uuid=uuid, update_obj={'page_title': page_title})
+ server_header = update_handler.fetcher.headers.get('server', '').strip().lower()[:255]
+ datastore.update_watch(uuid=uuid, update_obj={'remote_server_reply': server_header})
except Exception as e:
- logger.warning(f"UUID: {uuid} Exception when extracting - {str(e)}")
+ pass
- # Record server header
- try:
- server_header = update_handler.fetcher.headers.get('server', '').strip().lower()[:255]
- datastore.update_watch(uuid=uuid, update_obj={'remote_server_reply': server_header})
- except Exception as e:
- pass
+ # Store favicon if necessary
+ if update_handler.fetcher.favicon_blob and update_handler.fetcher.favicon_blob.get('base64'):
+ watch.bump_favicon(url=update_handler.fetcher.favicon_blob.get('url'),
+ favicon_base_64=update_handler.fetcher.favicon_blob.get('base64')
+ )
- # Store favicon if necessary
- if update_handler.fetcher.favicon_blob and update_handler.fetcher.favicon_blob.get('base64'):
- watch.bump_favicon(url=update_handler.fetcher.favicon_blob.get('url'),
- favicon_base_64=update_handler.fetcher.favicon_blob.get('base64')
- )
+ datastore.update_watch(uuid=uuid, update_obj={'fetch_time': round(time.time() - fetch_start_time, 3),
+ 'check_count': count})
- datastore.update_watch(uuid=uuid, update_obj={'fetch_time': round(time.time() - fetch_start_time, 3),
- 'check_count': count})
-
- # NOW clear fetcher content - after all processing is complete
- # This is the last point where we need the fetcher data
- if update_handler and hasattr(update_handler, 'fetcher') and update_handler.fetcher:
- update_handler.fetcher.clear_content()
- logger.debug(f"Cleared fetcher content for UUID {uuid}")
+ # NOW clear fetcher content - after all processing is complete
+ # This is the last point where we need the fetcher data
+ if update_handler and hasattr(update_handler, 'fetcher') and update_handler.fetcher:
+ update_handler.fetcher.clear_content()
+ logger.debug(f"Cleared fetcher content for UUID {uuid}")
- # Explicitly delete update_handler to free all references
- if update_handler:
- del update_handler
- update_handler = None
+ # Explicitly delete update_handler to free all references
+ if update_handler:
+ del update_handler
+ update_handler = None
# Force aggressive memory cleanup after clearing
import gc
@@ -485,6 +490,9 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore, exec
pass
except Exception as e:
+ import traceback
+ logger.error(traceback.format_exc())
+
logger.error(f"Worker {worker_id} unexpected error processing {uuid}: {e}")
logger.error(f"Worker {worker_id} traceback:", exc_info=True)
diff --git a/docker-compose.yml b/docker-compose.yml
index 9d353dd8e8d..d15b1dece2d 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -16,6 +16,13 @@ services:
# Log output levels: TRACE, DEBUG(default), INFO, SUCCESS, WARNING, ERROR, CRITICAL
# - LOGGER_LEVEL=TRACE
#
+ # Plugins! See https://changedetection.io/plugins for more plugins.
+ # Install additional Python packages (processor plugins, etc.)
+ # Example: Install the OSINT reconnaissance processor plugin
+ # - EXTRA_PACKAGES=changedetection.io-osint-processor
+ # Multiple packages can be installed by separating with spaces:
+ # - EXTRA_PACKAGES=changedetection.io-osint-processor another-plugin
+ #
#
# Uncomment below and the "sockpuppetbrowser" to use a real Chrome browser (It uses the "playwright" protocol)
# - PLAYWRIGHT_DRIVER_URL=ws://browser-sockpuppet-chrome:3000
diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh
new file mode 100755
index 00000000000..ac243289c84
--- /dev/null
+++ b/docker-entrypoint.sh
@@ -0,0 +1,28 @@
+#!/bin/bash
+set -e
+
+# Install additional packages from EXTRA_PACKAGES env var
+# Uses a marker file to avoid reinstalling on every container restart
+INSTALLED_MARKER="/datastore/.extra_packages_installed"
+CURRENT_PACKAGES="$EXTRA_PACKAGES"
+
+if [ -n "$EXTRA_PACKAGES" ]; then
+ # Check if we need to install/update packages
+ if [ ! -f "$INSTALLED_MARKER" ] || [ "$(cat $INSTALLED_MARKER 2>/dev/null)" != "$CURRENT_PACKAGES" ]; then
+ echo "Installing extra packages: $EXTRA_PACKAGES"
+ pip3 install --no-cache-dir $EXTRA_PACKAGES
+
+ if [ $? -eq 0 ]; then
+ echo "$CURRENT_PACKAGES" > "$INSTALLED_MARKER"
+ echo "Extra packages installed successfully"
+ else
+ echo "ERROR: Failed to install extra packages"
+ exit 1
+ fi
+ else
+ echo "Extra packages already installed: $EXTRA_PACKAGES"
+ fi
+fi
+
+# Execute the main command
+exec "$@"
diff --git a/requirements.txt b/requirements.txt
index ebd26a7d431..49138fab7e7 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -51,9 +51,9 @@ linkify-it-py
# - Needed for apprise/spush, and maybe others? hopefully doesnt trigger a rust compile.
# - Requires extra wheel for rPi, adds build time for arm/v8 which is not in piwheels
-# Pinned to 43.0.1 for ARM compatibility (45.x may not have pre-built ARM wheels)
+# Pinned to 44.x for ARM compatibility and sslyze compatibility (sslyze requires <45) and (45.x may not have pre-built ARM wheels)
# Also pinned because dependabot wants specific versions
-cryptography==46.0.3
+cryptography==44.0.0
# apprise mqtt https://github.com/dgtlmoon/changedetection.io/issues/315
# use any version other than 2.0.x due to https://github.com/eclipse/paho.mqtt.python/issues/814