diff --git a/.github/test/Dockerfile-alpine b/.github/test/Dockerfile-alpine index c037dc6c2..029cb1dc0 100644 --- a/.github/test/Dockerfile-alpine +++ b/.github/test/Dockerfile-alpine @@ -7,6 +7,8 @@ ENV PYTHONUNBUFFERED=1 COPY requirements.txt /requirements.txt +ARG TARGETPLATFORM + RUN \ apk add --update --no-cache --virtual=build-dependencies \ build-base \ @@ -27,7 +29,19 @@ RUN \ file \ nodejs \ poppler-utils \ - python3 && \ + python3 \ + glib \ + libsm \ + libxext \ + libxrender && \ + case "$TARGETPLATFORM" in \ + linux/arm/v7|linux/arm/v8) \ + echo "INFO: Skipping py3-opencv on $TARGETPLATFORM (using pixelmatch fallback)" \ + ;; \ + *) \ + apk add --update --no-cache py3-opencv || echo "WARN: py3-opencv install failed, using pixelmatch fallback" \ + ;; \ + esac && \ echo "**** pip3 install test of changedetection.io ****" && \ python3 -m venv /lsiopy && \ pip install -U pip wheel setuptools && \ diff --git a/Dockerfile b/Dockerfile index d2fe3d53d..c0d6a18d4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -34,6 +34,7 @@ ENV OPENSSL_LIB_DIR="/usr/lib/arm-linux-gnueabihf" ENV OPENSSL_INCLUDE_DIR="/usr/include/openssl" # Additional environment variables for cryptography Rust build ENV CRYPTOGRAPHY_DONT_BUILD_RUST=1 + RUN --mount=type=cache,id=pip,sharing=locked,target=/tmp/pip-cache \ pip install \ --prefer-binary \ @@ -43,7 +44,6 @@ RUN --mount=type=cache,id=pip,sharing=locked,target=/tmp/pip-cache \ --target=/dependencies \ -r /requirements.txt - # Playwright is an alternative to Selenium # Excluded this package from requirements.txt to prevent arm/v6 and arm/v7 builds from failing # https://github.com/dgtlmoon/changedetection.io/pull/1067 also musl/alpine (not supported) @@ -55,6 +55,25 @@ RUN --mount=type=cache,id=pip,sharing=locked,target=/tmp/pip-cache \ playwright~=1.56.0 \ || echo "WARN: Failed to install Playwright. The application can still run, but the Playwright option will be disabled." +# OpenCV is optional for fast image comparison (pixelmatch is the fallback) +# Skip on arm/v7 and arm/v8 where builds take weeks - excluded from requirements.txt +ARG TARGETPLATFORM +RUN --mount=type=cache,id=pip,sharing=locked,target=/tmp/pip-cache \ + case "$TARGETPLATFORM" in \ + linux/arm/v7|linux/arm/v8) \ + echo "INFO: Skipping OpenCV on $TARGETPLATFORM (build takes too long), using pixelmatch fallback" \ + ;; \ + *) \ + pip install \ + --prefer-binary \ + --extra-index-url https://www.piwheels.org/simple \ + --cache-dir=/tmp/pip-cache \ + --target=/dependencies \ + opencv-python-headless>=4.8.0.76 \ + || echo "WARN: OpenCV install failed, will use pixelmatch fallback" \ + ;; \ + esac + # Final image stage FROM python:${PYTHON_VERSION}-slim-bookworm @@ -69,6 +88,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ # favicon type detection and other uses file \ zlib1g \ + # OpenCV dependencies for image processing + libglib2.0-0 \ + libsm6 \ + libxext6 \ + libxrender-dev \ && apt-get clean && rm -rf /var/lib/apt/lists/* diff --git a/changedetectionio/__init__.py b/changedetectionio/__init__.py index 106fb536c..304c71009 100644 --- a/changedetectionio/__init__.py +++ b/changedetectionio/__init__.py @@ -6,6 +6,7 @@ __version__ = '0.51.4' from changedetectionio.strtobool import strtobool from json.decoder import JSONDecodeError +import logging import os import getopt import platform @@ -19,6 +20,57 @@ from changedetectionio import store from changedetectionio.flask_app import changedetection_app from loguru import logger +# ============================================================================== +# Multiprocessing Configuration - CRITICAL for Thread Safety +# ============================================================================== +# +# PROBLEM: Python 3.12+ warns about fork() with multi-threaded processes: +# "This process is multi-threaded, use of fork() may lead to deadlocks" +# +# WHY IT'S DANGEROUS: +# 1. This Flask app has multiple threads (HTTP handlers, workers, SocketIO) +# 2. fork() copies ONLY the calling thread to the child process +# 3. BUT fork() also copies all locks/mutexes in their current state +# 4. If another thread held a lock during fork() → child has locked lock with no owner +# 5. Result: PERMANENT DEADLOCK if child tries to acquire that lock +# +# SOLUTION: Use 'spawn' instead of 'fork' +# - spawn starts a fresh Python interpreter (no inherited threads or locks) +# - Slower (~200ms vs ~1ms) but safe with multi-threaded parent +# - Consistent across all platforms (Windows already uses spawn by default) +# +# IMPLEMENTATION: +# 1. Explicit contexts everywhere (primary protection): +# - Watch.py: ctx = multiprocessing.get_context('spawn') +# - playwright.py: ctx = multiprocessing.get_context('spawn') +# - puppeteer.py: ctx = multiprocessing.get_context('spawn') +# +# 2. Global default (defense-in-depth, below): +# - Safety net if future code forgets explicit context +# - Protects against third-party libraries using Process() +# - Costs nothing (explicit contexts always override it) +# +# WHY BOTH? +# - Explicit contexts: Clear, self-documenting, always works +# - Global default: Safety net for forgotten contexts or library code +# - If someone writes "Process()" instead of "ctx.Process()", still safe! +# +# See: https://docs.python.org/3/library/multiprocessing.html#contexts-and-start-methods +# ============================================================================== + +import multiprocessing +import sys + +# Set spawn as global default (safety net - all our code uses explicit contexts anyway) +# Skip in tests to avoid breaking pytest-flask's LiveServer fixture (uses unpicklable local functions) +if 'pytest' not in sys.modules: + try: + if multiprocessing.get_start_method(allow_none=True) is None: + multiprocessing.set_start_method('spawn', force=False) + logger.debug("Set multiprocessing default to 'spawn' for thread safety (explicit contexts used everywhere)") + except RuntimeError: + logger.debug(f"Multiprocessing start method already set: {multiprocessing.get_start_method()}") + # Only global so we can access it in the signal handler app = None datastore = None @@ -165,6 +217,11 @@ def main(): " WARNING, ERROR, CRITICAL") sys.exit(2) + # Disable verbose pyppeteer logging to prevent memory leaks from large CDP messages + # Set both parent and child loggers since pyppeteer hardcodes DEBUG level + logging.getLogger('pyppeteer.connection').setLevel(logging.WARNING) + logging.getLogger('pyppeteer.connection.Connection').setLevel(logging.WARNING) + # isnt there some @thingy to attach to each route to tell it, that this route needs a datastore app_config = {'datastore_path': datastore_path} diff --git a/changedetectionio/api/Watch.py b/changedetectionio/api/Watch.py index 77721a1a8..dc80fc020 100644 --- a/changedetectionio/api/Watch.py +++ b/changedetectionio/api/Watch.py @@ -2,12 +2,13 @@ import os from changedetectionio.validate_url import is_safe_valid_url -from flask_expects_json import expects_json +from . import auth from changedetectionio import queuedWatchMetaData, strtobool from changedetectionio import worker_handler -from flask_restful import abort, Resource from flask import request, make_response, send_from_directory -from . import auth +from flask_expects_json import expects_json +from flask_restful import abort, Resource +from loguru import logger import copy # Import schemas from __init__.py @@ -127,7 +128,60 @@ class Watch(Resource): if request.json.get('url') and not is_safe_valid_url(request.json.get('url')): return "Invalid URL", 400 - watch.update(request.json) + # Handle processor-config-* fields separately (save to JSON, not datastore) + from changedetectionio import processors + processor_config_data = {} + regular_data = {} + + for key, value in request.json.items(): + if key.startswith('processor_config_'): + config_key = key.replace('processor_config_', '') + if value: # Only save non-empty values + processor_config_data[config_key] = value + else: + regular_data[key] = value + + # Update watch with regular (non-processor-config) fields + watch.update(regular_data) + + # Save processor config to JSON file if any config data exists + if processor_config_data: + try: + processor_name = request.json.get('processor', watch.get('processor')) + if processor_name: + # Create a processor instance to access config methods + from changedetectionio.processors import difference_detection_processor + processor_instance = difference_detection_processor(self.datastore, 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, processor_config_data) + logger.debug(f"API: Saved processor config to {config_filename}: {processor_config_data}") + + # Call optional edit_hook if processor has one + try: + import importlib + edit_hook_module_name = f'changedetectionio.processors.{processor_name}.edit_hook' + + try: + edit_hook = importlib.import_module(edit_hook_module_name) + logger.debug(f"API: Found edit_hook module for {processor_name}") + + if hasattr(edit_hook, 'on_config_save'): + logger.info(f"API: Calling edit_hook.on_config_save for {processor_name}") + # Call hook and get updated config + updated_config = edit_hook.on_config_save(watch, processor_config_data, self.datastore) + # Save updated config back to file + processor_instance.update_extra_watch_config(config_filename, updated_config) + logger.info(f"API: Edit hook updated config: {updated_config}") + else: + logger.debug(f"API: Edit hook module found but no on_config_save function") + except ModuleNotFoundError: + logger.debug(f"API: No edit_hook module for processor {processor_name} (this is normal)") + except Exception as hook_error: + logger.error(f"API: Edit hook error (non-fatal): {hook_error}", exc_info=True) + + except Exception as e: + logger.error(f"API: Failed to save processor config: {e}") return "OK", 200 diff --git a/changedetectionio/async_update_worker.py b/changedetectionio/async_update_worker.py index ef0c97b19..4b18b8a57 100644 --- a/changedetectionio/async_update_worker.py +++ b/changedetectionio/async_update_worker.py @@ -42,13 +42,13 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore): try: # Use native janus async interface - no threads needed! queued_item_data = await asyncio.wait_for(q.async_get(), timeout=1.0) - + except asyncio.TimeoutError: # No jobs available, continue loop continue except Exception as e: logger.critical(f"CRITICAL: Worker {worker_id} failed to get queue item: {type(e).__name__}: {e}") - + # Log queue health for debugging try: queue_size = q.qsize() @@ -56,15 +56,28 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore): logger.critical(f"CRITICAL: Worker {worker_id} queue health - size: {queue_size}, empty: {is_empty}") except Exception as health_e: logger.critical(f"CRITICAL: Worker {worker_id} queue health check failed: {health_e}") - + await asyncio.sleep(0.1) continue - + uuid = queued_item_data.item.get('uuid') - fetch_start_time = round(time.time()) - - # Mark this UUID as being processed + + # RACE CONDITION FIX: Check if this UUID is already being processed by another worker from changedetectionio import worker_handler + from changedetectionio.queuedWatchMetaData import PrioritizedItem + if worker_handler.is_watch_running(uuid): + logger.trace(f"Worker {worker_id} skipping UUID {uuid} - already being processed, re-queuing for later") + # Re-queue with MUCH lower priority (higher number = processed later) + # This prevents tight loop where high-priority item keeps getting picked immediately + deferred_priority = max(1000, queued_item_data.priority * 10) + deferred_item = PrioritizedItem(priority=deferred_priority, item=queued_item_data.item) + worker_handler.queue_item_async_safe(q, deferred_item, silent=True) + await asyncio.sleep(0.1) # Brief pause to avoid tight loop + continue + + fetch_start_time = round(time.time()) + + # Mark this UUID as being processed worker_handler.set_uuid_processing(uuid, processing=True) try: @@ -89,9 +102,8 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore): processor = watch.get('processor', 'text_json_diff') # Init a new 'difference_detection_processor' - processor_module_name = f"changedetectionio.processors.{processor}.processor" try: - processor_module = importlib.import_module(processor_module_name) + processor_module = importlib.import_module(f"changedetectionio.processors.{processor}.processor") except ModuleNotFoundError as e: print(f"Processor module '{processor}' not found.") raise e @@ -332,7 +344,7 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore): fetch_start_time += 1 await asyncio.sleep(1) - watch.save_history_text(contents=contents, + watch.save_history_blob(contents=contents, timestamp=int(fetch_start_time), snapshot_id=update_obj.get('previous_md5', 'none')) @@ -439,6 +451,10 @@ async def async_update_worker(worker_id, q, notification_q, app, datastore): # 3. GC can't collect the object anyway (still referenced by datastore) # 4. It would just cause confusion + # Force garbage collection after cleanup + import gc + gc.collect() + logger.debug(f"Worker {worker_id} completed watch {uuid} in {time.time()-fetch_start_time:.2f}s") except Exception as cleanup_error: logger.error(f"Worker {worker_id} error during cleanup: {cleanup_error}") diff --git a/changedetectionio/blueprint/settings/templates/settings.html b/changedetectionio/blueprint/settings/templates/settings.html index ce6a06949..d822baf14 100644 --- a/changedetectionio/blueprint/settings/templates/settings.html +++ b/changedetectionio/blueprint/settings/templates/settings.html @@ -42,11 +42,12 @@
{{ render_field(form.requests.form.time_between_check, class="time-check-widget") }} + Default recheck time for all watches, current system minimum is {{min_system_recheck_seconds}} seconds (more info).
- +
- {{ render_time_schedule_form(form.requests, available_timezones, timezone_default_config) }} + {{ render_time_schedule_form(form.requests, available_timezones, timezone_default_config) }}
diff --git a/changedetectionio/blueprint/ui/diff.py b/changedetectionio/blueprint/ui/diff.py index ed99ab8da..7300c0221 100644 --- a/changedetectionio/blueprint/ui/diff.py +++ b/changedetectionio/blueprint/ui/diff.py @@ -1,6 +1,5 @@ from flask import Blueprint, request, redirect, url_for, flash, render_template, make_response, send_from_directory -import os -import time + import re import importlib from loguru import logger @@ -93,6 +92,11 @@ def construct_blueprint(datastore: ChangeDetectionStore): flash("No history found for the specified link, bad link?", "error") return redirect(url_for('watchlist.index')) + dates = list(watch.history.keys()) + if not dates or len(dates) < 2: + flash("Not enough history (2 snapshots required) to show difference page for this watch.", "error") + return redirect(url_for('watchlist.index')) + # Get the processor type for this watch processor_name = watch.get('processor', 'text_json_diff') @@ -240,4 +244,73 @@ def construct_blueprint(datastore: ChangeDetectionStore): redirect=redirect ) + @diff_blueprint.route("/diff//processor-asset/", methods=['GET']) + @login_optionally_required + def processor_asset(uuid, asset_name): + """ + Serve processor-specific binary assets (images, files, etc.). + + This route is processor-aware: it delegates to the processor's + difference.py module, allowing different processor types to serve + custom assets without embedding them as base64 in templates. + + This solves memory issues with large binary data (e.g., screenshots) + by streaming them as separate HTTP responses instead of embedding + in the HTML template. + + Each processor implements processors/{type}/difference.py::get_asset() + which returns (binary_data, content_type, cache_control_header). + + Example URLs: + - /diff/{uuid}/processor-asset/before + - /diff/{uuid}/processor-asset/after + - /diff/{uuid}/processor-asset/rendered_diff + """ + # More for testing, possible to return the first/only + if uuid == 'first': + uuid = list(datastore.data['watching'].keys()).pop() + + try: + watch = datastore.data['watching'][uuid] + except KeyError: + flash("No history found for the specified link, bad link?", "error") + return redirect(url_for('watchlist.index')) + + # Get the processor type for this watch + processor_name = watch.get('processor', 'text_json_diff') + + try: + # Try to import the processor's difference module + processor_module = importlib.import_module(f'changedetectionio.processors.{processor_name}.difference') + + # Call the processor's get_asset() function + if hasattr(processor_module, 'get_asset'): + result = processor_module.get_asset( + asset_name=asset_name, + watch=watch, + datastore=datastore, + request=request + ) + + if result is None: + from flask import abort + abort(404, description=f"Asset '{asset_name}' not found") + + binary_data, content_type, cache_control = result + + response = make_response(binary_data) + response.headers['Content-Type'] = content_type + if cache_control: + response.headers['Cache-Control'] = cache_control + return response + else: + logger.warning(f"Processor {processor_name} does not implement get_asset()") + from flask import abort + abort(404, description=f"Processor '{processor_name}' does not support assets") + + except (ImportError, ModuleNotFoundError) as e: + logger.warning(f"Processor {processor_name} does not have a difference module: {e}") + from flask import abort + abort(404, description=f"Processor '{processor_name}' not found") + return diff_blueprint diff --git a/changedetectionio/blueprint/ui/edit.py b/changedetectionio/blueprint/ui/edit.py index 0747b4ee9..f696979c0 100644 --- a/changedetectionio/blueprint/ui/edit.py +++ b/changedetectionio/blueprint/ui/edit.py @@ -1,8 +1,7 @@ -import time from copy import deepcopy import os import importlib.resources -from flask import Blueprint, request, redirect, url_for, flash, render_template, make_response, send_from_directory, abort +from flask import Blueprint, request, redirect, url_for, flash, render_template, abort from loguru import logger from jinja2 import Environment, FileSystemLoader @@ -96,6 +95,26 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe form.datastore = datastore form.watch = default + # Load processor-specific config from JSON file for GET requests + if request.method == 'GET' and processor_name: + try: + from changedetectionio.processors.base import difference_detection_processor + # Create a processor instance to access config methods + processor_instance = difference_detection_processor(datastore, uuid) + # Use processor name as filename so each processor keeps its own config + config_filename = f'{processor_name}.json' + processor_config = processor_instance.get_extra_watch_config(config_filename) + + if processor_config: + # Populate processor-config-* fields from JSON + for config_key, config_value in processor_config.items(): + field_name = f'processor_config_{config_key}' + if hasattr(form, field_name): + getattr(form, field_name).data = config_value + logger.debug(f"Loaded processor config from {config_filename}: {field_name} = {config_value}") + except Exception as e: + logger.warning(f"Failed to load processor config: {e}") + for p in datastore.extra_browsers: form.fetch_backend.choices.append(p) @@ -114,11 +133,6 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe if request.method == 'POST' and form.validate(): - # If they changed processor, it makes sense to reset it. - if datastore.data['watching'][uuid].get('processor') != form.data.get('processor'): - datastore.data['watching'][uuid].clear_watch() - flash("Reset watch history due to change of processor") - extra_update_obj = { 'consecutive_filter_failures': 0, 'last_error' : False @@ -129,7 +143,60 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe extra_update_obj['time_between_check'] = form.time_between_check.data - # Ignore text + # Handle processor-config-* fields separately (save to JSON, not datastore) + processor_config_data = {} + fields_to_remove = [] + for field_name, field_value in form.data.items(): + if field_name.startswith('processor_config_'): + config_key = field_name.replace('processor_config_', '') + if field_value: # Only save non-empty values + processor_config_data[config_key] = field_value + fields_to_remove.append(field_name) + + # Save processor config to JSON file if any config data exists + if processor_config_data: + try: + processor_name = form.data.get('processor') + # Create a processor instance to access config methods + processor_instance = processors.difference_detection_processor(datastore, 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, processor_config_data) + logger.debug(f"Saved processor config to {config_filename}: {processor_config_data}") + + # Call optional edit_hook if processor has one + try: + # Try to import the edit_hook module from the processor package + import importlib + edit_hook_module_name = f'changedetectionio.processors.{processor_name}.edit_hook' + + try: + edit_hook = importlib.import_module(edit_hook_module_name) + logger.debug(f"Found edit_hook module for {processor_name}") + + if hasattr(edit_hook, 'on_config_save'): + logger.info(f"Calling edit_hook.on_config_save for {processor_name}") + watch_obj = datastore.data['watching'][uuid] + # Call hook and get updated config + updated_config = edit_hook.on_config_save(watch_obj, processor_config_data, datastore) + # Save updated config back to file + processor_instance.update_extra_watch_config(config_filename, updated_config) + logger.info(f"Edit hook updated config: {updated_config}") + else: + logger.debug(f"Edit hook module found but no on_config_save function") + except ModuleNotFoundError: + logger.debug(f"No edit_hook module for processor {processor_name} (this is normal)") + except Exception as hook_error: + logger.error(f"Edit hook error (non-fatal): {hook_error}", exc_info=True) + + except Exception as e: + logger.error(f"Failed to save processor config: {e}") + + # Remove processor-config-* fields from form.data before updating datastore + for field_name in fields_to_remove: + form.data.pop(field_name, None) + + # Ignore text form_ignore_text = form.ignore_text.data datastore.data['watching'][uuid]['ignore_text'] = form_ignore_text @@ -231,12 +298,17 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe # Get fetcher capabilities instead of hardcoded logic capabilities = get_fetcher_capabilities(watch, datastore) app_rss_token = datastore.data['settings']['application'].get('rss_access_token'), + + c = [f"processor-{watch.get('processor')}"] + if worker_handler.is_watch_running(uuid): + c.append('checking-now') + template_args = { 'available_processors': processors.available_processors(), 'available_timezones': sorted(available_timezones()), 'browser_steps_config': browser_step_ui_config, 'emailprefix': os.getenv('NOTIFICATION_MAIL_BUTTON_PREFIX', False), - 'extra_classes': 'checking-now' if worker_handler.is_watch_running(uuid) else '', + 'extra_classes': ' '.join(c), 'extra_notification_token_placeholder_info': datastore.get_unique_notification_token_placeholders_available(), 'extra_processor_config': form.extra_tab_content(), 'extra_title': f" - Edit - {watch.label}", diff --git a/changedetectionio/blueprint/ui/preview.py b/changedetectionio/blueprint/ui/preview.py index daf81f5c6..4c3af9ec1 100644 --- a/changedetectionio/blueprint/ui/preview.py +++ b/changedetectionio/blueprint/ui/preview.py @@ -12,10 +12,19 @@ def construct_blueprint(datastore: ChangeDetectionStore): @preview_blueprint.route("/preview/", methods=['GET']) @login_optionally_required def preview_page(uuid): - content = [] - versions = [] - timestamp = None + """ + Render the preview page for a watch. + This route is processor-aware: it delegates rendering to the processor's + preview.py module, allowing different processor types to provide + custom visualizations: + - text_json_diff: Text preview with syntax highlighting + - image_ssim_diff: Image preview with proper rendering + - restock_diff: Could show latest price/stock data + + Each processor implements processors/{type}/preview.py::render() + If a processor doesn't have a preview module, falls back to default text preview. + """ # More for testing, possible to return the first/only if uuid == 'first': uuid = list(datastore.data['watching'].keys()).pop() @@ -26,6 +35,33 @@ def construct_blueprint(datastore: ChangeDetectionStore): flash("No history found for the specified link, bad link?", "error") return redirect(url_for('watchlist.index')) + # Get the processor type for this watch + processor_name = watch.get('processor', 'text_json_diff') + + try: + # Try to import the processor's preview module + import importlib + processor_module = importlib.import_module(f'changedetectionio.processors.{processor_name}.preview') + + # Call the processor's render() function + if hasattr(processor_module, 'render'): + return processor_module.render( + watch=watch, + datastore=datastore, + request=request, + url_for=url_for, + render_template=render_template, + flash=flash, + redirect=redirect + ) + except (ImportError, ModuleNotFoundError) as e: + logger.debug(f"Processor {processor_name} does not have a preview module, using default preview: {e}") + + # Fallback: if processor doesn't have preview module, use default text preview + content = [] + versions = [] + timestamp = None + system_uses_webdriver = datastore.data['settings']['application']['fetch_backend'] == 'html_webdriver' extra_stylesheets = [url_for('static_content', group='styles', filename='diff.css')] @@ -92,4 +128,73 @@ def construct_blueprint(datastore: ChangeDetectionStore): return output + @preview_blueprint.route("/preview//processor-asset/", methods=['GET']) + @login_optionally_required + def processor_asset(uuid, asset_name): + """ + Serve processor-specific binary assets for preview (images, files, etc.). + + This route is processor-aware: it delegates to the processor's + preview.py module, allowing different processor types to serve + custom assets without embedding them as base64 in templates. + + This solves memory issues with large binary data by streaming them + as separate HTTP responses instead of embedding in the HTML template. + + Each processor implements processors/{type}/preview.py::get_asset() + which returns (binary_data, content_type, cache_control_header). + + Example URLs: + - /preview/{uuid}/processor-asset/screenshot?version=123456789 + """ + from flask import make_response + + # More for testing, possible to return the first/only + if uuid == 'first': + uuid = list(datastore.data['watching'].keys()).pop() + + try: + watch = datastore.data['watching'][uuid] + except KeyError: + flash("No history found for the specified link, bad link?", "error") + return redirect(url_for('watchlist.index')) + + # Get the processor type for this watch + processor_name = watch.get('processor', 'text_json_diff') + + try: + # Try to import the processor's preview module + import importlib + processor_module = importlib.import_module(f'changedetectionio.processors.{processor_name}.preview') + + # Call the processor's get_asset() function + if hasattr(processor_module, 'get_asset'): + result = processor_module.get_asset( + asset_name=asset_name, + watch=watch, + datastore=datastore, + request=request + ) + + if result is None: + from flask import abort + abort(404, description=f"Asset '{asset_name}' not found") + + binary_data, content_type, cache_control = result + + response = make_response(binary_data) + response.headers['Content-Type'] = content_type + if cache_control: + response.headers['Cache-Control'] = cache_control + return response + else: + logger.warning(f"Processor {processor_name} does not implement get_asset()") + from flask import abort + abort(404, description=f"Processor '{processor_name}' does not support assets") + + except (ImportError, ModuleNotFoundError) as e: + logger.warning(f"Processor {processor_name} does not have a preview module: {e}") + from flask import abort + abort(404, description=f"Processor '{processor_name}' not found") + return preview_blueprint diff --git a/changedetectionio/blueprint/ui/templates/diff.html b/changedetectionio/blueprint/ui/templates/diff.html index 08ae6bf86..01fc7eb32 100644 --- a/changedetectionio/blueprint/ui/templates/diff.html +++ b/changedetectionio/blueprint/ui/templates/diff.html @@ -97,7 +97,7 @@ {% if last_error_text %}
  • Error Text
  • {% endif %} {% if last_error_screenshot %}
  • Error Screenshot
  • {% endif %}
  • Text
  • -
  • Screenshot
  • +
  • Current screenshot
  • Extract Data
  • diff --git a/changedetectionio/blueprint/ui/templates/edit.html b/changedetectionio/blueprint/ui/templates/edit.html index 8bbc53489..a362d6f09 100644 --- a/changedetectionio/blueprint/ui/templates/edit.html +++ b/changedetectionio/blueprint/ui/templates/edit.html @@ -50,7 +50,7 @@ {% endif %}
  • Browser Steps
  • - {% if watch['processor'] == 'text_json_diff' %} + {% if watch['processor'] == 'text_json_diff' or watch['processor'] == 'image_ssim_diff' %}
  • Visual Filter Selector
  • Filters & Triggers
  • Conditions
  • @@ -284,7 +284,7 @@ Math: {{ 1 + 1 }}") }}
    - {% if watch['processor'] == 'text_json_diff' %} + {% if watch['processor'] == 'text_json_diff' or watch['processor'] == 'image_ssim_diff' %}
    + +
    +
    +
    + {% if versions|length >= 1 %} + + + + + + + + + {% endif %} +
    +
    + + Change Detection: {{ "%.2f"|format(change_percentage) }}% of pixels changed + {% if change_percentage > 0.1 %} + ⚠ Change Detected + {% else %} + ✓ No Significant Change + {% endif %} + +
    + {%- if versions|length >= 2 -%} +
    + Keyboard: + ← Previous +   → Next +
    + {%- endif -%} +
    +
    + +
    + +
    + +
    +

    Interactive Comparison

    +
    + Drag slider to compare Previous ({{ from_version|format_timestamp_timeago }}) + vs Current ({{ to_version|format_timestamp_timeago }}) +
    +
    + + + + + + Previous + + + + + + + Current + +
    + +
    + +
    + Previous screenshot +
    + + +
    + Current screenshot +
    + + +
    + Previous + Current +
    + + +
    +
    +
    +
    +
    + + +
    +

    Difference Visualization

    +
    + Red = Changed Pixels +
    +
    + + + + + + Download + +
    + Difference visualization with red highlights +
    +
    + + {% if comparison_data and comparison_data.get('history') and comparison_data.history|length > 1 %} +
    +

    Comparison History

    +

    Recent comparison results (last {{ comparison_data.history|length }} checks)

    +
    + + + + + + + + + + + {% for entry in comparison_data.history|reverse %} + + + + + + + {% endfor %} + +
    TimestampChange %MethodChanged?
    {{ entry.timestamp|format_timestamp_timeago }}{{ "%.2f"|format(entry.change_percentage) }}%{{ entry.method }} + {% if entry.changed %} + Yes + {% else %} + No + {% endif %} +
    +
    +
    + {% endif %} +
    + + + + + +{% endblock %} diff --git a/changedetectionio/processors/image_ssim_diff/templates/image_ssim_diff/preview.html b/changedetectionio/processors/image_ssim_diff/templates/image_ssim_diff/preview.html new file mode 100644 index 000000000..a1385cd31 --- /dev/null +++ b/changedetectionio/processors/image_ssim_diff/templates/image_ssim_diff/preview.html @@ -0,0 +1,35 @@ +{% extends 'base.html' %} + +{% block content %} + + {% if versions|length >= 2 %} +
    +
    +
    + + + +
    +
    +
    + Keyboard: + ← Previous   + → Next +
    + {% endif %} + +
    +

    Screenshot from {{ timestamp|format_timestamp_timeago }}

    + Screenshot preview +
    +{% endblock %} diff --git a/changedetectionio/processors/image_ssim_diff/util.py b/changedetectionio/processors/image_ssim_diff/util.py new file mode 100644 index 000000000..2ec527599 --- /dev/null +++ b/changedetectionio/processors/image_ssim_diff/util.py @@ -0,0 +1,22 @@ +""" +DEPRECATED: All multiprocessing functions have been removed. + +The image_ssim_diff processor now uses LibVIPS via ImageDiffHandler abstraction, +which provides superior performance and memory efficiency through streaming +architecture and automatic threading. + +All image operations are now handled by: +- imagehandler.py: Abstract base class defining the interface +- libvips_handler.py: LibVIPS implementation with streaming and threading + +Historical note: This file previously contained multiprocessing workers for: +- Template matching (find_region_with_template_matching_isolated) +- Template regeneration (regenerate_template_isolated) +- Image cropping (crop_image_isolated, crop_pil_image_isolated) + +These have been replaced by handler methods which are: +- Faster (no subprocess overhead) +- More memory efficient (LibVIPS streaming) +- Cleaner (no multiprocessing deadlocks) +- Better tested (no logger/forking issues) +""" diff --git a/changedetectionio/processors/restock_diff/processor.py b/changedetectionio/processors/restock_diff/processor.py index 75dcb77a4..e6c0688e9 100644 --- a/changedetectionio/processors/restock_diff/processor.py +++ b/changedetectionio/processors/restock_diff/processor.py @@ -1,4 +1,4 @@ -from .. import difference_detection_processor +from ..base import difference_detection_processor from ..exceptions import ProcessorException from . import Restock from loguru import logger @@ -9,6 +9,8 @@ import time urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) name = 'Re-stock & Price detection for pages with a SINGLE product' description = 'Detects if the product goes back to in-stock' +processor_weight = 1 +list_badge_text = "Restock" class UnableToExtractRestockData(Exception): def __init__(self, status_code): diff --git a/changedetectionio/processors/text_json_diff/processor.py b/changedetectionio/processors/text_json_diff/processor.py index 9e8e8ffae..b56b8b694 100644 --- a/changedetectionio/processors/text_json_diff/processor.py +++ b/changedetectionio/processors/text_json_diff/processor.py @@ -7,7 +7,7 @@ import re import urllib3 from changedetectionio.conditions import execute_ruleset_against_all_plugins -from changedetectionio.processors import difference_detection_processor +from ..base import difference_detection_processor from changedetectionio.html_tools import PERL_STYLE_REGEX, cdata_in_document_to_text, TRANSLATE_WHITESPACE_TABLE from changedetectionio import html_tools, content_fetchers from changedetectionio.blueprint.price_data_follower import PRICE_DATA_TRACK_ACCEPT @@ -19,6 +19,8 @@ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) name = 'Webpage Text/HTML, JSON and PDF changes' description = 'Detects all text changes where possible' +processor_weight = -100 +list_badge_text = "Text" JSON_FILTER_PREFIXES = ['json:', 'jq:', 'jqraw:'] diff --git a/changedetectionio/queue_handlers.py b/changedetectionio/queue_handlers.py index e6d5e6cfe..b13f6b21c 100644 --- a/changedetectionio/queue_handlers.py +++ b/changedetectionio/queue_handlers.py @@ -89,20 +89,20 @@ class RecheckPriorityQueue: try: # Wait for notification self.sync_q.get(block=block, timeout=timeout) - + # Get highest priority item with self._lock: if not self._priority_items: logger.critical(f"CRITICAL: Queue notification received but no priority items available") raise Exception("Priority queue inconsistency") item = heapq.heappop(self._priority_items) - + # Emit signals self._emit_get_signals() - + logger.debug(f"Successfully retrieved item: {self._get_item_uuid(item)}") return item - + except Exception as e: logger.critical(f"CRITICAL: Failed to get item from queue: {str(e)}") raise @@ -141,20 +141,20 @@ class RecheckPriorityQueue: try: # Wait for notification await self.async_q.get() - + # Get highest priority item with self._lock: if not self._priority_items: logger.critical(f"CRITICAL: Async queue notification received but no priority items available") raise Exception("Priority queue inconsistency") item = heapq.heappop(self._priority_items) - + # Emit signals self._emit_get_signals() - + logger.debug(f"Successfully async retrieved item: {self._get_item_uuid(item)}") return item - + except Exception as e: logger.critical(f"CRITICAL: Failed to async get item from queue: {str(e)}") raise diff --git a/changedetectionio/run_basic_tests.sh b/changedetectionio/run_basic_tests.sh index ac572c94a..26e33c231 100755 --- a/changedetectionio/run_basic_tests.sh +++ b/changedetectionio/run_basic_tests.sh @@ -61,15 +61,27 @@ data_sanity_test () { data_sanity_test +echo "-------------------- Running rest of tests in parallel -------------------------------" + # REMOVE_REQUESTS_OLD_SCREENSHOTS disabled so that we can write a screenshot and send it in test_notifications.py without a real browser -REMOVE_REQUESTS_OLD_SCREENSHOTS=false pytest -n 30 --dist load tests/test_*.py +REMOVE_REQUESTS_OLD_SCREENSHOTS=false \ +pytest tests/test_*.py \ + -n 30 \ + --dist=load \ + -vvv \ + -s \ + --capture=no \ + --log-cli-level=DEBUG \ + --log-cli-format="%(asctime)s [%(process)d] [%(levelname)s] %(name)s: %(message)s" + +echo "---------------------------- DONE parallel test ---------------------------------------" -#time pytest -n auto --dist loadfile -vv --tb=long tests/test_*.py echo "RUNNING WITH BASE_URL SET" # Now re-run some tests with BASE_URL enabled # Re #65 - Ability to include a link back to the installation, in the notification. export BASE_URL="https://really-unique-domain.io" + REMOVE_REQUESTS_OLD_SCREENSHOTS=false pytest -vv -s --maxfail=1 tests/test_notification.py diff --git a/changedetectionio/static/js/global-settings.js b/changedetectionio/static/js/global-settings.js index e6a71604f..2cd3947e0 100644 --- a/changedetectionio/static/js/global-settings.js +++ b/changedetectionio/static/js/global-settings.js @@ -24,6 +24,19 @@ $(document).ready(function () { $(target).toggle(); }); + // Handle processor radio button changes - update body class + $('input[name="processor"]').on('change', function() { + var selectedProcessor = $(this).val(); + + // Remove any existing processor-* classes from body + $('body').removeClass(function(index, className) { + return (className.match(/\bprocessor-\S+/g) || []).join(' '); + }); + + // Add the new processor class + $('body').addClass('processor-' + selectedProcessor); + }); + // Time zone config related $(".local-time").each(function (e) { $(this).text(new Date($(this).data("utc")).toLocaleString()); diff --git a/changedetectionio/static/js/visual-selector.js b/changedetectionio/static/js/visual-selector.js index f6f8e79c2..efb456e96 100644 --- a/changedetectionio/static/js/visual-selector.js +++ b/changedetectionio/static/js/visual-selector.js @@ -11,6 +11,18 @@ $(document).ready(() => { let c, xctx, ctx; let xScale = 1, yScale = 1; let selectorImage, selectorImageRect, selectorData; + let elementHandlers = {}; // Store references to element selection handlers (needed for draw mode toggling) + + // Box drawing mode variables (for image_ssim_diff processor) + let drawMode = false; + let isDrawing = false; + let isDragging = false; + let drawStartX, drawStartY; + let dragOffsetX, dragOffsetY; + let drawnBox = null; + let resizeHandle = null; + const HANDLE_SIZE = 8; + const isImageProcessor = $('input[value="image_ssim_diff"]').is(':checked'); // Global jQuery selectors with "Elem" appended @@ -141,6 +153,10 @@ $(document).ready(() => { setScale(); reflowSelector(); + + // Initialize draw mode after everything is set up + initializeDrawMode(); + $fetchingUpdateNoticeElem.fadeOut(); }); } @@ -201,9 +217,14 @@ $(document).ready(() => { highlightCurrentSelected(); updateFiltersText(); - $selectorCanvasElem.bind('mousemove', handleMouseMove.debounce(5)); - $selectorCanvasElem.bind('mousedown', handleMouseDown.debounce(5)); - $selectorCanvasElem.bind('mouseleave', highlightCurrentSelected.debounce(5)); + // Store handler references for later use + elementHandlers.handleMouseMove = handleMouseMove.debounce(5); + elementHandlers.handleMouseDown = handleMouseDown.debounce(5); + elementHandlers.handleMouseLeave = highlightCurrentSelected.debounce(5); + + $selectorCanvasElem.bind('mousemove', elementHandlers.handleMouseMove); + $selectorCanvasElem.bind('mousedown', elementHandlers.handleMouseDown); + $selectorCanvasElem.bind('mouseleave', elementHandlers.handleMouseLeave); function handleMouseMove(e) { if (!e.offsetX && !e.offsetY) { @@ -257,4 +278,372 @@ $(document).ready(() => { xctx.strokeRect(sel.left * xScale, sel.top * yScale, sel.width * xScale, sel.height * yScale); }); } + + // ============= BOX DRAWING MODE (for image_ssim_diff processor) ============= + + function initializeDrawMode() { + if (!isImageProcessor || !c) return; + + const $selectorModeRadios = $('input[name="selector-mode"]'); + const $boundingBoxField = $('#bounding_box'); + const $selectionModeField = $('#selection_mode'); + + // Load existing selection mode if present + const savedMode = $selectionModeField.val(); + if (savedMode && (savedMode === 'element' || savedMode === 'draw')) { + $selectorModeRadios.filter(`[value="${savedMode}"]`).prop('checked', true); + console.log('Loaded saved mode:', savedMode); + } + + // Load existing bounding box if present + const existingBox = $boundingBoxField.val(); + if (existingBox) { + try { + const parts = existingBox.split(',').map(p => parseFloat(p)); + if (parts.length === 4) { + drawnBox = { + x: parts[0] * xScale, + y: parts[1] * yScale, + width: parts[2] * xScale, + height: parts[3] * yScale + }; + console.log('Loaded saved bounding box:', existingBox); + } + } catch (e) { + console.error('Failed to parse existing bounding box:', e); + } + } + + // Update mode when radio changes + $selectorModeRadios.off('change').on('change', function() { + const newMode = $(this).val(); + drawMode = newMode === 'draw'; + console.log('Mode changed to:', newMode); + + // Save the mode to the hidden field + $selectionModeField.val(newMode); + + if (drawMode) { + enableDrawMode(); + } else { + disableDrawMode(); + } + }); + + // Set initial mode based on which radio is checked + drawMode = $selectorModeRadios.filter(':checked').val() === 'draw'; + console.log('Initial mode:', drawMode ? 'draw' : 'element'); + + // Save initial mode + $selectionModeField.val(drawMode ? 'draw' : 'element'); + + if (drawMode) { + enableDrawMode(); + } + } + + function enableDrawMode() { + console.log('Enabling draw mode...'); + + // Unbind element selection handlers + $selectorCanvasElem.unbind('mousemove mousedown mouseleave'); + + // Set cursor to crosshair + $selectorCanvasElem.css('cursor', 'crosshair'); + + // Bind draw mode handlers + $selectorCanvasElem.on('mousedown', handleDrawMouseDown); + $selectorCanvasElem.on('mousemove', handleDrawMouseMove); + $selectorCanvasElem.on('mouseup', handleDrawMouseUp); + $selectorCanvasElem.on('mouseleave', handleDrawMouseUp); + + // Clear element selections and xpath display + currentSelections = []; + $includeFiltersElem.val(''); + $selectorCurrentXpathElem.html('Draw mode - click and drag to select an area'); + + // Clear the canvas + if (ctx && xctx) { + ctx.clearRect(0, 0, c.width, c.height); + xctx.clearRect(0, 0, c.width, c.height); + } + + // Redraw if we have an existing box + if (drawnBox) { + drawBox(); + } + } + + function disableDrawMode() { + console.log('Disabling draw mode, switching to element mode...'); + + // Unbind draw handlers + $selectorCanvasElem.unbind('mousedown mousemove mouseup mouseleave'); + + // Reset cursor + $selectorCanvasElem.css('cursor', 'default'); + + // Clear drawn box + drawnBox = null; + $('#bounding_box').val(''); + + // Clear the canvases + if (ctx && xctx) { + ctx.clearRect(0, 0, c.width, c.height); + xctx.clearRect(0, 0, c.width, c.height); + } + + // Restore element selections from include_filters + currentSelections = []; + if (selectorData && selectorData['size_pos']) { + let existingFilters = splitToList($includeFiltersElem.val()); + + selectorData['size_pos'].forEach(sel => { + if ((!runInClearMode && sel.highlight_as_custom_filter) || existingFilters.includes(sel.xpath)) { + console.log("Restoring selection: " + sel.xpath); + currentSelections.push(sel); + } + }); + } + + // Re-enable element selection handlers using stored references + if (elementHandlers.handleMouseMove) { + $selectorCanvasElem.bind('mousemove', elementHandlers.handleMouseMove); + $selectorCanvasElem.bind('mousedown', elementHandlers.handleMouseDown); + $selectorCanvasElem.bind('mouseleave', elementHandlers.handleMouseLeave); + } + + // Restore the element selection display + $selectorCurrentXpathElem.html('Hover over elements to select'); + + // Highlight the restored selections + highlightCurrentSelected(); + } + + function handleDrawMouseDown(e) { + const rect = c.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + // Check if clicking on a resize handle + if (drawnBox) { + resizeHandle = getResizeHandle(x, y); + if (resizeHandle) { + isDrawing = true; + drawStartX = x; + drawStartY = y; + return; + } + + // Check if clicking inside the box (for dragging) + if (isInsideBox(x, y)) { + isDragging = true; + dragOffsetX = x - drawnBox.x; + dragOffsetY = y - drawnBox.y; + $selectorCanvasElem.css('cursor', 'move'); + return; + } + } + + // Start new box + isDrawing = true; + drawStartX = x; + drawStartY = y; + drawnBox = { x: x, y: y, width: 0, height: 0 }; + } + + function handleDrawMouseMove(e) { + const rect = c.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + // Update cursor based on position + if (!isDrawing && !isDragging && drawnBox) { + const handle = getResizeHandle(x, y); + if (handle) { + $selectorCanvasElem.css('cursor', getHandleCursor(handle)); + } else if (isInsideBox(x, y)) { + $selectorCanvasElem.css('cursor', 'move'); + } else { + $selectorCanvasElem.css('cursor', 'crosshair'); + } + } + + // Handle dragging the box + if (isDragging) { + drawnBox.x = x - dragOffsetX; + drawnBox.y = y - dragOffsetY; + drawBox(); + return; + } + + if (!isDrawing) return; + + if (resizeHandle) { + // Resize existing box + resizeBox(x, y); + } else { + // Draw new box + drawnBox.width = x - drawStartX; + drawnBox.height = y - drawStartY; + } + + drawBox(); + } + + function handleDrawMouseUp(e) { + if (!isDrawing && !isDragging) return; + + isDrawing = false; + isDragging = false; + resizeHandle = null; + + if (drawnBox) { + // Normalize box (handle negative dimensions) + if (drawnBox.width < 0) { + drawnBox.x += drawnBox.width; + drawnBox.width = Math.abs(drawnBox.width); + } + if (drawnBox.height < 0) { + drawnBox.y += drawnBox.height; + drawnBox.height = Math.abs(drawnBox.height); + } + + // Constrain to canvas bounds + drawnBox.x = Math.max(0, Math.min(drawnBox.x, c.width - drawnBox.width)); + drawnBox.y = Math.max(0, Math.min(drawnBox.y, c.height - drawnBox.height)); + + // Save to form field (convert from scaled to natural coordinates) + const naturalX = Math.round(drawnBox.x / xScale); + const naturalY = Math.round(drawnBox.y / yScale); + const naturalWidth = Math.round(drawnBox.width / xScale); + const naturalHeight = Math.round(drawnBox.height / yScale); + + $('#bounding_box').val(`${naturalX},${naturalY},${naturalWidth},${naturalHeight}`); + + drawBox(); + } + } + + function drawBox() { + if (!drawnBox) return; + + // Clear and redraw + ctx.clearRect(0, 0, c.width, c.height); + xctx.clearRect(0, 0, c.width, c.height); + + // Draw box + ctx.strokeStyle = STROKE_STYLE_REDLINE; + ctx.fillStyle = FILL_STYLE_REDLINE; + ctx.lineWidth = 3; + + const drawX = drawnBox.width >= 0 ? drawnBox.x : drawnBox.x + drawnBox.width; + const drawY = drawnBox.height >= 0 ? drawnBox.y : drawnBox.y + drawnBox.height; + const drawW = Math.abs(drawnBox.width); + const drawH = Math.abs(drawnBox.height); + + ctx.strokeRect(drawX, drawY, drawW, drawH); + ctx.fillRect(drawX, drawY, drawW, drawH); + + // Draw resize handles + if (!isDrawing) { + drawResizeHandles(drawX, drawY, drawW, drawH); + } + } + + function drawResizeHandles(x, y, w, h) { + ctx.fillStyle = '#fff'; + ctx.strokeStyle = '#000'; + ctx.lineWidth = 1; + + const handles = [ + { x: x, y: y }, // top-left + { x: x + w, y: y }, // top-right + { x: x, y: y + h }, // bottom-left + { x: x + w, y: y + h } // bottom-right + ]; + + handles.forEach(handle => { + ctx.fillRect(handle.x - HANDLE_SIZE/2, handle.y - HANDLE_SIZE/2, HANDLE_SIZE, HANDLE_SIZE); + ctx.strokeRect(handle.x - HANDLE_SIZE/2, handle.y - HANDLE_SIZE/2, HANDLE_SIZE, HANDLE_SIZE); + }); + } + + function isInsideBox(x, y) { + if (!drawnBox) return false; + + const drawX = drawnBox.width >= 0 ? drawnBox.x : drawnBox.x + drawnBox.width; + const drawY = drawnBox.height >= 0 ? drawnBox.y : drawnBox.y + drawnBox.height; + const drawW = Math.abs(drawnBox.width); + const drawH = Math.abs(drawnBox.height); + + return x >= drawX && x <= drawX + drawW && y >= drawY && y <= drawY + drawH; + } + + function getResizeHandle(x, y) { + if (!drawnBox) return null; + + const drawX = drawnBox.width >= 0 ? drawnBox.x : drawnBox.x + drawnBox.width; + const drawY = drawnBox.height >= 0 ? drawnBox.y : drawnBox.y + drawnBox.height; + const drawW = Math.abs(drawnBox.width); + const drawH = Math.abs(drawnBox.height); + + const handles = { + 'tl': { x: drawX, y: drawY }, + 'tr': { x: drawX + drawW, y: drawY }, + 'bl': { x: drawX, y: drawY + drawH }, + 'br': { x: drawX + drawW, y: drawY + drawH } + }; + + for (const [key, handle] of Object.entries(handles)) { + if (Math.abs(x - handle.x) <= HANDLE_SIZE && Math.abs(y - handle.y) <= HANDLE_SIZE) { + return key; + } + } + + return null; + } + + function getHandleCursor(handle) { + const cursors = { + 'tl': 'nw-resize', + 'tr': 'ne-resize', + 'bl': 'sw-resize', + 'br': 'se-resize' + }; + return cursors[handle] || 'crosshair'; + } + + function resizeBox(x, y) { + const dx = x - drawStartX; + const dy = y - drawStartY; + + const originalBox = { ...drawnBox }; + + switch (resizeHandle) { + case 'tl': + drawnBox.x = x; + drawnBox.y = y; + drawnBox.width = originalBox.x + originalBox.width - x; + drawnBox.height = originalBox.y + originalBox.height - y; + break; + case 'tr': + drawnBox.y = y; + drawnBox.width = x - originalBox.x; + drawnBox.height = originalBox.y + originalBox.height - y; + break; + case 'bl': + drawnBox.x = x; + drawnBox.width = originalBox.x + originalBox.width - x; + drawnBox.height = y - originalBox.y; + break; + case 'br': + drawnBox.width = x - originalBox.x; + drawnBox.height = y - originalBox.y; + break; + } + + drawStartX = x; + drawStartY = y; + } }); \ No newline at end of file diff --git a/changedetectionio/static/styles/scss/diff-image.scss b/changedetectionio/static/styles/scss/diff-image.scss new file mode 100644 index 000000000..35c05201e --- /dev/null +++ b/changedetectionio/static/styles/scss/diff-image.scss @@ -0,0 +1,259 @@ +/** + * Image Comparison Diff Styles + * Styles for the interactive image comparison slider and screenshot diff visualization + */ + +.comparison-score { + padding: 1em; + background: var(--color-table-stripe); + border-radius: 4px; + margin: 1em 0; + border: 1px solid var(--color-border-table-cell); + color: var(--color-text); +} + +.change-detected { + color: #d32f2f; + font-weight: bold; +} + +.no-change { + color: #388e3c; + font-weight: bold; +} + +.comparison-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1em; + margin: 1em 1em; + + @media (max-width: 1200px) { + grid-template-columns: 1fr; + } +} + +/* Interactive Image Comparison Slider */ +.image-comparison { + position: relative; + width: 100%; + overflow: hidden; + border: 1px solid var(--color-border-table-cell); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + user-select: none; + + img { + display: block; + width: 100%; + height: auto; + max-width: 100%; + border: none; + box-shadow: none; + } +} + +/* Image wrappers with checkered background */ +.comparison-image-wrapper { + position: relative; + width: 100%; + display: flex; + align-items: flex-start; + justify-content: center; + /* Very light checkered background pattern */ + background-color: var(--color-background); + background-image: + linear-gradient(45deg, var(--color-table-stripe) 25%, transparent 25%), + linear-gradient(-45deg, var(--color-table-stripe) 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, var(--color-table-stripe) 75%), + linear-gradient(-45deg, transparent 75%, var(--color-table-stripe) 75%); + background-size: 20px 20px; + background-position: 0 0, 0 10px, 10px -10px, -10px 0px; +} + +.comparison-after { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + clip-path: inset(0 0 0 50%); +} + +.comparison-slider { + position: absolute; + top: 0; + left: 50%; + width: 4px; + height: 100%; + background: #0078e7; + cursor: ew-resize; + transform: translateX(-2px); + z-index: 10; +} + +.comparison-handle { + position: absolute; + top: 50%; + left: 50%; + width: 48px; + height: 48px; + background: #0078e7; + border: 3px solid white; + border-radius: 50%; + transform: translate(-50%, -50%); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + display: flex; + align-items: center; + justify-content: center; + cursor: ew-resize; + transition: top 0.1s ease-out; + + &::after { + content: '⇄'; + color: white; + font-size: 24px; + font-weight: bold; + pointer-events: none; + } +} + +.comparison-labels { + position: absolute; + top: 10px; + width: 100%; + display: flex; + justify-content: space-between; + padding: 0 0px; + z-index: 5; + pointer-events: none; +} + +.comparison-label { + background: rgba(0, 0, 0, 0.7); + color: white; + padding: 0.5em 1em; + border-radius: 4px; + font-size: 0.9em; + font-weight: bold; +} + +.screenshot-panel { + text-align: center; + background: var(--color-background); + border: 1px solid var(--color-border-table-cell); + border-radius: 4px; + padding: 1em; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); + + h3 { + margin: 0 0 1em 0; + font-size: 1.1em; + color: var(--color-text); + border-bottom: 2px solid var(--color-background-button-primary); + padding-bottom: 0.5em; + } + + &.diff h3 { + border-bottom-color: #d32f2f; + } + + img { + max-width: 100%; + height: auto; + border: 1px solid var(--color-border-table-cell); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + } +} + +.version-selector { + display: inline-block; + margin: 0 0.5em; + + label { + font-weight: bold; + margin-right: 0.5em; + color: var(--color-text); + } +} + +#settings { + background: var(--color-background); + padding: 1.5em; + border-radius: 4px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); + margin-bottom: 2em; + border: 1px solid var(--color-border-table-cell); + + h2 { + margin-top: 0; + color: var(--color-text); + } +} + +.diff-fieldset { + border: none; + padding: 0; + margin: 0; +} + +.edit-link { + float: right; + margin-top: -0.5em; +} + +.comparison-description { + color: var(--color-text-input-description); + font-size: 0.9em; + margin-bottom: 1em; +} + +.download-link { + color: var(--color-link); + text-decoration: none; + display: inline-flex; + align-items: center; + gap: 0.3em; + font-size: 0.85em; + + &:hover { + text-decoration: underline; + } +} + +.diff-section-header { + color: #d32f2f; + font-size: 0.9em; + margin-bottom: 1em; + font-weight: bold; + display: flex; + align-items: center; + justify-content: center; + gap: 1em; +} + +.comparison-history-section { + margin-top: 3em; + padding: 1em; + background: var(--color-background); + border: 1px solid var(--color-border-table-cell); + border-radius: 4px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); + + h3 { + color: var(--color-text); + } + + p { + color: var(--color-text-input-description); + font-size: 0.9em; + } +} + +.history-changed-yes { + color: #d32f2f; + font-weight: bold; +} + +.history-changed-no { + color: #388e3c; +} diff --git a/changedetectionio/static/styles/scss/parts/_diff_image.scss b/changedetectionio/static/styles/scss/parts/_diff_image.scss new file mode 100644 index 000000000..f9285752b --- /dev/null +++ b/changedetectionio/static/styles/scss/parts/_diff_image.scss @@ -0,0 +1,10 @@ +body.processor-image_ssim_diff { + #edit-text-filter { + .text-filtering { + display: none; + } + } + #conditions-tab { + display: none; + } +} \ No newline at end of file diff --git a/changedetectionio/static/styles/scss/styles.scss b/changedetectionio/static/styles/scss/styles.scss index 331bf64fe..0a2827af2 100644 --- a/changedetectionio/static/styles/scss/styles.scss +++ b/changedetectionio/static/styles/scss/styles.scss @@ -21,6 +21,8 @@ @use "parts/socket"; @use "parts/visualselector"; @use "parts/widgets"; +@use "parts/diff_image"; + body { color: var(--color-text); @@ -182,6 +184,13 @@ code { margin-right: 4px; } +/* Processor type badges - colors auto-generated from processor names */ +.processor-badge { + @extend .inline-tag; + font-size: 0.85em; + font-weight: 500; +} + .watch-tag-list { color: var(--color-white); background: var(--color-text-watch-tag-list); @@ -774,7 +783,7 @@ textarea::placeholder { display: block; li { - margin-right: 3px; + margin-right: 1px; display: inline-block; color: var(--color-text-tab); border-top-left-radius: 5px; @@ -799,7 +808,7 @@ textarea::placeholder { a { display: block; - padding: 0.8em; + padding: 0.7em; color: var(--color-text-tab); } } diff --git a/changedetectionio/static/styles/styles.css b/changedetectionio/static/styles/styles.css index 0a5b02a47..b89990c11 100644 --- a/changedetectionio/static/styles/styles.css +++ b/changedetectionio/static/styles/styles.css @@ -1 +1 @@ -:root{--color-white: #fff;--color-grey-50: #111;--color-grey-100: #262626;--color-grey-200: #333;--color-grey-300: #444;--color-grey-325: #555;--color-grey-350: #565d64;--color-grey-400: #666;--color-grey-500: #777;--color-grey-600: #999;--color-grey-700: #cbcbcb;--color-grey-750: #ddd;--color-grey-800: #e0e0e0;--color-grey-850: #eee;--color-grey-900: #f2f2f2;--color-black: #000;--color-dark-red: #a00;--color-light-red: #dd0000;--color-background-page: var(--color-grey-100);--color-background-gradient-first: #5ad8f7;--color-background-gradient-second: #2f50af;--color-background-gradient-third: #9150bf;--color-background: var(--color-white);--color-text: var(--color-grey-200);--color-link: #1b98f8;--color-menu-accent: #ed5900;--color-background-code: var(--color-grey-850);--color-error: var(--color-dark-red);--color-error-input: #ffebeb;--color-error-list: var(--color-light-red);--color-table-background: var(--color-background);--color-table-stripe: var(--color-grey-900);--color-text-tab: var(--color-white);--color-background-tab: rgba(255, 255, 255, 0.2);--color-background-tab-hover: rgba(255, 255, 255, 0.5);--color-text-tab-active: #222;--color-api-key: #0078e7;--color-background-button-primary: #0078e7;--color-background-button-green: #42dd53;--color-background-button-red: #dd4242;--color-background-button-success: rgb(28, 184, 65);--color-background-button-error: rgb(202, 60, 60);--color-text-button-error: var(--color-white);--color-background-button-warning: rgb(202, 60, 60);--color-text-button-warning: var(--color-white);--color-background-button-secondary: rgb(66, 184, 221);--color-background-button-cancel: rgb(200, 200, 200);--color-text-button: var(--color-white);--color-background-button-tag: rgb(99, 99, 99);--color-background-snapshot-age: #dfdfdf;--color-error-text-snapshot-age: var(--color-white);--color-error-background-snapshot-age: #ff0000;--color-background-button-tag-active: #9c9c9c;--color-text-messages: var(--color-white);--color-background-messages-message: rgba(255, 255, 255, .2);--color-background-messages-error: rgba(255, 1, 1, .5);--color-background-messages-notice: rgba(255, 255, 255, .5);--color-border-notification: #ccc;--color-background-checkbox-operations: rgba(0, 0, 0, 0.05);--color-warning: #ff3300;--color-border-warning: var(--color-warning);--color-text-legend: var(--color-white);--color-link-new-version: #e07171;--color-last-checked: #bbb;--color-text-footer: #444;--color-border-watch-table-cell: #eee;--color-text-watch-tag-list: rgba(231, 0, 105, 0.4);--color-background-new-watch-form: rgba(0, 0, 0, 0.05);--color-background-new-watch-input: var(--color-white);--color-background-new-watch-input-transparent: rgba(255, 255, 255, 0.1);--color-text-new-watch-input: var(--color-text);--color-border-input: var(--color-grey-500);--color-shadow-input: var(--color-grey-400);--color-background-input: var(--color-white);--color-text-input: var(--color-text);--color-text-input-description: var(--color-grey-500);--color-text-input-placeholder: var(--color-grey-600);--color-background-table-thead: var(--color-grey-800);--color-border-table-cell: var(--color-grey-700);--color-text-menu-heading: var(--color-grey-350);--color-text-menu-link: var(--color-grey-500);--color-background-menu-link-hover: var(--color-grey-850);--color-text-menu-link-hover: var(--color-grey-300);--color-shadow-jump: var(--color-grey-500);--color-icon-github: var(--color-black);--color-icon-github-hover: var(--color-grey-300);--color-watch-table-error: var(--color-dark-red);--color-watch-table-row-text: var(--color-grey-100);--highlight-trigger-text-bg-color: #1b98f8;--highlight-ignored-text-bg-color: var(--color-grey-700);--highlight-blocked-text-bg-color: rgb(202, 60, 60)}html[data-darkmode=true]{--color-link: #59bdfb;--color-text: var(--color-white);--color-background-gradient-first: #3f90a5;--color-background-gradient-second: #1e316c;--color-background-gradient-third: #4d2c64;--color-background-new-watch-input: var(--color-grey-100);--color-background-new-watch-input-transparent: var(--color-grey-100);--color-text-new-watch-input: var(--color-text);--color-background-table-thead: var(--color-grey-200);--color-table-background: var(--color-grey-300);--color-table-stripe: var(--color-grey-325);--color-background: var(--color-grey-300);--color-text-menu-heading: var(--color-grey-850);--color-text-menu-link: var(--color-grey-800);--color-border-table-cell: var(--color-grey-400);--color-text-tab-active: var(--color-text);--color-border-input: var(--color-grey-400);--color-shadow-input: var(--color-grey-50);--color-background-input: var(--color-grey-350);--color-text-input-description: var(--color-grey-600);--color-text-input-placeholder: var(--color-grey-600);--color-text-watch-tag-list: rgba(250, 62, 146, 0.4);--color-background-code: var(--color-grey-200);--color-background-tab: rgba(0, 0, 0, 0.2);--color-background-tab-hover: rgba(0, 0, 0, 0.5);--color-background-snapshot-age: var(--color-grey-200);--color-shadow-jump: var(--color-grey-200);--color-icon-github: var(--color-white);--color-icon-github-hover: var(--color-grey-700);--color-watch-table-error: var(--color-light-red);--color-watch-table-row-text: var(--color-grey-800)}html[data-darkmode=true] .icon-spread{filter:hue-rotate(-10deg) brightness(1.5)}html[data-darkmode=true] .watch-table .title-col a[target=_blank]::after,html[data-darkmode=true] .watch-table .current-diff-url::after{filter:invert(0.5) hue-rotate(10deg) brightness(2)}html[data-darkmode=true] .watch-table .status-browsersteps{filter:invert(0.5) hue-rotate(10deg) brightness(1.5)}html[data-darkmode=true] .watch-table .watch-controls .state-off img{opacity:.3}html[data-darkmode=true] .watch-table .watch-controls .state-on img{opacity:1}html[data-darkmode=true] .watch-table .unviewed{color:#fff}html[data-darkmode=true] .watch-table .unviewed.error{color:var(--color-watch-table-error)}.arrow{border:solid #1b98f8;border-width:0 2px 2px 0;display:inline-block;padding:3px}.arrow.right{transform:rotate(-45deg);-webkit-transform:rotate(-45deg)}.arrow.left{transform:rotate(135deg);-webkit-transform:rotate(135deg)}.arrow.up,.arrow.asc{transform:rotate(-135deg);-webkit-transform:rotate(-135deg)}.arrow.down,.arrow.desc{transform:rotate(45deg);-webkit-transform:rotate(45deg)}#browser_steps th{display:none}#browser_steps li{list-style:decimal;padding:5px}#browser_steps li.browser-step-with-error{background-color:#ffd6d6;border-radius:4px}#browser_steps li:not(:first-child):hover{opacity:1}#browser_steps li .control{padding-left:5px;padding-right:5px}#browser_steps li .control a{font-size:70%}#browser_steps li.empty{padding:0px;opacity:.35}#browser_steps li.empty .control{display:none}#browser_steps li:hover{background:#eee}#browser_steps li>label{display:none}@media only screen and (min-width: 760px){#browser-steps .flex-wrapper{display:flex;flex-flow:row;height:70vh;font-size:80%}#browser-steps .flex-wrapper #browser-steps-ui{flex-grow:1;flex-shrink:1;flex-basis:0;background-color:#eee;border-radius:5px}#browser-steps-fieldlist{flex-grow:0;flex-shrink:0;flex-basis:auto;max-width:400px;padding-left:1rem;overflow-y:scroll}#browsersteps-selector-wrapper{height:100% !important}}#browsersteps-selector-wrapper{width:100%;overflow-y:scroll;position:relative;height:80vh}#browsersteps-selector-wrapper>img{position:absolute;max-width:100%}#browsersteps-selector-wrapper>canvas{position:relative;max-width:100%}#browsersteps-selector-wrapper>canvas:hover{cursor:pointer}#browsersteps-selector-wrapper .loader{position:absolute;left:50%;top:50%;transform:translate(-50%, -50%);z-index:100;max-width:350px;text-align:center}#browsersteps-selector-wrapper .spinner,#browsersteps-selector-wrapper .spinner:after{width:80px;height:80px;font-size:3px}#browsersteps-selector-wrapper #browsersteps-click-start{color:var(--color-grey-400)}#browsersteps-selector-wrapper #browsersteps-click-start:hover{cursor:pointer}ul#requests-extra_proxies{list-style:none}ul#requests-extra_proxies li>label{display:none}ul#requests-extra_proxies table tr{display:table-row}ul#requests-extra_proxies table tr input[type=text]{width:100%}@media only screen and (min-width: 1024px){ul#requests-extra_proxies table tr{display:inline}}#request label[for=proxy]{display:inline-block}body.proxy-check-active #request .proxy-check-details{font-size:80%;color:#555;display:block;padding-left:2em;max-width:500px}body.proxy-check-active #request .proxy-timing{font-size:80%;padding-left:1rem;color:var(--color-link)}#recommended-proxy{display:grid;gap:2rem;padding-bottom:1em}@media(min-width: 991px){#recommended-proxy{grid-template-columns:repeat(2, 1fr)}}#recommended-proxy>div{border:1px #aaa solid;border-radius:4px;padding:1em}#extra-proxies-setting{border:1px solid var(--color-grey-800);border-radius:4px;margin:1em;padding:1em}ul#requests-extra_browsers{list-style:none}ul#requests-extra_browsers li>label{display:none}ul#requests-extra_browsers table tr{display:table-row}ul#requests-extra_browsers table tr input[type=text]{width:100%}@media only screen and (min-width: 1280px){ul#requests-extra_browsers table tr{display:inline}ul#requests-extra_browsers table tr input[type=text]{width:100%}}#extra-browsers-setting{border:1px solid var(--color-grey-800);border-radius:4px;margin:1em;padding:1em}.pagination-page-info{color:#fff;font-size:.85rem;text-transform:capitalize}.pagination.menu>*{display:inline-block}.pagination.menu li{display:inline-block}.pagination.menu a{padding:.65rem;margin:3px;border:none;background:#444;border-radius:2px;color:var(--color-text-button)}.pagination.menu a.disabled{display:none}.pagination.menu a.active{font-weight:bold;background:#888}.pagination.menu a:hover{background:#999}.spinner,.spinner:after{border-radius:50%;width:10px;height:10px}.spinner{margin:0px auto;font-size:3px;vertical-align:middle;display:inline-block;text-indent:-9999em;border-top:1.1em solid rgba(38,104,237,.2);border-right:1.1em solid rgba(38,104,237,.2);border-bottom:1.1em solid rgba(38,104,237,.2);border-left:1.1em solid #2668ed;-webkit-transform:translateZ(0);-ms-transform:translateZ(0);transform:translateZ(0);-webkit-animation:load8 1.1s infinite linear;animation:load8 1.1s infinite linear}@-webkit-keyframes load8{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}@keyframes load8{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}#toggle-light-mode .icon-dark{display:none}html[data-darkmode=true] #toggle-light-mode .icon-light{display:none}html[data-darkmode=true] #toggle-light-mode .icon-dark{display:block}.pure-menu-link{padding:.5rem 1em;line-height:1.2rem}.pure-menu-item svg{height:1.2rem}.pure-menu-item *{vertical-align:middle}.pure-menu-item .github-link{height:1.8rem;display:block}.pure-menu-item .github-link svg{height:100%}.pure-menu-item .bi-heart:hover{cursor:pointer}#overlay{opacity:.95;position:fixed;width:350px;max-width:100%;height:100%;top:0;right:-350px;background-color:var(--color-table-stripe);z-index:2;transform:translateX(0);transition:transform .5s ease}#overlay.visible{transform:translateX(-100%)}#overlay .content{font-size:.875rem;padding:1rem;margin-top:5rem;max-width:400px;color:var(--color-watch-table-row-text)}#heartpath{transition:all ease .3s !important}#heartpath:hover{fill:red !important;transition:all ease .3s !important}.minitabs-wrapper{width:100%}.minitabs-wrapper>div[id]{padding:20px;border:1px solid #ccc;border-top:none}.minitabs-wrapper .minitabs-content{width:100%;display:flex}.minitabs-wrapper .minitabs-content>div{flex:1 1 auto;min-width:0;overflow:scroll}.minitabs-wrapper .minitabs{display:flex;border-bottom:1px solid #ccc}.minitabs-wrapper .minitab{flex:1;text-align:center;padding:12px 0;text-decoration:none;color:#333;background-color:#f1f1f1;border:1px solid #ccc;border-bottom:none;cursor:pointer;transition:background-color .3s}.minitabs-wrapper .minitab:hover{background-color:#ddd}.minitabs-wrapper .minitab.active{background-color:#fff;font-weight:bold}@media(min-width: 800px){body.preview-text-enabled #filters-and-triggers>div{display:flex;gap:20px;position:relative}}body.preview-text-enabled #edit-text-filter,body.preview-text-enabled #text-preview{flex:1;align-self:flex-start}body.preview-text-enabled #edit-text-filter #pro-tips{display:none}body.preview-text-enabled #text-preview{position:sticky;top:20px;padding-top:1rem;padding-bottom:1rem;display:block !important}body.preview-text-enabled #activate-text-preview{background-color:var(--color-grey-500)}body.preview-text-enabled .monospace-preview{background:var(--color-background-input);border:1px solid var(--color-grey-600);padding:1rem;color:var(--color-text-input);font-family:"Courier New",Courier,monospace;font-size:70%;word-break:break-word;white-space:pre-wrap}#activate-text-preview{right:0;position:absolute;z-index:3;box-shadow:1px 1px 4px var(--color-shadow-jump)}.watch-table{width:100%;font-size:80%}.watch-table tr{color:var(--color-watch-table-row-text)}.watch-table tr.unviewed{font-weight:bold}.watch-table td{white-space:nowrap}.watch-table td.title-col{word-break:break-all;white-space:normal}.watch-table td a.external::after{content:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAQElEQVR42qXKwQkAIAxDUUdxtO6/RBQkQZvSi8I/pL4BoGw/XPkh4XigPmsUgh0626AjRsgxHTkUThsG2T/sIlzdTsp52kSS1wAAAABJRU5ErkJggg==);margin:0 3px 0 5px}.watch-table th{white-space:nowrap}.watch-table th a{font-weight:normal}.watch-table th a.active{font-weight:bolder}.watch-table th a.inactive .arrow{display:none}.watch-table tr.checking-now td:first-child{position:relative}.watch-table tr.checking-now td:first-child::before{content:"";position:absolute;top:0;bottom:0;left:0;width:3px;background-color:#293eff}.watch-table tr.checking-now td.last-checked .spinner-wrapper{display:inline-block !important}.watch-table tr.checking-now td.last-checked .innertext{display:none !important}.watch-table tr.queued a.recheck{display:none !important}.watch-table tr.queued a.already-in-queue-button{display:inline-block !important}.watch-table tr.paused a.pause-toggle.state-on{display:inline !important}.watch-table tr.paused a.pause-toggle.state-off{display:none !important}.watch-table tr.notification_muted a.mute-toggle.state-on{display:inline !important}.watch-table tr.notification_muted a.mute-toggle.state-off{display:none !important}.watch-table tr.has-error{color:var(--color-watch-table-error)}.watch-table tr.has-error .error-text{display:block !important}.watch-table tr.single-history a.preview-link{display:inline-block !important}.watch-table tr.multiple-history a.history-link{display:inline-block !important}#watch-table-wrapper #post-list-buttons{text-align:right;padding:0px;margin:0px}#watch-table-wrapper #post-list-buttons li{display:inline-block}#watch-table-wrapper #post-list-buttons a{border-top-left-radius:initial;border-top-right-radius:initial;border-bottom-left-radius:5px;border-bottom-right-radius:5px}#watch-table-wrapper.has-error #post-list-buttons #post-list-with-errors{display:inline-block !important}#watch-table-wrapper.has-unread-changes #post-list-buttons #post-list-unread,#watch-table-wrapper.has-unread-changes #post-list-buttons #post-list-mark-views,#watch-table-wrapper.has-unread-changes #post-list-buttons #post-list-unread{display:inline-block !important}@media(max-width: 767px){.watch-table thead{display:block}.watch-table thead tr th{display:inline-block}}@media(max-width: 767px)and (max-width: 768px){.watch-table thead tr th .hide-on-mobile{display:none}}@media(max-width: 767px){.watch-table thead .empty-cell{display:none}.watch-table .last-checked{margin-left:calc(20px + .5rem)}.watch-table .last-checked>span{vertical-align:middle}.watch-table .last-changed{margin-left:calc(20px + .5rem)}.watch-table .last-checked::before{color:var(--color-text);content:"Last Checked "}.watch-table .last-changed::before{color:var(--color-text);content:"Last Changed "}.watch-table td.inline{display:inline-block}.watch-table .pure-table td,.watch-table .pure-table th{border:none}.watch-table td{border:none;border-bottom:1px solid var(--color-border-watch-table-cell);vertical-align:middle}.watch-table td:before{top:6px;left:6px;width:45%;padding-right:10px;white-space:nowrap}.watch-table.pure-table-striped tr{background-color:var(--color-table-background)}.watch-table.pure-table-striped tr:nth-child(2n-1){background-color:var(--color-table-stripe)}.watch-table.pure-table-striped tr:nth-child(2n-1) td{background-color:inherit}}@media(max-width: 767px){.watch-table tbody tr{padding-bottom:10px;padding-top:10px;display:grid;grid-template-columns:20px 1fr 100px;grid-template-rows:auto auto auto auto;gap:.5rem}.watch-table tbody tr .counter-i{display:none}.watch-table tbody tr td.checkbox-uuid{display:grid;place-items:center}.watch-table tbody tr>td{border-bottom:none}.watch-table tbody tr>td.title-col{grid-column:1/-1;grid-row:1}.watch-table tbody tr>td.title-col .watch-title{font-size:.92rem}.watch-table tbody tr>td.title-col .link-spread{display:none}.watch-table tbody tr>td.last-checked{grid-column:1/-1;grid-row:2}.watch-table tbody tr>td.last-changed{grid-column:1/-1;grid-row:3}.watch-table tbody tr>td.checkbox-uuid{grid-column:1;grid-row:4}.watch-table tbody tr>td.buttons{grid-column:2;grid-row:4;display:flex;align-items:center;justify-content:flex-start}.watch-table tbody tr>td.watch-controls{grid-column:3;grid-row:4;display:grid;place-items:center}.watch-table tbody tr>td.watch-controls a img{padding:10px}.pure-table td{padding:3px !important}}ul#conditions_match_logic{list-style:none}ul#conditions_match_logic input,ul#conditions_match_logic label,ul#conditions_match_logic li{display:inline-block}ul#conditions_match_logic li{padding-right:1em}.fieldlist_formfields{width:100%;background-color:var(--color-background, #fff);border-radius:4px;border:1px solid var(--color-border-table-cell, #cbcbcb)}.fieldlist_formfields .fieldlist-header{display:flex;background-color:var(--color-background-table-thead, #e0e0e0);font-weight:bold;border-bottom:1px solid var(--color-border-table-cell, #cbcbcb)}.fieldlist_formfields .fieldlist-header-cell{flex:1;padding:.5em 1em;text-align:left}.fieldlist_formfields .fieldlist-header-cell:last-child{flex:0 0 120px}.fieldlist_formfields .fieldlist-body{display:flex;flex-direction:column}.fieldlist_formfields .fieldlist-row{display:flex;border-bottom:1px solid var(--color-border-table-cell, #cbcbcb)}.fieldlist_formfields .fieldlist-row:last-child{border-bottom:none}.fieldlist_formfields .fieldlist-row:nth-child(2n-1){background-color:var(--color-table-stripe, #f2f2f2)}.fieldlist_formfields .fieldlist-row.error-row{background-color:var(--color-error-input, #ffdddd)}.fieldlist_formfields .fieldlist-cell{flex:1;padding:.5em 1em;display:flex;flex-direction:column;justify-content:center}.fieldlist_formfields .fieldlist-cell input,.fieldlist_formfields .fieldlist-cell select{width:100%}.fieldlist_formfields .fieldlist-cell.fieldlist-actions{flex:0 0 120px;display:flex;flex-direction:row;align-items:center;gap:4px}.fieldlist_formfields ul.errors{margin-top:.5em;margin-bottom:0;padding:.5em;background-color:var(--color-error-background-snapshot-age, #ffdddd);border-radius:4px;list-style-position:inside}@media only screen and (max-width: 760px){.fieldlist_formfields .fieldlist-header,.fieldlist_formfields .fieldlist-row{flex-direction:column}.fieldlist_formfields .fieldlist-header-cell{display:none}.fieldlist_formfields .fieldlist-row{padding:.5em 0;border-bottom:2px solid var(--color-border-table-cell, #cbcbcb)}.fieldlist_formfields .fieldlist-cell{padding:.25em .5em}.fieldlist_formfields .fieldlist-cell.fieldlist-actions{flex:1;justify-content:flex-start;padding-top:.5em}.fieldlist_formfields .fieldlist-cell:not(:last-child){margin-bottom:.5em}.fieldlist_formfields .fieldlist-cell::before{content:attr(data-label);font-weight:bold;margin-bottom:.25em}}.fieldlist_formfields .addRuleRow,.fieldlist_formfields .removeRuleRow,.fieldlist_formfields .verifyRuleRow{cursor:pointer;border:none;padding:4px 8px;border-radius:3px;font-weight:bold;background-color:#aaa;color:var(--color-foreground-text, #fff)}.fieldlist_formfields .addRuleRow:hover,.fieldlist_formfields .removeRuleRow:hover,.fieldlist_formfields .verifyRuleRow:hover{background-color:#999}.watch-table.favicon-not-enabled tr .favicon{display:none}.watch-table tr td.inline.title-col .flex-wrapper{display:flex;align-items:center;gap:4px}.watch-table td,.watch-table th{vertical-align:middle}.watch-table tr.has-favicon.unviewed img.favicon{opacity:1 !important}.watch-table .status-icons{white-space:nowrap;display:flex;align-items:center;gap:4px}.watch-table .status-icons>*{vertical-align:middle}.title-col{padding:10px}.title-wrapper{display:flex;align-items:center;gap:10px}.title-col-inner{display:inline-block;vertical-align:middle}.watch-table img.favicon{vertical-align:middle;max-width:25px;max-height:25px;height:25px;padding-right:4px}body.checking-now #checking-now-fixed-tab{display:block !important}#checking-now-fixed-tab{background:#ccc;border-radius:5px;bottom:0;color:var(--color-text);display:none;font-size:.8rem;left:0;padding:5px;position:fixed}#selector-wrapper{height:100%;text-align:center;max-height:70vh;overflow-y:scroll;position:relative}#selector-wrapper>img{position:absolute;z-index:4;max-width:100%}#selector-wrapper>canvas{position:relative;z-index:5;max-width:100%}#selector-wrapper>canvas:hover{cursor:pointer}#selector-current-xpath{font-size:80%}.ternary-radio-group{display:flex;gap:0;border:1px solid var(--color-grey-750);border-radius:4px;overflow:hidden;width:fit-content;background:var(--color-background)}.ternary-radio-group .ternary-radio-option{position:relative;cursor:pointer;margin:0;display:flex;align-items:center}.ternary-radio-group .ternary-radio-option input[type=radio]{position:absolute;opacity:0;width:0;height:0}.ternary-radio-group .ternary-radio-option .ternary-radio-label{padding:8px 16px;background:var(--color-grey-900);border:none;border-right:1px solid var(--color-grey-750);font-size:13px;font-weight:500;color:var(--color-text);transition:all .2s ease;cursor:pointer;display:block;text-align:center}.ternary-radio-group .ternary-radio-option:last-child .ternary-radio-label{border-right:none}.ternary-radio-group .ternary-radio-option input:checked+.ternary-radio-label{background:var(--color-link);color:var(--color-text-button);font-weight:600}.ternary-radio-group .ternary-radio-option input:checked+.ternary-radio-label.ternary-default{background:var(--color-grey-600);color:var(--color-text-button)}.ternary-radio-group .ternary-radio-option input:checked+.ternary-radio-label:hover{background:#1a7bc4}.ternary-radio-group .ternary-radio-option input:checked+.ternary-radio-label:hover.ternary-default{background:var(--color-grey-500)}.ternary-radio-group .ternary-radio-option:hover .ternary-radio-label{background:var(--color-grey-800)}@media(max-width: 480px){.ternary-radio-group{width:100%}.ternary-radio-group .ternary-radio-label{flex:1;min-width:auto}}input[type=radio].pure-radio:checked+label,input[type=radio].pure-radio:checked{background:var(--color-link);color:var(--color-text-button)}html[data-darkmode=true] .ternary-radio-group .ternary-radio-option .ternary-radio-label{background:var(--color-grey-350)}html[data-darkmode=true] .ternary-radio-group .ternary-radio-option:hover .ternary-radio-label{background:var(--color-grey-400)}html[data-darkmode=true] .ternary-radio-group .ternary-radio-option input:checked+.ternary-radio-label{background:var(--color-link);color:var(--color-text-button)}html[data-darkmode=true] .ternary-radio-group .ternary-radio-option input:checked+.ternary-radio-label.ternary-default{background:var(--color-grey-600)}html[data-darkmode=true] .ternary-radio-group .ternary-radio-option input:checked+.ternary-radio-label:hover{background:#1a7bc4}html[data-darkmode=true] .ternary-radio-group .ternary-radio-option input:checked+.ternary-radio-label:hover.ternary-default{background:var(--color-grey-500)}body{color:var(--color-text);background:var(--color-background-page);font-family:Helvetica Neue,Helvetica,Lucida Grande,Arial,Ubuntu,Cantarell,Fira Sans,sans-serif}.visually-hidden{clip:rect(0 0 0 0);clip-path:inset(50%);height:1px;overflow:hidden;position:absolute;white-space:nowrap;width:1px}.status-icon{display:inline-block;height:1rem;vertical-align:middle}.pure-table-even{background:var(--color-background)}a{text-decoration:none;color:var(--color-link)}a.github-link{color:var(--color-icon-github);margin:0 1rem 0 .5rem}a.github-link svg{fill:currentColor}a.github-link:hover{color:var(--color-icon-github-hover)}#search-q{opacity:0;-webkit-transition:all .9s ease;-moz-transition:all .9s ease;transition:all .9s ease;width:0;display:none}#search-q.expanded{width:auto;display:inline-block;opacity:1}#search-result-info{color:#fff}button.toggle-button{vertical-align:middle;background:rgba(0,0,0,0);border:none;cursor:pointer;color:var(--color-icon-github)}button.toggle-button:hover{color:var(--color-icon-github-hover)}button.toggle-button svg{fill:currentColor}button.toggle-button .icon-light{display:block}.pure-menu-horizontal{background:var(--color-background);padding:5px;display:flex;justify-content:space-between;align-items:center}#pure-menu-horizontal-spinner{height:3px;background:linear-gradient(-75deg, #ff6000, #ff8f00, #ffdd00, #ed0000);background-size:400% 400%;width:100%;animation:gradient 200s ease infinite}body.spinner-active #pure-menu-horizontal-spinner{animation:gradient 1s ease infinite}@keyframes gradient{0%{background-position:0% 50%}50%{background-position:100% 50%}100%{background-position:0% 50%}}.pure-menu-heading{color:var(--color-text-menu-heading)}.pure-menu-link{color:var(--color-text-menu-link)}.pure-menu-link:hover{background-color:var(--color-background-menu-link-hover);color:var(--color-text-menu-link-hover)}.tab-pane-inner{scroll-margin-top:200px}section.content{padding-top:100px;padding-bottom:1em;flex-direction:column;display:flex;align-items:center;justify-content:center}code{background:var(--color-background-code);color:var(--color-text)}.inline-tag,.restock-label,.tracking-ldjson-price-data,.watch-tag-list{white-space:nowrap;border-radius:5px;padding:2px 5px;margin-right:4px}.watch-tag-list{color:var(--color-white);background:var(--color-text-watch-tag-list)}@media(min-width: 768px){.box{margin:0 1em !important}}.box{max-width:100%;margin:0 .3em;flex-direction:column;display:flex;justify-content:center}body:after{content:"";background:linear-gradient(130deg, var(--color-background-gradient-first), var(--color-background-gradient-second) 41.07%, var(--color-background-gradient-third) 84.05%)}body:after,body:before{display:block;height:650px;position:absolute;top:0;left:0;width:100%;z-index:-1}body::after{opacity:.91}body::before{content:""}body:after,body:before{-webkit-clip-path:polygon(100% 0, 0 0, 0 77.5%, 1% 77.4%, 2% 77.1%, 3% 76.6%, 4% 75.9%, 5% 75.05%, 6% 74.05%, 7% 72.95%, 8% 71.75%, 9% 70.55%, 10% 69.3%, 11% 68.05%, 12% 66.9%, 13% 65.8%, 14% 64.8%, 15% 64%, 16% 63.35%, 17% 62.85%, 18% 62.6%, 19% 62.5%, 20% 62.65%, 21% 63%, 22% 63.5%, 23% 64.2%, 24% 65.1%, 25% 66.1%, 26% 67.2%, 27% 68.4%, 28% 69.65%, 29% 70.9%, 30% 72.15%, 31% 73.3%, 32% 74.35%, 33% 75.3%, 34% 76.1%, 35% 76.75%, 36% 77.2%, 37% 77.45%, 38% 77.5%, 39% 77.3%, 40% 76.95%, 41% 76.4%, 42% 75.65%, 43% 74.75%, 44% 73.75%, 45% 72.6%, 46% 71.4%, 47% 70.15%, 48% 68.9%, 49% 67.7%, 50% 66.55%, 51% 65.5%, 52% 64.55%, 53% 63.75%, 54% 63.15%, 55% 62.75%, 56% 62.55%, 57% 62.5%, 58% 62.7%, 59% 63.1%, 60% 63.7%, 61% 64.45%, 62% 65.4%, 63% 66.45%, 64% 67.6%, 65% 68.8%, 66% 70.05%, 67% 71.3%, 68% 72.5%, 69% 73.6%, 70% 74.65%, 71% 75.55%, 72% 76.35%, 73% 76.9%, 74% 77.3%, 75% 77.5%, 76% 77.45%, 77% 77.25%, 78% 76.8%, 79% 76.2%, 80% 75.4%, 81% 74.45%, 82% 73.4%, 83% 72.25%, 84% 71.05%, 85% 69.8%, 86% 68.55%, 87% 67.35%, 88% 66.2%, 89% 65.2%, 90% 64.3%, 91% 63.55%, 92% 63%, 93% 62.65%, 94% 62.5%, 95% 62.55%, 96% 62.8%, 97% 63.3%, 98% 63.9%, 99% 64.75%, 100% 65.7%);clip-path:polygon(100% 0, 0 0, 0 77.5%, 1% 77.4%, 2% 77.1%, 3% 76.6%, 4% 75.9%, 5% 75.05%, 6% 74.05%, 7% 72.95%, 8% 71.75%, 9% 70.55%, 10% 69.3%, 11% 68.05%, 12% 66.9%, 13% 65.8%, 14% 64.8%, 15% 64%, 16% 63.35%, 17% 62.85%, 18% 62.6%, 19% 62.5%, 20% 62.65%, 21% 63%, 22% 63.5%, 23% 64.2%, 24% 65.1%, 25% 66.1%, 26% 67.2%, 27% 68.4%, 28% 69.65%, 29% 70.9%, 30% 72.15%, 31% 73.3%, 32% 74.35%, 33% 75.3%, 34% 76.1%, 35% 76.75%, 36% 77.2%, 37% 77.45%, 38% 77.5%, 39% 77.3%, 40% 76.95%, 41% 76.4%, 42% 75.65%, 43% 74.75%, 44% 73.75%, 45% 72.6%, 46% 71.4%, 47% 70.15%, 48% 68.9%, 49% 67.7%, 50% 66.55%, 51% 65.5%, 52% 64.55%, 53% 63.75%, 54% 63.15%, 55% 62.75%, 56% 62.55%, 57% 62.5%, 58% 62.7%, 59% 63.1%, 60% 63.7%, 61% 64.45%, 62% 65.4%, 63% 66.45%, 64% 67.6%, 65% 68.8%, 66% 70.05%, 67% 71.3%, 68% 72.5%, 69% 73.6%, 70% 74.65%, 71% 75.55%, 72% 76.35%, 73% 76.9%, 74% 77.3%, 75% 77.5%, 76% 77.45%, 77% 77.25%, 78% 76.8%, 79% 76.2%, 80% 75.4%, 81% 74.45%, 82% 73.4%, 83% 72.25%, 84% 71.05%, 85% 69.8%, 86% 68.55%, 87% 67.35%, 88% 66.2%, 89% 65.2%, 90% 64.3%, 91% 63.55%, 92% 63%, 93% 62.65%, 94% 62.5%, 95% 62.55%, 96% 62.8%, 97% 63.3%, 98% 63.9%, 99% 64.75%, 100% 65.7%)}.button-small{font-size:85%}.button-xsmall{font-size:70%}.fetch-error{padding-top:1em;font-size:80%;max-width:400px;display:block}.pure-button-primary,a.pure-button-primary,.pure-button-selected,a.pure-button-selected{background-color:var(--color-background-button-primary)}.button-secondary{color:var(--color-text-button);border-radius:4px;text-shadow:0 1px 1px rgba(0,0,0,.2)}.button-success{background:var(--color-background-button-success)}.button-tag{background:var(--color-background-button-tag);color:var(--color-text-button);font-size:65%;border-bottom-left-radius:initial;border-bottom-right-radius:initial;margin-right:4px}.button-tag.active{background:var(--color-background-button-tag-active);font-weight:bold}.button-error{background:var(--color-background-button-error);color:var(--color-text-button-error)}.button-warning{background:var(--color-background-button-warning);color:var(--color-text-button-warning)}.button-secondary{background:var(--color-background-button-secondary)}.button-cancel{background:var(--color-background-button-cancel)}.messages li{list-style:none;padding:1em;border-radius:10px;color:var(--color-text-messages);font-weight:bold}.messages li.message{background:var(--color-background-messages-message)}.messages li.error{background:var(--color-background-messages-error)}.messages li.notice{background:var(--color-background-messages-notice)}.messages.with-share-link>*:hover{cursor:pointer}.notifications-wrapper{padding-top:.5rem}.notifications-wrapper #notification-test-log{margin-top:1rem;padding:1rem;white-space:pre-wrap;word-break:break-word;overflow-wrap:break-word;max-width:100%;box-sizing:border-box;max-height:12rem;overflow-y:scroll;border:1px solid var(--color-border-notification);border-radius:5px}label:hover{cursor:pointer}.grey-form-border{border:1px solid var(--color-border-notification);padding:.5rem;border-radius:5px}#notification-error-log{border:1px solid var(--color-border-notification);padding:1rem;border-radius:5px;overflow-wrap:break-word}#token-table.pure-table td,#token-table.pure-table th{font-size:80%}.pure-form input[type=text].transparent-field{background-color:var(--color-background-new-watch-input-transparent) !important;color:var(--color-white) !important;border:1px solid hsla(0,0%,100%,.2) !important;box-shadow:none !important;-webkit-box-shadow:none !important}.pure-form input[type=text].transparent-field::placeholder{opacity:.5;color:hsla(0,0%,100%,.7);font-weight:lighter}#new-watch-form{background:var(--color-background-new-watch-form);padding:1em;border-radius:10px;margin-bottom:1em;max-width:100%}#new-watch-form #url::placeholder{font-weight:bold}#new-watch-form input{display:inline-block;margin-bottom:5px}#new-watch-form input:not(.pure-button){background-color:var(--color-background-new-watch-input);color:var(--color-text-new-watch-input)}#new-watch-form .label{display:none}#new-watch-form legend{color:var(--color-text-legend);font-weight:bold}@media only screen and (min-width: 760px){#new-watch-form #watch-add-wrapper-zone{display:flex;gap:.3rem;flex-direction:row;min-width:70vw}}#new-watch-form #watch-add-wrapper-zone>span{flex-grow:0}#new-watch-form #watch-add-wrapper-zone>span input{width:100%;padding-right:1em}#new-watch-form #watch-add-wrapper-zone>span:first-child{flex-grow:1}@media only screen and (max-width: 760px){#new-watch-form #watch-add-wrapper-zone #url{width:100%}}#new-watch-form #watch-group-tag{font-size:.9rem;padding:.3rem;display:flex;align-items:center;gap:.5rem;color:var(--color-white)}#new-watch-form #watch-group-tag label,#new-watch-form #watch-group-tag input{margin:0}#new-watch-form #watch-group-tag input{flex:1}#diff-col{padding-left:40px}#diff-jump{position:fixed;left:0px;top:120px;background:var(--color-background);padding:10px;border-top-right-radius:5px;border-bottom-right-radius:5px;box-shadow:1px 1px 4px var(--color-shadow-jump)}#diff-jump a{color:var(--color-link);cursor:pointer;-moz-user-select:none;-webkit-user-select:none;-ms-user-select:none;user-select:none;-o-user-select:none}footer{padding:10px;background:var(--color-background);color:var(--color-text-footer);text-align:center}#feed-icon{vertical-align:middle}.sticky-tab{position:absolute;top:60px;font-size:65%;background:var(--color-background);padding:10px}.sticky-tab#left-sticky{left:0;position:fixed;border-top-right-radius:5px;border-bottom-right-radius:5px;box-shadow:1px 1px 4px var(--color-shadow-jump)}.sticky-tab#right-sticky{right:0px}.sticky-tab#hosted-sticky{right:0px;top:100px;font-weight:bold}#new-version-text a{color:var(--color-link-new-version)}.watch-controls{color:#f8321b}.watch-controls .state-on img{opacity:.8}.watch-controls img{opacity:.2}.watch-controls img:hover{transition:opacity .3s;opacity:.8}.monospaced-textarea textarea{width:100%;font-family:monospace;white-space:pre;overflow-wrap:normal;overflow-x:auto}.pure-form fieldset{padding-top:0px}.pure-form fieldset ul{padding-bottom:0px;margin-bottom:0px}.pure-form .pure-control-group,.pure-form .pure-group,.pure-form .pure-controls{padding-bottom:1em}.pure-form .pure-control-group div,.pure-form .pure-group div,.pure-form .pure-controls div{margin:0px}.pure-form .pure-control-group .checkbox>*,.pure-form .pure-group .checkbox>*,.pure-form .pure-controls .checkbox>*{display:inline;vertical-align:middle}.pure-form .pure-control-group .checkbox>label,.pure-form .pure-group .checkbox>label,.pure-form .pure-controls .checkbox>label{padding-left:5px}.pure-form .pure-control-group legend,.pure-form .pure-group legend,.pure-form .pure-controls legend{color:var(--color-text-legend)}.pure-form .error input{background-color:var(--color-error-input)}.pure-form ul.errors{padding:.5em .6em;border:1px solid var(--color-error-list);border-radius:4px;vertical-align:middle;-webkit-box-sizing:border-box;box-sizing:border-box}.pure-form ul.errors li{margin-left:1em;color:var(--color-error-list)}.pure-form label{font-weight:bold}.pure-form textarea{width:100%}.pure-form .inline-radio ul{margin:0px;list-style:none}.pure-form .inline-radio ul li{display:flex;align-items:center;gap:1em}@media only screen and (max-width: 760px),(min-device-width: 768px)and (max-device-width: 1024px){.edit-form{padding:.5em;margin:0}#nav-menu{overflow-x:scroll}}@media only screen and (max-width: 760px),(min-device-width: 768px)and (max-device-width: 800px){div.sticky-tab#hosted-sticky{top:60px;left:0px;right:auto}section.content{padding-top:110px}div.tabs.collapsable ul li{display:block;border-radius:0px;margin-right:0px}input[type=text]{width:100%}}.pure-table{border-color:var(--color-border-table-cell)}.pure-table thead{background-color:var(--color-background-table-thead);color:var(--color-text);border-bottom:1px solid var(--color-background-table-thead)}.pure-table td,.pure-table th{border-left-color:var(--color-border-table-cell)}.pure-table-striped tr:nth-child(2n-1) td{background-color:var(--color-table-stripe)}.pure-form input[type=color],.pure-form input[type=date],.pure-form input[type=datetime-local],.pure-form input[type=datetime],.pure-form input[type=email],.pure-form input[type=month],.pure-form input[type=number],.pure-form input[type=password],.pure-form input[type=search],.pure-form input[type=tel],.pure-form input[type=text],.pure-form input[type=time],.pure-form input[type=url],.pure-form input[type=week],.pure-form select,.pure-form textarea{border:var(--color-border-input);box-shadow:inset 0 1px 3px var(--color-shadow-input);background-color:var(--color-background-input);color:var(--color-text-input)}.pure-form input[type=color]:active,.pure-form input[type=date]:active,.pure-form input[type=datetime-local]:active,.pure-form input[type=datetime]:active,.pure-form input[type=email]:active,.pure-form input[type=month]:active,.pure-form input[type=number]:active,.pure-form input[type=password]:active,.pure-form input[type=search]:active,.pure-form input[type=tel]:active,.pure-form input[type=text]:active,.pure-form input[type=time]:active,.pure-form input[type=url]:active,.pure-form input[type=week]:active,.pure-form select:active,.pure-form textarea:active{background-color:var(--color-background-input)}input::placeholder,textarea::placeholder{color:var(--color-text-input-placeholder)}.m-d{min-width:100%}@media only screen and (min-width: 761px){.m-d{min-width:80%}}.tabs ul{margin:0px;padding:0px;display:block}.tabs ul li{margin-right:3px;display:inline-block;color:var(--color-text-tab);border-top-left-radius:5px;border-top-right-radius:5px;background-color:var(--color-background-tab)}.tabs ul li:not(.active):hover{background-color:var(--color-background-tab-hover)}.tabs ul li.active,.tabs ul li :target{background-color:var(--color-background)}.tabs ul li.active a,.tabs ul li :target a{color:var(--color-text-tab-active);font-weight:bold}.tabs ul li a{display:block;padding:.8em;color:var(--color-text-tab)}.pure-form-stacked>div:first-child{display:block}.login-form .inner{background:var(--color-background);padding:20px;border-radius:5px}.tab-pane-inner{padding:0px}.tab-pane-inner:not(:target){display:none}.tab-pane-inner:target{display:block}.beta-logo{height:50px;right:-3px;top:-3px;position:absolute}#selector-header{padding-bottom:1em}body.full-width .edit-form{width:95%}.edit-form{min-width:70%;max-width:95%}.edit-form .box-wrap{position:relative}.edit-form .inner{background:var(--color-background);padding:20px}.edit-form #actions{display:block;background:var(--color-background)}.edit-form #actions .pure-control-group{display:flex;gap:.625em;flex-wrap:wrap}.edit-form .pure-form-message-inline{padding-left:0;color:var(--color-text-input-description)}.edit-form .pure-form-message-inline code{font-size:.875em}.border-fieldset{border:1px solid #ccc;padding:1rem;border-radius:5px;margin-bottom:1rem}.border-fieldset h3{margin-top:0}.border-fieldset fieldset:last-of-type{padding-bottom:0}.border-fieldset fieldset:last-of-type .pure-control-group{padding-bottom:0}ul{padding-left:1em;padding-top:0px;margin-top:4px}.time-check-widget tr{display:inline}.time-check-widget tr input[type=number]{width:5em}@media only screen and (max-width: 760px){.time-check-widget tbody{display:grid;grid-template-columns:auto 1fr auto 1fr;gap:.625em .3125em;align-items:center}.time-check-widget tr{display:contents}.time-check-widget tr th{text-align:right;padding-right:5px}.time-check-widget tr input[type=number]{width:100%;max-width:5em}}#webdriver_delay{width:5em}#api-key:hover{cursor:pointer}#api-key-copy{color:var(--color-api-key)}.button-green{background-color:var(--color-background-button-green)}.button-red{background-color:var(--color-background-button-red)}.noselect{-webkit-touch-callout:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}#checkbox-operations{background:var(--color-background-checkbox-operations);padding:1em;border-radius:10px;margin-bottom:1em;display:none}#checkbox-operations button{margin-bottom:3px;margin-top:3px;display:inline-flex;align-items:center}.checkbox-uuid>*{vertical-align:middle}.inline-warning{border:1px solid var(--color-border-warning);padding:.5rem;border-radius:5px;color:var(--color-warning)}.inline-warning>span{display:inline-block;vertical-align:middle}.inline-warning img.inline-warning-icon{display:inline;height:26px;vertical-align:middle}.tracking-ldjson-price-data{background-color:var(--color-background-button-green);color:#000;opacity:.6}.ldjson-price-track-offer{font-weight:bold;font-style:italic}.ldjson-price-track-offer a.pure-button{border-radius:3px;padding:3px;background-color:var(--color-background-button-green)}.price-follow-tag-icon{display:inline-block;height:.8rem;vertical-align:middle}#quick-watch-processor-type ul#processor{color:#fff;padding-left:0px}#quick-watch-processor-type ul#processor li{list-style:none;font-size:.9rem;display:grid;grid-template-columns:auto 1fr;align-items:center;gap:.5rem;margin-bottom:.5rem}#quick-watch-processor-type label,#quick-watch-processor-type input{padding:0;margin:0}.restock-label.in-stock{background-color:var(--color-background-button-green);color:#fff}.restock-label.not-in-stock{background-color:var(--color-background-button-cancel);color:#777}.restock-label.error{background-color:var(--color-background-button-error);color:#fff;opacity:.7}.restock-label svg{vertical-align:middle}#chrome-extension-link{padding:9px;border:1px solid var(--color-grey-800);border-radius:10px;vertical-align:middle}#chrome-extension-link img{height:21px;padding:2px;vertical-align:middle}#realtime-conn-error{position:fixed;bottom:0;left:0;background:var(--color-warning);padding:10px;font-size:.8rem;color:#fff;opacity:.8}#bottom-horizontal-offscreen{position:fixed;bottom:0;left:0;right:0;width:100%;min-height:50px;max-height:50vh;background:hsla(0,0%,100%,.7215686275);border-top:1px solid var(--color-border-table-cell);padding:10px;box-shadow:0 -2px 10px rgba(0,0,0,.2);z-index:100;overflow-y:auto;transition:opacity .3s ease-in-out;scroll-margin-bottom:10px;display:flex;justify-content:center;align-items:center}ul#highlightSnippetActions{list-style:none}ul#highlightSnippetActions li{display:inline-block} +:root{--color-white: #fff;--color-grey-50: #111;--color-grey-100: #262626;--color-grey-200: #333;--color-grey-300: #444;--color-grey-325: #555;--color-grey-350: #565d64;--color-grey-400: #666;--color-grey-500: #777;--color-grey-600: #999;--color-grey-700: #cbcbcb;--color-grey-750: #ddd;--color-grey-800: #e0e0e0;--color-grey-850: #eee;--color-grey-900: #f2f2f2;--color-black: #000;--color-dark-red: #a00;--color-light-red: #dd0000;--color-background-page: var(--color-grey-100);--color-background-gradient-first: #5ad8f7;--color-background-gradient-second: #2f50af;--color-background-gradient-third: #9150bf;--color-background: var(--color-white);--color-text: var(--color-grey-200);--color-link: #1b98f8;--color-menu-accent: #ed5900;--color-background-code: var(--color-grey-850);--color-error: var(--color-dark-red);--color-error-input: #ffebeb;--color-error-list: var(--color-light-red);--color-table-background: var(--color-background);--color-table-stripe: var(--color-grey-900);--color-text-tab: var(--color-white);--color-background-tab: rgba(255, 255, 255, 0.2);--color-background-tab-hover: rgba(255, 255, 255, 0.5);--color-text-tab-active: #222;--color-api-key: #0078e7;--color-background-button-primary: #0078e7;--color-background-button-green: #42dd53;--color-background-button-red: #dd4242;--color-background-button-success: rgb(28, 184, 65);--color-background-button-error: rgb(202, 60, 60);--color-text-button-error: var(--color-white);--color-background-button-warning: rgb(202, 60, 60);--color-text-button-warning: var(--color-white);--color-background-button-secondary: rgb(66, 184, 221);--color-background-button-cancel: rgb(200, 200, 200);--color-text-button: var(--color-white);--color-background-button-tag: rgb(99, 99, 99);--color-background-snapshot-age: #dfdfdf;--color-error-text-snapshot-age: var(--color-white);--color-error-background-snapshot-age: #ff0000;--color-background-button-tag-active: #9c9c9c;--color-text-messages: var(--color-white);--color-background-messages-message: rgba(255, 255, 255, .2);--color-background-messages-error: rgba(255, 1, 1, .5);--color-background-messages-notice: rgba(255, 255, 255, .5);--color-border-notification: #ccc;--color-background-checkbox-operations: rgba(0, 0, 0, 0.05);--color-warning: #ff3300;--color-border-warning: var(--color-warning);--color-text-legend: var(--color-white);--color-link-new-version: #e07171;--color-last-checked: #bbb;--color-text-footer: #444;--color-border-watch-table-cell: #eee;--color-text-watch-tag-list: rgba(231, 0, 105, 0.4);--color-background-new-watch-form: rgba(0, 0, 0, 0.05);--color-background-new-watch-input: var(--color-white);--color-background-new-watch-input-transparent: rgba(255, 255, 255, 0.1);--color-text-new-watch-input: var(--color-text);--color-border-input: var(--color-grey-500);--color-shadow-input: var(--color-grey-400);--color-background-input: var(--color-white);--color-text-input: var(--color-text);--color-text-input-description: var(--color-grey-500);--color-text-input-placeholder: var(--color-grey-600);--color-background-table-thead: var(--color-grey-800);--color-border-table-cell: var(--color-grey-700);--color-text-menu-heading: var(--color-grey-350);--color-text-menu-link: var(--color-grey-500);--color-background-menu-link-hover: var(--color-grey-850);--color-text-menu-link-hover: var(--color-grey-300);--color-shadow-jump: var(--color-grey-500);--color-icon-github: var(--color-black);--color-icon-github-hover: var(--color-grey-300);--color-watch-table-error: var(--color-dark-red);--color-watch-table-row-text: var(--color-grey-100);--highlight-trigger-text-bg-color: #1b98f8;--highlight-ignored-text-bg-color: var(--color-grey-700);--highlight-blocked-text-bg-color: rgb(202, 60, 60)}html[data-darkmode=true]{--color-link: #59bdfb;--color-text: var(--color-white);--color-background-gradient-first: #3f90a5;--color-background-gradient-second: #1e316c;--color-background-gradient-third: #4d2c64;--color-background-new-watch-input: var(--color-grey-100);--color-background-new-watch-input-transparent: var(--color-grey-100);--color-text-new-watch-input: var(--color-text);--color-background-table-thead: var(--color-grey-200);--color-table-background: var(--color-grey-300);--color-table-stripe: var(--color-grey-325);--color-background: var(--color-grey-300);--color-text-menu-heading: var(--color-grey-850);--color-text-menu-link: var(--color-grey-800);--color-border-table-cell: var(--color-grey-400);--color-text-tab-active: var(--color-text);--color-border-input: var(--color-grey-400);--color-shadow-input: var(--color-grey-50);--color-background-input: var(--color-grey-350);--color-text-input-description: var(--color-grey-600);--color-text-input-placeholder: var(--color-grey-600);--color-text-watch-tag-list: rgba(250, 62, 146, 0.4);--color-background-code: var(--color-grey-200);--color-background-tab: rgba(0, 0, 0, 0.2);--color-background-tab-hover: rgba(0, 0, 0, 0.5);--color-background-snapshot-age: var(--color-grey-200);--color-shadow-jump: var(--color-grey-200);--color-icon-github: var(--color-white);--color-icon-github-hover: var(--color-grey-700);--color-watch-table-error: var(--color-light-red);--color-watch-table-row-text: var(--color-grey-800)}html[data-darkmode=true] .icon-spread{filter:hue-rotate(-10deg) brightness(1.5)}html[data-darkmode=true] .watch-table .title-col a[target=_blank]::after,html[data-darkmode=true] .watch-table .current-diff-url::after{filter:invert(0.5) hue-rotate(10deg) brightness(2)}html[data-darkmode=true] .watch-table .status-browsersteps{filter:invert(0.5) hue-rotate(10deg) brightness(1.5)}html[data-darkmode=true] .watch-table .watch-controls .state-off img{opacity:.3}html[data-darkmode=true] .watch-table .watch-controls .state-on img{opacity:1}html[data-darkmode=true] .watch-table .unviewed{color:#fff}html[data-darkmode=true] .watch-table .unviewed.error{color:var(--color-watch-table-error)}.arrow{border:solid #1b98f8;border-width:0 2px 2px 0;display:inline-block;padding:3px}.arrow.right{transform:rotate(-45deg);-webkit-transform:rotate(-45deg)}.arrow.left{transform:rotate(135deg);-webkit-transform:rotate(135deg)}.arrow.up,.arrow.asc{transform:rotate(-135deg);-webkit-transform:rotate(-135deg)}.arrow.down,.arrow.desc{transform:rotate(45deg);-webkit-transform:rotate(45deg)}#browser_steps th{display:none}#browser_steps li{list-style:decimal;padding:5px}#browser_steps li.browser-step-with-error{background-color:#ffd6d6;border-radius:4px}#browser_steps li:not(:first-child):hover{opacity:1}#browser_steps li .control{padding-left:5px;padding-right:5px}#browser_steps li .control a{font-size:70%}#browser_steps li.empty{padding:0px;opacity:.35}#browser_steps li.empty .control{display:none}#browser_steps li:hover{background:#eee}#browser_steps li>label{display:none}@media only screen and (min-width: 760px){#browser-steps .flex-wrapper{display:flex;flex-flow:row;height:70vh;font-size:80%}#browser-steps .flex-wrapper #browser-steps-ui{flex-grow:1;flex-shrink:1;flex-basis:0;background-color:#eee;border-radius:5px}#browser-steps-fieldlist{flex-grow:0;flex-shrink:0;flex-basis:auto;max-width:400px;padding-left:1rem;overflow-y:scroll}#browsersteps-selector-wrapper{height:100% !important}}#browsersteps-selector-wrapper{width:100%;overflow-y:scroll;position:relative;height:80vh}#browsersteps-selector-wrapper>img{position:absolute;max-width:100%}#browsersteps-selector-wrapper>canvas{position:relative;max-width:100%}#browsersteps-selector-wrapper>canvas:hover{cursor:pointer}#browsersteps-selector-wrapper .loader{position:absolute;left:50%;top:50%;transform:translate(-50%, -50%);z-index:100;max-width:350px;text-align:center}#browsersteps-selector-wrapper .spinner,#browsersteps-selector-wrapper .spinner:after{width:80px;height:80px;font-size:3px}#browsersteps-selector-wrapper #browsersteps-click-start{color:var(--color-grey-400)}#browsersteps-selector-wrapper #browsersteps-click-start:hover{cursor:pointer}ul#requests-extra_proxies{list-style:none}ul#requests-extra_proxies li>label{display:none}ul#requests-extra_proxies table tr{display:table-row}ul#requests-extra_proxies table tr input[type=text]{width:100%}@media only screen and (min-width: 1024px){ul#requests-extra_proxies table tr{display:inline}}#request label[for=proxy]{display:inline-block}body.proxy-check-active #request .proxy-check-details{font-size:80%;color:#555;display:block;padding-left:2em;max-width:500px}body.proxy-check-active #request .proxy-timing{font-size:80%;padding-left:1rem;color:var(--color-link)}#recommended-proxy{display:grid;gap:2rem;padding-bottom:1em}@media(min-width: 991px){#recommended-proxy{grid-template-columns:repeat(2, 1fr)}}#recommended-proxy>div{border:1px #aaa solid;border-radius:4px;padding:1em}#extra-proxies-setting{border:1px solid var(--color-grey-800);border-radius:4px;margin:1em;padding:1em}ul#requests-extra_browsers{list-style:none}ul#requests-extra_browsers li>label{display:none}ul#requests-extra_browsers table tr{display:table-row}ul#requests-extra_browsers table tr input[type=text]{width:100%}@media only screen and (min-width: 1280px){ul#requests-extra_browsers table tr{display:inline}ul#requests-extra_browsers table tr input[type=text]{width:100%}}#extra-browsers-setting{border:1px solid var(--color-grey-800);border-radius:4px;margin:1em;padding:1em}.pagination-page-info{color:#fff;font-size:.85rem;text-transform:capitalize}.pagination.menu>*{display:inline-block}.pagination.menu li{display:inline-block}.pagination.menu a{padding:.65rem;margin:3px;border:none;background:#444;border-radius:2px;color:var(--color-text-button)}.pagination.menu a.disabled{display:none}.pagination.menu a.active{font-weight:bold;background:#888}.pagination.menu a:hover{background:#999}.spinner,.spinner:after{border-radius:50%;width:10px;height:10px}.spinner{margin:0px auto;font-size:3px;vertical-align:middle;display:inline-block;text-indent:-9999em;border-top:1.1em solid rgba(38,104,237,.2);border-right:1.1em solid rgba(38,104,237,.2);border-bottom:1.1em solid rgba(38,104,237,.2);border-left:1.1em solid #2668ed;-webkit-transform:translateZ(0);-ms-transform:translateZ(0);transform:translateZ(0);-webkit-animation:load8 1.1s infinite linear;animation:load8 1.1s infinite linear}@-webkit-keyframes load8{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}@keyframes load8{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}100%{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}#toggle-light-mode .icon-dark{display:none}html[data-darkmode=true] #toggle-light-mode .icon-light{display:none}html[data-darkmode=true] #toggle-light-mode .icon-dark{display:block}.pure-menu-link{padding:.5rem 1em;line-height:1.2rem}.pure-menu-item svg{height:1.2rem}.pure-menu-item *{vertical-align:middle}.pure-menu-item .github-link{height:1.8rem;display:block}.pure-menu-item .github-link svg{height:100%}.pure-menu-item .bi-heart:hover{cursor:pointer}#overlay{opacity:.95;position:fixed;width:350px;max-width:100%;height:100%;top:0;right:-350px;background-color:var(--color-table-stripe);z-index:2;transform:translateX(0);transition:transform .5s ease}#overlay.visible{transform:translateX(-100%)}#overlay .content{font-size:.875rem;padding:1rem;margin-top:5rem;max-width:400px;color:var(--color-watch-table-row-text)}#heartpath{transition:all ease .3s !important}#heartpath:hover{fill:red !important;transition:all ease .3s !important}.minitabs-wrapper{width:100%}.minitabs-wrapper>div[id]{padding:20px;border:1px solid #ccc;border-top:none}.minitabs-wrapper .minitabs-content{width:100%;display:flex}.minitabs-wrapper .minitabs-content>div{flex:1 1 auto;min-width:0;overflow:scroll}.minitabs-wrapper .minitabs{display:flex;border-bottom:1px solid #ccc}.minitabs-wrapper .minitab{flex:1;text-align:center;padding:12px 0;text-decoration:none;color:#333;background-color:#f1f1f1;border:1px solid #ccc;border-bottom:none;cursor:pointer;transition:background-color .3s}.minitabs-wrapper .minitab:hover{background-color:#ddd}.minitabs-wrapper .minitab.active{background-color:#fff;font-weight:bold}@media(min-width: 800px){body.preview-text-enabled #filters-and-triggers>div{display:flex;gap:20px;position:relative}}body.preview-text-enabled #edit-text-filter,body.preview-text-enabled #text-preview{flex:1;align-self:flex-start}body.preview-text-enabled #edit-text-filter #pro-tips{display:none}body.preview-text-enabled #text-preview{position:sticky;top:20px;padding-top:1rem;padding-bottom:1rem;display:block !important}body.preview-text-enabled #activate-text-preview{background-color:var(--color-grey-500)}body.preview-text-enabled .monospace-preview{background:var(--color-background-input);border:1px solid var(--color-grey-600);padding:1rem;color:var(--color-text-input);font-family:"Courier New",Courier,monospace;font-size:70%;word-break:break-word;white-space:pre-wrap}#activate-text-preview{right:0;position:absolute;z-index:3;box-shadow:1px 1px 4px var(--color-shadow-jump)}.watch-table{width:100%;font-size:80%}.watch-table tr{color:var(--color-watch-table-row-text)}.watch-table tr.unviewed{font-weight:bold}.watch-table td{white-space:nowrap}.watch-table td.title-col{word-break:break-all;white-space:normal}.watch-table td a.external::after{content:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAQElEQVR42qXKwQkAIAxDUUdxtO6/RBQkQZvSi8I/pL4BoGw/XPkh4XigPmsUgh0626AjRsgxHTkUThsG2T/sIlzdTsp52kSS1wAAAABJRU5ErkJggg==);margin:0 3px 0 5px}.watch-table th{white-space:nowrap}.watch-table th a{font-weight:normal}.watch-table th a.active{font-weight:bolder}.watch-table th a.inactive .arrow{display:none}.watch-table tr.checking-now td:first-child{position:relative}.watch-table tr.checking-now td:first-child::before{content:"";position:absolute;top:0;bottom:0;left:0;width:3px;background-color:#293eff}.watch-table tr.checking-now td.last-checked .spinner-wrapper{display:inline-block !important}.watch-table tr.checking-now td.last-checked .innertext{display:none !important}.watch-table tr.queued a.recheck{display:none !important}.watch-table tr.queued a.already-in-queue-button{display:inline-block !important}.watch-table tr.paused a.pause-toggle.state-on{display:inline !important}.watch-table tr.paused a.pause-toggle.state-off{display:none !important}.watch-table tr.notification_muted a.mute-toggle.state-on{display:inline !important}.watch-table tr.notification_muted a.mute-toggle.state-off{display:none !important}.watch-table tr.has-error{color:var(--color-watch-table-error)}.watch-table tr.has-error .error-text{display:block !important}.watch-table tr.single-history a.preview-link{display:inline-block !important}.watch-table tr.multiple-history a.history-link{display:inline-block !important}#watch-table-wrapper #post-list-buttons{text-align:right;padding:0px;margin:0px}#watch-table-wrapper #post-list-buttons li{display:inline-block}#watch-table-wrapper #post-list-buttons a{border-top-left-radius:initial;border-top-right-radius:initial;border-bottom-left-radius:5px;border-bottom-right-radius:5px}#watch-table-wrapper.has-error #post-list-buttons #post-list-with-errors{display:inline-block !important}#watch-table-wrapper.has-unread-changes #post-list-buttons #post-list-unread,#watch-table-wrapper.has-unread-changes #post-list-buttons #post-list-mark-views,#watch-table-wrapper.has-unread-changes #post-list-buttons #post-list-unread{display:inline-block !important}@media(max-width: 767px){.watch-table thead{display:block}.watch-table thead tr th{display:inline-block}}@media(max-width: 767px)and (max-width: 768px){.watch-table thead tr th .hide-on-mobile{display:none}}@media(max-width: 767px){.watch-table thead .empty-cell{display:none}.watch-table .last-checked{margin-left:calc(20px + .5rem)}.watch-table .last-checked>span{vertical-align:middle}.watch-table .last-changed{margin-left:calc(20px + .5rem)}.watch-table .last-checked::before{color:var(--color-text);content:"Last Checked "}.watch-table .last-changed::before{color:var(--color-text);content:"Last Changed "}.watch-table td.inline{display:inline-block}.watch-table .pure-table td,.watch-table .pure-table th{border:none}.watch-table td{border:none;border-bottom:1px solid var(--color-border-watch-table-cell);vertical-align:middle}.watch-table td:before{top:6px;left:6px;width:45%;padding-right:10px;white-space:nowrap}.watch-table.pure-table-striped tr{background-color:var(--color-table-background)}.watch-table.pure-table-striped tr:nth-child(2n-1){background-color:var(--color-table-stripe)}.watch-table.pure-table-striped tr:nth-child(2n-1) td{background-color:inherit}}@media(max-width: 767px){.watch-table tbody tr{padding-bottom:10px;padding-top:10px;display:grid;grid-template-columns:20px 1fr 100px;grid-template-rows:auto auto auto auto;gap:.5rem}.watch-table tbody tr .counter-i{display:none}.watch-table tbody tr td.checkbox-uuid{display:grid;place-items:center}.watch-table tbody tr>td{border-bottom:none}.watch-table tbody tr>td.title-col{grid-column:1/-1;grid-row:1}.watch-table tbody tr>td.title-col .watch-title{font-size:.92rem}.watch-table tbody tr>td.title-col .link-spread{display:none}.watch-table tbody tr>td.last-checked{grid-column:1/-1;grid-row:2}.watch-table tbody tr>td.last-changed{grid-column:1/-1;grid-row:3}.watch-table tbody tr>td.checkbox-uuid{grid-column:1;grid-row:4}.watch-table tbody tr>td.buttons{grid-column:2;grid-row:4;display:flex;align-items:center;justify-content:flex-start}.watch-table tbody tr>td.watch-controls{grid-column:3;grid-row:4;display:grid;place-items:center}.watch-table tbody tr>td.watch-controls a img{padding:10px}.pure-table td{padding:3px !important}}ul#conditions_match_logic{list-style:none}ul#conditions_match_logic input,ul#conditions_match_logic label,ul#conditions_match_logic li{display:inline-block}ul#conditions_match_logic li{padding-right:1em}.fieldlist_formfields{width:100%;background-color:var(--color-background, #fff);border-radius:4px;border:1px solid var(--color-border-table-cell, #cbcbcb)}.fieldlist_formfields .fieldlist-header{display:flex;background-color:var(--color-background-table-thead, #e0e0e0);font-weight:bold;border-bottom:1px solid var(--color-border-table-cell, #cbcbcb)}.fieldlist_formfields .fieldlist-header-cell{flex:1;padding:.5em 1em;text-align:left}.fieldlist_formfields .fieldlist-header-cell:last-child{flex:0 0 120px}.fieldlist_formfields .fieldlist-body{display:flex;flex-direction:column}.fieldlist_formfields .fieldlist-row{display:flex;border-bottom:1px solid var(--color-border-table-cell, #cbcbcb)}.fieldlist_formfields .fieldlist-row:last-child{border-bottom:none}.fieldlist_formfields .fieldlist-row:nth-child(2n-1){background-color:var(--color-table-stripe, #f2f2f2)}.fieldlist_formfields .fieldlist-row.error-row{background-color:var(--color-error-input, #ffdddd)}.fieldlist_formfields .fieldlist-cell{flex:1;padding:.5em 1em;display:flex;flex-direction:column;justify-content:center}.fieldlist_formfields .fieldlist-cell input,.fieldlist_formfields .fieldlist-cell select{width:100%}.fieldlist_formfields .fieldlist-cell.fieldlist-actions{flex:0 0 120px;display:flex;flex-direction:row;align-items:center;gap:4px}.fieldlist_formfields ul.errors{margin-top:.5em;margin-bottom:0;padding:.5em;background-color:var(--color-error-background-snapshot-age, #ffdddd);border-radius:4px;list-style-position:inside}@media only screen and (max-width: 760px){.fieldlist_formfields .fieldlist-header,.fieldlist_formfields .fieldlist-row{flex-direction:column}.fieldlist_formfields .fieldlist-header-cell{display:none}.fieldlist_formfields .fieldlist-row{padding:.5em 0;border-bottom:2px solid var(--color-border-table-cell, #cbcbcb)}.fieldlist_formfields .fieldlist-cell{padding:.25em .5em}.fieldlist_formfields .fieldlist-cell.fieldlist-actions{flex:1;justify-content:flex-start;padding-top:.5em}.fieldlist_formfields .fieldlist-cell:not(:last-child){margin-bottom:.5em}.fieldlist_formfields .fieldlist-cell::before{content:attr(data-label);font-weight:bold;margin-bottom:.25em}}.fieldlist_formfields .addRuleRow,.fieldlist_formfields .removeRuleRow,.fieldlist_formfields .verifyRuleRow{cursor:pointer;border:none;padding:4px 8px;border-radius:3px;font-weight:bold;background-color:#aaa;color:var(--color-foreground-text, #fff)}.fieldlist_formfields .addRuleRow:hover,.fieldlist_formfields .removeRuleRow:hover,.fieldlist_formfields .verifyRuleRow:hover{background-color:#999}.watch-table.favicon-not-enabled tr .favicon{display:none}.watch-table tr td.inline.title-col .flex-wrapper{display:flex;align-items:center;gap:4px}.watch-table td,.watch-table th{vertical-align:middle}.watch-table tr.has-favicon.unviewed img.favicon{opacity:1 !important}.watch-table .status-icons{white-space:nowrap;display:flex;align-items:center;gap:4px}.watch-table .status-icons>*{vertical-align:middle}.title-col{padding:10px}.title-wrapper{display:flex;align-items:center;gap:10px}.title-col-inner{display:inline-block;vertical-align:middle}.watch-table img.favicon{vertical-align:middle;max-width:25px;max-height:25px;height:25px;padding-right:4px}body.checking-now #checking-now-fixed-tab{display:block !important}#checking-now-fixed-tab{background:#ccc;border-radius:5px;bottom:0;color:var(--color-text);display:none;font-size:.8rem;left:0;padding:5px;position:fixed}#selector-wrapper{height:100%;text-align:center;max-height:70vh;overflow-y:scroll;position:relative}#selector-wrapper>img{position:absolute;z-index:4;max-width:100%}#selector-wrapper>canvas{position:relative;z-index:5;max-width:100%}#selector-wrapper>canvas:hover{cursor:pointer}#selector-current-xpath{font-size:80%}.ternary-radio-group{display:flex;gap:0;border:1px solid var(--color-grey-750);border-radius:4px;overflow:hidden;width:fit-content;background:var(--color-background)}.ternary-radio-group .ternary-radio-option{position:relative;cursor:pointer;margin:0;display:flex;align-items:center}.ternary-radio-group .ternary-radio-option input[type=radio]{position:absolute;opacity:0;width:0;height:0}.ternary-radio-group .ternary-radio-option .ternary-radio-label{padding:8px 16px;background:var(--color-grey-900);border:none;border-right:1px solid var(--color-grey-750);font-size:13px;font-weight:500;color:var(--color-text);transition:all .2s ease;cursor:pointer;display:block;text-align:center}.ternary-radio-group .ternary-radio-option:last-child .ternary-radio-label{border-right:none}.ternary-radio-group .ternary-radio-option input:checked+.ternary-radio-label{background:var(--color-link);color:var(--color-text-button);font-weight:600}.ternary-radio-group .ternary-radio-option input:checked+.ternary-radio-label.ternary-default{background:var(--color-grey-600);color:var(--color-text-button)}.ternary-radio-group .ternary-radio-option input:checked+.ternary-radio-label:hover{background:#1a7bc4}.ternary-radio-group .ternary-radio-option input:checked+.ternary-radio-label:hover.ternary-default{background:var(--color-grey-500)}.ternary-radio-group .ternary-radio-option:hover .ternary-radio-label{background:var(--color-grey-800)}@media(max-width: 480px){.ternary-radio-group{width:100%}.ternary-radio-group .ternary-radio-label{flex:1;min-width:auto}}input[type=radio].pure-radio:checked+label,input[type=radio].pure-radio:checked{background:var(--color-link);color:var(--color-text-button)}html[data-darkmode=true] .ternary-radio-group .ternary-radio-option .ternary-radio-label{background:var(--color-grey-350)}html[data-darkmode=true] .ternary-radio-group .ternary-radio-option:hover .ternary-radio-label{background:var(--color-grey-400)}html[data-darkmode=true] .ternary-radio-group .ternary-radio-option input:checked+.ternary-radio-label{background:var(--color-link);color:var(--color-text-button)}html[data-darkmode=true] .ternary-radio-group .ternary-radio-option input:checked+.ternary-radio-label.ternary-default{background:var(--color-grey-600)}html[data-darkmode=true] .ternary-radio-group .ternary-radio-option input:checked+.ternary-radio-label:hover{background:#1a7bc4}html[data-darkmode=true] .ternary-radio-group .ternary-radio-option input:checked+.ternary-radio-label:hover.ternary-default{background:var(--color-grey-500)}body.processor-image_ssim_diff #edit-text-filter .text-filtering{display:none}body.processor-image_ssim_diff #conditions-tab{display:none}body{color:var(--color-text);background:var(--color-background-page);font-family:Helvetica Neue,Helvetica,Lucida Grande,Arial,Ubuntu,Cantarell,Fira Sans,sans-serif}.visually-hidden{clip:rect(0 0 0 0);clip-path:inset(50%);height:1px;overflow:hidden;position:absolute;white-space:nowrap;width:1px}.status-icon{display:inline-block;height:1rem;vertical-align:middle}.pure-table-even{background:var(--color-background)}a{text-decoration:none;color:var(--color-link)}a.github-link{color:var(--color-icon-github);margin:0 1rem 0 .5rem}a.github-link svg{fill:currentColor}a.github-link:hover{color:var(--color-icon-github-hover)}#search-q{opacity:0;-webkit-transition:all .9s ease;-moz-transition:all .9s ease;transition:all .9s ease;width:0;display:none}#search-q.expanded{width:auto;display:inline-block;opacity:1}#search-result-info{color:#fff}button.toggle-button{vertical-align:middle;background:rgba(0,0,0,0);border:none;cursor:pointer;color:var(--color-icon-github)}button.toggle-button:hover{color:var(--color-icon-github-hover)}button.toggle-button svg{fill:currentColor}button.toggle-button .icon-light{display:block}.pure-menu-horizontal{background:var(--color-background);padding:5px;display:flex;justify-content:space-between;align-items:center}#pure-menu-horizontal-spinner{height:3px;background:linear-gradient(-75deg, #ff6000, #ff8f00, #ffdd00, #ed0000);background-size:400% 400%;width:100%;animation:gradient 200s ease infinite}body.spinner-active #pure-menu-horizontal-spinner{animation:gradient 1s ease infinite}@keyframes gradient{0%{background-position:0% 50%}50%{background-position:100% 50%}100%{background-position:0% 50%}}.pure-menu-heading{color:var(--color-text-menu-heading)}.pure-menu-link{color:var(--color-text-menu-link)}.pure-menu-link:hover{background-color:var(--color-background-menu-link-hover);color:var(--color-text-menu-link-hover)}.tab-pane-inner{scroll-margin-top:200px}section.content{padding-top:100px;padding-bottom:1em;flex-direction:column;display:flex;align-items:center;justify-content:center}code{background:var(--color-background-code);color:var(--color-text)}.inline-tag,.restock-label,.tracking-ldjson-price-data,.watch-tag-list,.processor-badge{white-space:nowrap;border-radius:5px;padding:2px 5px;margin-right:4px}.processor-badge{font-size:.85em;font-weight:500}.watch-tag-list{color:var(--color-white);background:var(--color-text-watch-tag-list)}@media(min-width: 768px){.box{margin:0 1em !important}}.box{max-width:100%;margin:0 .3em;flex-direction:column;display:flex;justify-content:center}body:after{content:"";background:linear-gradient(130deg, var(--color-background-gradient-first), var(--color-background-gradient-second) 41.07%, var(--color-background-gradient-third) 84.05%)}body:after,body:before{display:block;height:650px;position:absolute;top:0;left:0;width:100%;z-index:-1}body::after{opacity:.91}body::before{content:""}body:after,body:before{-webkit-clip-path:polygon(100% 0, 0 0, 0 77.5%, 1% 77.4%, 2% 77.1%, 3% 76.6%, 4% 75.9%, 5% 75.05%, 6% 74.05%, 7% 72.95%, 8% 71.75%, 9% 70.55%, 10% 69.3%, 11% 68.05%, 12% 66.9%, 13% 65.8%, 14% 64.8%, 15% 64%, 16% 63.35%, 17% 62.85%, 18% 62.6%, 19% 62.5%, 20% 62.65%, 21% 63%, 22% 63.5%, 23% 64.2%, 24% 65.1%, 25% 66.1%, 26% 67.2%, 27% 68.4%, 28% 69.65%, 29% 70.9%, 30% 72.15%, 31% 73.3%, 32% 74.35%, 33% 75.3%, 34% 76.1%, 35% 76.75%, 36% 77.2%, 37% 77.45%, 38% 77.5%, 39% 77.3%, 40% 76.95%, 41% 76.4%, 42% 75.65%, 43% 74.75%, 44% 73.75%, 45% 72.6%, 46% 71.4%, 47% 70.15%, 48% 68.9%, 49% 67.7%, 50% 66.55%, 51% 65.5%, 52% 64.55%, 53% 63.75%, 54% 63.15%, 55% 62.75%, 56% 62.55%, 57% 62.5%, 58% 62.7%, 59% 63.1%, 60% 63.7%, 61% 64.45%, 62% 65.4%, 63% 66.45%, 64% 67.6%, 65% 68.8%, 66% 70.05%, 67% 71.3%, 68% 72.5%, 69% 73.6%, 70% 74.65%, 71% 75.55%, 72% 76.35%, 73% 76.9%, 74% 77.3%, 75% 77.5%, 76% 77.45%, 77% 77.25%, 78% 76.8%, 79% 76.2%, 80% 75.4%, 81% 74.45%, 82% 73.4%, 83% 72.25%, 84% 71.05%, 85% 69.8%, 86% 68.55%, 87% 67.35%, 88% 66.2%, 89% 65.2%, 90% 64.3%, 91% 63.55%, 92% 63%, 93% 62.65%, 94% 62.5%, 95% 62.55%, 96% 62.8%, 97% 63.3%, 98% 63.9%, 99% 64.75%, 100% 65.7%);clip-path:polygon(100% 0, 0 0, 0 77.5%, 1% 77.4%, 2% 77.1%, 3% 76.6%, 4% 75.9%, 5% 75.05%, 6% 74.05%, 7% 72.95%, 8% 71.75%, 9% 70.55%, 10% 69.3%, 11% 68.05%, 12% 66.9%, 13% 65.8%, 14% 64.8%, 15% 64%, 16% 63.35%, 17% 62.85%, 18% 62.6%, 19% 62.5%, 20% 62.65%, 21% 63%, 22% 63.5%, 23% 64.2%, 24% 65.1%, 25% 66.1%, 26% 67.2%, 27% 68.4%, 28% 69.65%, 29% 70.9%, 30% 72.15%, 31% 73.3%, 32% 74.35%, 33% 75.3%, 34% 76.1%, 35% 76.75%, 36% 77.2%, 37% 77.45%, 38% 77.5%, 39% 77.3%, 40% 76.95%, 41% 76.4%, 42% 75.65%, 43% 74.75%, 44% 73.75%, 45% 72.6%, 46% 71.4%, 47% 70.15%, 48% 68.9%, 49% 67.7%, 50% 66.55%, 51% 65.5%, 52% 64.55%, 53% 63.75%, 54% 63.15%, 55% 62.75%, 56% 62.55%, 57% 62.5%, 58% 62.7%, 59% 63.1%, 60% 63.7%, 61% 64.45%, 62% 65.4%, 63% 66.45%, 64% 67.6%, 65% 68.8%, 66% 70.05%, 67% 71.3%, 68% 72.5%, 69% 73.6%, 70% 74.65%, 71% 75.55%, 72% 76.35%, 73% 76.9%, 74% 77.3%, 75% 77.5%, 76% 77.45%, 77% 77.25%, 78% 76.8%, 79% 76.2%, 80% 75.4%, 81% 74.45%, 82% 73.4%, 83% 72.25%, 84% 71.05%, 85% 69.8%, 86% 68.55%, 87% 67.35%, 88% 66.2%, 89% 65.2%, 90% 64.3%, 91% 63.55%, 92% 63%, 93% 62.65%, 94% 62.5%, 95% 62.55%, 96% 62.8%, 97% 63.3%, 98% 63.9%, 99% 64.75%, 100% 65.7%)}.button-small{font-size:85%}.button-xsmall{font-size:70%}.fetch-error{padding-top:1em;font-size:80%;max-width:400px;display:block}.pure-button-primary,a.pure-button-primary,.pure-button-selected,a.pure-button-selected{background-color:var(--color-background-button-primary)}.button-secondary{color:var(--color-text-button);border-radius:4px;text-shadow:0 1px 1px rgba(0,0,0,.2)}.button-success{background:var(--color-background-button-success)}.button-tag{background:var(--color-background-button-tag);color:var(--color-text-button);font-size:65%;border-bottom-left-radius:initial;border-bottom-right-radius:initial;margin-right:4px}.button-tag.active{background:var(--color-background-button-tag-active);font-weight:bold}.button-error{background:var(--color-background-button-error);color:var(--color-text-button-error)}.button-warning{background:var(--color-background-button-warning);color:var(--color-text-button-warning)}.button-secondary{background:var(--color-background-button-secondary)}.button-cancel{background:var(--color-background-button-cancel)}.messages li{list-style:none;padding:1em;border-radius:10px;color:var(--color-text-messages);font-weight:bold}.messages li.message{background:var(--color-background-messages-message)}.messages li.error{background:var(--color-background-messages-error)}.messages li.notice{background:var(--color-background-messages-notice)}.messages.with-share-link>*:hover{cursor:pointer}.notifications-wrapper{padding-top:.5rem}.notifications-wrapper #notification-test-log{margin-top:1rem;padding:1rem;white-space:pre-wrap;word-break:break-word;overflow-wrap:break-word;max-width:100%;box-sizing:border-box;max-height:12rem;overflow-y:scroll;border:1px solid var(--color-border-notification);border-radius:5px}label:hover{cursor:pointer}.grey-form-border{border:1px solid var(--color-border-notification);padding:.5rem;border-radius:5px}#notification-error-log{border:1px solid var(--color-border-notification);padding:1rem;border-radius:5px;overflow-wrap:break-word}#token-table.pure-table td,#token-table.pure-table th{font-size:80%}.pure-form input[type=text].transparent-field{background-color:var(--color-background-new-watch-input-transparent) !important;color:var(--color-white) !important;border:1px solid hsla(0,0%,100%,.2) !important;box-shadow:none !important;-webkit-box-shadow:none !important}.pure-form input[type=text].transparent-field::placeholder{opacity:.5;color:hsla(0,0%,100%,.7);font-weight:lighter}#new-watch-form{background:var(--color-background-new-watch-form);padding:1em;border-radius:10px;margin-bottom:1em;max-width:100%}#new-watch-form #url::placeholder{font-weight:bold}#new-watch-form input{display:inline-block;margin-bottom:5px}#new-watch-form input:not(.pure-button){background-color:var(--color-background-new-watch-input);color:var(--color-text-new-watch-input)}#new-watch-form .label{display:none}#new-watch-form legend{color:var(--color-text-legend);font-weight:bold}@media only screen and (min-width: 760px){#new-watch-form #watch-add-wrapper-zone{display:flex;gap:.3rem;flex-direction:row;min-width:70vw}}#new-watch-form #watch-add-wrapper-zone>span{flex-grow:0}#new-watch-form #watch-add-wrapper-zone>span input{width:100%;padding-right:1em}#new-watch-form #watch-add-wrapper-zone>span:first-child{flex-grow:1}@media only screen and (max-width: 760px){#new-watch-form #watch-add-wrapper-zone #url{width:100%}}#new-watch-form #watch-group-tag{font-size:.9rem;padding:.3rem;display:flex;align-items:center;gap:.5rem;color:var(--color-white)}#new-watch-form #watch-group-tag label,#new-watch-form #watch-group-tag input{margin:0}#new-watch-form #watch-group-tag input{flex:1}#diff-col{padding-left:40px}#diff-jump{position:fixed;left:0px;top:120px;background:var(--color-background);padding:10px;border-top-right-radius:5px;border-bottom-right-radius:5px;box-shadow:1px 1px 4px var(--color-shadow-jump)}#diff-jump a{color:var(--color-link);cursor:pointer;-moz-user-select:none;-webkit-user-select:none;-ms-user-select:none;user-select:none;-o-user-select:none}footer{padding:10px;background:var(--color-background);color:var(--color-text-footer);text-align:center}#feed-icon{vertical-align:middle}.sticky-tab{position:absolute;top:60px;font-size:65%;background:var(--color-background);padding:10px}.sticky-tab#left-sticky{left:0;position:fixed;border-top-right-radius:5px;border-bottom-right-radius:5px;box-shadow:1px 1px 4px var(--color-shadow-jump)}.sticky-tab#right-sticky{right:0px}.sticky-tab#hosted-sticky{right:0px;top:100px;font-weight:bold}#new-version-text a{color:var(--color-link-new-version)}.watch-controls{color:#f8321b}.watch-controls .state-on img{opacity:.8}.watch-controls img{opacity:.2}.watch-controls img:hover{transition:opacity .3s;opacity:.8}.monospaced-textarea textarea{width:100%;font-family:monospace;white-space:pre;overflow-wrap:normal;overflow-x:auto}.pure-form fieldset{padding-top:0px}.pure-form fieldset ul{padding-bottom:0px;margin-bottom:0px}.pure-form .pure-control-group,.pure-form .pure-group,.pure-form .pure-controls{padding-bottom:1em}.pure-form .pure-control-group div,.pure-form .pure-group div,.pure-form .pure-controls div{margin:0px}.pure-form .pure-control-group .checkbox>*,.pure-form .pure-group .checkbox>*,.pure-form .pure-controls .checkbox>*{display:inline;vertical-align:middle}.pure-form .pure-control-group .checkbox>label,.pure-form .pure-group .checkbox>label,.pure-form .pure-controls .checkbox>label{padding-left:5px}.pure-form .pure-control-group legend,.pure-form .pure-group legend,.pure-form .pure-controls legend{color:var(--color-text-legend)}.pure-form .error input{background-color:var(--color-error-input)}.pure-form ul.errors{padding:.5em .6em;border:1px solid var(--color-error-list);border-radius:4px;vertical-align:middle;-webkit-box-sizing:border-box;box-sizing:border-box}.pure-form ul.errors li{margin-left:1em;color:var(--color-error-list)}.pure-form label{font-weight:bold}.pure-form textarea{width:100%}.pure-form .inline-radio ul{margin:0px;list-style:none}.pure-form .inline-radio ul li{display:flex;align-items:center;gap:1em}@media only screen and (max-width: 760px),(min-device-width: 768px)and (max-device-width: 1024px){.edit-form{padding:.5em;margin:0}#nav-menu{overflow-x:scroll}}@media only screen and (max-width: 760px),(min-device-width: 768px)and (max-device-width: 800px){div.sticky-tab#hosted-sticky{top:60px;left:0px;right:auto}section.content{padding-top:110px}div.tabs.collapsable ul li{display:block;border-radius:0px;margin-right:0px}input[type=text]{width:100%}}.pure-table{border-color:var(--color-border-table-cell)}.pure-table thead{background-color:var(--color-background-table-thead);color:var(--color-text);border-bottom:1px solid var(--color-background-table-thead)}.pure-table td,.pure-table th{border-left-color:var(--color-border-table-cell)}.pure-table-striped tr:nth-child(2n-1) td{background-color:var(--color-table-stripe)}.pure-form input[type=color],.pure-form input[type=date],.pure-form input[type=datetime-local],.pure-form input[type=datetime],.pure-form input[type=email],.pure-form input[type=month],.pure-form input[type=number],.pure-form input[type=password],.pure-form input[type=search],.pure-form input[type=tel],.pure-form input[type=text],.pure-form input[type=time],.pure-form input[type=url],.pure-form input[type=week],.pure-form select,.pure-form textarea{border:var(--color-border-input);box-shadow:inset 0 1px 3px var(--color-shadow-input);background-color:var(--color-background-input);color:var(--color-text-input)}.pure-form input[type=color]:active,.pure-form input[type=date]:active,.pure-form input[type=datetime-local]:active,.pure-form input[type=datetime]:active,.pure-form input[type=email]:active,.pure-form input[type=month]:active,.pure-form input[type=number]:active,.pure-form input[type=password]:active,.pure-form input[type=search]:active,.pure-form input[type=tel]:active,.pure-form input[type=text]:active,.pure-form input[type=time]:active,.pure-form input[type=url]:active,.pure-form input[type=week]:active,.pure-form select:active,.pure-form textarea:active{background-color:var(--color-background-input)}input::placeholder,textarea::placeholder{color:var(--color-text-input-placeholder)}.m-d{min-width:100%}@media only screen and (min-width: 761px){.m-d{min-width:80%}}.tabs ul{margin:0px;padding:0px;display:block}.tabs ul li{margin-right:1px;display:inline-block;color:var(--color-text-tab);border-top-left-radius:5px;border-top-right-radius:5px;background-color:var(--color-background-tab)}.tabs ul li:not(.active):hover{background-color:var(--color-background-tab-hover)}.tabs ul li.active,.tabs ul li :target{background-color:var(--color-background)}.tabs ul li.active a,.tabs ul li :target a{color:var(--color-text-tab-active);font-weight:bold}.tabs ul li a{display:block;padding:.7em;color:var(--color-text-tab)}.pure-form-stacked>div:first-child{display:block}.login-form .inner{background:var(--color-background);padding:20px;border-radius:5px}.tab-pane-inner{padding:0px}.tab-pane-inner:not(:target){display:none}.tab-pane-inner:target{display:block}.beta-logo{height:50px;right:-3px;top:-3px;position:absolute}#selector-header{padding-bottom:1em}body.full-width .edit-form{width:95%}.edit-form{min-width:70%;max-width:95%}.edit-form .box-wrap{position:relative}.edit-form .inner{background:var(--color-background);padding:20px}.edit-form #actions{display:block;background:var(--color-background)}.edit-form #actions .pure-control-group{display:flex;gap:.625em;flex-wrap:wrap}.edit-form .pure-form-message-inline{padding-left:0;color:var(--color-text-input-description)}.edit-form .pure-form-message-inline code{font-size:.875em}.border-fieldset{border:1px solid #ccc;padding:1rem;border-radius:5px;margin-bottom:1rem}.border-fieldset h3{margin-top:0}.border-fieldset fieldset:last-of-type{padding-bottom:0}.border-fieldset fieldset:last-of-type .pure-control-group{padding-bottom:0}ul{padding-left:1em;padding-top:0px;margin-top:4px}.time-check-widget tr{display:inline}.time-check-widget tr input[type=number]{width:5em}@media only screen and (max-width: 760px){.time-check-widget tbody{display:grid;grid-template-columns:auto 1fr auto 1fr;gap:.625em .3125em;align-items:center}.time-check-widget tr{display:contents}.time-check-widget tr th{text-align:right;padding-right:5px}.time-check-widget tr input[type=number]{width:100%;max-width:5em}}#webdriver_delay{width:5em}#api-key:hover{cursor:pointer}#api-key-copy{color:var(--color-api-key)}.button-green{background-color:var(--color-background-button-green)}.button-red{background-color:var(--color-background-button-red)}.noselect{-webkit-touch-callout:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}#checkbox-operations{background:var(--color-background-checkbox-operations);padding:1em;border-radius:10px;margin-bottom:1em;display:none}#checkbox-operations button{margin-bottom:3px;margin-top:3px;display:inline-flex;align-items:center}.checkbox-uuid>*{vertical-align:middle}.inline-warning{border:1px solid var(--color-border-warning);padding:.5rem;border-radius:5px;color:var(--color-warning)}.inline-warning>span{display:inline-block;vertical-align:middle}.inline-warning img.inline-warning-icon{display:inline;height:26px;vertical-align:middle}.tracking-ldjson-price-data{background-color:var(--color-background-button-green);color:#000;opacity:.6}.ldjson-price-track-offer{font-weight:bold;font-style:italic}.ldjson-price-track-offer a.pure-button{border-radius:3px;padding:3px;background-color:var(--color-background-button-green)}.price-follow-tag-icon{display:inline-block;height:.8rem;vertical-align:middle}#quick-watch-processor-type ul#processor{color:#fff;padding-left:0px}#quick-watch-processor-type ul#processor li{list-style:none;font-size:.9rem;display:grid;grid-template-columns:auto 1fr;align-items:center;gap:.5rem;margin-bottom:.5rem}#quick-watch-processor-type label,#quick-watch-processor-type input{padding:0;margin:0}.restock-label.in-stock{background-color:var(--color-background-button-green);color:#fff}.restock-label.not-in-stock{background-color:var(--color-background-button-cancel);color:#777}.restock-label.error{background-color:var(--color-background-button-error);color:#fff;opacity:.7}.restock-label svg{vertical-align:middle}#chrome-extension-link{padding:9px;border:1px solid var(--color-grey-800);border-radius:10px;vertical-align:middle}#chrome-extension-link img{height:21px;padding:2px;vertical-align:middle}#realtime-conn-error{position:fixed;bottom:0;left:0;background:var(--color-warning);padding:10px;font-size:.8rem;color:#fff;opacity:.8}#bottom-horizontal-offscreen{position:fixed;bottom:0;left:0;right:0;width:100%;min-height:50px;max-height:50vh;background:hsla(0,0%,100%,.7215686275);border-top:1px solid var(--color-border-table-cell);padding:10px;box-shadow:0 -2px 10px rgba(0,0,0,.2);z-index:100;overflow-y:auto;transition:opacity .3s ease-in-out;scroll-margin-bottom:10px;display:flex;justify-content:center;align-items:center}ul#highlightSnippetActions{list-style:none}ul#highlightSnippetActions li{display:inline-block} diff --git a/changedetectionio/store.py b/changedetectionio/store.py index ba223ae3a..2a79b865b 100644 --- a/changedetectionio/store.py +++ b/changedetectionio/store.py @@ -1,3 +1,5 @@ +import shutil + from changedetectionio.strtobool import strtobool from changedetectionio.validate_url import is_safe_valid_url @@ -1122,3 +1124,15 @@ class ChangeDetectionStore: else: # safe fallback to text self.data['settings']['application']['rss_content_format'] = RSS_CONTENT_FORMAT_DEFAULT + + # Different processors now hold their own history.txt + def update_25(self): + for uuid, watch in self.data['watching'].items(): + processor = self.data['watching'][uuid].get('processor') + if processor != 'text_json_diff': + old_history_txt = os.path.join(self.datastore_path, "history.txt") + target_history_name = f"history-{processor}.txt" + if os.path.isfile(old_history_txt) and not os.path.isfile(target_history_name): + new_history_txt = os.path.join(self.datastore_path, target_history_name) + logger.debug(f"Renaming history index {old_history_txt} to {new_history_txt}...") + shutil.move(old_history_txt, new_history_txt) diff --git a/changedetectionio/templates/base.html b/changedetectionio/templates/base.html index 6be09740e..b28c89cfd 100644 --- a/changedetectionio/templates/base.html +++ b/changedetectionio/templates/base.html @@ -71,8 +71,7 @@