mirror of
				https://github.com/dgtlmoon/changedetection.io.git
				synced 2025-10-31 14:47:21 +00:00 
			
		
		
		
	Compare commits
	
		
			9 Commits
		
	
	
		
			0.50.4
			...
			UI-browser
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 67a56fe73f | ||
|   | 9b39b2853b | ||
|   | 892d38ba42 | ||
|   | b170e191d4 | ||
|   | edb78efcca | ||
|   | 383f90b70c | ||
|   | 6948418865 | ||
|   | cd80e317f3 | ||
|   | 8c26210804 | 
							
								
								
									
										113
									
								
								changedetectionio/conditions/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										113
									
								
								changedetectionio/conditions/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,113 @@ | ||||
| from json_logic import jsonLogic | ||||
| from json_logic.builtins import BUILTINS | ||||
| from .pluggy_interface import plugin_manager  # Import the pluggy plugin manager | ||||
| from . import default_plugin | ||||
|  | ||||
| import re | ||||
|  | ||||
| # List of all supported JSON Logic operators | ||||
| operator_choices = [ | ||||
|     (">", "Greater Than"), | ||||
|     ("<", "Less Than"), | ||||
|     (">=", "Greater Than or Equal To"), | ||||
|     ("<=", "Less Than or Equal To"), | ||||
|     ("==", "Equals"), | ||||
|     ("!=", "Not Equals"), | ||||
|     ("in", "Contains"), | ||||
|     ("!in", "Does Not Contain"), | ||||
|     ("contains_regex", "Text Matches Regex"), | ||||
|     ("!contains_regex", "Text Does NOT Match Regex"), | ||||
|     ("changed > minutes", "Changed more than X minutes ago"), | ||||
| #    ("watch_uuid_unviewed_change", "Watch UUID had an unviewed change"),  #('if'? ) | ||||
| #    ("watch_uuid_not_unviewed_change", "Watch UUID NOT had an unviewed change") #('if'? ) | ||||
| #    ("watch_uuid_changed", "Watch UUID had unviewed change"), | ||||
| #    ("watch_uuid_not_changed", "Watch UUID did NOT have unviewed change"), | ||||
| #    ("!!", "Is Truthy"), | ||||
| #    ("!", "Is Falsy"), | ||||
| #    ("and", "All Conditions Must Be True"), | ||||
| #    ("or", "At Least One Condition Must Be True"), | ||||
| #    ("max", "Maximum of Values"), | ||||
| #    ("min", "Minimum of Values"), | ||||
| #    ("+", "Addition"), | ||||
| #    ("-", "Subtraction"), | ||||
| #    ("*", "Multiplication"), | ||||
| #    ("/", "Division"), | ||||
| #    ("%", "Modulo"), | ||||
| #    ("log", "Logarithm"), | ||||
| #    ("if", "Conditional If-Else") | ||||
| ] | ||||
|  | ||||
| # Fields available in the rules | ||||
| field_choices = [ | ||||
|     ("extracted_number", "Extracted Number"), | ||||
|     ("page_filtered_text", "Page text After Filters"), | ||||
|     ("page_title", "Page Title"), # actual page title <title> | ||||
|     ("watch_uuid", "Watch UUID"), | ||||
|     #("watch_history_length", "History Length"),  # Would never equate | ||||
|     ("watch_history", "All Watch Text History"), | ||||
|     ("watch_check_count", "Watch Check Count"), | ||||
|     ("watch_uuid", "Other Watch by UUID"), # (Maybe this is 'if' ??) | ||||
|     #("requests_get", "Web GET requests (https://..)") | ||||
| ] | ||||
|  | ||||
|  | ||||
| # ✅ 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, 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, text, re.IGNORECASE)) | ||||
|  | ||||
|  | ||||
| # ✅ Custom function to check if "watch_uuid" has changed | ||||
| def watch_uuid_changed(_, previous_uuid, current_uuid): | ||||
|     """Returns True if the watch UUID has changed.""" | ||||
|     return previous_uuid != current_uuid | ||||
|  | ||||
| # ✅ Custom function to check if "watch_uuid" has NOT changed | ||||
| def watch_uuid_not_changed(_, previous_uuid, current_uuid): | ||||
|     """Returns True if the watch UUID has NOT changed.""" | ||||
|     return previous_uuid == current_uuid | ||||
|  | ||||
| # Define the extended operations dictionary | ||||
| CUSTOM_OPERATIONS = { | ||||
|     **BUILTINS,  # Include all standard operators | ||||
|     "watch_uuid_changed": watch_uuid_changed, | ||||
|     "watch_uuid_not_changed": watch_uuid_not_changed, | ||||
|     "contains_regex": contains_regex, | ||||
|     "!contains_regex": not_contains_regex | ||||
| } | ||||
|  | ||||
| # ✅ 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) | ||||
|  | ||||
| def run(ruleset, data): | ||||
|     """ | ||||
|     Execute a JSON Logic rule against given data. | ||||
|  | ||||
|     :param ruleset: JSON Logic rule dictionary. | ||||
|     :param data: Dictionary containing the facts. | ||||
|     :return: Boolean result of rule evaluation. | ||||
|     """ | ||||
|  | ||||
|  | ||||
|     try: | ||||
|         return jsonLogic(ruleset, data, CUSTOM_OPERATIONS) | ||||
|     except Exception as e: | ||||
|         # raise some custom nice handler | ||||
|         print(f"❌ Error evaluating JSON Logic: {e}") | ||||
|         return False | ||||
							
								
								
									
										30
									
								
								changedetectionio/conditions/default_plugin.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								changedetectionio/conditions/default_plugin.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,30 @@ | ||||
