kopia lustrzana https://github.com/dgtlmoon/changedetection.io
New major functionality CONDITIONS - Compare values, check numbers within range, etc
rodzic
1c2cfc37aa
commit
7e7d5dc383
|
@ -2,6 +2,7 @@ recursive-include changedetectionio/api *
|
||||||
recursive-include changedetectionio/apprise_plugin *
|
recursive-include changedetectionio/apprise_plugin *
|
||||||
recursive-include changedetectionio/blueprint *
|
recursive-include changedetectionio/blueprint *
|
||||||
recursive-include changedetectionio/content_fetchers *
|
recursive-include changedetectionio/content_fetchers *
|
||||||
|
recursive-include changedetectionio/conditions *
|
||||||
recursive-include changedetectionio/model *
|
recursive-include changedetectionio/model *
|
||||||
recursive-include changedetectionio/processors *
|
recursive-include changedetectionio/processors *
|
||||||
recursive-include changedetectionio/static *
|
recursive-include changedetectionio/static *
|
||||||
|
|
|
@ -0,0 +1,135 @@
|
||||||
|
from flask import Blueprint
|
||||||
|
|
||||||
|
from json_logic.builtins import BUILTINS
|
||||||
|
|
||||||
|
from .exceptions import EmptyConditionRuleRowNotUsable
|
||||||
|
from .pluggy_interface import plugin_manager # Import the pluggy plugin manager
|
||||||
|
from . import default_plugin
|
||||||
|
|
||||||
|
# List of all supported JSON Logic operators
|
||||||
|
operator_choices = [
|
||||||
|
(None, "Choose one"),
|
||||||
|
(">", "Greater Than"),
|
||||||
|
("<", "Less Than"),
|
||||||
|
(">=", "Greater Than or Equal To"),
|
||||||
|
("<=", "Less Than or Equal To"),
|
||||||
|
("==", "Equals"),
|
||||||
|
("!=", "Not Equals"),
|
||||||
|
("in", "Contains"),
|
||||||
|
("!in", "Does Not Contain"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Fields available in the rules
|
||||||
|
field_choices = [
|
||||||
|
(None, "Choose one"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# The data we will feed the JSON Rules to see if it passes the test/conditions or not
|
||||||
|
EXECUTE_DATA = {}
|
||||||
|
|
||||||
|
|
||||||
|
# Define the extended operations dictionary
|
||||||
|
CUSTOM_OPERATIONS = {
|
||||||
|
**BUILTINS, # Include all standard operators
|
||||||
|
}
|
||||||
|
|
||||||
|
def filter_complete_rules(ruleset):
|
||||||
|
rules = [
|
||||||
|
rule for rule in ruleset
|
||||||
|
if all(value not in ("", False, "None", None) for value in [rule["operator"], rule["field"], rule["value"]])
|
||||||
|
]
|
||||||
|
return rules
|
||||||
|
|
||||||
|
def convert_to_jsonlogic(logic_operator: str, rule_dict: list):
|
||||||
|
"""
|
||||||
|
Convert a structured rule dict into a JSON Logic rule.
|
||||||
|
|
||||||
|
:param rule_dict: Dictionary containing conditions.
|
||||||
|
:return: JSON Logic rule as a dictionary.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
json_logic_conditions = []
|
||||||
|
|
||||||
|
for condition in rule_dict:
|
||||||
|
operator = condition["operator"]
|
||||||
|
field = condition["field"]
|
||||||
|
value = condition["value"]
|
||||||
|
|
||||||
|
if not operator or operator == 'None' or not value or not field:
|
||||||
|
raise EmptyConditionRuleRowNotUsable()
|
||||||
|
|
||||||
|
# Convert value to int/float if possible
|
||||||
|
try:
|
||||||
|
if isinstance(value, str) and "." in value and str != "None":
|
||||||
|
value = float(value)
|
||||||
|
else:
|
||||||
|
value = int(value)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass # Keep as a string if conversion fails
|
||||||
|
|
||||||
|
# Handle different JSON Logic operators properly
|
||||||
|
if operator == "in":
|
||||||
|
json_logic_conditions.append({"in": [value, {"var": field}]}) # value first
|
||||||
|
elif operator in ("!", "!!", "-"):
|
||||||
|
json_logic_conditions.append({operator: [{"var": field}]}) # Unary operators
|
||||||
|
elif operator in ("min", "max", "cat"):
|
||||||
|
json_logic_conditions.append({operator: value}) # Multi-argument operators
|
||||||
|
else:
|
||||||
|
json_logic_conditions.append({operator: [{"var": field}, value]}) # Standard binary operators
|
||||||
|
|
||||||
|
return {logic_operator: json_logic_conditions} if len(json_logic_conditions) > 1 else json_logic_conditions[0]
|
||||||
|
|
||||||
|
|
||||||
|
def execute_ruleset_against_all_plugins(current_watch_uuid: str, application_datastruct, ephemeral_data={} ):
|
||||||
|
"""
|
||||||
|
Build our data and options by calling our plugins then pass it to jsonlogic and see if the conditions pass
|
||||||
|
|
||||||
|
:param ruleset: JSON Logic rule dictionary.
|
||||||
|
:param extracted_data: Dictionary containing the facts. <-- maybe the app struct+uuid
|
||||||
|
:return: Dictionary of plugin results.
|
||||||
|
"""
|
||||||
|
from json_logic import jsonLogic
|
||||||
|
|
||||||
|
EXECUTE_DATA = {}
|
||||||
|
result = True
|
||||||
|
|
||||||
|
ruleset_settings = application_datastruct['watching'].get(current_watch_uuid)
|
||||||
|
|
||||||
|
if ruleset_settings.get("conditions"):
|
||||||
|
logic_operator = "and" if ruleset_settings.get("conditions_match_logic", "ALL") == "ALL" else "or"
|
||||||
|
complete_rules = filter_complete_rules(ruleset_settings['conditions'])
|
||||||
|
if complete_rules:
|
||||||
|
# Give all plugins a chance to update the data dict again (that we will test the conditions against)
|
||||||
|
for plugin in plugin_manager.get_plugins():
|
||||||
|
new_execute_data = plugin.add_data(current_watch_uuid=current_watch_uuid,
|
||||||
|
application_datastruct=application_datastruct,
|
||||||
|
ephemeral_data=ephemeral_data)
|
||||||
|
|
||||||
|
if new_execute_data and isinstance(new_execute_data, dict):
|
||||||
|
EXECUTE_DATA.update(new_execute_data)
|
||||||
|
|
||||||
|
# Create the ruleset
|
||||||
|
ruleset = convert_to_jsonlogic(logic_operator=logic_operator, rule_dict=complete_rules)
|
||||||
|
|
||||||
|
# Pass the custom operations dictionary to jsonLogic
|
||||||
|
if not jsonLogic(logic=ruleset, data=EXECUTE_DATA, operations=CUSTOM_OPERATIONS):
|
||||||
|
result = False
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# Load plugins dynamically
|
||||||
|
for plugin in plugin_manager.get_plugins():
|
||||||
|
new_ops = plugin.register_operators()
|
||||||
|
if isinstance(new_ops, dict):
|
||||||
|
CUSTOM_OPERATIONS.update(new_ops)
|
||||||
|
|
||||||
|
new_operator_choices = plugin.register_operator_choices()
|
||||||
|
if isinstance(new_operator_choices, list):
|
||||||
|
operator_choices.extend(new_operator_choices)
|
||||||
|
|
||||||
|
new_field_choices = plugin.register_field_choices()
|
||||||
|
if isinstance(new_field_choices, list):
|
||||||
|
field_choices.extend(new_field_choices)
|
||||||
|
|
|
@ -0,0 +1,78 @@
|
||||||
|
# Flask Blueprint Definition
|
||||||
|
import json
|
||||||
|
|
||||||
|
from flask import Blueprint
|
||||||
|
|
||||||
|
from changedetectionio.conditions import execute_ruleset_against_all_plugins
|
||||||
|
|
||||||
|
|
||||||
|
def construct_blueprint(datastore):
|
||||||
|
from changedetectionio.flask_app import login_optionally_required
|
||||||
|
|
||||||
|
conditions_blueprint = Blueprint('conditions', __name__, template_folder="templates")
|
||||||
|
|
||||||
|
@conditions_blueprint.route("/<string:watch_uuid>/verify-condition-single-rule", methods=['POST'])
|
||||||
|
@login_optionally_required
|
||||||
|
def verify_condition_single_rule(watch_uuid):
|
||||||
|
"""Verify a single condition rule against the current snapshot"""
|
||||||
|
from changedetectionio.processors.text_json_diff import prepare_filter_prevew
|
||||||
|
from flask import request, jsonify
|
||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
|
ephemeral_data = {}
|
||||||
|
|
||||||
|
# Get the watch data
|
||||||
|
watch = datastore.data['watching'].get(watch_uuid)
|
||||||
|
if not watch:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Watch not found'}), 404
|
||||||
|
|
||||||
|
# First use prepare_filter_prevew to process the form data
|
||||||
|
# This will return text_after_filter which is after all current form settings are applied
|
||||||
|
# Create ephemeral data with the text from the current snapshot
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Call prepare_filter_prevew to get a processed version of the content with current form settings
|
||||||
|
# We'll ignore the returned response and just use the datastore which is modified by the function
|
||||||
|
|
||||||
|
# this should apply all filters etc so then we can run the CONDITIONS against the final output text
|
||||||
|
result = prepare_filter_prevew(datastore=datastore,
|
||||||
|
form_data=request.form,
|
||||||
|
watch_uuid=watch_uuid)
|
||||||
|
|
||||||
|
ephemeral_data['text'] = result.get('after_filter', '')
|
||||||
|
# Create a temporary watch data structure with this single rule
|
||||||
|
tmp_watch_data = deepcopy(datastore.data['watching'].get(watch_uuid))
|
||||||
|
|
||||||
|
# Override the conditions in the temporary watch
|
||||||
|
rule_json = request.args.get("rule")
|
||||||
|
rule = json.loads(rule_json) if rule_json else None
|
||||||
|
tmp_watch_data['conditions'] = [rule]
|
||||||
|
tmp_watch_data['conditions_match_logic'] = "ALL" # Single rule, so use ALL
|
||||||
|
|
||||||
|
# Create a temporary application data structure for the rule check
|
||||||
|
temp_app_data = {
|
||||||
|
'watching': {
|
||||||
|
watch_uuid: tmp_watch_data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Execute the rule against the current snapshot with form data
|
||||||
|
result = execute_ruleset_against_all_plugins(
|
||||||
|
current_watch_uuid=watch_uuid,
|
||||||
|
application_datastruct=temp_app_data,
|
||||||
|
ephemeral_data=ephemeral_data
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'result': result,
|
||||||
|
'message': 'Condition passes' if result else 'Condition does not pass'
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Error verifying condition: {str(e)}'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
return conditions_blueprint
|
|
@ -0,0 +1,78 @@
|
||||||
|
import re
|
||||||
|
|
||||||
|
import pluggy
|
||||||
|
from price_parser import Price
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
hookimpl = pluggy.HookimplMarker("changedetectionio_conditions")
|
||||||
|
|
||||||
|
|
||||||
|
@hookimpl
|
||||||
|
def register_operators():
|
||||||
|
def starts_with(_, text, prefix):
|
||||||
|
return text.lower().strip().startswith(str(prefix).strip().lower())
|
||||||
|
|
||||||
|
def ends_with(_, text, suffix):
|
||||||
|
return text.lower().strip().endswith(str(suffix).strip().lower())
|
||||||
|
|
||||||
|
def length_min(_, text, strlen):
|
||||||
|
return len(text) >= int(strlen)
|
||||||
|
|
||||||
|
def length_max(_, text, strlen):
|
||||||
|
return len(text) <= int(strlen)
|
||||||
|
|
||||||
|
# ✅ Custom function for case-insensitive regex matching
|
||||||
|
def contains_regex(_, text, pattern):
|
||||||
|
"""Returns True if `text` contains `pattern` (case-insensitive regex match)."""
|
||||||
|
return bool(re.search(pattern, str(text), re.IGNORECASE))
|
||||||
|
|
||||||
|
# ✅ Custom function for NOT matching case-insensitive regex
|
||||||
|
def not_contains_regex(_, text, pattern):
|
||||||
|
"""Returns True if `text` does NOT contain `pattern` (case-insensitive regex match)."""
|
||||||
|
return not bool(re.search(pattern, str(text), re.IGNORECASE))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"!contains_regex": not_contains_regex,
|
||||||
|
"contains_regex": contains_regex,
|
||||||
|
"ends_with": ends_with,
|
||||||
|
"length_max": length_max,
|
||||||
|
"length_min": length_min,
|
||||||
|
"starts_with": starts_with,
|
||||||
|
}
|
||||||
|
|
||||||
|
@hookimpl
|
||||||
|
def register_operator_choices():
|
||||||
|
return [
|
||||||
|
("starts_with", "Text Starts With"),
|
||||||
|
("ends_with", "Text Ends With"),
|
||||||
|
("length_min", "Length minimum"),
|
||||||
|
("length_max", "Length maximum"),
|
||||||
|
("contains_regex", "Text Matches Regex"),
|
||||||
|
("!contains_regex", "Text Does NOT Match Regex"),
|
||||||
|
]
|
||||||
|
|
||||||
|
@hookimpl
|
||||||
|
def register_field_choices():
|
||||||
|
return [
|
||||||
|
("extracted_number", "Extracted number after 'Filters & Triggers'"),
|
||||||
|
# ("meta_description", "Meta Description"),
|
||||||
|
# ("meta_keywords", "Meta Keywords"),
|
||||||
|
("page_filtered_text", "Page text after 'Filters & Triggers'"),
|
||||||
|
#("page_title", "Page <title>"), # actual page title <title>
|
||||||
|
]
|
||||||
|
|
||||||
|
@hookimpl
|
||||||
|
def add_data(current_watch_uuid, application_datastruct, ephemeral_data):
|
||||||
|
|
||||||
|
res = {}
|
||||||
|
if 'text' in ephemeral_data:
|
||||||
|
res['page_filtered_text'] = ephemeral_data['text']
|
||||||
|
|
||||||
|
# Better to not wrap this in try/except so that the UI can see any errors
|
||||||
|
price = Price.fromstring(ephemeral_data.get('text'))
|
||||||
|
if price and price.amount != None:
|
||||||
|
# This is slightly misleading, it's extracting a PRICE not a Number..
|
||||||
|
res['extracted_number'] = float(price.amount)
|
||||||
|
logger.debug(f"Extracted number result: '{price}' - returning float({res['extracted_number']})")
|
||||||
|
|
||||||
|
return res
|
|
@ -0,0 +1,6 @@
|
||||||
|
class EmptyConditionRuleRowNotUsable(Exception):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__("One of the 'conditions' rulesets is incomplete, cannot run.")
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.args[0]
|
|
@ -0,0 +1,44 @@
|
||||||
|
# Condition Rule Form (for each rule row)
|
||||||
|
from wtforms import Form, SelectField, StringField, validators
|
||||||
|
from wtforms import validators
|
||||||
|
|
||||||
|
class ConditionFormRow(Form):
|
||||||
|
|
||||||
|
# ✅ Ensure Plugins Are Loaded BEFORE Importing Choices
|
||||||
|
from changedetectionio.conditions import plugin_manager
|
||||||
|
from changedetectionio.conditions import operator_choices, field_choices
|
||||||
|
field = SelectField(
|
||||||
|
"Field",
|
||||||
|
choices=field_choices,
|
||||||
|
validators=[validators.Optional()]
|
||||||
|
)
|
||||||
|
|
||||||
|
operator = SelectField(
|
||||||
|
"Operator",
|
||||||
|
choices=operator_choices,
|
||||||
|
validators=[validators.Optional()]
|
||||||
|
)
|
||||||
|
|
||||||
|
value = StringField("Value", validators=[validators.Optional()])
|
||||||
|
|
||||||
|
def validate(self, extra_validators=None):
|
||||||
|
# First, run the default validators
|
||||||
|
if not super().validate(extra_validators):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Custom validation logic
|
||||||
|
# If any of the operator/field/value is set, then they must be all set
|
||||||
|
if any(value not in ("", False, "None", None) for value in [self.operator.data, self.field.data, self.value.data]):
|
||||||
|
if not self.operator.data or self.operator.data == 'None':
|
||||||
|
self.operator.errors.append("Operator is required.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not self.field.data or self.field.data == 'None':
|
||||||
|
self.field.errors.append("Field is required.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not self.value.data:
|
||||||
|
self.value.errors.append("Value is required.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True # Only return True if all conditions pass
|
|
@ -0,0 +1,44 @@
|
||||||
|
import pluggy
|
||||||
|
from . import default_plugin # Import the default plugin
|
||||||
|
|
||||||
|
# ✅ Ensure that the namespace in HookspecMarker matches PluginManager
|
||||||
|
PLUGIN_NAMESPACE = "changedetectionio_conditions"
|
||||||
|
|
||||||
|
hookspec = pluggy.HookspecMarker(PLUGIN_NAMESPACE)
|
||||||
|
hookimpl = pluggy.HookimplMarker(PLUGIN_NAMESPACE)
|
||||||
|
|
||||||
|
|
||||||
|
class ConditionsSpec:
|
||||||
|
"""Hook specifications for extending JSON Logic conditions."""
|
||||||
|
|
||||||
|
@hookspec
|
||||||
|
def register_operators():
|
||||||
|
"""Return a dictionary of new JSON Logic operators."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@hookspec
|
||||||
|
def register_operator_choices():
|
||||||
|
"""Return a list of new operator choices."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@hookspec
|
||||||
|
def register_field_choices():
|
||||||
|
"""Return a list of new field choices."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@hookspec
|
||||||
|
def add_data(current_watch_uuid, application_datastruct, ephemeral_data):
|
||||||
|
"""Add to the datadict"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ✅ Set up Pluggy Plugin Manager
|
||||||
|
plugin_manager = pluggy.PluginManager(PLUGIN_NAMESPACE)
|
||||||
|
|
||||||
|
# ✅ Register hookspecs (Ensures they are detected)
|
||||||
|
plugin_manager.add_hookspecs(ConditionsSpec)
|
||||||
|
|
||||||
|
# ✅ Register built-in plugins manually
|
||||||
|
plugin_manager.register(default_plugin, "default_plugin")
|
||||||
|
|
||||||
|
# ✅ Discover installed plugins from external packages (if any)
|
||||||
|
plugin_manager.load_setuptools_entrypoints(PLUGIN_NAMESPACE)
|
|
@ -1382,8 +1382,10 @@ def changedetection_app(config=None, datastore_o=None):
|
||||||
@login_optionally_required
|
@login_optionally_required
|
||||||
def watch_get_preview_rendered(uuid):
|
def watch_get_preview_rendered(uuid):
|
||||||
'''For when viewing the "preview" of the rendered text from inside of Edit'''
|
'''For when viewing the "preview" of the rendered text from inside of Edit'''
|
||||||
|
from flask import jsonify
|
||||||
from .processors.text_json_diff import prepare_filter_prevew
|
from .processors.text_json_diff import prepare_filter_prevew
|
||||||
return prepare_filter_prevew(watch_uuid=uuid, datastore=datastore)
|
result = prepare_filter_prevew(watch_uuid=uuid, form_data=request.form, datastore=datastore)
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/form/add/quickwatch", methods=['POST'])
|
@app.route("/form/add/quickwatch", methods=['POST'])
|
||||||
|
@ -1684,6 +1686,9 @@ def changedetection_app(config=None, datastore_o=None):
|
||||||
import changedetectionio.blueprint.backups as backups
|
import changedetectionio.blueprint.backups as backups
|
||||||
app.register_blueprint(backups.construct_blueprint(datastore), url_prefix='/backups')
|
app.register_blueprint(backups.construct_blueprint(datastore), url_prefix='/backups')
|
||||||
|
|
||||||
|
import changedetectionio.conditions.blueprint as conditions
|
||||||
|
app.register_blueprint(conditions.construct_blueprint(datastore), url_prefix='/conditions')
|
||||||
|
|
||||||
# @todo handle ctrl break
|
# @todo handle ctrl break
|
||||||
ticker_thread = threading.Thread(target=ticker_thread_check_time_launch_checks).start()
|
ticker_thread = threading.Thread(target=ticker_thread_check_time_launch_checks).start()
|
||||||
threading.Thread(target=notification_runner).start()
|
threading.Thread(target=notification_runner).start()
|
||||||
|
|
|
@ -3,6 +3,7 @@ import re
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from wtforms.widgets.core import TimeInput
|
from wtforms.widgets.core import TimeInput
|
||||||
|
|
||||||
|
from changedetectionio.conditions.form import ConditionFormRow
|
||||||
from changedetectionio.strtobool import strtobool
|
from changedetectionio.strtobool import strtobool
|
||||||
|
|
||||||
from wtforms import (
|
from wtforms import (
|
||||||
|
@ -305,8 +306,10 @@ class ValidateAppRiseServers(object):
|
||||||
def __call__(self, form, field):
|
def __call__(self, form, field):
|
||||||
import apprise
|
import apprise
|
||||||
apobj = apprise.Apprise()
|
apobj = apprise.Apprise()
|
||||||
|
|
||||||
# so that the custom endpoints are registered
|
# so that the custom endpoints are registered
|
||||||
from changedetectionio.apprise_plugin import apprise_custom_api_call_wrapper
|
from .apprise_asset import asset
|
||||||
|
|
||||||
for server_url in field.data:
|
for server_url in field.data:
|
||||||
url = server_url.strip()
|
url = server_url.strip()
|
||||||
if url.startswith("#"):
|
if url.startswith("#"):
|
||||||
|
@ -509,6 +512,7 @@ class quickWatchForm(Form):
|
||||||
edit_and_watch_submit_button = SubmitField('Edit > Watch', render_kw={"class": "pure-button pure-button-primary"})
|
edit_and_watch_submit_button = SubmitField('Edit > Watch', render_kw={"class": "pure-button pure-button-primary"})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Common to a single watch and the global settings
|
# Common to a single watch and the global settings
|
||||||
class commonSettingsForm(Form):
|
class commonSettingsForm(Form):
|
||||||
from . import processors
|
from . import processors
|
||||||
|
@ -596,6 +600,10 @@ class processor_text_json_diff_form(commonSettingsForm):
|
||||||
notification_muted = BooleanField('Notifications Muted / Off', default=False)
|
notification_muted = BooleanField('Notifications Muted / Off', default=False)
|
||||||
notification_screenshot = BooleanField('Attach screenshot to notification (where possible)', default=False)
|
notification_screenshot = BooleanField('Attach screenshot to notification (where possible)', default=False)
|
||||||
|
|
||||||
|
conditions_match_logic = RadioField(u'Match', choices=[('ALL', 'Match all of the following'),('ANY', 'Match any of the following')], default='ALL')
|
||||||
|
conditions = FieldList(FormField(ConditionFormRow), min_entries=1) # Add rule logic here
|
||||||
|
|
||||||
|
|
||||||
def extra_tab_content(self):
|
def extra_tab_content(self):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
|
@ -28,13 +28,13 @@ def _task(watch, update_handler):
|
||||||
return text_after_filter
|
return text_after_filter
|
||||||
|
|
||||||
|
|
||||||
def prepare_filter_prevew(datastore, watch_uuid):
|
def prepare_filter_prevew(datastore, watch_uuid, form_data):
|
||||||
'''Used by @app.route("/edit/<string:uuid>/preview-rendered", methods=['POST'])'''
|
'''Used by @app.route("/edit/<string:uuid>/preview-rendered", methods=['POST'])'''
|
||||||
from changedetectionio import forms, html_tools
|
from changedetectionio import forms, html_tools
|
||||||
from changedetectionio.model.Watch import model as watch_model
|
from changedetectionio.model.Watch import model as watch_model
|
||||||
from concurrent.futures import ProcessPoolExecutor
|
from concurrent.futures import ProcessPoolExecutor
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from flask import request, jsonify
|
from flask import request
|
||||||
import brotli
|
import brotli
|
||||||
import importlib
|
import importlib
|
||||||
import os
|
import os
|
||||||
|
@ -50,12 +50,12 @@ def prepare_filter_prevew(datastore, watch_uuid):
|
||||||
|
|
||||||
if tmp_watch and tmp_watch.history and os.path.isdir(tmp_watch.watch_data_dir):
|
if tmp_watch and tmp_watch.history and os.path.isdir(tmp_watch.watch_data_dir):
|
||||||
# Splice in the temporary stuff from the form
|
# Splice in the temporary stuff from the form
|
||||||
form = forms.processor_text_json_diff_form(formdata=request.form if request.method == 'POST' else None,
|
form = forms.processor_text_json_diff_form(formdata=form_data if request.method == 'POST' else None,
|
||||||
data=request.form
|
data=form_data
|
||||||
)
|
)
|
||||||
|
|
||||||
# Only update vars that came in via the AJAX post
|
# Only update vars that came in via the AJAX post
|
||||||
p = {k: v for k, v in form.data.items() if k in request.form.keys()}
|
p = {k: v for k, v in form.data.items() if k in form_data.keys()}
|
||||||
tmp_watch.update(p)
|
tmp_watch.update(p)
|
||||||
blank_watch_no_filters = watch_model()
|
blank_watch_no_filters = watch_model()
|
||||||
blank_watch_no_filters['url'] = tmp_watch.get('url')
|
blank_watch_no_filters['url'] = tmp_watch.get('url')
|
||||||
|
@ -103,13 +103,12 @@ def prepare_filter_prevew(datastore, watch_uuid):
|
||||||
|
|
||||||
logger.trace(f"Parsed in {time.time() - now:.3f}s")
|
logger.trace(f"Parsed in {time.time() - now:.3f}s")
|
||||||
|
|
||||||
return jsonify(
|
return ({
|
||||||
{
|
|
||||||
'after_filter': text_after_filter,
|
'after_filter': text_after_filter,
|
||||||
'before_filter': text_before_filter.decode('utf-8') if isinstance(text_before_filter, bytes) else text_before_filter,
|
'before_filter': text_before_filter.decode('utf-8') if isinstance(text_before_filter, bytes) else text_before_filter,
|
||||||
'duration': time.time() - now,
|
'duration': time.time() - now,
|
||||||
'trigger_line_numbers': trigger_line_numbers,
|
'trigger_line_numbers': trigger_line_numbers,
|
||||||
'ignore_line_numbers': ignore_line_numbers,
|
'ignore_line_numbers': ignore_line_numbers,
|
||||||
}
|
})
|
||||||
)
|
|
||||||
|
|
||||||
|
|
|
@ -6,6 +6,7 @@ import os
|
||||||
import re
|
import re
|
||||||
import urllib3
|
import urllib3
|
||||||
|
|
||||||
|
from changedetectionio.conditions import execute_ruleset_against_all_plugins
|
||||||
from changedetectionio.processors import difference_detection_processor
|
from changedetectionio.processors import difference_detection_processor
|
||||||
from changedetectionio.html_tools import PERL_STYLE_REGEX, cdata_in_document_to_text, TRANSLATE_WHITESPACE_TABLE
|
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 import html_tools, content_fetchers
|
||||||
|
@ -331,6 +332,16 @@ class perform_site_check(difference_detection_processor):
|
||||||
if result:
|
if result:
|
||||||
blocked = True
|
blocked = True
|
||||||
|
|
||||||
|
# And check if 'conditions' will let this pass through
|
||||||
|
if watch.get('conditions') and watch.get('conditions_match_logic'):
|
||||||
|
if not execute_ruleset_against_all_plugins(current_watch_uuid=watch.get('uuid'),
|
||||||
|
application_datastruct=self.datastore.data,
|
||||||
|
ephemeral_data={
|
||||||
|
'text': stripped_text_from_html
|
||||||
|
}
|
||||||
|
):
|
||||||
|
# Conditions say "Condition not met" so we block it.
|
||||||
|
blocked = True
|
||||||
|
|
||||||
# Looks like something changed, but did it match all the rules?
|
# Looks like something changed, but did it match all the rules?
|
||||||
if blocked:
|
if blocked:
|
||||||
|
|
|
@ -0,0 +1,150 @@
|
||||||
|
$(document).ready(function () {
|
||||||
|
// Function to set up button event handlers
|
||||||
|
function setupButtonHandlers() {
|
||||||
|
// Unbind existing handlers first to prevent duplicates
|
||||||
|
$(".addRuleRow, .removeRuleRow, .verifyRuleRow").off("click");
|
||||||
|
|
||||||
|
// Add row button handler
|
||||||
|
$(".addRuleRow").on("click", function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
let currentRow = $(this).closest("tr");
|
||||||
|
|
||||||
|
// Clone without events
|
||||||
|
let newRow = currentRow.clone(false);
|
||||||
|
|
||||||
|
// Reset input values in the cloned row
|
||||||
|
newRow.find("input").val("");
|
||||||
|
newRow.find("select").prop("selectedIndex", 0);
|
||||||
|
|
||||||
|
// Insert the new row after the current one
|
||||||
|
currentRow.after(newRow);
|
||||||
|
|
||||||
|
// Reindex all rows
|
||||||
|
reindexRules();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove row button handler
|
||||||
|
$(".removeRuleRow").on("click", function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Only remove if there's more than one row
|
||||||
|
if ($("#rulesTable tbody tr").length > 1) {
|
||||||
|
$(this).closest("tr").remove();
|
||||||
|
reindexRules();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify rule button handler
|
||||||
|
$(".verifyRuleRow").on("click", function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
let row = $(this).closest("tr");
|
||||||
|
let field = row.find("select[name$='field']").val();
|
||||||
|
let operator = row.find("select[name$='operator']").val();
|
||||||
|
let value = row.find("input[name$='value']").val();
|
||||||
|
|
||||||
|
// Validate that all fields are filled
|
||||||
|
if (!field || field === "None" || !operator || operator === "None" || !value) {
|
||||||
|
alert("Please fill in all fields (Field, Operator, and Value) before verifying.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Create a rule object
|
||||||
|
const rule = {
|
||||||
|
field: field,
|
||||||
|
operator: operator,
|
||||||
|
value: value
|
||||||
|
};
|
||||||
|
|
||||||
|
// Show a spinner or some indication that verification is in progress
|
||||||
|
const $button = $(this);
|
||||||
|
const originalHTML = $button.html();
|
||||||
|
$button.html("⌛").prop("disabled", true);
|
||||||
|
|
||||||
|
// Collect form data - similar to request_textpreview_update() in watch-settings.js
|
||||||
|
let formData = new FormData();
|
||||||
|
$('#edit-text-filter textarea, #edit-text-filter input').each(function() {
|
||||||
|
const $element = $(this);
|
||||||
|
const name = $element.attr('name');
|
||||||
|
if (name) {
|
||||||
|
if ($element.is(':checkbox')) {
|
||||||
|
formData.append(name, $element.is(':checked') ? $element.val() : false);
|
||||||
|
} else {
|
||||||
|
formData.append(name, $element.val());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also collect select values
|
||||||
|
$('#edit-text-filter select').each(function() {
|
||||||
|
const $element = $(this);
|
||||||
|
const name = $element.attr('name');
|
||||||
|
if (name) {
|
||||||
|
formData.append(name, $element.val());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// Send the request to verify the rule
|
||||||
|
$.ajax({
|
||||||
|
url: verify_condition_rule_url+"?"+ new URLSearchParams({ rule: JSON.stringify(rule) }).toString(),
|
||||||
|
type: "POST",
|
||||||
|
data: formData,
|
||||||
|
processData: false, // Prevent jQuery from converting FormData to a string
|
||||||
|
contentType: false, // Let the browser set the correct content type
|
||||||
|
success: function (response) {
|
||||||
|
if (response.status === "success") {
|
||||||
|
if (response.result) {
|
||||||
|
alert("✅ Condition PASSES verification against current snapshot!");
|
||||||
|
} else {
|
||||||
|
alert("❌ Condition FAILS verification against current snapshot.");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
alert("Error: " + response.message);
|
||||||
|
}
|
||||||
|
$button.html(originalHTML).prop("disabled", false);
|
||||||
|
},
|
||||||
|
error: function (xhr) {
|
||||||
|
let errorMsg = "Error verifying condition.";
|
||||||
|
if (xhr.responseJSON && xhr.responseJSON.message) {
|
||||||
|
errorMsg = xhr.responseJSON.message;
|
||||||
|
}
|
||||||
|
alert(errorMsg);
|
||||||
|
$button.html(originalHTML).prop("disabled", false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to reindex form elements and re-setup event handlers
|
||||||
|
function reindexRules() {
|
||||||
|
// Unbind all button handlers first
|
||||||
|
$(".addRuleRow, .removeRuleRow, .verifyRuleRow").off("click");
|
||||||
|
|
||||||
|
// Reindex all form elements
|
||||||
|
$("#rulesTable tbody tr").each(function(index) {
|
||||||
|
$(this).find("select, input").each(function() {
|
||||||
|
let oldName = $(this).attr("name");
|
||||||
|
let oldId = $(this).attr("id");
|
||||||
|
|
||||||
|
if (oldName) {
|
||||||
|
let newName = oldName.replace(/\d+/, index);
|
||||||
|
$(this).attr("name", newName);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldId) {
|
||||||
|
let newId = oldId.replace(/\d+/, index);
|
||||||
|
$(this).attr("id", newId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reattach event handlers after reindexing
|
||||||
|
setupButtonHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial setup of button handlers
|
||||||
|
setupButtonHandlers();
|
||||||
|
});
|
|
@ -26,7 +26,6 @@ function set_active_tab() {
|
||||||
if (tab.length) {
|
if (tab.length) {
|
||||||
tab[0].parentElement.className = "active";
|
tab[0].parentElement.className = "active";
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function focus_error_tab() {
|
function focus_error_tab() {
|
||||||
|
|
|
@ -0,0 +1,9 @@
|
||||||
|
ul#conditions_match_logic {
|
||||||
|
list-style: none;
|
||||||
|
input, label, li {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
li {
|
||||||
|
padding-right: 1em;
|
||||||
|
}
|
||||||
|
}
|
|
@ -13,6 +13,7 @@
|
||||||
@import "parts/_menu";
|
@import "parts/_menu";
|
||||||
@import "parts/_love";
|
@import "parts/_love";
|
||||||
@import "parts/preview_text_filter";
|
@import "parts/preview_text_filter";
|
||||||
|
@import "parts/_edit";
|
||||||
|
|
||||||
body {
|
body {
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
|
|
|
@ -523,6 +523,13 @@ body.preview-text-enabled {
|
||||||
z-index: 3;
|
z-index: 3;
|
||||||
box-shadow: 1px 1px 4px var(--color-shadow-jump); }
|
box-shadow: 1px 1px 4px var(--color-shadow-jump); }
|
||||||
|
|
||||||
|
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; }
|
||||||
|
|
||||||
body {
|
body {
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
background: var(--color-background-page);
|
background: var(--color-background-page);
|
||||||
|
|
|
@ -61,6 +61,43 @@
|
||||||
{{ field(**kwargs)|safe }}
|
{{ field(**kwargs)|safe }}
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
|
{% macro render_fieldlist_of_formfields_as_table(fieldlist, table_id="rulesTable") %}
|
||||||
|
<table class="fieldlist_formfields pure-table" id="{{ table_id }}">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{% for subfield in fieldlist[0] %}
|
||||||
|
<th>{{ subfield.label }}</th>
|
||||||
|
{% endfor %}
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for form_row in fieldlist %}
|
||||||
|
<tr {% if form_row.errors %} class="error-row" {% endif %}>
|
||||||
|
{% for subfield in form_row %}
|
||||||
|
<td>
|
||||||
|
{{ subfield()|safe }}
|
||||||
|
{% if subfield.errors %}
|
||||||
|
<ul class="errors">
|
||||||
|
{% for error in subfield.errors %}
|
||||||
|
<li class="error">{{ error }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endfor %}
|
||||||
|
<td>
|
||||||
|
<button type="button" class="addRuleRow">+</button>
|
||||||
|
<button type="button" class="removeRuleRow">-</button>
|
||||||
|
<button type="button" class="verifyRuleRow" title="Verify this rule against current snapshot">✓</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
|
||||||
{% macro playwright_warning() %}
|
{% macro playwright_warning() %}
|
||||||
<p><strong>Error - Playwright support for Chrome based fetching is not enabled.</strong> Alternatively try our <a href="https://changedetection.io">very affordable subscription based service which has all this setup for you</a>.</p>
|
<p><strong>Error - Playwright support for Chrome based fetching is not enabled.</strong> Alternatively try our <a href="https://changedetection.io">very affordable subscription based service which has all this setup for you</a>.</p>
|
||||||
<p>You may need to <a href="https://github.com/dgtlmoon/changedetection.io/blob/09ebc6ec6338545bdd694dc6eee57f2e9d2b8075/docker-compose.yml#L31">Enable playwright environment variable</a> and uncomment the <strong>sockpuppetbrowser</strong> in the <a href="https://github.com/dgtlmoon/changedetection.io/blob/master/docker-compose.yml">docker-compose.yml</a> file.</p>
|
<p>You may need to <a href="https://github.com/dgtlmoon/changedetection.io/blob/09ebc6ec6338545bdd694dc6eee57f2e9d2b8075/docker-compose.yml#L31">Enable playwright environment variable</a> and uncomment the <strong>sockpuppetbrowser</strong> in the <a href="https://github.com/dgtlmoon/changedetection.io/blob/master/docker-compose.yml">docker-compose.yml</a> file.</p>
|
||||||
|
|
|
@ -1,11 +1,14 @@
|
||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% from '_helpers.html' import render_field, render_checkbox_field, render_button, render_time_schedule_form, playwright_warning, only_webdriver_type_watches_warning %}
|
{% from '_helpers.html' import render_field, render_checkbox_field, render_button, render_time_schedule_form, playwright_warning, only_webdriver_type_watches_warning, render_fieldlist_of_formfields_as_table %}
|
||||||
{% from '_common_fields.html' import render_common_settings_form %}
|
{% from '_common_fields.html' import render_common_settings_form %}
|
||||||
<script src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script>
|
<script src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script>
|
||||||
<script src="{{url_for('static_content', group='js', filename='vis.js')}}" defer></script>
|
<script src="{{url_for('static_content', group='js', filename='vis.js')}}" defer></script>
|
||||||
<script src="{{url_for('static_content', group='js', filename='global-settings.js')}}" defer></script>
|
<script src="{{url_for('static_content', group='js', filename='global-settings.js')}}" defer></script>
|
||||||
<script src="{{url_for('static_content', group='js', filename='scheduler.js')}}" defer></script>
|
<script src="{{url_for('static_content', group='js', filename='scheduler.js')}}" defer></script>
|
||||||
|
<script src="{{url_for('static_content', group='js', filename='conditions.js')}}" defer></script>
|
||||||
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const browser_steps_available_screenshots=JSON.parse('{{ watch.get_browsersteps_available_screenshots|tojson }}');
|
const browser_steps_available_screenshots=JSON.parse('{{ watch.get_browsersteps_available_screenshots|tojson }}');
|
||||||
const browser_steps_config=JSON.parse('{{ browser_steps_config|tojson }}');
|
const browser_steps_config=JSON.parse('{{ browser_steps_config|tojson }}');
|
||||||
|
@ -50,6 +53,7 @@
|
||||||
{% if watch['processor'] == 'text_json_diff' %}
|
{% if watch['processor'] == 'text_json_diff' %}
|
||||||
<li class="tab"><a id="visualselector-tab" href="#visualselector">Visual Filter Selector</a></li>
|
<li class="tab"><a id="visualselector-tab" href="#visualselector">Visual Filter Selector</a></li>
|
||||||
<li class="tab" id="filters-and-triggers-tab"><a href="#filters-and-triggers">Filters & Triggers</a></li>
|
<li class="tab" id="filters-and-triggers-tab"><a href="#filters-and-triggers">Filters & Triggers</a></li>
|
||||||
|
<li class="tab" id="conditions-tab"><a href="#conditions">Conditions</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<li class="tab"><a href="#notifications">Notifications</a></li>
|
<li class="tab"><a href="#notifications">Notifications</a></li>
|
||||||
<li class="tab"><a href="#stats">Stats</a></li>
|
<li class="tab"><a href="#stats">Stats</a></li>
|
||||||
|
@ -274,13 +278,39 @@ Math: {{ 1 + 1 }}") }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="#notifications" id="notification-setting-reset-to-default" class="pure-button button-xsmall" style="right: 20px; top: 20px; position: absolute; background-color: #5f42dd; border-radius: 4px; font-size: 70%; color: #fff">Use system defaults</a>
|
<a href="#notifications" id="notification-setting-reset-to-default" class="pure-button button-xsmall" style="right: 20px; top: 20px; position: absolute; background-color: #5f42dd; border-radius: 4px; font-size: 70%; color: #fff">Use system defaults</a>
|
||||||
|
|
||||||
{{ render_common_settings_form(form, emailprefix, settings_application, extra_notification_token_placeholder_info) }}
|
{{ render_common_settings_form(form, emailprefix, settings_application, extra_notification_token_placeholder_info) }}
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if watch['processor'] == 'text_json_diff' %}
|
{% if watch['processor'] == 'text_json_diff' %}
|
||||||
|
|
||||||
|
<div class="tab-pane-inner" id="conditions">
|
||||||
|
<script>
|
||||||
|
const verify_condition_rule_url="{{url_for('conditions.verify_condition_single_rule', watch_uuid=uuid)}}";
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
.verifyRuleRow {
|
||||||
|
background-color: #4caf50;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.verifyRuleRow:hover {
|
||||||
|
background-color: #45a049;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div class="pure-control-group">
|
||||||
|
{{ render_field(form.conditions_match_logic) }}
|
||||||
|
{{ render_fieldlist_of_formfields_as_table(form.conditions) }}
|
||||||
|
<div class="pure-form-message-inline">
|
||||||
|
<br>
|
||||||
|
Use the verify (✓) button to test if a condition passes against the current snapshot.<br><br>
|
||||||
|
Did you know that <strong>conditions</strong> can be extended with your own custom plugin? tutorials coming soon!<br>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="tab-pane-inner" id="filters-and-triggers">
|
<div class="tab-pane-inner" id="filters-and-triggers">
|
||||||
<span id="activate-text-preview" class="pure-button pure-button-primary button-xsmall">Activate preview</span>
|
<span id="activate-text-preview" class="pure-button pure-button-primary button-xsmall">Activate preview</span>
|
||||||
<div>
|
<div>
|
||||||
|
|
|
@ -165,6 +165,7 @@ def test_check_add_line_contains_trigger(client, live_server, measure_memory_usa
|
||||||
client.get(url_for("form_watch_checknow"), follow_redirects=True)
|
client.get(url_for("form_watch_checknow"), follow_redirects=True)
|
||||||
wait_for_all_checks(client)
|
wait_for_all_checks(client)
|
||||||
res = client.get(url_for("index"))
|
res = client.get(url_for("index"))
|
||||||
|
|
||||||
assert b'unviewed' in res.data
|
assert b'unviewed' in res.data
|
||||||
|
|
||||||
# Takes a moment for apprise to fire
|
# Takes a moment for apprise to fire
|
||||||
|
|
|
@ -0,0 +1,133 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
from flask import url_for
|
||||||
|
from .util import live_server_setup, wait_for_all_checks
|
||||||
|
|
||||||
|
def set_original_response(number="50"):
|
||||||
|
test_return_data = f"""<html>
|
||||||
|
<body>
|
||||||
|
<h1>Test Page for Conditions</h1>
|
||||||
|
<p>This page contains a number that will be tested with conditions.</p>
|
||||||
|
<div class="number-container">Current value: {number}</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
with open("test-datastore/endpoint-content.txt", "w") as f:
|
||||||
|
f.write(test_return_data)
|
||||||
|
|
||||||
|
def set_number_in_range_response(number="75"):
|
||||||
|
test_return_data = f"""<html>
|
||||||
|
<body>
|
||||||
|
<h1>Test Page for Conditions</h1>
|
||||||
|
<p>This page contains a number that will be tested with conditions.</p>
|
||||||
|
<div class="number-container">Current value: {number}</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
with open("test-datastore/endpoint-content.txt", "w") as f:
|
||||||
|
f.write(test_return_data)
|
||||||
|
|
||||||
|
def set_number_out_of_range_response(number="150"):
|
||||||
|
test_return_data = f"""<html>
|
||||||
|
<body>
|
||||||
|
<h1>Test Page for Conditions</h1>
|
||||||
|
<p>This page contains a number that will be tested with conditions.</p>
|
||||||
|
<div class="number-container">Current value: {number}</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
with open("test-datastore/endpoint-content.txt", "w") as f:
|
||||||
|
f.write(test_return_data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_conditions_with_text_and_number(client, live_server):
|
||||||
|
"""Test that both text and number conditions work together with AND logic."""
|
||||||
|
|
||||||
|
set_original_response("50")
|
||||||
|
live_server_setup(live_server)
|
||||||
|
|
||||||
|
test_url = url_for('test_endpoint', _external=True)
|
||||||
|
|
||||||
|
# Add our URL to the import page
|
||||||
|
res = client.post(
|
||||||
|
url_for("import_page"),
|
||||||
|
data={"urls": test_url},
|
||||||
|
follow_redirects=True
|
||||||
|
)
|
||||||
|
assert b"1 Imported" in res.data
|
||||||
|
wait_for_all_checks(client)
|
||||||
|
|
||||||
|
# Configure the watch with two conditions connected with AND:
|
||||||
|
# 1. The page filtered text must contain "5" (first digit of value)
|
||||||
|
# 2. The extracted number should be >= 20 and <= 100
|
||||||
|
res = client.post(
|
||||||
|
url_for("edit_page", uuid="first"),
|
||||||
|
data={
|
||||||
|
"url": test_url,
|
||||||
|
"fetch_backend": "html_requests",
|
||||||
|
"include_filters": ".number-container",
|
||||||
|
"title": "Number AND Text Condition Test",
|
||||||
|
"conditions_match_logic": "ALL", # ALL = AND logic
|
||||||
|
"conditions-0-operator": "in",
|
||||||
|
"conditions-0-field": "page_filtered_text",
|
||||||
|
"conditions-0-value": "5",
|
||||||
|
|
||||||
|
"conditions-1-operator": ">=",
|
||||||
|
"conditions-1-field": "extracted_number",
|
||||||
|
"conditions-1-value": "20",
|
||||||
|
|
||||||
|
"conditions-2-operator": "<=",
|
||||||
|
"conditions-2-field": "extracted_number",
|
||||||
|
"conditions-2-value": "100",
|
||||||
|
|
||||||
|
# So that 'operations' from pluggy discovery are tested
|
||||||
|
"conditions-3-operator": "length_min",
|
||||||
|
"conditions-3-field": "page_filtered_text",
|
||||||
|
"conditions-3-value": "1",
|
||||||
|
|
||||||
|
# So that 'operations' from pluggy discovery are tested
|
||||||
|
"conditions-4-operator": "length_max",
|
||||||
|
"conditions-4-field": "page_filtered_text",
|
||||||
|
"conditions-4-value": "100",
|
||||||
|
|
||||||
|
# So that 'operations' from pluggy discovery are tested
|
||||||
|
"conditions-5-operator": "contains_regex",
|
||||||
|
"conditions-5-field": "page_filtered_text",
|
||||||
|
"conditions-5-value": "\d",
|
||||||
|
},
|
||||||
|
follow_redirects=True
|
||||||
|
)
|
||||||
|
assert b"Updated watch." in res.data
|
||||||
|
|
||||||
|
wait_for_all_checks(client)
|
||||||
|
client.get(url_for("mark_all_viewed"), follow_redirects=True)
|
||||||
|
wait_for_all_checks(client)
|
||||||
|
|
||||||
|
# Case 1
|
||||||
|
set_number_in_range_response("70.5")
|
||||||
|
client.get(url_for("form_watch_checknow"), follow_redirects=True)
|
||||||
|
wait_for_all_checks(client)
|
||||||
|
|
||||||
|
# 75 is > 20 and < 100 and contains "5"
|
||||||
|
res = client.get(url_for("index"))
|
||||||
|
assert b'unviewed' in res.data
|
||||||
|
|
||||||
|
|
||||||
|
# Case 2: Change with one condition violated
|
||||||
|
# Number out of range (150) but contains '5'
|
||||||
|
client.get(url_for("mark_all_viewed"), follow_redirects=True)
|
||||||
|
set_number_out_of_range_response("150.5")
|
||||||
|
|
||||||
|
|
||||||
|
client.get(url_for("form_watch_checknow"), follow_redirects=True)
|
||||||
|
wait_for_all_checks(client)
|
||||||
|
|
||||||
|
# Should NOT be marked as having changes since not all conditions are met
|
||||||
|
res = client.get(url_for("index"))
|
||||||
|
assert b'unviewed' not in res.data
|
||||||
|
|
||||||
|
res = client.get(url_for("form_delete", uuid="all"), follow_redirects=True)
|
||||||
|
assert b'Deleted' in res.data
|
|
@ -0,0 +1,82 @@
|
||||||
|
from changedetectionio.conditions import execute_ruleset_against_all_plugins
|
||||||
|
from changedetectionio.store import ChangeDetectionStore
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class TestTriggerConditions(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
|
||||||
|
# Create a temporary directory for the test datastore
|
||||||
|
self.test_datastore_path = tempfile.mkdtemp()
|
||||||
|
|
||||||
|
# Initialize ChangeDetectionStore with our test path and no default watches
|
||||||
|
self.store = ChangeDetectionStore(
|
||||||
|
datastore_path=self.test_datastore_path,
|
||||||
|
include_default_watches=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add a test watch
|
||||||
|
watch_url = "https://example.com"
|
||||||
|
self.watch_uuid = self.store.add_watch(url=watch_url)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
# Clean up the test datastore
|
||||||
|
self.store.stop_thread = True
|
||||||
|
time.sleep(0.5) # Give thread time to stop
|
||||||
|
shutil.rmtree(self.test_datastore_path)
|
||||||
|
|
||||||
|
def test_conditions_execution_pass(self):
|
||||||
|
# Get the watch object
|
||||||
|
watch = self.store.data['watching'][self.watch_uuid]
|
||||||
|
|
||||||
|
# Create and save a snapshot
|
||||||
|
first_content = "I saw 100 people at a rock show"
|
||||||
|
timestamp1 = int(time.time())
|
||||||
|
snapshot_id1 = str(uuid.uuid4())
|
||||||
|
watch.save_history_text(contents=first_content,
|
||||||
|
timestamp=timestamp1,
|
||||||
|
snapshot_id=snapshot_id1)
|
||||||
|
|
||||||
|
# Add another snapshot
|
||||||
|
second_content = "I saw 200 people at a rock show"
|
||||||
|
timestamp2 = int(time.time()) + 60
|
||||||
|
snapshot_id2 = str(uuid.uuid4())
|
||||||
|
watch.save_history_text(contents=second_content,
|
||||||
|
timestamp=timestamp2,
|
||||||
|
snapshot_id=snapshot_id2)
|
||||||
|
|
||||||
|
# Verify both snapshots are stored
|
||||||
|
history = watch.history
|
||||||
|
self.assertEqual(len(history), 2)
|
||||||
|
|
||||||
|
# Retrieve and check snapshots
|
||||||
|
#snapshot1 = watch.get_history_snapshot(str(timestamp1))
|
||||||
|
#snapshot2 = watch.get_history_snapshot(str(timestamp2))
|
||||||
|
|
||||||
|
self.store.data['watching'][self.watch_uuid].update(
|
||||||
|
{
|
||||||
|
"conditions_match_logic": "ALL",
|
||||||
|
"conditions": [
|
||||||
|
{"operator": ">=", "field": "extracted_number", "value": "10"},
|
||||||
|
{"operator": "<=", "field": "extracted_number", "value": "5000"},
|
||||||
|
{"operator": "in", "field": "page_text", "value": "rock"},
|
||||||
|
#{"operator": "starts_with", "field": "page_text", "value": "I saw"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# ephemeral_data - some data that could exist before the watch saved a new version
|
||||||
|
result = execute_ruleset_against_all_plugins(current_watch_uuid=self.watch_uuid,
|
||||||
|
application_datastruct=self.store.data,
|
||||||
|
ephemeral_data={'text': "I saw 500 people at a rock show"})
|
||||||
|
|
||||||
|
# @todo - now we can test that 'Extract number' increased more than X since last time
|
||||||
|
self.assertTrue(result)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
|
@ -98,5 +98,17 @@ greenlet >= 3.0.3
|
||||||
# Pinned or it causes problems with flask_expects_json which seems unmaintained
|
# Pinned or it causes problems with flask_expects_json which seems unmaintained
|
||||||
referencing==0.35.1
|
referencing==0.35.1
|
||||||
|
|
||||||
|
# For conditions
|
||||||
|
panzi-json-logic
|
||||||
|
# For conditions - extracted number from a body of text
|
||||||
|
price-parser
|
||||||
|
|
||||||
# Scheduler - Windows seemed to miss a lot of default timezone info (even "UTC" !)
|
# Scheduler - Windows seemed to miss a lot of default timezone info (even "UTC" !)
|
||||||
tzdata
|
tzdata
|
||||||
|
|
||||||
|
#typing_extensions ==4.8.0
|
||||||
|
|
||||||
|
pluggy ~= 1.5
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
Ładowanie…
Reference in New Issue