diff --git a/changedetectionio/__init__.py b/changedetectionio/__init__.py index ed772445..2f95521f 100644 --- a/changedetectionio/__init__.py +++ b/changedetectionio/__init__.py @@ -187,6 +187,10 @@ def main(): logger.critical(str(e)) return + # Inject datastore into plugins that need access to settings + from changedetectionio.pluggy_interface import inject_datastore_into_plugins + inject_datastore_into_plugins(datastore) + if default_url: datastore.add_watch(url = default_url) diff --git a/changedetectionio/blueprint/watchlist/templates/watch-overview.html b/changedetectionio/blueprint/watchlist/templates/watch-overview.html index 87e54fb6..3e08e69b 100644 --- a/changedetectionio/blueprint/watchlist/templates/watch-overview.html +++ b/changedetectionio/blueprint/watchlist/templates/watch-overview.html @@ -205,7 +205,7 @@ document.addEventListener('DOMContentLoaded', function() { {%- if watch.get('restock') and watch['restock']['price'] != None -%} {%- if watch['restock']['price'] != None -%} - {{ watch['restock']['price']|format_number_locale }} {{ watch['restock']['currency'] }} + {{ watch['restock']['price']|format_number_locale if watch['restock'].get('price') else '' }} {{ watch['restock'].get('currency','') }} {%- endif -%} {%- elif not watch.has_restock_info -%} diff --git a/changedetectionio/pluggy_interface.py b/changedetectionio/pluggy_interface.py index aa13b488..223f5f09 100644 --- a/changedetectionio/pluggy_interface.py +++ b/changedetectionio/pluggy_interface.py @@ -2,6 +2,7 @@ import pluggy import os import importlib import sys +from loguru import logger # Global plugin namespace for changedetection.io PLUGIN_NAMESPACE = "changedetectionio" @@ -59,6 +60,31 @@ class ChangeDetectionSpec: """ 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 + # Set up Plugin Manager plugin_manager = pluggy.PluginManager(PLUGIN_NAMESPACE) @@ -99,6 +125,22 @@ 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 @@ -155,4 +197,36 @@ def collect_fetcher_status_icons(fetcher_name): 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 \ No newline at end of file diff --git a/changedetectionio/processors/restock_diff/processor.py b/changedetectionio/processors/restock_diff/processor.py index 1fa81058..75dcb77a 100644 --- a/changedetectionio/processors/restock_diff/processor.py +++ b/changedetectionio/processors/restock_diff/processor.py @@ -187,6 +187,8 @@ class perform_site_check(difference_detection_processor): itemprop_availability = {} + + # Try built-in extraction first, this will scan metadata in the HTML try: itemprop_availability = get_itemprop_availability(self.fetcher.content) except MoreThanOnePriceFound as e: @@ -198,6 +200,33 @@ class perform_site_check(difference_detection_processor): xpath_data=self.fetcher.xpath_data ) + # If built-in extraction didn't get both price AND availability, try plugin override + # Only check plugin if this watch is using a fetcher that might provide better data + has_price = itemprop_availability.get('price') is not None + has_availability = itemprop_availability.get('availability') is not None + + # @TODO !!! some setting like "Use as fallback" or "always use", "t + if not (has_price and has_availability) or True: + from changedetectionio.pluggy_interface import get_itemprop_availability_from_plugin + fetcher_name = watch.get('fetch_backend', 'html_requests') + + # Only try plugin override if not using system default (which might be anything) + if fetcher_name and fetcher_name != 'system': + logger.debug("Calling extra plugins for getting item price/availability") + plugin_availability = get_itemprop_availability_from_plugin(self.fetcher.content, fetcher_name, self.fetcher, watch.link) + + if plugin_availability: + # Plugin provided better data, use it + plugin_has_price = plugin_availability.get('price') is not None + plugin_has_availability = plugin_availability.get('availability') is not None + + # Only use plugin data if it's actually better than what we have + if plugin_has_price or plugin_has_availability: + itemprop_availability = plugin_availability + logger.info(f"Using plugin-provided availability data for fetcher '{fetcher_name}' (built-in had price={has_price}, availability={has_availability}; plugin has price={plugin_has_price}, availability={plugin_has_availability})") + if not plugin_availability: + logger.debug("No item price/availability from plugins") + # Something valid in get_itemprop_availability() by scraping metadata ? if itemprop_availability.get('price') or itemprop_availability.get('availability'): # Store for other usage