| import pluggy | ||||
|  | ||||
| hookimpl = pluggy.HookimplMarker("conditions") | ||||
|  | ||||
| @hookimpl | ||||
| def register_operators(): | ||||
|     def starts_with(_, text, prefix): | ||||
|         return text.lower().startswith(prefix.lower()) | ||||
|  | ||||
|     def ends_with(_, text, suffix): | ||||
|         return text.lower().endswith(suffix.lower()) | ||||
|  | ||||
|     return { | ||||
|         "starts_with": starts_with, | ||||
|         "ends_with": ends_with | ||||
|     } | ||||
|  | ||||
| @hookimpl | ||||
| def register_operator_choices(): | ||||
|     return [ | ||||
|         ("starts_with", "Text Starts With"), | ||||
|         ("ends_with", "Text Ends With"), | ||||
|     ] | ||||
|  | ||||
| @hookimpl | ||||
| def register_field_choices(): | ||||
|     return [ | ||||
|         ("meta_description", "Meta Description"), | ||||
|         ("meta_keywords", "Meta Keywords"), | ||||
|     ] | ||||
							
								
								
									
										32
									
								
								changedetectionio/conditions/pluggy_interface.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								changedetectionio/conditions/pluggy_interface.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,32 @@ | ||||
| import pluggy | ||||
|  | ||||
| # Define `pluggy` hookspecs (Specifications for Plugins) | ||||
| hookspec = pluggy.HookspecMarker("conditions") | ||||
| hookimpl = pluggy.HookimplMarker("conditions") | ||||
|  | ||||
|  | ||||
| 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 | ||||
|  | ||||
|  | ||||
| # ✅ Set up `pluggy` Plugin Manager | ||||
| plugin_manager = pluggy.PluginManager("conditions") | ||||
| plugin_manager.add_hookspecs(ConditionsSpec) | ||||
|  | ||||
| # Discover installed plugins | ||||
| plugin_manager.load_setuptools_entrypoints("conditions") | ||||
| @@ -758,6 +758,25 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|             for p in datastore.proxy_list: | ||||
|                 form.proxy.choices.append(tuple((p, datastore.proxy_list[p]['label']))) | ||||
|  | ||||
|         # Example JSON Rule | ||||
|         DEFAULT_RULE = { | ||||
|             "and": [ | ||||
|                 {">": [{"var": "extracted_number"}, 5000]}, | ||||
|                 {"<": [{"var": "extracted_number"}, 80000]}, | ||||
|                 {"in": ["rock", {"var": "page_text"}]} | ||||
|             ] | ||||
|         } | ||||
|         form.conditions.pop_entry()  # Remove the default empty row | ||||
|         for condition in DEFAULT_RULE["and"]: | ||||
|             operator, values = list(condition.items())[0] | ||||
|             field = values[0]["var"] if isinstance(values[0], dict) else values[1]["var"] | ||||
|             value = values[1] if isinstance(values[1], (str, int)) else values[0] | ||||
|  | ||||
|             form.conditions.append_entry({ | ||||
|                 "operator": operator, | ||||
|                 "field": field, | ||||
|                 "value": value | ||||
|             }) | ||||
|  | ||||
|         if request.method == 'POST' and form.validate(): | ||||
|  | ||||
| @@ -793,6 +812,14 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|                 extra_update_obj['filter_text_replaced'] = True | ||||
|                 extra_update_obj['filter_text_removed'] = True | ||||
|  | ||||
|             # Convert form input into JSON Logic format | ||||
|             extra_update_obj["conditions"] = { | ||||
|                 "and": [ | ||||
|                     {c.operator.data: [{"var": c.field.data}, c.value.data]} | ||||
|                     for c in getattr(form, "conditions", []) or [] | ||||
|                 ] | ||||
|             } if getattr(form, "conditions", None) else {} | ||||
|  | ||||
|             # Because wtforms doesn't support accessing other data in process_ , but we convert the CSV list of tags back to a list of UUIDs | ||||
|             tag_uuids = [] | ||||
|             if form.data.get('tags'): | ||||
|   | ||||
| @@ -4,6 +4,7 @@ from loguru import logger | ||||
| from wtforms.widgets.core import TimeInput | ||||
|  | ||||
| from changedetectionio.strtobool import strtobool | ||||
| from flask_wtf import FlaskForm | ||||
|  | ||||
| from wtforms import ( | ||||
|     BooleanField, | ||||
| @@ -306,7 +307,6 @@ class ValidateAppRiseServers(object): | ||||
|         import apprise | ||||
|         apobj = apprise.Apprise() | ||||
|         # so that the custom endpoints are registered | ||||
|         from changedetectionio.apprise_plugin import apprise_custom_api_call_wrapper | ||||
|         for server_url in field.data: | ||||
|             url = server_url.strip() | ||||
|             if url.startswith("#"): | ||||
| @@ -509,6 +509,23 @@ class quickWatchForm(Form): | ||||
|     edit_and_watch_submit_button = SubmitField('Edit > Watch', render_kw={"class": "pure-button pure-button-primary"}) | ||||
|  | ||||
|  | ||||
|  | ||||
| # Condition Rule Form (for each rule row) | ||||
| class ConditionForm(FlaskForm): | ||||
|     from changedetectionio.conditions import operator_choices, field_choices | ||||
|  | ||||
|     operator = SelectField( | ||||
|         "Operator", | ||||
|         choices=operator_choices, | ||||
|         validators=[validators.Optional()] | ||||
|     ) | ||||
|     field = SelectField( | ||||
|         "Field", | ||||
|         choices=field_choices, | ||||
|         validators=[validators.Optional()] | ||||
|     ) | ||||
|     value = StringField("Value", validators=[validators.Optional()]) | ||||
|  | ||||
| # Common to a single watch and the global settings | ||||
| class commonSettingsForm(Form): | ||||
|     from . import processors | ||||
| @@ -596,6 +613,9 @@ class processor_text_json_diff_form(commonSettingsForm): | ||||
|     notification_muted = BooleanField('Notifications Muted / Off', default=False) | ||||
|     notification_screenshot = BooleanField('Attach screenshot to notification (where possible)', default=False) | ||||
|  | ||||
|     conditions = FieldList(FormField(ConditionForm), min_entries=1)  # Add rule logic here | ||||
|  | ||||
|  | ||||
|     def extra_tab_content(self): | ||||
|         return None | ||||
|  | ||||
|   | ||||
| @@ -6,6 +6,39 @@ | ||||
| <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='scheduler.js')}}" defer></script> | ||||
|  | ||||
| <script> | ||||
|     function addRuleRow() { | ||||
|         let rulesContainer = document.getElementById("rules-container"); | ||||
|         let lastRule = document.querySelector(".rule-row:last-child"); | ||||
|         let newRule = lastRule.cloneNode(true); | ||||
|  | ||||
|         // Get the new unique index for the added row | ||||
|         let ruleCount = document.querySelectorAll(".rule-row").length; | ||||
|  | ||||
|         // Update all IDs, names, and labels to have the correct index | ||||
|         newRule.querySelectorAll("select, input, label").forEach(element => { | ||||
|             if (element.id) { | ||||
|                 element.id = element.id.replace(/\d+/, ruleCount); | ||||
|             } | ||||
|             if (element.name) { | ||||
|                 element.name = element.name.replace(/\d+/, ruleCount); | ||||
|             } | ||||
|             if (element.hasAttribute("for")) { | ||||
|                 element.setAttribute("for", element.getAttribute("for").replace(/\d+/, ruleCount)); | ||||
|             } | ||||
|             if (element.tagName === "INPUT") { | ||||
|                 element.value = ""; // Clear input field value | ||||
|             } | ||||
|         }); | ||||
|  | ||||
|         // Append the new rule to the grid container | ||||
|         rulesContainer.appendChild(newRule); | ||||
|     } | ||||
| </script> | ||||
|  | ||||
|  | ||||
|  | ||||
| <script> | ||||
|     const browser_steps_available_screenshots=JSON.parse('{{ watch.get_browsersteps_available_screenshots|tojson }}'); | ||||
|     const browser_steps_config=JSON.parse('{{ browser_steps_config|tojson }}'); | ||||
| @@ -45,12 +78,12 @@ | ||||
|             {% if extra_tab_content %} | ||||
|             <li class="tab"><a href="#extras_tab">{{ extra_tab_content }}</a></li> | ||||
|             {% endif %} | ||||
|             {% if playwright_enabled %} | ||||
|             <li class="tab"><a id="browsersteps-tab" href="#browser-steps">Browser Steps</a></li> | ||||
|             {% endif %} | ||||
|         <!-- should goto extra forms? --> | ||||
|             {% if watch['processor'] == 'text_json_diff' %} | ||||
|             <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="conditions-tab"><a href="#conditions">Conditions</a></li> | ||||
|             {% endif %} | ||||
|             <li class="tab"><a href="#notifications">Notifications</a></li> | ||||
|             <li class="tab"><a href="#stats">Stats</a></li> | ||||
| @@ -199,8 +232,15 @@ Math: {{ 1 + 1 }}") }} | ||||
|                     </div> | ||||
|             </fieldset> | ||||
|             </div> | ||||
|             {% if playwright_enabled %} | ||||
|  | ||||
|             <div class="tab-pane-inner" id="browser-steps"> | ||||
|                             <span class="pure-form-message-inline"> | ||||
|                                 <p>Sorry, this functionality only works with Playwright/Chrome enabled watches.</p> | ||||
|                                 <p>Enable the Playwright Chrome fetcher, or alternatively try our <a href="https://lemonade.changedetection.io/start">very affordable subscription based service</a>.</p> | ||||
|                                 <p>This is because Selenium/WebDriver can not extract full page screenshots reliably.</p> | ||||
|                             </span> | ||||
|  | ||||
|             {% if playwright_enabled %} | ||||
|                 <img class="beta-logo" src="{{url_for('static_content', group='images', filename='beta-logo.png')}}" alt="New beta functionality"> | ||||
|                 <fieldset> | ||||
|                     <div class="pure-control-group"> | ||||
| @@ -240,8 +280,9 @@ Math: {{ 1 + 1 }}") }} | ||||
|                         </div> | ||||
|                     </div> | ||||
|                 </fieldset> | ||||
|                 {% endif %} | ||||
|             </div> | ||||
|             {% endif %} | ||||
|  | ||||
|  | ||||
|             <div class="tab-pane-inner" id="notifications"> | ||||
|                 <fieldset> | ||||
| @@ -271,6 +312,36 @@ Math: {{ 1 + 1 }}") }} | ||||
|             </div> | ||||
|  | ||||
|             {% if watch['processor'] == 'text_json_diff' %} | ||||
|  | ||||
|             <div class="tab-pane-inner" id="conditions"> | ||||
|                 <!-- Grid Header --> | ||||
|                 <table> | ||||
|                     <thead> | ||||
|                     <td>In Value</td> | ||||
|                     <td>Operator</td> | ||||
|                     <td>Value</td> | ||||
|                     <td></td> | ||||
|  | ||||
|                     </thead> | ||||
|                     <tbody> | ||||
|                     <!-- Rule Rows (Dynamic Content) --> | ||||
|  | ||||
|                     {% for rule in form.conditions %} | ||||
|                         <tr> | ||||
|                             <td>{{ rule.field() }}</td> | ||||
|  | ||||
|                             <td>{{ rule.operator() }}</td> | ||||
|                             <td>{{ rule.value() }}</td> | ||||
|  | ||||
|                             <td> | ||||
|                                 <button type="button" onclick="addRuleRow()">AND +</button> | ||||
|                             </td> | ||||
|                         </tr> | ||||
|                     {% endfor %} | ||||
|                     </tbody> | ||||
|                 </table> | ||||
|  | ||||
|             </div> | ||||
|             <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> | ||||
|               <div> | ||||
|   | ||||
| @@ -98,5 +98,14 @@ greenlet >= 3.0.3 | ||||
| # Pinned or it causes problems with flask_expects_json which seems unmaintained | ||||
| referencing==0.35.1 | ||||
|  | ||||
| panzi-json-logic | ||||
|  | ||||
| # Scheduler - Windows seemed to miss a lot of default timezone info (even "UTC" !) | ||||
| tzdata | ||||
|  | ||||
| #typing_extensions ==4.8.0 | ||||
|  | ||||
| pluggy ~= 1.5 | ||||
|  | ||||
|  | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user