mirror of
				https://github.com/dgtlmoon/changedetection.io.git
				synced 2025-10-31 14:47:21 +00:00 
			
		
		
		
	Compare commits
	
		
			21 Commits
		
	
	
		
			HTML-notif
			...
			plugins-tr
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 6d5970e55a | ||
|   | 8e833a2d71 | ||
|   | efacc1cb6b | ||
|   | 6c39c868f2 | ||
|   | b6195cf5af | ||
|   | d01032b639 | ||
|   | 63a8802f32 | ||
|   | 35d3ebeba5 | ||
|   | 9182918139 | ||
|   | 822a985b16 | ||
|   | 03725992d0 | ||
|   | b612e5ace0 | ||
|   | d6470bc963 | ||
|   | a218b10c5f | ||
|   | 80ed6cbfc5 | ||
|   | 80c05516f7 | ||
|   | eff6c1cdd3 | ||
|   | b9a068b050 | ||
|   | a262f373cc | ||
|   | 673ec24fa3 | ||
|   | 9a073fc9aa | 
| @@ -33,7 +33,6 @@ def sigshutdown_handler(_signo, _stack_frame): | ||||
|     global datastore | ||||
|     name = signal.Signals(_signo).name | ||||
|     logger.critical(f'Shutdown: Got Signal - {name} ({_signo}), Saving DB to disk and calling shutdown') | ||||
|     datastore.sync_to_json() | ||||
|     logger.success('Sync JSON to disk complete.') | ||||
|     # This will throw a SystemExit exception, because eventlet.wsgi.server doesn't know how to deal with it. | ||||
|     # Solution: move to gevent or other server in the future (#2014) | ||||
|   | ||||
| @@ -12,11 +12,10 @@ import copy | ||||
| # See docs/README.md for rebuilding the docs/apidoc information | ||||
|  | ||||
| from . import api_schema | ||||
| from ..model import watch_base | ||||
| from ..model import schema as watch_schema | ||||
|  | ||||
| # Build a JSON Schema atleast partially based on our Watch model | ||||
| watch_base_config = watch_base() | ||||
| schema = api_schema.build_watch_json_schema(watch_base_config) | ||||
| schema = api_schema.build_watch_json_schema(watch_schema) | ||||
|  | ||||
| schema_create_watch = copy.deepcopy(schema) | ||||
| schema_create_watch['required'] = ['url'] | ||||
| @@ -53,9 +52,9 @@ class Watch(Resource): | ||||
|         @apiSuccess (200) {JSON} WatchJSON JSON Full JSON object of the watch | ||||
|         """ | ||||
|         from copy import deepcopy | ||||
|         watch = deepcopy(self.datastore.data['watching'].get(uuid)) | ||||
|         watch = self.datastore.data['watching'].get(uuid) | ||||
|         if not watch: | ||||
|             abort(404, message='No watch exists with the UUID of {}'.format(uuid)) | ||||
|             abort(404, message=f'No watch exists with the UUID of {uuid}') | ||||
|  | ||||
|         if request.args.get('recheck'): | ||||
|             self.update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid})) | ||||
| @@ -73,13 +72,16 @@ class Watch(Resource): | ||||
|             self.datastore.data['watching'].get(uuid).unmute() | ||||
|             return "OK", 200 | ||||
|  | ||||
|         # Return without history, get that via another API call | ||||
|         # Properties are not returned as a JSON, so add the required props manually | ||||
|         watch['history_n'] = watch.history_n | ||||
|         # attr .last_changed will check for the last written text snapshot on change | ||||
|         watch['last_changed'] = watch.last_changed | ||||
|         watch['viewed'] = watch.viewed | ||||
|         return watch | ||||
|  | ||||
|         response = dict(watch.get_data()) | ||||
|          | ||||
|         # Add properties that aren't included in the standard dictionary items (they are properties/attr) | ||||
|         response['history_n'] = watch.history_n | ||||
|         response['last_changed'] = watch.last_changed | ||||
|         response['viewed'] = watch.viewed | ||||
|         response['title'] = watch.get('title') | ||||
|          | ||||
|         return response | ||||
|  | ||||
|     @auth.check_token | ||||
|     def delete(self, uuid): | ||||
| @@ -114,16 +116,17 @@ class Watch(Resource): | ||||
|         @apiSuccess (200) {String} OK Was updated | ||||
|         @apiSuccess (500) {String} ERR Some other error | ||||
|         """ | ||||
|         watch = self.datastore.data['watching'].get(uuid) | ||||
|         if not watch: | ||||
|             abort(404, message='No watch exists with the UUID of {}'.format(uuid)) | ||||
|  | ||||
|         if not self.datastore.data['watching'].get(uuid): | ||||
|             abort(404, message=f'No watch exists with the UUID of {uuid}') | ||||
|  | ||||
|         if request.json.get('proxy'): | ||||
|             plist = self.datastore.proxy_list | ||||
|             if not request.json.get('proxy') in plist: | ||||
|                 return "Invalid proxy choice, currently supported proxies are '{}'".format(', '.join(plist)), 400 | ||||
|                 return f"Invalid proxy choice, currently supported proxies are '{', '.join(plist)}'", 400 | ||||
|  | ||||
|         watch.update(request.json) | ||||
|         self.datastore.data['watching'][uuid].update(request.json) | ||||
|         self.datastore.data['watching'][uuid].save_data() | ||||
|  | ||||
|         return "OK", 200 | ||||
|  | ||||
|   | ||||
| @@ -89,8 +89,6 @@ def construct_blueprint(datastore: ChangeDetectionStore): | ||||
|             flash("Maximum number of backups reached, please remove some", "error") | ||||
|             return redirect(url_for('backups.index')) | ||||
|  | ||||
|         # Be sure we're written fresh | ||||
|         datastore.sync_to_json() | ||||
|         zip_thread = threading.Thread(target=create_backup, args=(datastore.datastore_path, datastore.data.get("watching"))) | ||||
|         zip_thread.start() | ||||
|         backup_threads.append(zip_thread) | ||||
|   | ||||
| @@ -63,7 +63,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe | ||||
|                     update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid})) | ||||
|  | ||||
|         # Could be some remaining, or we could be on GET | ||||
|         form = forms.importForm(formdata=request.form if request.method == 'POST' else None) | ||||
|         form = forms.importForm(formdata=request.form if request.method == 'POST' else None, datastore=datastore) | ||||
|         output = render_template("import.html", | ||||
|                                 form=form, | ||||
|                                 import_url_list_remaining="\n".join(remaining_urls), | ||||
|   | ||||
| @@ -3,7 +3,6 @@ import time | ||||
| from wtforms import ValidationError | ||||
| from loguru import logger | ||||
|  | ||||
| from changedetectionio.forms import validate_url | ||||
|  | ||||
|  | ||||
| class Importer(): | ||||
| @@ -151,6 +150,7 @@ class import_xlsx_wachete(Importer): | ||||
|         self.new_uuids = [] | ||||
|  | ||||
|         from openpyxl import load_workbook | ||||
|         from changedetectionio.forms import validate_url | ||||
|  | ||||
|         try: | ||||
|             wb = load_workbook(data) | ||||
|   | ||||
| @@ -1,25 +1,28 @@ | ||||
|  | ||||
| from changedetectionio.strtobool import strtobool | ||||
| from flask import Blueprint, flash, redirect, url_for | ||||
| from flask_login import login_required | ||||
| from changedetectionio.store import ChangeDetectionStore | ||||
| from changedetectionio import queuedWatchMetaData | ||||
| from queue import PriorityQueue | ||||
| from changedetectionio import queuedWatchMetaData | ||||
| from changedetectionio.processors.constants import PRICE_DATA_TRACK_ACCEPT, PRICE_DATA_TRACK_REJECT | ||||
|  | ||||
| PRICE_DATA_TRACK_ACCEPT = 'accepted' | ||||
| PRICE_DATA_TRACK_REJECT = 'rejected' | ||||
|  | ||||
| def construct_blueprint(datastore: ChangeDetectionStore, update_q: PriorityQueue): | ||||
| def construct_blueprint(datastore, update_q: PriorityQueue): | ||||
|  | ||||
|     price_data_follower_blueprint = Blueprint('price_data_follower', __name__) | ||||
|  | ||||
|     @login_required | ||||
|     @price_data_follower_blueprint.route("/<string:uuid>/accept", methods=['GET']) | ||||
|     def accept(uuid): | ||||
|  | ||||
|         old_data = datastore.data['watching'][uuid].get_data() | ||||
|  | ||||
|         datastore.data['watching'][uuid] = datastore.rehydrate_entity(default_dict=old_data, processor_override='restock_diff') | ||||
|         datastore.data['watching'][uuid]['track_ldjson_price_data'] = PRICE_DATA_TRACK_ACCEPT | ||||
|         datastore.data['watching'][uuid]['processor'] = 'restock_diff' | ||||
|         datastore.data['watching'][uuid].clear_watch() | ||||
|  | ||||
|         # Queue the watch for updating | ||||
|         update_q.put(queuedWatchMetaData.PrioritizedItem(priority=1, item={'uuid': uuid})) | ||||
|  | ||||
|         return redirect(url_for("index")) | ||||
|  | ||||
|     @login_required | ||||
|   | ||||
							
								
								
									
										3
									
								
								changedetectionio/blueprint/price_data_follower/flags.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								changedetectionio/blueprint/price_data_follower/flags.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
|  | ||||
| PRICE_DATA_TRACK_ACCEPT = 'accepted' | ||||
| PRICE_DATA_TRACK_REJECT = 'rejected' | ||||
| @@ -71,12 +71,12 @@ def construct_blueprint(datastore: ChangeDetectionStore): | ||||
|  | ||||
|                 if not os.getenv("SALTED_PASS", False) and len(form.application.form.password.encrypted_password): | ||||
|                     datastore.data['settings']['application']['password'] = form.application.form.password.encrypted_password | ||||
|                     datastore.needs_write_urgent = True | ||||
|                     datastore.save_settings() | ||||
|                     flash("Password protection enabled.", 'notice') | ||||
|                     flask_login.logout_user() | ||||
|                     return redirect(url_for('index')) | ||||
|  | ||||
|                 datastore.needs_write_urgent = True | ||||
|                 datastore.save_settings() | ||||
|                 flash("Settings updated.") | ||||
|  | ||||
|             else: | ||||
| @@ -84,6 +84,24 @@ def construct_blueprint(datastore: ChangeDetectionStore): | ||||
|  | ||||
|         # Convert to ISO 8601 format, all date/time relative events stored as UTC time | ||||
|         utc_time = datetime.now(ZoneInfo("UTC")).isoformat() | ||||
|          | ||||
|         # Get processor plugins info | ||||
|         from changedetectionio.processors import get_all_plugins_info | ||||
|         plugins_info = get_all_plugins_info() | ||||
|  | ||||
|         # Process settings including plugin toggles | ||||
|         if request.method == 'POST' and form.validate(): | ||||
|             # Process the main form data | ||||
|             app_update = dict(deepcopy(form.data['application'])) | ||||
|              | ||||
|             # Don't update password with '' or False (Added by wtforms when not in submission) | ||||
|             if 'password' in app_update and not app_update['password']: | ||||
|                 del (app_update['password']) | ||||
|  | ||||
|             datastore.data['settings']['application'].update(app_update) | ||||
|             datastore.data['settings']['requests'].update(form.data['requests']) | ||||
|             datastore.save_settings() | ||||
|             flash("Settings updated.") | ||||
|  | ||||
|         output = render_template("settings.html", | ||||
|                                 api_key=datastore.data['settings']['application'].get('api_access_token'), | ||||
| @@ -93,6 +111,7 @@ def construct_blueprint(datastore: ChangeDetectionStore): | ||||
|                                 form=form, | ||||
|                                 hide_remove_pass=os.getenv("SALTED_PASS", False), | ||||
|                                 min_system_recheck_seconds=int(os.getenv('MINIMUM_SECONDS_RECHECK_TIME', 3)), | ||||
|                                 plugins_info=plugins_info, | ||||
|                                 settings_application=datastore.data['settings']['application'], | ||||
|                                 timezone_default_config=datastore.data['settings']['application'].get('timezone'), | ||||
|                                 utc_time=utc_time, | ||||
| @@ -105,7 +124,6 @@ def construct_blueprint(datastore: ChangeDetectionStore): | ||||
|     def settings_reset_api_key(): | ||||
|         secret = secrets.token_hex(16) | ||||
|         datastore.data['settings']['application']['api_access_token'] = secret | ||||
|         datastore.needs_write_urgent = True | ||||
|         flash("API Key was regenerated.") | ||||
|         return redirect(url_for('settings.settings_page')+'#api') | ||||
|          | ||||
|   | ||||
| @@ -9,6 +9,7 @@ | ||||
|     const email_notification_prefix=JSON.parse('{{emailprefix|tojson}}'); | ||||
| {% endif %} | ||||
| </script> | ||||
|  | ||||
| <script src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script> | ||||
| <script src="{{url_for('static_content', group='js', filename='plugins.js')}}" defer></script> | ||||
| <script src="{{url_for('static_content', group='js', filename='notifications.js')}}" defer></script> | ||||
| @@ -25,6 +26,7 @@ | ||||
|             <li class="tab"><a href="#api">API</a></li> | ||||
|             <li class="tab"><a href="#timedate">Time & Date</a></li> | ||||
|             <li class="tab"><a href="#proxies">CAPTCHA & Proxies</a></li> | ||||
|             <li class="tab"><a href="#plugins">Plugins</a></li> | ||||
|         </ul> | ||||
|     </div> | ||||
|     <div class="box-wrap inner"> | ||||
| @@ -296,11 +298,37 @@ nav | ||||
|                     {{ render_field(form.requests.form.extra_browsers) }} | ||||
|                 </div> | ||||
|             </div> | ||||
|             <div class="tab-pane-inner" id="plugins"> | ||||
|               <div class="pure-control-group"> | ||||
|                 <h4>Registered Plugins</h4> | ||||
|                   <p>The following plugins are currently registered in the system - <a href="https://changedetection.io/plugins">Get more plugins here</a></p> | ||||
|                  | ||||
|                 <table class="pure-table pure-table-striped"> | ||||
|                     <thead> | ||||
|                         <tr> | ||||
|                             <th>Name</th> | ||||
|                             <th>Description</th> | ||||
|                             <th>Version</th> | ||||
|                         </tr> | ||||
|                     </thead> | ||||
|                     <tbody> | ||||
|                         {% for plugin in plugins_info %} | ||||
|                         <tr> | ||||
|                             <td>{{ plugin.name }}</td> | ||||
|                             <td>{{ plugin.description }}</td> | ||||
|                             <td>{{ plugin.version }}</td> | ||||
|                         </tr> | ||||
|                         {% endfor %} | ||||
|                     </tbody> | ||||
|                 </table> | ||||
|               </div> | ||||
|             </div> | ||||
|  | ||||
|             <div id="actions"> | ||||
|                 <div class="pure-control-group"> | ||||
|                     {{ render_button(form.save_button) }} | ||||
|                     <a href="{{url_for('index')}}" class="pure-button button-small button-cancel">Back</a> | ||||
|                     <a href="{{url_for('clear_all_history')}}" class="pure-button button-small button-error">Clear Snapshot History</a> | ||||
|                     <a href="{{url_for('ui.clear_all_history')}}" class="pure-button button-small button-error">Clear Snapshot History</a> | ||||
|                 </div> | ||||
|             </div> | ||||
|         </form> | ||||
|   | ||||
| @@ -56,6 +56,7 @@ def construct_blueprint(datastore: ChangeDetectionStore): | ||||
|     def mute(uuid): | ||||
|         if datastore.data['settings']['application']['tags'].get(uuid): | ||||
|             datastore.data['settings']['application']['tags'][uuid]['notification_muted'] = not datastore.data['settings']['application']['tags'][uuid]['notification_muted'] | ||||
|             datastore.data['settings']['application']['tags'][uuid].save_data() | ||||
|         return redirect(url_for('tags.tags_overview_page')) | ||||
|  | ||||
|     @tags_blueprint.route("/delete/<string:uuid>", methods=['GET']) | ||||
| @@ -176,7 +177,8 @@ def construct_blueprint(datastore: ChangeDetectionStore): | ||||
|  | ||||
|         datastore.data['settings']['application']['tags'][uuid].update(form.data) | ||||
|         datastore.data['settings']['application']['tags'][uuid]['processor'] = 'restock_diff' | ||||
|         datastore.needs_write_urgent = True | ||||
|         datastore.data['settings']['application']['tags'][uuid].save_data() | ||||
|  | ||||
|         flash("Updated") | ||||
|  | ||||
|         return redirect(url_for('tags.tags_overview_page')) | ||||
|   | ||||
| @@ -163,6 +163,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, running_updat | ||||
|                 uuid = uuid.strip() | ||||
|                 if datastore.data['watching'].get(uuid): | ||||
|                     datastore.data['watching'][uuid.strip()]['paused'] = True | ||||
|                     datastore.data['watching'][uuid.strip()].save_data() | ||||
|             flash("{} watches paused".format(len(uuids))) | ||||
|  | ||||
|         elif (op == 'unpause'): | ||||
| @@ -170,6 +171,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, running_updat | ||||
|                 uuid = uuid.strip() | ||||
|                 if datastore.data['watching'].get(uuid): | ||||
|                     datastore.data['watching'][uuid.strip()]['paused'] = False | ||||
|                     datastore.data['watching'][uuid.strip()].save_data() | ||||
|             flash("{} watches unpaused".format(len(uuids))) | ||||
|  | ||||
|         elif (op == 'mark-viewed'): | ||||
| @@ -184,6 +186,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, running_updat | ||||
|                 uuid = uuid.strip() | ||||
|                 if datastore.data['watching'].get(uuid): | ||||
|                     datastore.data['watching'][uuid.strip()]['notification_muted'] = True | ||||
|                     datastore.data['watching'][uuid.strip()].save_data() | ||||
|             flash("{} watches muted".format(len(uuids))) | ||||
|  | ||||
|         elif (op == 'unmute'): | ||||
| @@ -191,6 +194,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, running_updat | ||||
|                 uuid = uuid.strip() | ||||
|                 if datastore.data['watching'].get(uuid): | ||||
|                     datastore.data['watching'][uuid.strip()]['notification_muted'] = False | ||||
|                     datastore.data['watching'][uuid.strip()].save_data() | ||||
|             flash("{} watches un-muted".format(len(uuids))) | ||||
|  | ||||
|         elif (op == 'recheck'): | ||||
| @@ -206,6 +210,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, running_updat | ||||
|                 uuid = uuid.strip() | ||||
|                 if datastore.data['watching'].get(uuid): | ||||
|                     datastore.data['watching'][uuid]["last_error"] = False | ||||
|                     datastore.data['watching'][uuid].save_data() | ||||
|             flash(f"{len(uuids)} watches errors cleared") | ||||
|  | ||||
|         elif (op == 'clear-history'): | ||||
| @@ -244,6 +249,9 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, running_updat | ||||
|  | ||||
|             flash(f"{len(uuids)} watches were tagged") | ||||
|  | ||||
|         for uuid in uuids: | ||||
|             datastore.data['watching'][uuid.strip()].save_data() | ||||
|  | ||||
|         return redirect(url_for('index')) | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -24,7 +24,6 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe | ||||
|     # https://stackoverflow.com/questions/42984453/wtforms-populate-form-with-data-if-data-exists | ||||
|     # https://wtforms.readthedocs.io/en/3.0.x/forms/#wtforms.form.Form.populate_obj ? | ||||
|     def edit_page(uuid): | ||||
|         from changedetectionio import forms | ||||
|         from changedetectionio.blueprint.browser_steps.browser_steps import browser_step_ui_config | ||||
|         from changedetectionio import processors | ||||
|         import importlib | ||||
| @@ -43,15 +42,15 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe | ||||
|  | ||||
|         switch_processor = request.args.get('switch_processor') | ||||
|         if switch_processor: | ||||
|             for p in processors.available_processors(): | ||||
|             for p in processors.available_processors(datastore): | ||||
|                 if p[0] == switch_processor: | ||||
|                     datastore.data['watching'][uuid]['processor'] = switch_processor | ||||
|                     flash(f"Switched to mode - {p[1]}.") | ||||
|                     datastore.clear_watch_history(uuid) | ||||
|                     redirect(url_for('ui_edit.edit_page', uuid=uuid)) | ||||
|  | ||||
|         # be sure we update with a copy instead of accidently editing the live object by reference | ||||
|         default = deepcopy(datastore.data['watching'][uuid]) | ||||
|  | ||||
|         default = datastore.data['watching'][uuid] | ||||
|  | ||||
|         # Defaults for proxy choice | ||||
|         if datastore.proxy_list is not None:  # When enabled | ||||
| @@ -61,31 +60,19 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe | ||||
|                 default['proxy'] = '' | ||||
|         # proxy_override set to the json/text list of the items | ||||
|  | ||||
|         # Does it use some custom form? does one exist? | ||||
|         processor_name = datastore.data['watching'][uuid].get('processor', '') | ||||
|         processor_classes = next((tpl for tpl in processors.find_processors() if tpl[1] == processor_name), None) | ||||
|         if not processor_classes: | ||||
|             flash(f"Cannot load the edit form for processor/plugin '{processor_classes[1]}', plugin missing?", 'error') | ||||
|         # Get the appropriate form class for this processor using the pluggy system | ||||
|         processor_name = datastore.data['watching'][uuid].get('processor', 'text_json_diff') | ||||
|         form_class = processors.get_form_class_for_processor(processor_name) | ||||
|          | ||||
|         if not form_class: | ||||
|             flash(f"Cannot load the edit form for processor/plugin '{processor_name}', plugin missing?", 'error') | ||||
|             return redirect(url_for('index')) | ||||
|  | ||||
|         parent_module = processors.get_parent_module(processor_classes[0]) | ||||
|  | ||||
|         try: | ||||
|             # Get the parent of the "processor.py" go up one, get the form (kinda spaghetti but its reusing existing code) | ||||
|             forms_module = importlib.import_module(f"{parent_module.__name__}.forms") | ||||
|             # Access the 'processor_settings_form' class from the 'forms' module | ||||
|             form_class = getattr(forms_module, 'processor_settings_form') | ||||
|         except ModuleNotFoundError as e: | ||||
|             # .forms didnt exist | ||||
|             form_class = forms.processor_text_json_diff_form | ||||
|         except AttributeError as e: | ||||
|             # .forms exists but no useful form | ||||
|             form_class = forms.processor_text_json_diff_form | ||||
|  | ||||
|         form = form_class(formdata=request.form if request.method == 'POST' else None, | ||||
|                           data=default, | ||||
|                           extra_notification_tokens=default.extra_notification_token_values(), | ||||
|                           default_system_settings=datastore.data['settings'] | ||||
|                           default_system_settings=datastore.data['settings'], | ||||
|                           datastore=datastore | ||||
|                           ) | ||||
|  | ||||
|         # For the form widget tag UUID back to "string name" for the field | ||||
| @@ -127,10 +114,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe | ||||
|                 extra_update_obj['paused'] = False | ||||
|  | ||||
|             extra_update_obj['time_between_check'] = form.time_between_check.data | ||||
|  | ||||
|              # Ignore text | ||||
|             form_ignore_text = form.ignore_text.data | ||||
|             datastore.data['watching'][uuid]['ignore_text'] = form_ignore_text | ||||
|             extra_update_obj['ignore_text'] = form.ignore_text.data | ||||
|  | ||||
|             # Be sure proxy value is None | ||||
|             if datastore.proxy_list is not None and form.data['proxy'] == '': | ||||
| @@ -156,22 +140,23 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe | ||||
|                         tag_uuids.append(datastore.add_tag(name=t)) | ||||
|                     extra_update_obj['tags'] = tag_uuids | ||||
|  | ||||
|             datastore.data['watching'][uuid].update(form.data) | ||||
|             datastore.data['watching'][uuid].update(extra_update_obj) | ||||
|  | ||||
|  | ||||
|  | ||||
|             if not datastore.data['watching'][uuid].get('tags'): | ||||
|                 # Force it to be a list, because form.data['tags'] will be string if nothing found | ||||
|                 # And del(form.data['tags'] ) wont work either for some reason | ||||
|                 datastore.data['watching'][uuid]['tags'] = [] | ||||
|  | ||||
|             datastore.update_watch(uuid=uuid, update_obj=form.data | extra_update_obj) | ||||
|  | ||||
|             # Recast it if need be to right data Watch handler | ||||
|             watch_class = processors.get_custom_watch_obj_for_processor(form.data.get('processor')) | ||||
|             processor_name = datastore.data['watching'][uuid].get('processor') | ||||
|             watch_class = processors.get_watch_model_for_processor(processor_name) | ||||
|             datastore.data['watching'][uuid] = watch_class(datastore_path=datastore.datastore_path, default=datastore.data['watching'][uuid]) | ||||
|             datastore.data['watching'][uuid].save_data() | ||||
|             flash("Updated watch - unpaused!" if request.args.get('unpause_on_save') else "Updated watch.") | ||||
|  | ||||
|             # Re #286 - We wait for syncing new data to disk in another thread every 60 seconds | ||||
|             # But in the case something is added we should save straight away | ||||
|             datastore.needs_write_urgent = True | ||||
|  | ||||
|             # Do not queue on edit if its not within the time range | ||||
|  | ||||
| @@ -198,6 +183,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe | ||||
|                         f"{uuid} - Recheck scheduler, error handling timezone, check skipped - TZ name '{tz_name}' - {str(e)}") | ||||
|                     return False | ||||
|  | ||||
|  | ||||
|             ############################# | ||||
|             if not datastore.data['watching'][uuid].get('paused') and is_in_schedule: | ||||
|                 # Queue the watch for immediate recheck, with a higher priority | ||||
| @@ -236,7 +222,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe | ||||
|             # Only works reliably with Playwright | ||||
|  | ||||
|             template_args = { | ||||
|                 'available_processors': processors.available_processors(), | ||||
|                 'available_processors': processors.available_processors(datastore), | ||||
|                 'available_timezones': sorted(available_timezones()), | ||||
|                 'browser_steps_config': browser_step_ui_config, | ||||
|                 'emailprefix': os.getenv('NOTIFICATION_MAIL_BUTTON_PREFIX', False), | ||||
|   | ||||
| @@ -191,7 +191,7 @@ def construct_blueprint(datastore: ChangeDetectionStore, update_q, queuedWatchMe | ||||
|     @login_optionally_required | ||||
|     def form_quick_watch_add(): | ||||
|         from changedetectionio import forms | ||||
|         form = forms.quickWatchForm(request.form) | ||||
|         form = forms.quickWatchForm(request.form, datastore=datastore) | ||||
|  | ||||
|         if not form.validate(): | ||||
|             for widget, l in form.errors.items(): | ||||
|   | ||||
| @@ -75,6 +75,7 @@ if os.getenv('FLASK_SERVER_NAME'): | ||||
| # Disables caching of the templates | ||||
| app.config['TEMPLATES_AUTO_RELOAD'] = True | ||||
| app.jinja_env.add_extension('jinja2.ext.loopcontrols') | ||||
| app.jinja_env.globals.update(hasattr=hasattr) | ||||
| csrf = CSRFProtect() | ||||
| csrf.init_app(app) | ||||
| notification_debug_log=[] | ||||
| @@ -343,7 +344,7 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|     @login_optionally_required | ||||
|     def index(): | ||||
|         global datastore | ||||
|         from changedetectionio import forms | ||||
|         from changedetectionio.forms import quickWatchForm | ||||
|  | ||||
|         active_tag_req = request.args.get('tag', '').lower().strip() | ||||
|         active_tag_uuid = active_tag = None | ||||
| @@ -369,7 +370,6 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|             elif op == 'mute': | ||||
|                 datastore.data['watching'][uuid].toggle_mute() | ||||
|  | ||||
|             datastore.needs_write = True | ||||
|             return redirect(url_for('index', tag = active_tag_uuid)) | ||||
|  | ||||
|         # Sort by last_changed and add the uuid which is usually the key.. | ||||
| @@ -394,7 +394,7 @@ def changedetection_app(config=None, datastore_o=None): | ||||
|             else: | ||||
|                 sorted_watches.append(watch) | ||||
|  | ||||
|         form = forms.quickWatchForm(request.form) | ||||
|         form = quickWatchForm(request.form, datastore=datastore) | ||||
|         page = request.args.get(get_page_parameter(), type=int, default=1) | ||||
|         total_count = len(sorted_watches) | ||||
|  | ||||
|   | ||||
| @@ -23,7 +23,7 @@ from wtforms import ( | ||||
| from flask_wtf.file import FileField, FileAllowed | ||||
| from wtforms.fields import FieldList | ||||
|  | ||||
| from wtforms.validators import ValidationError | ||||
| from wtforms.validators import ValidationError, Optional | ||||
|  | ||||
| from validators.url import url as url_validator | ||||
|  | ||||
| @@ -508,8 +508,14 @@ class quickWatchForm(Form): | ||||
|     url = fields.URLField('URL', validators=[validateURL()]) | ||||
|     tags = StringTagUUID('Group tag', [validators.Optional()]) | ||||
|     watch_submit_button = SubmitField('Watch', render_kw={"class": "pure-button pure-button-primary"}) | ||||
|     processor = RadioField(u'Processor', choices=processors.available_processors(), default="text_json_diff") | ||||
|     processor = RadioField(u'Processor', default="text_json_diff") | ||||
|     edit_and_watch_submit_button = SubmitField('Edit > Watch', render_kw={"class": "pure-button pure-button-primary"}) | ||||
|      | ||||
|     def __init__(self, formdata=None, obj=None, prefix="", data=None, meta=None, **kwargs): | ||||
|         super().__init__(formdata, obj, prefix, data, meta, **kwargs) | ||||
|         # Set processor choices based on datastore if available | ||||
|         #datastore = kwargs.get('datastore') | ||||
|         self.processor.choices = self.processors.available_processors() | ||||
|  | ||||
|  | ||||
|  | ||||
| @@ -522,6 +528,13 @@ class commonSettingsForm(Form): | ||||
|         self.notification_body.extra_notification_tokens = kwargs.get('extra_notification_tokens', {}) | ||||
|         self.notification_title.extra_notification_tokens = kwargs.get('extra_notification_tokens', {}) | ||||
|         self.notification_urls.extra_notification_tokens = kwargs.get('extra_notification_tokens', {}) | ||||
|          | ||||
|         # Set processor choices based on datastore if available | ||||
|         datastore = kwargs.get('datastore') | ||||
|         if datastore: | ||||
|             self.processor.choices = self.processors.available_processors(datastore) | ||||
|         else: | ||||
|             self.processor.choices = self.processors.available_processors() | ||||
|  | ||||
|     extract_title_as_title = BooleanField('Extract <title> from document and use as watch title', default=False) | ||||
|     fetch_backend = RadioField(u'Fetch Method', choices=content_fetchers.available_fetchers(), validators=[ValidateContentFetcherIsReady()]) | ||||
| @@ -529,17 +542,26 @@ class commonSettingsForm(Form): | ||||
|     notification_format = SelectField('Notification format', choices=valid_notification_formats.keys()) | ||||
|     notification_title = StringField('Notification Title', default='ChangeDetection.io Notification - {{ watch_url }}', validators=[validators.Optional(), ValidateJinja2Template()]) | ||||
|     notification_urls = StringListField('Notification URL List', validators=[validators.Optional(), ValidateAppRiseServers(), ValidateJinja2Template()]) | ||||
|     processor = RadioField( label=u"Processor - What do you want to achieve?", choices=processors.available_processors(), default="text_json_diff") | ||||
|     processor = RadioField( label=u"Processor - What do you want to achieve?", default="text_json_diff") | ||||
|     timezone = StringField("Timezone for watch schedule", render_kw={"list": "timezones"}, validators=[validateTimeZoneName()]) | ||||
|     webdriver_delay = IntegerField('Wait seconds before extracting text', validators=[validators.Optional(), validators.NumberRange(min=1, message="Should contain one or more seconds")]) | ||||
|  | ||||
|  | ||||
| class importForm(Form): | ||||
|     from . import processors | ||||
|     processor = RadioField(u'Processor', choices=processors.available_processors(), default="text_json_diff") | ||||
|     processor = RadioField(u'Processor', default="text_json_diff") | ||||
|     urls = TextAreaField('URLs') | ||||
|     xlsx_file = FileField('Upload .xlsx file', validators=[FileAllowed(['xlsx'], 'Must be .xlsx file!')]) | ||||
|     file_mapping = SelectField('File mapping', [validators.DataRequired()], choices={('wachete', 'Wachete mapping'), ('custom','Custom mapping')}) | ||||
|      | ||||
|     def __init__(self, formdata=None, obj=None, prefix="", data=None, meta=None, **kwargs): | ||||
|         super().__init__(formdata, obj, prefix, data, meta, **kwargs) | ||||
|         # Set processor choices based on datastore if available | ||||
|         datastore = kwargs.get('datastore') | ||||
|         if datastore: | ||||
|             self.processor.choices = self.processors.available_processors(datastore) | ||||
|         else: | ||||
|             self.processor.choices = self.processors.available_processors() | ||||
|  | ||||
| class SingleBrowserStep(Form): | ||||
|  | ||||
| @@ -714,11 +736,12 @@ class globalSettingsRequestForm(Form): | ||||
|     default_ua = FormField(DefaultUAInputForm, label="Default User-Agent overrides") | ||||
|  | ||||
|     def validate_extra_proxies(self, extra_validators=None): | ||||
|         for e in self.data['extra_proxies']: | ||||
|             if e.get('proxy_name') or e.get('proxy_url'): | ||||
|                 if not e.get('proxy_name','').strip() or not e.get('proxy_url','').strip(): | ||||
|                     self.extra_proxies.errors.append('Both a name, and a Proxy URL is required.') | ||||
|                     return False | ||||
|         if self.data.get('extra_proxies'): | ||||
|             for e in self.data['extra_proxies']: | ||||
|                 if e.get('proxy_name') or e.get('proxy_url'): | ||||
|                     if not e.get('proxy_name','').strip() or not e.get('proxy_url','').strip(): | ||||
|                         self.extra_proxies.errors.append('Both a name, and a Proxy URL is required.') | ||||
|                         return False | ||||
|  | ||||
|  | ||||
| # datastore.data['settings']['application'].. | ||||
| @@ -749,7 +772,6 @@ class globalSettingsApplicationForm(commonSettingsForm): | ||||
|                                                                   validators=[validators.NumberRange(min=0, | ||||
|                                                                                                      message="Should contain zero or more attempts")]) | ||||
|  | ||||
|  | ||||
| class globalSettingsForm(Form): | ||||
|     # Define these as FormFields/"sub forms", this way it matches the JSON storage | ||||
|     # datastore.data['settings']['application'].. | ||||
|   | ||||
| @@ -53,7 +53,7 @@ class model(dict): | ||||
|                     'shared_diff_access': False, | ||||
|                     'webdriver_delay': None , # Extra delay in seconds before extracting text | ||||
|                     'tags': {}, #@todo use Tag.model initialisers | ||||
|                     'timezone': None, # Default IANA timezone name | ||||
|                     'timezone': None # Default IANA timezone name | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|   | ||||
| @@ -1,14 +1,57 @@ | ||||
|  | ||||
| from changedetectionio.model import watch_base | ||||
| import os | ||||
| import json | ||||
| import uuid as uuid_builder | ||||
| import time | ||||
| from copy import deepcopy | ||||
| from loguru import logger | ||||
|  | ||||
| from changedetectionio.model import watch_base, schema | ||||
|  | ||||
|  | ||||
| class model(watch_base): | ||||
|     """Tag model that writes to tags/{uuid}/tag.json instead of the main watch directory""" | ||||
|     __datastore_path = None | ||||
|  | ||||
|     def __init__(self, *arg, **kw): | ||||
|         super(model, self).__init__(*arg, **kw) | ||||
|         self.__datastore_path = kw.get("datastore_path") | ||||
|  | ||||
|         self['overrides_watch'] = kw.get('default', {}).get('overrides_watch') | ||||
|  | ||||
|         if kw.get('default'): | ||||
|             self.update(kw['default']) | ||||
|             del kw['default'] | ||||
|  | ||||
|     @property | ||||
|     def watch_data_dir(self): | ||||
|         # Override to use tags directory instead of the normal watch data directory | ||||
|         datastore_path = getattr(self, '_model__datastore_path', None) | ||||
|         if datastore_path: | ||||
|             tags_path = os.path.join(datastore_path, 'tags') | ||||
|             # Make sure the tags directory exists | ||||
|             if not os.path.exists(tags_path): | ||||
|                 os.makedirs(tags_path) | ||||
|             return os.path.join(tags_path, self['uuid']) | ||||
|         return None | ||||
|          | ||||
|     def save_data(self): | ||||
|         """Override to save tag to tags/{uuid}/tag.json""" | ||||
|         logger.debug(f"Saving tag {self['uuid']}") | ||||
|  | ||||
|         if not self.get('uuid'): | ||||
|             # Might have been called when creating the tag | ||||
|             return | ||||
|  | ||||
|         tags_path = os.path.join(self.__datastore_path, 'tags') | ||||
|         if not os.path.isdir(tags_path): | ||||
|             os.mkdir(os.path.join(tags_path)) | ||||
|  | ||||
|         path = os.path.join(tags_path, self.get('uuid')+".json") | ||||
|         try: | ||||
|             with open(path + ".tmp", 'w') as json_file: | ||||
|                 json.dump(self.get_data(), json_file, indent=4) | ||||
|             os.replace(path + ".tmp", path) | ||||
|         except Exception as e: | ||||
|             logger.error(f"Error writing JSON for tag {self.get('uuid')}!! (JSON file save was skipped) : {str(e)}") | ||||
|  | ||||
|   | ||||
| @@ -38,17 +38,13 @@ class model(watch_base): | ||||
|     jitter_seconds = 0 | ||||
|  | ||||
|     def __init__(self, *arg, **kw): | ||||
|         self.__datastore_path = kw.get('datastore_path') | ||||
|         if kw.get('datastore_path'): | ||||
|             del kw['datastore_path'] | ||||
|  | ||||
|         super(model, self).__init__(*arg, **kw) | ||||
|  | ||||
|         if kw.get('default'): | ||||
|             self.update(kw['default']) | ||||
|             del kw['default'] | ||||
|  | ||||
|         if self.get('default'): | ||||
|             del self['default'] | ||||
|  | ||||
|         # Be sure the cached timestamp is ready | ||||
|         bump = self.history | ||||
|  | ||||
| @@ -300,6 +296,7 @@ class model(watch_base): | ||||
|     # result_obj from fetch_site_status.run() | ||||
|     def save_history_text(self, contents, timestamp, snapshot_id): | ||||
|         import brotli | ||||
|         import tempfile | ||||
|  | ||||
|         logger.trace(f"{self.get('uuid')} - Updating history.txt with timestamp {timestamp}") | ||||
|  | ||||
| @@ -416,11 +413,6 @@ class model(watch_base): | ||||
|     def snapshot_error_screenshot_ctime(self): | ||||
|         return self.__get_file_ctime('last-error-screenshot.png') | ||||
|  | ||||
|     @property | ||||
|     def watch_data_dir(self): | ||||
|         # The base dir of the watch data | ||||
|         return os.path.join(self.__datastore_path, self['uuid']) if self.__datastore_path else None | ||||
|  | ||||
|     def get_error_text(self): | ||||
|         """Return the text saved from a previous request that resulted in a non-200 error""" | ||||
|         fname = os.path.join(self.watch_data_dir, "last-error.txt") | ||||
|   | ||||
| @@ -1,135 +1,246 @@ | ||||
| import os | ||||
| import uuid | ||||
| from copy import deepcopy | ||||
| from loguru import logger | ||||
| import time | ||||
| import json | ||||
|  | ||||
| from changedetectionio import strtobool | ||||
| from changedetectionio.notification import default_notification_format_for_watch | ||||
|  | ||||
| schema = { | ||||
|     # Custom notification content | ||||
|     # Re #110, so then if this is set to None, we know to use the default value instead | ||||
|     # Requires setting to None on submit if it's the same as the default | ||||
|     # Should be all None by default, so we use the system default in this case. | ||||
|     'body': None, | ||||
|     'browser_steps': [], | ||||
|     'browser_steps_last_error_step': None, | ||||
|     'check_count': 0, | ||||
|     'check_unique_lines': False,  # On change-detected, compare against all history if its something new | ||||
|     'consecutive_filter_failures': 0,  # Every time the CSS/xPath filter cannot be located, reset when all is fine. | ||||
|     'content-type': None, | ||||
|     'date_created': None, | ||||
|     'extract_text': [],  # Extract text by regex after filters | ||||
|     'extract_title_as_title': False, | ||||
|     'fetch_backend': 'system',  # plaintext, playwright etc | ||||
|     'fetch_time': 0.0, | ||||
|     'filter_failure_notification_send': strtobool(os.getenv('FILTER_FAILURE_NOTIFICATION_SEND_DEFAULT', 'True')), | ||||
|     'filter_text_added': True, | ||||
|     'filter_text_removed': True, | ||||
|     'filter_text_replaced': True, | ||||
|     'follow_price_changes': True, | ||||
|     'has_ldjson_price_data': None, | ||||
|     'headers': {},  # Extra headers to send | ||||
|     'ignore_text': [],  # List of text to ignore when calculating the comparison checksum | ||||
|     'in_stock_only': True,  # Only trigger change on going to instock from out-of-stock | ||||
|     'include_filters': [], | ||||
|     'last_checked': 0, | ||||
|     'last_error': False, | ||||
|     'last_modified': None, | ||||
|     'last_viewed': 0,  # history key value of the last viewed via the [diff] link | ||||
|     'method': 'GET', | ||||
|     'notification_alert_count': 0, | ||||
|     'notification_body': None, | ||||
|     'notification_format': default_notification_format_for_watch, | ||||
|     'notification_muted': False, | ||||
|     'notification_screenshot': False,  # Include the latest screenshot if available and supported by the apprise URL | ||||
|     'notification_title': None, | ||||
|     'notification_urls': [],  # List of URLs to add to the notification Queue (Usually AppRise) | ||||
|     'paused': False, | ||||
|     'previous_md5': False, | ||||
|     'previous_md5_before_filters': False,  # Used for skipping changedetection entirely | ||||
|     'processor': 'text_json_diff',  # could be restock_diff or others from .processors | ||||
|     'processor_state': {}, # Extra configs for custom processors/plugins, keyed by processor name | ||||
|     'price_change_threshold_percent': None, | ||||
|     'proxy': None,  # Preferred proxy connection | ||||
|     'remote_server_reply': None,  # From 'server' reply header | ||||
|     'sort_text_alphabetically': False, | ||||
|     'subtractive_selectors': [], | ||||
|     'tag': '',  # Old system of text name for a tag, to be removed | ||||
|     'tags': [],  # list of UUIDs to App.Tags | ||||
|     'text_should_not_be_present': [],  # Text that should not present | ||||
|     'time_between_check': {'weeks': None, 'days': None, 'hours': None, 'minutes': None, 'seconds': None}, | ||||
|     'time_between_check_use_default': True, | ||||
|     "time_schedule_limit": { | ||||
|         "enabled": False, | ||||
|         "monday": { | ||||
|             "enabled": True, | ||||
|             "start_time": "00:00", | ||||
|             "duration": { | ||||
|                 "hours": "24", | ||||
|                 "minutes": "00" | ||||
|             } | ||||
|         }, | ||||
|         "tuesday": { | ||||
|             "enabled": True, | ||||
|             "start_time": "00:00", | ||||
|             "duration": { | ||||
|                 "hours": "24", | ||||
|                 "minutes": "00" | ||||
|             } | ||||
|         }, | ||||
|         "wednesday": { | ||||
|             "enabled": True, | ||||
|             "start_time": "00:00", | ||||
|             "duration": { | ||||
|                 "hours": "24", | ||||
|                 "minutes": "00" | ||||
|             } | ||||
|         }, | ||||
|         "thursday": { | ||||
|             "enabled": True, | ||||
|             "start_time": "00:00", | ||||
|             "duration": { | ||||
|                 "hours": "24", | ||||
|                 "minutes": "00" | ||||
|             } | ||||
|         }, | ||||
|         "friday": { | ||||
|             "enabled": True, | ||||
|             "start_time": "00:00", | ||||
|             "duration": { | ||||
|                 "hours": "24", | ||||
|                 "minutes": "00" | ||||
|             } | ||||
|         }, | ||||
|         "saturday": { | ||||
|             "enabled": True, | ||||
|             "start_time": "00:00", | ||||
|             "duration": { | ||||
|                 "hours": "24", | ||||
|                 "minutes": "00" | ||||
|             } | ||||
|         }, | ||||
|         "sunday": { | ||||
|             "enabled": True, | ||||
|             "start_time": "00:00", | ||||
|             "duration": { | ||||
|                 "hours": "24", | ||||
|                 "minutes": "00" | ||||
|             } | ||||
|         }, | ||||
|     }, | ||||
|     'title': None, | ||||
|     'track_ldjson_price_data': None, | ||||
|     'trim_text_whitespace': False, | ||||
|     'remove_duplicate_lines': False, | ||||
|     'trigger_text': [],  # List of text or regex to wait for until a change is detected | ||||
|     'url': '', | ||||
|     'uuid': None, | ||||
|     'webdriver_delay': None, | ||||
|     'webdriver_js_execute_code': None,  # Run before change-detection | ||||
| } | ||||
|  | ||||
| class watch_base(dict): | ||||
|     __data = {} | ||||
|     __datastore_path = None | ||||
|     __save_enabled = True | ||||
|  | ||||
|     def __init__(self, *arg, **kw): | ||||
|         self.update({ | ||||
|             # Custom notification content | ||||
|             # Re #110, so then if this is set to None, we know to use the default value instead | ||||
|             # Requires setting to None on submit if it's the same as the default | ||||
|             # Should be all None by default, so we use the system default in this case. | ||||
|             'body': None, | ||||
|             'browser_steps': [], | ||||
|             'browser_steps_last_error_step': None, | ||||
|             'check_count': 0, | ||||
|             'check_unique_lines': False,  # On change-detected, compare against all history if its something new | ||||
|             'consecutive_filter_failures': 0,  # Every time the CSS/xPath filter cannot be located, reset when all is fine. | ||||
|             'content-type': None, | ||||
|             'date_created': None, | ||||
|             'extract_text': [],  # Extract text by regex after filters | ||||
|             'extract_title_as_title': False, | ||||
|             'fetch_backend': 'system',  # plaintext, playwright etc | ||||
|             'fetch_time': 0.0, | ||||
|             'filter_failure_notification_send': strtobool(os.getenv('FILTER_FAILURE_NOTIFICATION_SEND_DEFAULT', 'True')), | ||||
|             'filter_text_added': True, | ||||
|             'filter_text_removed': True, | ||||
|             'filter_text_replaced': True, | ||||
|             'follow_price_changes': True, | ||||
|             'has_ldjson_price_data': None, | ||||
|             'headers': {},  # Extra headers to send | ||||
|             'ignore_text': [],  # List of text to ignore when calculating the comparison checksum | ||||
|             'in_stock_only': True,  # Only trigger change on going to instock from out-of-stock | ||||
|             'include_filters': [], | ||||
|             'last_checked': 0, | ||||
|             'last_error': False, | ||||
|             'last_viewed': 0,  # history key value of the last viewed via the [diff] link | ||||
|             'method': 'GET', | ||||
|             'notification_alert_count': 0, | ||||
|             'notification_body': None, | ||||
|             'notification_format': default_notification_format_for_watch, | ||||
|             'notification_muted': False, | ||||
|             'notification_screenshot': False,  # Include the latest screenshot if available and supported by the apprise URL | ||||
|             'notification_title': None, | ||||
|             'notification_urls': [],  # List of URLs to add to the notification Queue (Usually AppRise) | ||||
|             'paused': False, | ||||
|             'previous_md5': False, | ||||
|             'previous_md5_before_filters': False,  # Used for skipping changedetection entirely | ||||
|             'processor': 'text_json_diff',  # could be restock_diff or others from .processors | ||||
|             'price_change_threshold_percent': None, | ||||
|             'proxy': None,  # Preferred proxy connection | ||||
|             'remote_server_reply': None,  # From 'server' reply header | ||||
|             'sort_text_alphabetically': False, | ||||
|             'subtractive_selectors': [], | ||||
|             'tag': '',  # Old system of text name for a tag, to be removed | ||||
|             'tags': [],  # list of UUIDs to App.Tags | ||||
|             'text_should_not_be_present': [],  # Text that should not present | ||||
|             'time_between_check': {'weeks': None, 'days': None, 'hours': None, 'minutes': None, 'seconds': None}, | ||||
|             'time_between_check_use_default': True, | ||||
|             "time_schedule_limit": { | ||||
|                 "enabled": False, | ||||
|                 "monday": { | ||||
|                     "enabled": True, | ||||
|                     "start_time": "00:00", | ||||
|                     "duration": { | ||||
|                         "hours": "24", | ||||
|                         "minutes": "00" | ||||
|                     } | ||||
|                 }, | ||||
|                 "tuesday": { | ||||
|                     "enabled": True, | ||||
|                     "start_time": "00:00", | ||||
|                     "duration": { | ||||
|                         "hours": "24", | ||||
|                         "minutes": "00" | ||||
|                     } | ||||
|                 }, | ||||
|                 "wednesday": { | ||||
|                     "enabled": True, | ||||
|                     "start_time": "00:00", | ||||
|                     "duration": { | ||||
|                         "hours": "24", | ||||
|                         "minutes": "00" | ||||
|                     } | ||||
|                 }, | ||||
|                 "thursday": { | ||||
|                     "enabled": True, | ||||
|                     "start_time": "00:00", | ||||
|                     "duration": { | ||||
|                         "hours": "24", | ||||
|                         "minutes": "00" | ||||
|                     } | ||||
|                 }, | ||||
|                 "friday": { | ||||
|                     "enabled": True, | ||||
|                     "start_time": "00:00", | ||||
|                     "duration": { | ||||
|                         "hours": "24", | ||||
|                         "minutes": "00" | ||||
|                     } | ||||
|                 }, | ||||
|                 "saturday": { | ||||
|                     "enabled": True, | ||||
|                     "start_time": "00:00", | ||||
|                     "duration": { | ||||
|                         "hours": "24", | ||||
|                         "minutes": "00" | ||||
|                     } | ||||
|                 }, | ||||
|                 "sunday": { | ||||
|                     "enabled": True, | ||||
|                     "start_time": "00:00", | ||||
|                     "duration": { | ||||
|                         "hours": "24", | ||||
|                         "minutes": "00" | ||||
|                     } | ||||
|                 }, | ||||
|             }, | ||||
|             'title': None, | ||||
|             'track_ldjson_price_data': None, | ||||
|             'trim_text_whitespace': False, | ||||
|             'remove_duplicate_lines': False, | ||||
|             'trigger_text': [],  # List of text or regex to wait for until a change is detected | ||||
|             'url': '', | ||||
|             'uuid': str(uuid.uuid4()), | ||||
|             'webdriver_delay': None, | ||||
|             'webdriver_js_execute_code': None,  # Run before change-detection | ||||
|         }) | ||||
|         # Initialize internal data storage | ||||
|  | ||||
|         super(watch_base, self).__init__(*arg, **kw) | ||||
|         self.__data = deepcopy(schema) | ||||
|         self.__datastore_path = kw.pop('datastore_path', None) | ||||
|         # Initialize as empty dict but maintain dict interface | ||||
|         super(watch_base, self).__init__() | ||||
|          | ||||
|         # Update with provided data | ||||
|         if arg or kw: | ||||
|             self.update(*arg, **kw) | ||||
|  | ||||
|         if self.get('default'): | ||||
|             del self['default'] | ||||
|         # Generate UUID if needed | ||||
|         if not self.__data.get('uuid'): | ||||
|             self.__data['uuid'] = str(uuid.uuid4()) | ||||
|  | ||||
|         if self.__data.get('default'): | ||||
|             del(self.__data['default']) | ||||
|  | ||||
|     @property | ||||
|     def watch_data_dir(self): | ||||
|         # The base dir of the watch data | ||||
|         return os.path.join(self.__datastore_path, self['uuid']) if self.__datastore_path else None | ||||
|  | ||||
|     def enable_saving(self): | ||||
|         self.__save_enabled = True | ||||
|  | ||||
|     # Dictionary interface methods to use self.__data | ||||
|     def __getitem__(self, key): | ||||
|         return self.__data[key] | ||||
|      | ||||
|     def __setitem__(self, key, value): | ||||
|         self.__data[key] = value | ||||
|         self.__data['last_modified'] = time.time() | ||||
|  | ||||
|     def __delitem__(self, key): | ||||
|         del self.__data[key] | ||||
|      | ||||
|     def __contains__(self, key): | ||||
|         return key in self.__data | ||||
|      | ||||
|     def __iter__(self): | ||||
|         return iter(self.__data) | ||||
|      | ||||
|     def __len__(self): | ||||
|         return len(self.__data) | ||||
|  | ||||
|     def get(self, key, default=None): | ||||
|         return self.__data.get(key, default) | ||||
|      | ||||
|     def update(self, *args, **kwargs): | ||||
|         if args: | ||||
|             if len(args) > 1: | ||||
|                 raise TypeError("update expected at most 1 arguments, got %d" % len(args)) | ||||
|             other = dict(args[0]) | ||||
|             for key in other: | ||||
|                 self.__data[key] = other[key] | ||||
|         for key in kwargs: | ||||
|             self.__data[key] = kwargs[key] | ||||
|  | ||||
|         self.__data['last_modified'] = time.time() | ||||
|  | ||||
|     def items(self): | ||||
|         return self.__data.items() | ||||
|      | ||||
|     def keys(self): | ||||
|         return self.__data.keys() | ||||
|          | ||||
|     def values(self): | ||||
|         return self.__data.values() | ||||
|          | ||||
|     def pop(self, key, default=None): | ||||
|         return self.__data.pop(key, default) | ||||
|          | ||||
|     def popitem(self): | ||||
|         return self.__data.popitem() | ||||
|          | ||||
|     def clear(self): | ||||
|         self.__data.clear() | ||||
|         self.__data['last_modified'] = time.time() | ||||
|          | ||||
|     def get_data(self): | ||||
|         """Returns the internal data dictionary""" | ||||
|         return self.__data | ||||
|  | ||||
|     def save_data(self): | ||||
|         if self.__save_enabled: | ||||
|             if not self.__data.get('uuid'): | ||||
|                 # Might have been called when creating the watch | ||||
|                 return | ||||
|  | ||||
|             logger.debug(f"Saving watch {self['uuid']}") | ||||
|             path = os.path.join(self.__datastore_path, self.get('uuid')) | ||||
|             filepath = os.path.join(str(path), "watch.json") | ||||
|             if not os.path.exists(path): | ||||
|                 os.mkdir(path) | ||||
|  | ||||
|             try: | ||||
|                 import tempfile | ||||
|                 with tempfile.NamedTemporaryFile(mode='wb+', delete=False) as tmp: | ||||
|                     tmp.write(json.dumps(self.get_data(), indent=2).encode('utf-8')) | ||||
|                     tmp.flush() | ||||
|                     os.replace(tmp.name, filepath) | ||||
|  | ||||
|  | ||||
|             except Exception as e: | ||||
|                 logger.error(f"Error writing JSON for {self.get('uuid')}!! (JSON file save was skipped) : {str(e)}") | ||||
|   | ||||
| @@ -4,12 +4,12 @@ from changedetectionio.strtobool import strtobool | ||||
| from copy import deepcopy | ||||
| from loguru import logger | ||||
| import hashlib | ||||
| import importlib | ||||
| import inspect | ||||
| import os | ||||
| import pkgutil | ||||
| import re | ||||
|  | ||||
|  | ||||
| from .pluggy_interface import plugin_manager, hookimpl | ||||
|  | ||||
| class difference_detection_processor(): | ||||
|  | ||||
|     browser_steps = None | ||||
| @@ -172,83 +172,208 @@ class difference_detection_processor(): | ||||
|         return changed_detected, update_obj, ''.encode('utf-8') | ||||
|  | ||||
|  | ||||
| def find_sub_packages(package_name): | ||||
| def get_all_plugins_info(): | ||||
|     """ | ||||
|     Find all sub-packages within the given package. | ||||
|  | ||||
|     :param package_name: The name of the base package to scan for sub-packages. | ||||
|     :return: A list of sub-package names. | ||||
|     Get information about all registered processor plugins | ||||
|     :return: A list of dictionaries with plugin info | ||||
|     """ | ||||
|     package = importlib.import_module(package_name) | ||||
|     return [name for _, name, is_pkg in pkgutil.iter_modules(package.__path__) if is_pkg] | ||||
|     plugins_info = [] | ||||
|      | ||||
|     # Collect from all registered plugins | ||||
|     for plugin in plugin_manager.get_plugins(): | ||||
|         if hasattr(plugin, "get_processor_name") and hasattr(plugin, "get_processor_description"): | ||||
|             processor_name = plugin.get_processor_name() | ||||
|             description = plugin.get_processor_description() | ||||
|              | ||||
|             # Get version if available | ||||
|             version = "N/A" | ||||
|             if hasattr(plugin, "get_processor_version"): | ||||
|                 plugin_version = plugin.get_processor_version() | ||||
|                 if plugin_version: | ||||
|                     version = plugin_version | ||||
|              | ||||
|             if processor_name and description: | ||||
|                 plugins_info.append({ | ||||
|                     "name": processor_name, | ||||
|                     "description": description, | ||||
|                     "version": version | ||||
|                 }) | ||||
|      | ||||
|     # Fallback if no plugins registered | ||||
|     if not plugins_info: | ||||
|         plugins_info = [ | ||||
|             {"name": "text_json_diff", "description": "Webpage Text/HTML, JSON and PDF changes", "version": "1.0.0"}, | ||||
|             {"name": "restock_diff", "description": "Re-stock & Price detection for single product pages", "version": "1.0.0"} | ||||
|         ] | ||||
|      | ||||
|     return plugins_info | ||||
|  | ||||
|  | ||||
| def find_processors(): | ||||
|     """ | ||||
|     Find all subclasses of DifferenceDetectionProcessor in the specified package. | ||||
|  | ||||
|     :param package_name: The name of the package to scan for processor modules. | ||||
|     :return: A list of (module, class) tuples. | ||||
|     """ | ||||
|     package_name = "changedetectionio.processors"  # Name of the current package/module | ||||
|  | ||||
|     processors = [] | ||||
|     sub_packages = find_sub_packages(package_name) | ||||
|  | ||||
|     for sub_package in sub_packages: | ||||
|         module_name = f"{package_name}.{sub_package}.processor" | ||||
|         try: | ||||
|             module = importlib.import_module(module_name) | ||||
|  | ||||
|             # Iterate through all classes in the module | ||||
|             for name, obj in inspect.getmembers(module, inspect.isclass): | ||||
|                 if issubclass(obj, difference_detection_processor) and obj is not difference_detection_processor: | ||||
|                     processors.append((module, sub_package)) | ||||
|         except (ModuleNotFoundError, ImportError) as e: | ||||
|             logger.warning(f"Failed to import module {module_name}: {e} (find_processors())") | ||||
|  | ||||
|     return processors | ||||
|  | ||||
|  | ||||
| def get_parent_module(module): | ||||
|     module_name = module.__name__ | ||||
|     if '.' not in module_name: | ||||
|         return None  # Top-level module has no parent | ||||
|     parent_module_name = module_name.rsplit('.', 1)[0] | ||||
|     try: | ||||
|         return importlib.import_module(parent_module_name) | ||||
|     except Exception as e: | ||||
|         pass | ||||
|  | ||||
|     return False | ||||
|  | ||||
|  | ||||
|  | ||||
| def get_custom_watch_obj_for_processor(processor_name): | ||||
|     from changedetectionio.model import Watch | ||||
|     watch_class = Watch.model | ||||
|     processor_classes = find_processors() | ||||
|     custom_watch_obj = next((tpl for tpl in processor_classes if tpl[1] == processor_name), None) | ||||
|     if custom_watch_obj: | ||||
|         # Parent of .processor.py COULD have its own Watch implementation | ||||
|         parent_module = get_parent_module(custom_watch_obj[0]) | ||||
|         if hasattr(parent_module, 'Watch'): | ||||
|             watch_class = parent_module.Watch | ||||
|  | ||||
|     return watch_class | ||||
|  | ||||
|  | ||||
| def available_processors(): | ||||
| def available_processors(datastore=None): | ||||
|     """ | ||||
|     Get a list of processors by name and description for the UI elements | ||||
|     :return: A list :) | ||||
|     Filtered by enabled_plugins setting if datastore is provided | ||||
|     :return: A list of tuples (processor_name, description) | ||||
|     """ | ||||
|     plugins_info = get_all_plugins_info() | ||||
|     processor_list = [] | ||||
|  | ||||
|     for plugin in plugins_info: | ||||
|         processor_list.append((plugin["name"], plugin["description"])) | ||||
|      | ||||
|     return processor_list | ||||
|  | ||||
| def get_processor_handler(processor_name, datastore, watch_uuid): | ||||
|     """ | ||||
|     Get the processor handler for the specified processor name | ||||
|     :return: The processor handler instance | ||||
|     """ | ||||
|     # Try each plugin in turn | ||||
|     for plugin in plugin_manager.get_plugins(): | ||||
|         if hasattr(plugin, "perform_site_check"): | ||||
|             handler = plugin.perform_site_check(datastore=datastore, watch_uuid=watch_uuid) | ||||
|             if handler: | ||||
|                 return handler | ||||
|      | ||||
|     # If no plugins handled it, use the appropriate built-in processor | ||||
|     watch = datastore.data['watching'].get(watch_uuid) | ||||
|     if watch and watch.get('processor') == 'restock_diff': | ||||
|         from .restock_diff.processor import perform_site_check | ||||
|         return perform_site_check(datastore=datastore, watch_uuid=watch_uuid) | ||||
|     else: | ||||
|         # Default to text_json_diff | ||||
|         from .text_json_diff.processor import perform_site_check | ||||
|         return perform_site_check(datastore=datastore, watch_uuid=watch_uuid) | ||||
|  | ||||
| def get_form_class_for_processor(processor_name): | ||||
|     """ | ||||
|     Get the form class for the specified processor name | ||||
|     :return: The form class | ||||
|     """ | ||||
|     # Try each plugin in turn | ||||
|     for plugin in plugin_manager.get_plugins(): | ||||
|         if hasattr(plugin, "get_form_class"): | ||||
|             form_class = plugin.get_form_class(processor_name=processor_name) | ||||
|             if form_class: | ||||
|                 return form_class | ||||
|      | ||||
|     # If no plugins provided a form class, use the appropriate built-in form | ||||
|     if processor_name == 'restock_diff': | ||||
|         try: | ||||
|             from .restock_diff.forms import processor_settings_form | ||||
|             return processor_settings_form | ||||
|         except ImportError: | ||||
|             pass | ||||
|      | ||||
|     # Default to text_json_diff form | ||||
|     from changedetectionio import forms | ||||
|     return forms.processor_text_json_diff_form | ||||
|  | ||||
| def get_watch_model_for_processor(processor_name): | ||||
|     """ | ||||
|     Get the Watch model class for the specified processor name | ||||
|     :return: The Watch model class | ||||
|     """ | ||||
|  | ||||
|     processor_classes = find_processors() | ||||
|     # Try each plugin in turn | ||||
|     for plugin in plugin_manager.get_plugins(): | ||||
|         if hasattr(plugin, "get_watch_model_class"): | ||||
|             model_class = plugin.get_watch_model_class(processor_name=processor_name) | ||||
|             if model_class: | ||||
|                 return model_class | ||||
|  | ||||
|     available = [] | ||||
|     for package, processor_class in processor_classes: | ||||
|         available.append((processor_class, package.name)) | ||||
|     # Default to standard Watch model | ||||
|     from changedetectionio.model import Watch | ||||
|     return Watch.model | ||||
|  | ||||
|     return available | ||||
| # Define plugin implementations for the built-in processors | ||||
| class TextJsonDiffPlugin: | ||||
|     @hookimpl | ||||
|     def get_processor_name(self): | ||||
|         return "text_json_diff" | ||||
|  | ||||
|     @hookimpl | ||||
|     def get_processor_description(self): | ||||
|         from .text_json_diff.processor import name | ||||
|         return name | ||||
|          | ||||
|     @hookimpl | ||||
|     def get_processor_version(self): | ||||
|         from changedetectionio import __version__ | ||||
|         return __version__ | ||||
|          | ||||
|     @hookimpl | ||||
|     def get_processor_ui_tag(self): | ||||
|         from .text_json_diff.processor import UI_tag | ||||
|         return UI_tag | ||||
|  | ||||
|     @hookimpl | ||||
|     def perform_site_check(self, datastore, watch_uuid): | ||||
|         watch = datastore.data['watching'].get(watch_uuid) | ||||
|         if watch and watch.get('processor', 'text_json_diff') == 'text_json_diff': | ||||
|             from .text_json_diff.processor import perform_site_check | ||||
|             return perform_site_check(datastore=datastore, watch_uuid=watch_uuid) | ||||
|         return None | ||||
|  | ||||
|     @hookimpl | ||||
|     def get_form_class(self, processor_name): | ||||
|         if processor_name == 'text_json_diff': | ||||
|             from changedetectionio import forms | ||||
|             return forms.processor_text_json_diff_form | ||||
|         return None | ||||
|  | ||||
|     @hookimpl | ||||
|     def get_watch_model_class(self, processor_name): | ||||
|         if processor_name == 'text_json_diff': | ||||
|             from changedetectionio.model import Watch | ||||
|             return Watch.model | ||||
|         return None | ||||
|  | ||||
| class RestockDiffPlugin: | ||||
|     @hookimpl | ||||
|     def get_processor_name(self): | ||||
|         return "restock_diff" | ||||
|  | ||||
|     @hookimpl | ||||
|     def get_processor_description(self): | ||||
|         from .restock_diff.processor import name | ||||
|         return name | ||||
|          | ||||
|     @hookimpl | ||||
|     def get_processor_version(self): | ||||
|         from changedetectionio import __version__ | ||||
|         return __version__ | ||||
|          | ||||
|     @hookimpl | ||||
|     def get_processor_ui_tag(self): | ||||
|         from .restock_diff.processor import UI_tag | ||||
|         return UI_tag | ||||
|  | ||||
|     @hookimpl | ||||
|     def perform_site_check(self, datastore, watch_uuid): | ||||
|         watch = datastore.data['watching'].get(watch_uuid) | ||||
|         if watch and watch.get('processor') == 'restock_diff': | ||||
|             from .restock_diff.processor import perform_site_check | ||||
|             return perform_site_check(datastore=datastore, watch_uuid=watch_uuid) | ||||
|         return None | ||||
|  | ||||
|     @hookimpl | ||||
|     def get_form_class(self, processor_name): | ||||
|         if processor_name == 'restock_diff': | ||||
|             try: | ||||
|                 from .restock_diff.forms import processor_settings_form | ||||
|                 return processor_settings_form | ||||
|             except ImportError: | ||||
|                 pass | ||||
|         return None | ||||
|  | ||||
|     @hookimpl | ||||
|     def get_watch_model_class(self, processor_name): | ||||
|         if processor_name == 'restock_diff': | ||||
|             from . import restock_diff | ||||
|             return restock_diff.Watch | ||||
|         return None | ||||
|  | ||||
|  | ||||
| # Register the built-in processor plugins | ||||
| plugin_manager.register(TextJsonDiffPlugin()) | ||||
| plugin_manager.register(RestockDiffPlugin()) | ||||
|   | ||||
							
								
								
									
										5
									
								
								changedetectionio/processors/constants.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								changedetectionio/processors/constants.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | ||||
| # Common constants used across processors | ||||
|  | ||||
| # Price data tracking constants | ||||
| PRICE_DATA_TRACK_ACCEPT = 'accepted' | ||||
| PRICE_DATA_TRACK_REJECT = 'rejected' | ||||
							
								
								
									
										85
									
								
								changedetectionio/processors/pluggy_interface.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										85
									
								
								changedetectionio/processors/pluggy_interface.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,85 @@ | ||||
| import pluggy | ||||
| from loguru import logger | ||||
|  | ||||
| # Ensure that the namespace in HookspecMarker matches PluginManager | ||||
| PLUGIN_NAMESPACE = "changedetectionio_processors" | ||||
|  | ||||
| hookspec = pluggy.HookspecMarker(PLUGIN_NAMESPACE) | ||||
| hookimpl = pluggy.HookimplMarker(PLUGIN_NAMESPACE) | ||||
|  | ||||
| UI_tags = {} | ||||
|  | ||||
| class ProcessorSpec: | ||||
|     """Hook specifications for difference detection processors.""" | ||||
|  | ||||
|     @hookspec | ||||
|     def get_processor_name(): | ||||
|         """Return the processor name for selection in the UI.""" | ||||
|         pass | ||||
|  | ||||
|     @hookspec | ||||
|     def get_processor_description(): | ||||
|         """Return a human-readable description of the processor.""" | ||||
|         pass | ||||
|          | ||||
|     @hookspec | ||||
|     def get_processor_version(): | ||||
|         """Return the processor plugin version.""" | ||||
|         pass | ||||
|      | ||||
|     @hookspec | ||||
|     def get_processor_ui_tag(): | ||||
|         """Return the UI tag for the processor (used for categorization in UI).""" | ||||
|         pass | ||||
|      | ||||
|     @hookspec | ||||
|     def perform_site_check(datastore, watch_uuid): | ||||
|         """Return the processor handler class or None if not applicable. | ||||
|          | ||||
|         Each plugin should check if it's the right processor for this watch | ||||
|         and return None if it's not. | ||||
|          | ||||
|         Should return an instance of a class that implements: | ||||
|         - call_browser(preferred_proxy_id=None): Fetch the content | ||||
|         - run_changedetection(watch): Analyze for changes and return tuple of (changed_detected, update_obj, contents) | ||||
|         """ | ||||
|         pass | ||||
|      | ||||
|     @hookspec | ||||
|     def get_form_class(processor_name): | ||||
|         """Return the WTForms form class for the processor settings or None if not applicable. | ||||
|          | ||||
|         Each plugin should check if it's the right processor and return None if not. | ||||
|         """ | ||||
|         pass | ||||
|  | ||||
|     @hookspec | ||||
|     def get_watch_model_class(processor_name): | ||||
|         """Return a custom Watch model class if needed or None if not applicable. | ||||
|          | ||||
|         Each plugin should check if it's the right processor and return None if not. | ||||
|         """ | ||||
|         pass | ||||
|  | ||||
| # Set up Pluggy Plugin Manager | ||||
| plugin_manager = pluggy.PluginManager(PLUGIN_NAMESPACE) | ||||
|  | ||||
| # Register hookspecs | ||||
| plugin_manager.add_hookspecs(ProcessorSpec) | ||||
|  | ||||
| # Initialize by loading plugins and building UI_tags dictionary | ||||
| try: | ||||
|     # Discover installed plugins from external packages (if any) | ||||
|     plugin_manager.load_setuptools_entrypoints(PLUGIN_NAMESPACE) | ||||
|     logger.info(f"Loaded plugins: {plugin_manager.get_plugins()}") | ||||
|      | ||||
|     # Build UI_tags dictionary from all plugins | ||||
|     for plugin in plugin_manager.get_plugins(): | ||||
|         if hasattr(plugin, "get_processor_name") and hasattr(plugin, "get_processor_ui_tag"): | ||||
|             plugin_name = plugin.get_processor_name() | ||||
|             ui_tag = plugin.get_processor_ui_tag() | ||||
|             if plugin_name and ui_tag: | ||||
|                 UI_tags[plugin_name] = ui_tag | ||||
|                 logger.info(f"Found UI tag for plugin {plugin_name}: {ui_tag}") | ||||
| except Exception as e: | ||||
|     logger.critical(f"Error loading plugins: {str(e)}") | ||||
| @@ -1,5 +1,4 @@ | ||||
|  | ||||
| from babel.numbers import parse_decimal | ||||
| from changedetectionio.model.Watch import model as BaseWatch | ||||
| from typing import Union | ||||
| import re | ||||
| @@ -7,6 +6,7 @@ import re | ||||
| class Restock(dict): | ||||
|  | ||||
|     def parse_currency(self, raw_value: str) -> Union[float, None]: | ||||
|         from babel.numbers import parse_decimal | ||||
|         # Clean and standardize the value (ie 1,400.00 should be 1400.00), even better would be store the whole thing as an integer. | ||||
|         standardized_value = raw_value | ||||
|  | ||||
| @@ -56,14 +56,19 @@ class Restock(dict): | ||||
|         super().__setitem__(key, value) | ||||
|  | ||||
| class Watch(BaseWatch): | ||||
|  | ||||
|     def load_extra_vars(self): | ||||
|         # something from disk? | ||||
|  | ||||
|     def __init__(self, *arg, **kw): | ||||
|         super().__init__(*arg, **kw) | ||||
|         # Restock Obj helps with the state of the situation | ||||
|         self['restock'] = Restock(kw['default']['restock']) if kw.get('default') and kw['default'].get('restock') else Restock() | ||||
|  | ||||
|         self['restock_settings'] = kw['default']['restock_settings'] if kw.get('default',{}).get('restock_settings') else { | ||||
|             'follow_price_changes': True, | ||||
|             'in_stock_processing' : 'in_stock_only' | ||||
|         } #@todo update | ||||
|         } | ||||
|  | ||||
|     def clear_watch(self): | ||||
|         super().clear_watch() | ||||
|   | ||||
| @@ -9,6 +9,7 @@ import time | ||||
| urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) | ||||
| name = 'Re-stock & Price detection for single product pages' | ||||
| description = 'Detects if the product goes back to in-stock' | ||||
| UI_tag = "Restock" | ||||
|  | ||||
| class UnableToExtractRestockData(Exception): | ||||
|     def __init__(self, status_code): | ||||
| @@ -152,7 +153,8 @@ class perform_site_check(difference_detection_processor): | ||||
|  | ||||
|         # Unset any existing notification error | ||||
|         update_obj = {'last_notification_error': False, 'last_error': False, 'restock':  Restock()} | ||||
|  | ||||
|         if not 'restock_settings' in watch.keys(): | ||||
|             raise Exception("Restock settings not found in watch.") | ||||
|         self.screenshot = self.fetcher.screenshot | ||||
|         self.xpath_data = self.fetcher.xpath_data | ||||
|  | ||||
|   | ||||
| @@ -10,13 +10,14 @@ from changedetectionio.conditions import execute_ruleset_against_all_plugins | ||||
| 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 import html_tools, content_fetchers | ||||
| from changedetectionio.blueprint.price_data_follower import PRICE_DATA_TRACK_ACCEPT, PRICE_DATA_TRACK_REJECT | ||||
| from changedetectionio.processors.constants import PRICE_DATA_TRACK_ACCEPT, PRICE_DATA_TRACK_REJECT | ||||
| from loguru import logger | ||||
|  | ||||
| urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) | ||||
|  | ||||
| name = 'Webpage Text/HTML, JSON and PDF changes' | ||||
| description = 'Detects all text changes where possible' | ||||
| UI_tag = "Text Diff" | ||||
|  | ||||
| json_filter_prefixes = ['json:', 'jq:', 'jqraw:'] | ||||
|  | ||||
|   | ||||
| @@ -1,4 +1,22 @@ | ||||
| (function ($) { | ||||
|     // Initialize plugin management UI when the DOM is ready | ||||
|     $(document).ready(function() { | ||||
|         // Add event handlers for plugin checkboxes | ||||
|         $("#plugins-table input[type='checkbox']").on('change', function() { | ||||
|             const isEnabled = $(this).is(':checked'); | ||||
|              | ||||
|             // For visual feedback, fade the row when disabled | ||||
|             if (isEnabled) { | ||||
|                 $(this).closest('tr').removeClass('disabled-plugin'); | ||||
|             } else { | ||||
|                 $(this).closest('tr').addClass('disabled-plugin'); | ||||
|             } | ||||
|              | ||||
|             const pluginName = $(this).closest('tr').find('td:nth-child(2)').text().trim(); | ||||
|             console.log(`Plugin ${pluginName} ${isEnabled ? 'enabled' : 'disabled'}`); | ||||
|         }); | ||||
|     }); | ||||
|      | ||||
|     /** | ||||
|      * debounce | ||||
|      * @param {integer} milliseconds This param indicates the number of milliseconds | ||||
|   | ||||
| @@ -6,7 +6,7 @@ from flask import ( | ||||
|  | ||||
| from .html_tools import TRANSLATE_WHITESPACE_TABLE | ||||
| from . model import App, Watch | ||||
| from copy import deepcopy, copy | ||||
| from copy import deepcopy | ||||
| from os import path, unlink | ||||
| from threading import Lock | ||||
| import json | ||||
| @@ -17,9 +17,9 @@ import threading | ||||
| import time | ||||
| import uuid as uuid_builder | ||||
| from loguru import logger | ||||
| from deepmerge import always_merger | ||||
|  | ||||
| from .processors import get_custom_watch_obj_for_processor | ||||
| from .processors.restock_diff import Restock | ||||
| from .processors import get_watch_model_for_processor | ||||
|  | ||||
| # Because the server will run as a daemon and wont know the URL for notification links when firing off a notification | ||||
| BASE_URL_NOT_SET_TEXT = '("Base URL" not set - see settings - notifications)' | ||||
| @@ -31,11 +31,6 @@ dictfilt = lambda x, y: dict([ (i,x[i]) for i in x if i in set(y) ]) | ||||
| # https://stackoverflow.com/questions/6190468/how-to-trigger-function-on-value-change | ||||
| class ChangeDetectionStore: | ||||
|     lock = Lock() | ||||
|     # For general updates/writes that can wait a few seconds | ||||
|     needs_write = False | ||||
|  | ||||
|     # For when we edit, we should write to disk | ||||
|     needs_write_urgent = False | ||||
|  | ||||
|     __version_check = True | ||||
|  | ||||
| @@ -46,12 +41,9 @@ class ChangeDetectionStore: | ||||
|         self.datastore_path = datastore_path | ||||
|         self.json_store_path = "{}/url-watches.json".format(self.datastore_path) | ||||
|         logger.info(f"Datastore path is '{self.json_store_path}'") | ||||
|         self.needs_write = False | ||||
|  | ||||
|         self.start_time = time.time() | ||||
|         self.stop_thread = False | ||||
|         # Base definition for all watchers | ||||
|         # deepcopy part of #569 - not sure why its needed exactly | ||||
|         self.generic_definition = deepcopy(Watch.model(datastore_path = datastore_path, default={})) | ||||
|  | ||||
|         if path.isfile('changedetectionio/source.txt'): | ||||
|             with open('changedetectionio/source.txt') as f: | ||||
| @@ -59,38 +51,30 @@ class ChangeDetectionStore: | ||||
|                 # So when someone gives us a backup file to examine, we know exactly what code they were running. | ||||
|                 self.__data['build_sha'] = f.read() | ||||
|  | ||||
|         self.generic_definition = deepcopy(Watch.model(datastore_path = datastore_path, default={})) | ||||
|  | ||||
|         try: | ||||
|             # @todo retest with ", encoding='utf-8'" | ||||
|             with open(self.json_store_path) as json_file: | ||||
|                 from_disk = json.load(json_file) | ||||
|             import os | ||||
|             # First load global settings from the main JSON file if it exists | ||||
|             if os.path.isfile(self.json_store_path): | ||||
|                 with open(self.json_store_path) as json_file: | ||||
|                     from_disk = json.load(json_file) | ||||
|                      | ||||
|                     # Load app_guid and settings from the main JSON file | ||||
|                     if 'app_guid' in from_disk: | ||||
|                         self.__data['app_guid'] = from_disk['app_guid'] | ||||
|      | ||||
|                     if 'settings' in from_disk: | ||||
|                         if 'headers' in from_disk['settings']: | ||||
|                             self.__data['settings']['headers'].update(from_disk['settings']['headers']) | ||||
|      | ||||
|                         if 'requests' in from_disk['settings']: | ||||
|                             self.__data['settings']['requests'].update(from_disk['settings']['requests']) | ||||
|      | ||||
|                         if 'application' in from_disk['settings']: | ||||
|                             self.__data['settings']['application'].update(from_disk['settings']['application']) | ||||
|  | ||||
|                 # @todo isnt there a way todo this dict.update recursively? | ||||
|                 # Problem here is if the one on the disk is missing a sub-struct, it wont be present anymore. | ||||
|                 if 'watching' in from_disk: | ||||
|                     self.__data['watching'].update(from_disk['watching']) | ||||
|  | ||||
|                 if 'app_guid' in from_disk: | ||||
|                     self.__data['app_guid'] = from_disk['app_guid'] | ||||
|  | ||||
|                 if 'settings' in from_disk: | ||||
|                     if 'headers' in from_disk['settings']: | ||||
|                         self.__data['settings']['headers'].update(from_disk['settings']['headers']) | ||||
|  | ||||
|                     if 'requests' in from_disk['settings']: | ||||
|                         self.__data['settings']['requests'].update(from_disk['settings']['requests']) | ||||
|  | ||||
|                     if 'application' in from_disk['settings']: | ||||
|                         self.__data['settings']['application'].update(from_disk['settings']['application']) | ||||
|  | ||||
|                 # Convert each existing watch back to the Watch.model object | ||||
|                 for uuid, watch in self.__data['watching'].items(): | ||||
|                     self.__data['watching'][uuid] = self.rehydrate_entity(uuid, watch) | ||||
|                     logger.info(f"Watching: {uuid} {watch['url']}") | ||||
|  | ||||
|                 # And for Tags also, should be Restock type because it has extra settings | ||||
|                 for uuid, tag in self.__data['settings']['application']['tags'].items(): | ||||
|                     self.__data['settings']['application']['tags'][uuid] = self.rehydrate_entity(uuid, tag, processor_override='restock_diff') | ||||
|                     logger.info(f"Tag: {uuid} {tag['title']}") | ||||
|  | ||||
|         # First time ran, Create the datastore. | ||||
|         except (FileNotFoundError): | ||||
| @@ -109,6 +93,8 @@ class ChangeDetectionStore: | ||||
|  | ||||
|         else: | ||||
|             # Bump the update version by running updates | ||||
|             self.scan_load_watches() | ||||
|             self.scan_load_tags() | ||||
|             self.run_updates() | ||||
|  | ||||
|         self.__data['version_tag'] = version_tag | ||||
| @@ -140,53 +126,93 @@ class ChangeDetectionStore: | ||||
|             secret = secrets.token_hex(16) | ||||
|             self.__data['settings']['application']['api_access_token'] = secret | ||||
|  | ||||
|         self.needs_write = True | ||||
|     def scan_load_watches(self): | ||||
|  | ||||
|         # Finally start the thread that will manage periodic data saves to JSON | ||||
|         save_data_thread = threading.Thread(target=self.save_datastore).start() | ||||
|         # Now scan for individual watch.json files in the datastore directory | ||||
|         import pathlib | ||||
|         watch_jsons = list(pathlib.Path(self.datastore_path).rglob("*/watch.json")) | ||||
|  | ||||
|     def rehydrate_entity(self, uuid, entity, processor_override=None): | ||||
|         """Set the dict back to the dict Watch object""" | ||||
|         entity['uuid'] = uuid | ||||
|         for watch_file in watch_jsons: | ||||
|             # Extract UUID from the directory name (parent directory of watch.json) | ||||
|             uuid = watch_file.parent.name | ||||
|  | ||||
|         if processor_override: | ||||
|             watch_class = get_custom_watch_obj_for_processor(processor_override) | ||||
|             entity['processor']=processor_override | ||||
|         else: | ||||
|             watch_class = get_custom_watch_obj_for_processor(entity.get('processor')) | ||||
|             try: | ||||
|                 with open(watch_file, 'r') as f: | ||||
|                     watch_data = json.load(f) | ||||
|                     # Create a Watch object and add it to the datastore | ||||
|                     self.__data['watching'][uuid] = self.rehydrate_entity(default_dict=watch_data) | ||||
|                     logger.info(f"Watching: {uuid} {watch_data.get('url')}") | ||||
|  | ||||
|         if entity.get('uuid') != 'text_json_diff': | ||||
|             logger.trace(f"Loading Watch object '{watch_class.__module__}.{watch_class.__name__}' for UUID {uuid}") | ||||
|             except Exception as e: | ||||
|                 logger.error(f"Error loading watch from {watch_file}: {str(e)}") | ||||
|                 continue | ||||
|         logger.debug(f"{len(self.__data['watching'])} watches loaded.") | ||||
|  | ||||
|         entity = watch_class(datastore_path=self.datastore_path, default=entity) | ||||
|     def scan_load_tags(self): | ||||
|         import pathlib | ||||
|         # Now scan for individual tag.json files in the tags directory | ||||
|         tags_path = os.path.join(self.datastore_path, 'tags') | ||||
|         if os.path.exists(tags_path): | ||||
|             tag_jsons = list(pathlib.Path(tags_path).rglob("*.json")) | ||||
|  | ||||
|             for tag_file in tag_jsons: | ||||
|                 # Extract UUID from the directory name (parent directory of tag.json) | ||||
|  | ||||
|                 try: | ||||
|                     with open(tag_file, 'r') as f: | ||||
|                         tag_data = json.load(f) | ||||
|                         uuid = str(tag_file).replace('.json', '') | ||||
|                         tag_data['uuid'] = uuid | ||||
|                         # Create a Tag object and add it to the datastore | ||||
|                         self.__data['settings']['application']['tags'][uuid] = self.rehydrate_entity( | ||||
|                             default_dict=tag_data, | ||||
|                             processor_override='restock_diff' | ||||
|                         ) | ||||
|                         logger.info(f"Tag: {uuid} {tag_data.get('title', 'No title found')}") | ||||
|                 except Exception as e: | ||||
|                     logger.error(f"Error loading tag from {tag_file}: {str(e)}") | ||||
|                     continue | ||||
|         logger.debug(f"{len(self.__data['settings']['application']['tags'])} tags loaded.") | ||||
|  | ||||
|     def rehydrate_entity(self, default_dict: dict, processor_override=None): | ||||
|  | ||||
|         if not processor_override and default_dict.get('processor'): | ||||
|             processor_override = default_dict.get('processor') | ||||
|         if not processor_override: | ||||
|             processor_override = 'text_json_diff' | ||||
|         watch_class = get_watch_model_for_processor(processor_override) | ||||
|         default_dict['processor'] = processor_override | ||||
|         entity = watch_class(datastore_path=self.datastore_path, default=default_dict) | ||||
|         entity.enable_saving() | ||||
|         return entity | ||||
|  | ||||
|     def set_last_viewed(self, uuid, timestamp): | ||||
|         logger.debug(f"Setting watch UUID: {uuid} last viewed to {int(timestamp)}") | ||||
|         self.data['watching'][uuid].update({'last_viewed': int(timestamp)}) | ||||
|         self.needs_write = True | ||||
|         self.data['watching'][uuid].save_data() | ||||
|  | ||||
|     def remove_password(self): | ||||
|         self.__data['settings']['application']['password'] = False | ||||
|         self.needs_write = True | ||||
|         self.save_settings() | ||||
|  | ||||
|     def update_watch(self, uuid, update_obj): | ||||
|  | ||||
|         """ | ||||
|         Update a watch with new values using the deepmerge library. | ||||
|         """ | ||||
|         # It's possible that the watch could be deleted before update | ||||
|         if not self.__data['watching'].get(uuid): | ||||
|         if not uuid in self.data['watching'].keys() or update_obj is None: | ||||
|             return | ||||
|  | ||||
|         with self.lock: | ||||
|         # In python 3.9 we have the |= dict operator, but that still will lose data on nested structures... | ||||
|         for dict_key, d in self.generic_definition.items(): | ||||
|             if isinstance(d, dict): | ||||
|                 if update_obj is not None and dict_key in update_obj: | ||||
|                     self.__data['watching'][uuid][dict_key].update(update_obj[dict_key]) | ||||
|                     del (update_obj[dict_key]) | ||||
|  | ||||
|             # In python 3.9 we have the |= dict operator, but that still will lose data on nested structures... | ||||
|             for dict_key, d in self.generic_definition.items(): | ||||
|                 if isinstance(d, dict): | ||||
|                     if update_obj is not None and dict_key in update_obj: | ||||
|                         self.__data['watching'][uuid][dict_key].update(update_obj[dict_key]) | ||||
|                         del (update_obj[dict_key]) | ||||
|         self.__data['watching'][uuid].update(update_obj) | ||||
|         self.__data['watching'][uuid].save_data() | ||||
|  | ||||
|             self.__data['watching'][uuid].update(update_obj) | ||||
|         self.needs_write = True | ||||
|  | ||||
|     @property | ||||
|     def threshold_seconds(self): | ||||
| @@ -246,8 +272,6 @@ class ChangeDetectionStore: | ||||
|                     shutil.rmtree(path) | ||||
|                 del self.data['watching'][uuid] | ||||
|  | ||||
|         self.needs_write_urgent = True | ||||
|  | ||||
|     # Clone a watch by UUID | ||||
|     def clone(self, uuid): | ||||
|         url = self.data['watching'][uuid].get('url') | ||||
| @@ -267,7 +291,6 @@ class ChangeDetectionStore: | ||||
|     # Remove a watchs data but keep the entry (URL etc) | ||||
|     def clear_watch_history(self, uuid): | ||||
|         self.__data['watching'][uuid].clear_watch() | ||||
|         self.needs_write_urgent = True | ||||
|  | ||||
|     def add_watch(self, url, tag='', extras=None, tag_uuids=None, write_to_disk_now=True): | ||||
|         import requests | ||||
| @@ -345,7 +368,7 @@ class ChangeDetectionStore: | ||||
|             apply_extras['tags'] = list(set(apply_extras.get('tags'))) | ||||
|  | ||||
|         # If the processor also has its own Watch implementation | ||||
|         watch_class = get_custom_watch_obj_for_processor(apply_extras.get('processor')) | ||||
|         watch_class = get_watch_model_for_processor(apply_extras.get('processor')) | ||||
|         new_watch = watch_class(datastore_path=self.datastore_path, url=url) | ||||
|  | ||||
|         new_uuid = new_watch.get('uuid') | ||||
| @@ -358,15 +381,11 @@ class ChangeDetectionStore: | ||||
|  | ||||
|         if not apply_extras.get('date_created'): | ||||
|             apply_extras['date_created'] = int(time.time()) | ||||
|  | ||||
|         new_watch.update(apply_extras) | ||||
|         new_watch.ensure_data_dir_exists() | ||||
|         new_watch.update(apply_extras) | ||||
|  | ||||
|         self.__data['watching'][new_uuid] = new_watch | ||||
|  | ||||
|  | ||||
|         if write_to_disk_now: | ||||
|             self.sync_to_json() | ||||
|  | ||||
|         self.__data['watching'][new_uuid].save_data() | ||||
|         logger.debug(f"Added '{url}'") | ||||
|  | ||||
|         return new_uuid | ||||
| @@ -380,58 +399,22 @@ class ChangeDetectionStore: | ||||
|  | ||||
|         return False | ||||
|  | ||||
|     def sync_to_json(self): | ||||
|         logger.info("Saving JSON..") | ||||
|     def save_settings(self): | ||||
|         logger.info("Saving application settings...") | ||||
|         try: | ||||
|             data = deepcopy(self.__data) | ||||
|         except RuntimeError as e: | ||||
|             # Try again in 15 seconds | ||||
|             time.sleep(15) | ||||
|             logger.error(f"! Data changed when writing to JSON, trying again.. {str(e)}") | ||||
|             self.sync_to_json() | ||||
|             return | ||||
|         else: | ||||
|  | ||||
|             try: | ||||
|                 # Re #286  - First write to a temp file, then confirm it looks OK and rename it | ||||
|                 # This is a fairly basic strategy to deal with the case that the file is corrupted, | ||||
|                 # system was out of memory, out of RAM etc | ||||
|                 with open(self.json_store_path+".tmp", 'w') as json_file: | ||||
|                     json.dump(data, json_file, indent=4) | ||||
|                 os.replace(self.json_store_path+".tmp", self.json_store_path) | ||||
|             except Exception as e: | ||||
|                 logger.error(f"Error writing JSON!! (Main JSON file save was skipped) : {str(e)}") | ||||
|  | ||||
|             self.needs_write = False | ||||
|             self.needs_write_urgent = False | ||||
|  | ||||
|     # Thread runner, this helps with thread/write issues when there are many operations that want to update the JSON | ||||
|     # by just running periodically in one thread, according to python, dict updates are threadsafe. | ||||
|     def save_datastore(self): | ||||
|  | ||||
|         while True: | ||||
|             if self.stop_thread: | ||||
|                 # Suppressing "Logging error in Loguru Handler #0" during CICD. | ||||
|                 # Not a meaningful difference for a real use-case just for CICD. | ||||
|                 # the side effect is a "Shutting down datastore thread" message | ||||
|                 # at the end of each test. | ||||
|                 # But still more looking better. | ||||
|                 import sys | ||||
|                 logger.remove() | ||||
|                 logger.add(sys.stderr) | ||||
|  | ||||
|                 logger.critical("Shutting down datastore thread") | ||||
|                 return | ||||
|  | ||||
|             if self.needs_write or self.needs_write_urgent: | ||||
|                 self.sync_to_json() | ||||
|  | ||||
|             # Once per minute is enough, more and it can cause high CPU usage | ||||
|             # better here is to use something like self.app.config.exit.wait(1), but we cant get to 'app' from here | ||||
|             for i in range(120): | ||||
|                 time.sleep(0.5) | ||||
|                 if self.stop_thread or self.needs_write_urgent: | ||||
|                     break | ||||
|             # Only save app settings, not the watches or tags (they're saved individually) | ||||
|             data = {'settings': self.__data.get('settings')} | ||||
|             #data = deepcopy(self.__data) | ||||
|              | ||||
|             # Remove the watches from the main JSON file | ||||
|             if 'watching' in data: | ||||
|                 del data['watching'] | ||||
|                  | ||||
|             # Remove the tags from the main JSON file since they're saved individually now | ||||
| #            if 'settings' in data and 'application' in data['settings'] and 'tags' in data['settings']['application']: | ||||
| #                del data['settings']['application']['tags'] | ||||
|         except Exception as e: | ||||
|             x=1 | ||||
|  | ||||
|     # Go through the datastore path and remove any snapshots that are not mentioned in the index | ||||
|     # This usually is not used, but can be handy. | ||||
| @@ -585,16 +568,17 @@ class ChangeDetectionStore: | ||||
|  | ||||
|         # Eventually almost everything todo with a watch will apply as a Tag | ||||
|         # So we use the same model as a Watch | ||||
|         with self.lock: | ||||
|             from .model import Tag | ||||
|             new_tag = Tag.model(datastore_path=self.datastore_path, default={ | ||||
|                 'title': name.strip(), | ||||
|                 'date_created': int(time.time()) | ||||
|             }) | ||||
|         from .model import Tag | ||||
|         new_tag = Tag.model(datastore_path=self.datastore_path, default={ | ||||
|             'title': name.strip(), | ||||
|             'date_created': int(time.time()) | ||||
|         }) | ||||
|  | ||||
|             new_uuid = new_tag.get('uuid') | ||||
|         new_uuid = new_tag.get('uuid') | ||||
|  | ||||
|         self.__data['settings']['application']['tags'][new_uuid] = new_tag | ||||
|         self.__data['settings']['application']['tags'][new_uuid].save_data() | ||||
|  | ||||
|             self.__data['settings']['application']['tags'][new_uuid] = new_tag | ||||
|  | ||||
|         return new_uuid | ||||
|  | ||||
| @@ -890,6 +874,7 @@ class ChangeDetectionStore: | ||||
|  | ||||
|     # Migrate old 'in_stock' values to the new Restock | ||||
|     def update_17(self): | ||||
|         from .processors.restock_diff import Restock | ||||
|         for uuid, watch in self.data['watching'].items(): | ||||
|             if 'in_stock' in watch: | ||||
|                 watch['restock'] = Restock({'in_stock': watch.get('in_stock')}) | ||||
|   | ||||
| @@ -1,3 +1,7 @@ | ||||
| {% macro hasattr(obj, name) -%} | ||||
|     {{ obj is defined and name in obj.__dict__ }} | ||||
| {%- endmacro %} | ||||
|  | ||||
| {% macro render_field(field) %} | ||||
|   <div {% if field.errors %} class="error" {% endif %}>{{ field.label }}</div> | ||||
|   <div {% if field.errors %} class="error" {% endif %}>{{ field(**kwargs)|safe }} | ||||
|   | ||||
| @@ -1,310 +0,0 @@ | ||||
| {% extends 'base.html' %} | ||||
|  | ||||
| {% block content %} | ||||
| {% from '_helpers.html' import render_field, render_checkbox_field, render_button, render_time_schedule_form %} | ||||
| {% from '_common_fields.html' import render_common_settings_form %} | ||||
| <script> | ||||
|     const notification_base_url="{{url_for('ui.ui_notification.ajax_callback_send_notification_test', mode="global-settings")}}"; | ||||
| {% if emailprefix %} | ||||
|     const email_notification_prefix=JSON.parse('{{emailprefix|tojson}}'); | ||||
| {% endif %} | ||||
| </script> | ||||
| <script src="{{url_for('static_content', group='js', filename='tabs.js')}}" defer></script> | ||||
| <script src="{{url_for('static_content', group='js', filename='plugins.js')}}" defer></script> | ||||
| <script src="{{url_for('static_content', group='js', filename='notifications.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='scheduler.js')}}" defer></script> | ||||
| <div class="edit-form"> | ||||
|     <div class="tabs collapsable"> | ||||
|         <ul> | ||||
|             <li class="tab" id=""><a href="#general">General</a></li> | ||||
|             <li class="tab"><a href="#notifications">Notifications</a></li> | ||||
|             <li class="tab"><a href="#fetching">Fetching</a></li> | ||||
|             <li class="tab"><a href="#filters">Global Filters</a></li> | ||||
|             <li class="tab"><a href="#api">API</a></li> | ||||
|             <li class="tab"><a href="#timedate">Time & Date</a></li> | ||||
|             <li class="tab"><a href="#proxies">CAPTCHA & Proxies</a></li> | ||||
|         </ul> | ||||
|     </div> | ||||
|     <div class="box-wrap inner"> | ||||
|         <form class="pure-form pure-form-stacked settings" action="{{url_for('settings.settings_page')}}" method="POST"> | ||||
|             <input type="hidden" name="csrf_token" value="{{ csrf_token() }}" > | ||||
|             <div class="tab-pane-inner" id="general"> | ||||
|                 <fieldset> | ||||
|                     <div class="pure-control-group"> | ||||
|                         {{ render_field(form.requests.form.time_between_check, class="time-check-widget") }} | ||||
|                         <span class="pure-form-message-inline">Default recheck time for all watches, current system minimum is <i>{{min_system_recheck_seconds}}</i> seconds (<a href="https://github.com/dgtlmoon/changedetection.io/wiki/Misc-system-settings#enviroment-variables">more info</a>).</span> | ||||
|                             <div id="time-between-check-schedule"> | ||||
|                                 <!-- Start Time and End Time --> | ||||
|                                 <div id="limit-between-time"> | ||||
|                                     {{ render_time_schedule_form(form.requests, available_timezones, timezone_default_config) }} | ||||
|                                 </div> | ||||
|                         </div> | ||||
|                     </div> | ||||
|                     <div class="pure-control-group"> | ||||
|                         {{ render_field(form.requests.form.jitter_seconds, class="jitter_seconds") }} | ||||
|                         <span class="pure-form-message-inline">Example - 3 seconds random jitter could trigger up to 3 seconds earlier or up to 3 seconds later</span> | ||||
|                     </div> | ||||
|                     <div class="pure-control-group"> | ||||
|                         {{ render_field(form.application.form.filter_failure_notification_threshold_attempts, class="filter_failure_notification_threshold_attempts") }} | ||||
|                         <span class="pure-form-message-inline">After this many consecutive times that the CSS/xPath filter is missing, send a notification | ||||
|                             <br> | ||||
|                         Set to <strong>0</strong> to disable | ||||
|                         </span> | ||||
|                     </div> | ||||
|                     <div class="pure-control-group"> | ||||
|                         {% if not hide_remove_pass %} | ||||
|                             {% if current_user.is_authenticated %} | ||||
|                                 {{ render_button(form.application.form.removepassword_button) }} | ||||
|                             {% else %} | ||||
|                             {{ render_field(form.application.form.password) }} | ||||
|                             <span class="pure-form-message-inline">Password protection for your changedetection.io application.</span> | ||||
|                             {% endif %} | ||||
|                         {% else %} | ||||
|                             <span class="pure-form-message-inline">Password is locked.</span> | ||||
|                         {% endif %} | ||||
|                     </div> | ||||
|  | ||||
|                     <div class="pure-control-group"> | ||||
|                         {{ render_checkbox_field(form.application.form.shared_diff_access, class="shared_diff_access") }} | ||||
|                         <span class="pure-form-message-inline">Allow access to view watch diff page when password is enabled (Good for sharing the diff page) | ||||
|                         </span> | ||||
|                     </div> | ||||
|                     <div class="pure-control-group"> | ||||
|                         {{ render_checkbox_field(form.application.form.rss_hide_muted_watches) }} | ||||
|                     </div> | ||||
|                     <div class="pure-control-group"> | ||||
|                         {{ render_field(form.application.form.pager_size) }} | ||||
|                         <span class="pure-form-message-inline">Number of items per page in the watch overview list, 0 to disable.</span> | ||||
|                     </div> | ||||
|  | ||||
|                     <div class="pure-control-group"> | ||||
|                         {{ render_checkbox_field(form.application.form.extract_title_as_title) }} | ||||
|                         <span class="pure-form-message-inline">Note: This will automatically apply to all existing watches.</span> | ||||
|                     </div> | ||||
|                     <div class="pure-control-group"> | ||||
|                         {{ render_checkbox_field(form.application.form.empty_pages_are_a_change) }} | ||||
|                         <span class="pure-form-message-inline">When a request returns no content, or the HTML does not contain any text, is this considered a change?</span> | ||||
|                     </div> | ||||
|                 {% if form.requests.proxy %} | ||||
|                     <div class="pure-control-group inline-radio"> | ||||
|                         {{ render_field(form.requests.form.proxy, class="fetch-backend-proxy") }} | ||||
|                         <span class="pure-form-message-inline"> | ||||
|                         Choose a default proxy for all watches | ||||
|                         </span> | ||||
|                     </div> | ||||
|                 {% endif %} | ||||
|                 </fieldset> | ||||
|             </div> | ||||
|  | ||||
|             <div class="tab-pane-inner" id="notifications"> | ||||
|                 <fieldset> | ||||
|                     <div class="field-group"> | ||||
|                         {{ render_common_settings_form(form.application.form, emailprefix, settings_application, extra_notification_token_placeholder_info) }} | ||||
|                     </div> | ||||
|                 </fieldset> | ||||
|                 <div class="pure-control-group" id="notification-base-url"> | ||||
|                     {{ render_field(form.application.form.base_url, class="m-d") }} | ||||
|                     <span class="pure-form-message-inline"> | ||||
|                         Base URL used for the <code>{{ '{{ base_url }}' }}</code> token in notification links.<br> | ||||
|                         Default value is the system environment variable '<code>BASE_URL</code>' - <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Configurable-BASE_URL-setting">read more here</a>. | ||||
|                     </span> | ||||
|                 </div> | ||||
|             </div> | ||||
|  | ||||
|             <div class="tab-pane-inner" id="fetching"> | ||||
|                 <div class="pure-control-group inline-radio"> | ||||
|                     {{ render_field(form.application.form.fetch_backend, class="fetch-backend") }} | ||||
|                     <span class="pure-form-message-inline"> | ||||
|                         <p>Use the <strong>Basic</strong> method (default) where your watched sites don't need Javascript to render.</p> | ||||
|                         <p>The <strong>Chrome/Javascript</strong> method requires a network connection to a running WebDriver+Chrome server, set by the ENV var 'WEBDRIVER_URL'. </p> | ||||
|                     </span> | ||||
|                 </div> | ||||
|                 <fieldset class="pure-group" id="webdriver-override-options" data-visible-for="application-fetch_backend=html_webdriver"> | ||||
|                     <div class="pure-form-message-inline"> | ||||
|                         <strong>If you're having trouble waiting for the page to be fully rendered (text missing etc), try increasing the 'wait' time here.</strong> | ||||
|                         <br> | ||||
|                         This will wait <i>n</i> seconds before extracting the text. | ||||
|                     </div> | ||||
|                     <div class="pure-control-group"> | ||||
|                         {{ render_field(form.application.form.webdriver_delay) }} | ||||
|                     </div> | ||||
|                 </fieldset> | ||||
|                 <div class="pure-control-group inline-radio"> | ||||
|                     {{ render_field(form.requests.form.default_ua) }} | ||||
|                     <span class="pure-form-message-inline"> | ||||
|                         Applied to all requests.<br><br> | ||||
|                         Note: Simply changing the User-Agent often does not defeat anti-robot technologies, it's important to consider <a href="https://changedetection.io/tutorial/what-are-main-types-anti-robot-mechanisms">all of the ways that the browser is detected</a>. | ||||
|                     </span> | ||||
|                 </div> | ||||
|                 <div class="pure-control-group"> | ||||
|                                         <br> | ||||
|                     Tip: <a href="https://github.com/dgtlmoon/changedetection.io/wiki/Proxy-configuration#brightdata-proxy-support">Connect using Bright Data and Oxylabs Proxies, find out more here.</a> | ||||
|  | ||||
|                 </div> | ||||
|             </div> | ||||
|  | ||||
|             <div class="tab-pane-inner" id="filters"> | ||||
|  | ||||
|                     <fieldset class="pure-group"> | ||||
|                     {{ render_checkbox_field(form.application.form.ignore_whitespace) }} | ||||
|                     <span class="pure-form-message-inline">Ignore whitespace, tabs and new-lines/line-feeds when considering if a change was detected.<br> | ||||
|                     <i>Note:</i> Changing this will change the status of your existing watches, possibly trigger alerts etc. | ||||
|                     </span> | ||||
|                     </fieldset> | ||||
|                 <fieldset class="pure-group"> | ||||
|                     {{ render_checkbox_field(form.application.form.render_anchor_tag_content) }} | ||||
|                     <span class="pure-form-message-inline">Render anchor tag content, default disabled, when enabled renders links as <code>(link text)[https://somesite.com]</code> | ||||
|                         <br> | ||||
|                     <i>Note:</i> Changing this could affect the content of your existing watches, possibly trigger alerts etc. | ||||
|                     </span> | ||||
|                     </fieldset> | ||||
|                     <fieldset class="pure-group"> | ||||
|                       {{ render_field(form.application.form.global_subtractive_selectors, rows=5, placeholder="header | ||||
| footer | ||||
| nav | ||||
| .stockticker | ||||
| //*[contains(text(), 'Advertisement')]") }} | ||||
|                       <span class="pure-form-message-inline"> | ||||
|                         <ul> | ||||
|                           <li> Remove HTML element(s) by CSS and XPath selectors before text conversion. </li> | ||||
|                           <li> Don't paste HTML here, use only CSS and XPath selectors </li> | ||||
|                           <li> Add multiple elements, CSS or XPath selectors per line to ignore multiple parts of the HTML. </li> | ||||
|                         </ul> | ||||
|                       </span> | ||||
|                     </fieldset> | ||||
|                     <fieldset class="pure-group"> | ||||
|                     {{ render_field(form.application.form.global_ignore_text, rows=5, placeholder="Some text to ignore in a line | ||||
| /some.regex\d{2}/ for case-INsensitive regex | ||||
|                     ") }} | ||||
|                     <span class="pure-form-message-inline">Note: This is applied globally in addition to the per-watch rules.</span><br> | ||||
|                     <span class="pure-form-message-inline"> | ||||
|                         <ul> | ||||
|                             <li>Matching text will be <strong>ignored</strong> in the text snapshot (you can still see it but it wont trigger a change)</li> | ||||
|                             <li>Note: This is applied globally in addition to the per-watch rules.</li> | ||||
|                             <li>Each line processed separately, any line matching will be ignored (removed before creating the checksum)</li> | ||||
|                             <li>Regular Expression support, wrap the entire line in forward slash <code>/regex/</code></li> | ||||
|                             <li>Changing this will affect the comparison checksum which may trigger an alert</li> | ||||
|                         </ul> | ||||
|                      </span> | ||||
|                     </fieldset> | ||||
|            </div> | ||||
|  | ||||
|             <div class="tab-pane-inner" id="api"> | ||||
|                 <h4>API Access</h4> | ||||
|                 <p>Drive your changedetection.io via API, More about <a href="https://github.com/dgtlmoon/changedetection.io/wiki/API-Reference">API access here</a></p> | ||||
|  | ||||
|                 <div class="pure-control-group"> | ||||
|                     {{ render_checkbox_field(form.application.form.api_access_token_enabled) }} | ||||
|                     <div class="pure-form-message-inline">Restrict API access limit by using <code>x-api-key</code> header - required for the Chrome Extension to work</div><br> | ||||
|                     <div class="pure-form-message-inline"><br>API Key <span id="api-key">{{api_key}}</span> | ||||
|                         <span style="display:none;" id="api-key-copy" >copy</span> | ||||
|                     </div> | ||||
|                 </div> | ||||
|                 <div class="pure-control-group"> | ||||
|                     <a href="{{url_for('settings.settings_reset_api_key')}}" class="pure-button button-small button-cancel">Regenerate API key</a> | ||||
|                 </div> | ||||
|                 <div class="pure-control-group"> | ||||
|                     <h4>Chrome Extension</h4> | ||||
|                     <p>Easily add any web-page to your changedetection.io installation from within Chrome.</p> | ||||
|                     <strong>Step 1</strong> Install the extension, <strong>Step 2</strong> Navigate to this page, | ||||
|                     <strong>Step 3</strong> Open the extension from the toolbar and click "<i>Sync API Access</i>" | ||||
|                     <p> | ||||
|                         <a id="chrome-extension-link" | ||||
|                            title="Try our new Chrome Extension!" | ||||
|                            href="https://chromewebstore.google.com/detail/changedetectionio-website/kefcfmgmlhmankjmnbijimhofdjekbop"> | ||||
|                             <img alt="Chrome store icon" src="{{ url_for('static_content', group='images', filename='Google-Chrome-icon.png') }}" alt="Chrome"> | ||||
|                             Chrome Webstore | ||||
|                         </a> | ||||
|                     </p> | ||||
|                 </div> | ||||
|             </div> | ||||
|             <div class="tab-pane-inner" id="timedate"> | ||||
|                 <div class="pure-control-group"> | ||||
|                     Ensure the settings below are correct, they are used to manage the time schedule for checking your web page watches. | ||||
|                 </div> | ||||
|                 <div class="pure-control-group"> | ||||
|                     <p><strong>UTC Time & Date from Server:</strong> <span id="utc-time" >{{ utc_time }}</span></p> | ||||
|                     <p><strong>Local Time & Date in Browser:</strong> <span class="local-time" data-utc="{{ utc_time }}"></span></p> | ||||
|                     <p> | ||||
|                        {{ render_field(form.application.form.timezone) }} | ||||
|                         <datalist id="timezones" style="display: none;"> | ||||
|                             {% for tz_name in available_timezones %} | ||||
|                                 <option value="{{ tz_name }}">{{ tz_name }}</option> | ||||
|                             {% endfor %} | ||||
|                         </datalist> | ||||
|                     </p> | ||||
|                 </div> | ||||
|             </div> | ||||
|             <div class="tab-pane-inner" id="proxies"> | ||||
|                 <div id="recommended-proxy"> | ||||
|                     <div> | ||||
|                         <img style="height: 2em;" src="{{url_for('static_content', group='images', filename='brightdata.svg')}}" alt="BrightData Proxy Provider"> | ||||
|                         <p>BrightData offer world-class proxy services, "Data Center" proxies are a very affordable way to proxy your requests, whilst <strong><a href="https://brightdata.grsm.io/n0r16zf7eivq">WebUnlocker</a></strong> can help solve most CAPTCHAs.</p> | ||||
|                         <p> | ||||
|                             BrightData offer many <a href="https://brightdata.com/proxy-types" target="new">many different types of proxies</a>, it is worth reading about what is best for your use-case. | ||||
|                         </p> | ||||
|  | ||||
|                         <p> | ||||
|                             When you have <a href="https://brightdata.grsm.io/n0r16zf7eivq">registered</a>, enabled the required services, visit the <A href="https://brightdata.com/cp/api_example?">API example page</A>, then select <strong>Python</strong>, set the country you wish to use, then copy+paste the access Proxy URL into the "Extra Proxies" boxes below.<br> | ||||
|                         </p> | ||||
|                         <p> | ||||
|                             The Proxy URL with BrightData should start with <code>http://brd-customer...</code> | ||||
|                         </p> | ||||
|                         <p>When you sign up using <a href="https://brightdata.grsm.io/n0r16zf7eivq">https://brightdata.grsm.io/n0r16zf7eivq</a> BrightData will match any first deposit up to $150</p> | ||||
|                     </div> | ||||
|                     <div> | ||||
|                         <img style="height: 2em;" | ||||
|                              src="{{url_for('static_content', group='images', filename='oxylabs.svg')}}" | ||||
|                              alt="Oxylabs Proxy Provider"> | ||||
|                         <p> | ||||
|                             Collect public data at scale with industry-leading web scraping solutions and the world’s | ||||
|                             largest ethical proxy network. | ||||
|                         </p> | ||||
|                         <p> | ||||
|                             Oxylabs also provide a <a href="https://oxylabs.io/products/web-unblocker"><strong>WebUnlocker</strong></a> | ||||
|                             proxy that bypasses sophisticated anti-bot systems, so you don’t have to.<br> | ||||
|                         </p> | ||||
|                         <p> | ||||
|                             Serve over <a href="https://oxylabs.io/location-proxy">195 countries</a>, providing <a | ||||
|                                 href="https://oxylabs.io/products/residential-proxy-pool">Residential</a>, <a | ||||
|                                 href="https://oxylabs.io/products/mobile-proxies">Mobile</a> and <a | ||||
|                                 href="https://oxylabs.io/products/rotating-isp-proxies">ISP proxies</a> and much more. | ||||
|                         </p> | ||||
|                         <p> | ||||
|                             Use the promo code <strong>boost35</strong> with this link <a href="https://oxylabs.go2cloud.org/SH2d">https://oxylabs.go2cloud.org/SH2d</a> for 35% off Residential, Mobile proxies, Web Unblocker, and Scraper APIs. Built-in proxies enable you to access data from all around the world and help overcome anti-bot solutions. | ||||
|  | ||||
|                         </p> | ||||
|  | ||||
|                          | ||||
|                     </div> | ||||
|                 </div> | ||||
|  | ||||
|                <p><strong>Tip</strong>: "Residential" and "Mobile" proxy type can be more successfull than "Data Center" for blocked websites. | ||||
|  | ||||
|                 <div class="pure-control-group" id="extra-proxies-setting"> | ||||
|                 {{ render_field(form.requests.form.extra_proxies) }} | ||||
|                 <span class="pure-form-message-inline">"Name" will be used for selecting the proxy in the Watch Edit settings</span><br> | ||||
|                 <span class="pure-form-message-inline">SOCKS5 proxies with authentication are only supported with 'plain requests' fetcher, for other fetchers you should whitelist the IP access instead</span> | ||||
|                 </div> | ||||
|                 <div class="pure-control-group" id="extra-browsers-setting"> | ||||
|                     <p> | ||||
|                     <span class="pure-form-message-inline"><i>Extra Browsers</i> can be attached to further defeat CAPTCHA's on websites that are particularly hard to scrape.</span><br> | ||||
|                     <span class="pure-form-message-inline">Simply paste the connection address into the box, <a href="https://changedetection.io/tutorial/using-bright-datas-scraping-browser-pass-captchas-and-other-protection-when-monitoring">More instructions and examples here</a> </span> | ||||
|                     </p> | ||||
|                     {{ render_field(form.requests.form.extra_browsers) }} | ||||
|                 </div> | ||||
|             </div> | ||||
|             <div id="actions"> | ||||
|                 <div class="pure-control-group"> | ||||
|                     {{ render_button(form.save_button) }} | ||||
|                     <a href="{{url_for('index')}}" class="pure-button button-small button-cancel">Back</a> | ||||
|                     <a href="{{url_for('ui.clear_all_history')}}" class="pure-button button-small button-error">Clear Snapshot History</a> | ||||
|                 </div> | ||||
|             </div> | ||||
|         </form> | ||||
|     </div> | ||||
| </div> | ||||
|  | ||||
| {% endblock %} | ||||
| @@ -57,8 +57,7 @@ def test_setup(client, live_server, measure_memory_usage): | ||||
|  | ||||
|  | ||||
| def test_api_simple(client, live_server, measure_memory_usage): | ||||
| #    live_server_setup(live_server) | ||||
|  | ||||
|     #live_server_setup(live_server) | ||||
|     api_key = extract_api_key_from_UI(client) | ||||
|  | ||||
|     # Create a watch | ||||
| @@ -291,7 +290,6 @@ def test_access_denied(client, live_server, measure_memory_usage): | ||||
|     assert b"Settings updated." in res.data | ||||
|  | ||||
| def test_api_watch_PUT_update(client, live_server, measure_memory_usage): | ||||
|  | ||||
|     #live_server_setup(live_server) | ||||
|     api_key = extract_api_key_from_UI(client) | ||||
|  | ||||
| @@ -373,7 +371,6 @@ def test_api_watch_PUT_update(client, live_server, measure_memory_usage): | ||||
|  | ||||
|  | ||||
| def test_api_import(client, live_server, measure_memory_usage): | ||||
|     #live_server_setup(live_server) | ||||
|     api_key = extract_api_key_from_UI(client) | ||||
|  | ||||
|     res = client.post( | ||||
|   | ||||
| @@ -101,7 +101,9 @@ def test_check_ldjson_price_autodetect(client, live_server, measure_memory_usage | ||||
|     # Accept it | ||||
|     uuid = next(iter(live_server.app.config['DATASTORE'].data['watching'])) | ||||
|     #time.sleep(1) | ||||
|     client.get(url_for('price_data_follower.accept', uuid=uuid, follow_redirects=True)) | ||||
|     res = client.get(url_for('price_data_follower.accept', uuid=uuid, follow_redirects=True)) | ||||
|     # should now be switched to restock_mode | ||||
|     wait_for_all_checks(client) | ||||
|     client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) | ||||
|     wait_for_all_checks(client) | ||||
|     # Offer should be gone | ||||
| @@ -154,6 +156,7 @@ def _test_runner_check_bad_format_ignored(live_server, client, has_ldjson_price_ | ||||
|     assert b"1 Imported" in res.data | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     assert len(client.application.config.get('DATASTORE').data['watching']) | ||||
|     for k,v in client.application.config.get('DATASTORE').data['watching'].items(): | ||||
|         assert v.get('last_error') == False | ||||
|         assert v.get('has_ldjson_price_data') == has_ldjson_price_data, f"Detected LDJSON data? should be {has_ldjson_price_data}" | ||||
| @@ -163,7 +166,7 @@ def _test_runner_check_bad_format_ignored(live_server, client, has_ldjson_price_ | ||||
|     client.get(url_for("ui.form_delete", uuid="all"), follow_redirects=True) | ||||
|  | ||||
|  | ||||
| def test_bad_ldjson_is_correctly_ignored(client, live_server, measure_memory_usage): | ||||
| def test_bad_ldjson_is_correctly_ignored(client, live_server): | ||||
|     #live_server_setup(live_server) | ||||
|     test_return_data = """ | ||||
|             <html> | ||||
|   | ||||
| @@ -1,6 +1,5 @@ | ||||
| #!/usr/bin/env python3 | ||||
| import json | ||||
| import urllib | ||||
|  | ||||
| from flask import url_for | ||||
| from .util import live_server_setup, wait_for_all_checks | ||||
| @@ -44,12 +43,14 @@ def set_number_out_of_range_response(number="150"): | ||||
|     with open("test-datastore/endpoint-content.txt", "w") as f: | ||||
|         f.write(test_return_data) | ||||
|  | ||||
| def test_setup(live_server): | ||||
|     live_server_setup(live_server) | ||||
|  | ||||
| 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) | ||||
|     #live_server_setup(live_server) | ||||
|  | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
|  | ||||
| @@ -138,6 +139,7 @@ def test_conditions_with_text_and_number(client, live_server): | ||||
| def test_condition_validate_rule_row(client, live_server): | ||||
|  | ||||
|     set_original_response("50") | ||||
|     #live_server_setup(live_server) | ||||
|  | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
|  | ||||
|   | ||||
							
								
								
									
										15
									
								
								changedetectionio/tests/test_plugins.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								changedetectionio/tests/test_plugins.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,15 @@ | ||||
| from flask import url_for | ||||
|  | ||||
| from changedetectionio.tests.util import live_server_setup | ||||
|  | ||||
|  | ||||
| def test_checkplugins_registered(live_server, client): | ||||
|     live_server_setup(live_server) | ||||
|     res = client.get( | ||||
|         url_for("settings.settings_page") | ||||
|     ) | ||||
|     assert res.status_code == 200 | ||||
|     # Should be registered in the info table | ||||
|     assert b'<td>Webpage Text/HTML, JSON and PDF changes' in res.data | ||||
|     assert b'<td>text_json_diff' in res.data | ||||
|  | ||||
| @@ -95,12 +95,14 @@ def test_itemprop_price_change(client, live_server): | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
|  | ||||
|     set_original_response(props_markup=instock_props[0], price="190.95") | ||||
|     client.post( | ||||
|     res = client.post( | ||||
|         url_for("ui.ui_views.form_quick_watch_add"), | ||||
|         data={"url": test_url, "tags": 'restock tests', 'processor': 'restock_diff'}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|  | ||||
|     assert res.status_code == 200 | ||||
|  | ||||
|     # A change in price, should trigger a change by default | ||||
|     wait_for_all_checks(client) | ||||
|     res = client.get(url_for("index")) | ||||
| @@ -110,6 +112,7 @@ def test_itemprop_price_change(client, live_server): | ||||
|     set_original_response(props_markup=instock_props[0], price='180.45') | ||||
|     client.get(url_for("ui.form_watch_checknow"), follow_redirects=True) | ||||
|     wait_for_all_checks(client) | ||||
|  | ||||
|     res = client.get(url_for("index")) | ||||
|     assert b'180.45' in res.data | ||||
|     assert b'unviewed' in res.data | ||||
| @@ -395,7 +398,7 @@ def test_data_sanity(client, live_server): | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
|     test_url2 = url_for('test_endpoint2', _external=True) | ||||
|     set_original_response(props_markup=instock_props[0], price="950.95") | ||||
|     client.post( | ||||
|     res = client.post( | ||||
|         url_for("ui.ui_views.form_quick_watch_add"), | ||||
|         data={"url": test_url, "tags": 'restock tests', 'processor': 'restock_diff'}, | ||||
|         follow_redirects=True | ||||
|   | ||||
							
								
								
									
										88
									
								
								changedetectionio/tests/test_restock_save_load_settings.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										88
									
								
								changedetectionio/tests/test_restock_save_load_settings.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,88 @@ | ||||
| #!/usr/bin/env python3 | ||||
| import os | ||||
| import time | ||||
| from flask import url_for | ||||
| from .util import live_server_setup, wait_for_all_checks, extract_UUID_from_client | ||||
|  | ||||
| def test_restock_settings_persistence(client, live_server): | ||||
|     """Test that restock processor and settings are correctly saved and loaded after app restart""" | ||||
|      | ||||
|     live_server_setup(live_server) | ||||
|      | ||||
|     # Create a test page with pricing information | ||||
|     test_return_data = """<html> | ||||
|        <body> | ||||
|      Some initial text<br> | ||||
|      <p>Which is across multiple lines</p> | ||||
|      <br> | ||||
|      So let's see what happens.  <br> | ||||
|      <div>price: $10.99</div> | ||||
|      <div id="sametext">Out of stock</div> | ||||
|      </body> | ||||
|      </html> | ||||
|     """ | ||||
|  | ||||
|     with open("test-datastore/endpoint-content.txt", "w") as f: | ||||
|         f.write(test_return_data) | ||||
|      | ||||
|     # Add our URL to the import page (pointing to our test endpoint) | ||||
|     test_url = url_for('test_endpoint', _external=True) | ||||
|      | ||||
|     # Add a new watch with the restock_diff processor | ||||
|     res = client.post( | ||||
|         url_for("ui.ui_views.form_quick_watch_add"), | ||||
|         data={"url": test_url, "tags": '', 'processor': 'restock_diff'}, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|      | ||||
|     # Wait for initial check to complete | ||||
|     wait_for_all_checks(client) | ||||
|      | ||||
|     # Get the UUID of the watch | ||||
|     uuid = extract_UUID_from_client(client) | ||||
|      | ||||
|     # Set custom restock settings | ||||
|     res = client.post( | ||||
|         url_for("ui.ui_edit.edit_page", uuid=uuid), | ||||
|         data={ | ||||
|             "url": test_url, | ||||
|             "tags": "", | ||||
|             "headers": "", | ||||
|             "restock_settings-price_change_min": 10, | ||||
|             "restock_settings-price_change_threshold_percent": 5, | ||||
|             'fetch_backend': "html_requests", | ||||
|             "processor" : 'restock_diff' | ||||
|         }, | ||||
|         follow_redirects=True | ||||
|     ) | ||||
|      | ||||
|     assert b"Updated watch." in res.data | ||||
|      | ||||
|     # Verify the settings were saved in the current datastore | ||||
|     app_config = client.application.config.get('DATASTORE').data | ||||
|     watch = app_config['watching'][uuid] | ||||
|      | ||||
|     assert watch.get('processor') == 'restock_diff' | ||||
|     assert watch['restock_settings'].get('price_change_min') == 10 | ||||
|     assert watch['restock_settings'].get('price_change_threshold_percent') == 5 | ||||
|      | ||||
|     # Restart the application by calling teardown and recreating the datastore | ||||
|     # This simulates shutting down and restarting the app | ||||
|     datastore = client.application.config.get('DATASTORE') | ||||
|     datastore.stop_thread = True | ||||
|  | ||||
|     # Create a new datastore instance that will read from the saved JSON | ||||
|     from changedetectionio import store | ||||
|     new_datastore = store.ChangeDetectionStore(datastore_path="./test-datastore", include_default_watches=False) | ||||
|     client.application.config['DATASTORE'] = new_datastore | ||||
|      | ||||
|     # Verify the watch settings were correctly loaded after restart | ||||
|     app_config = client.application.config.get('DATASTORE').data | ||||
|     watch = app_config['watching'][uuid] | ||||
|      | ||||
|     # Check that processor mode is correctly preserved | ||||
|     assert watch.get('processor') == 'restock_diff', "Watch processor mode should be preserved as 'restock_diff'" | ||||
|      | ||||
|     # Check that the restock settings were correctly preserved | ||||
|     assert watch['restock_settings'].get('price_change_min') == 10, "price_change_min setting should be preserved" | ||||
|     assert watch['restock_settings'].get('price_change_threshold_percent') == 5, "price_change_threshold_percent setting should be preserved" | ||||
| @@ -270,20 +270,16 @@ class update_worker(threading.Thread): | ||||
|                     logger.info(f"Processing watch UUID {uuid} Priority {queued_item_data.priority} URL {watch['url']}") | ||||
|  | ||||
|                     try: | ||||
|                         # Get processor handler from pluggy plugin system | ||||
|                         from changedetectionio.processors import get_processor_handler | ||||
|                          | ||||
|                         # Processor is what we are using for detecting the "Change" | ||||
|                         processor = watch.get('processor', 'text_json_diff') | ||||
|  | ||||
|                         # Init a new 'difference_detection_processor', first look in processors | ||||
|                         processor_module_name = f"changedetectionio.processors.{processor}.processor" | ||||
|                         try: | ||||
|                             processor_module = importlib.import_module(processor_module_name) | ||||
|                         except ModuleNotFoundError as e: | ||||
|                             print(f"Processor module '{processor}' not found.") | ||||
|                             raise e | ||||
|  | ||||
|                         update_handler = processor_module.perform_site_check(datastore=self.datastore, | ||||
|                                                                              watch_uuid=uuid | ||||
|                                                                              ) | ||||
|                         processor_name = watch.get('processor', 'text_json_diff') | ||||
|                          | ||||
|                         # Get the handler via the plugin system | ||||
|                         update_handler = get_processor_handler(processor_name=processor_name,  | ||||
|                                                               datastore=self.datastore, | ||||
|                                                               watch_uuid=uuid) | ||||
|  | ||||
|                         update_handler.call_browser() | ||||
|  | ||||
| @@ -531,14 +527,13 @@ class update_worker(threading.Thread): | ||||
|                         try: | ||||
|                             self.datastore.update_watch(uuid=uuid, update_obj=update_obj) | ||||
|  | ||||
|  | ||||
|                             # Also save the snapshot on the first time checked, "last checked" will always be updated, so we just check history length. | ||||
|                             if changed_detected or not watch.history_n: | ||||
|  | ||||
|                                 if update_handler.screenshot: | ||||
|                                 if hasattr(update_handler, "screenshot") and update_handler.screenshot: | ||||
|                                     watch.save_screenshot(screenshot=update_handler.screenshot) | ||||
|  | ||||
|                                 if update_handler.xpath_data: | ||||
|                                 if hasattr(update_handler, "xpath_data") and update_handler.xpath_data: | ||||
|                                     watch.save_xpath_data(data=update_handler.xpath_data) | ||||
|  | ||||
|                                 # Small hack so that we sleep just enough to allow 1 second  between history snapshots | ||||
| @@ -591,6 +586,7 @@ class update_worker(threading.Thread): | ||||
|                                                                        'check_count': count | ||||
|                                                                        }) | ||||
|  | ||||
|                     watch.save_data() | ||||
|  | ||||
|                 self.current_uuid = None  # Done | ||||
|                 self.q.task_done() | ||||
|   | ||||
| @@ -73,7 +73,7 @@ jq~=1.3; python_version >= "3.8" and sys_platform == "linux" | ||||
|  | ||||
| # playwright is installed at Dockerfile build time because it's not available on all platforms | ||||
|  | ||||
| pyppeteer-ng==2.0.0rc5 | ||||
| pyppeteer-ng==2.0.0rc6 | ||||
| pyppeteerstealth>=0.0.4 | ||||
|  | ||||
| # Include pytest, so if theres a support issue we can ask them to run these tests on their setup | ||||
| @@ -112,3 +112,4 @@ pluggy ~= 1.5 | ||||
|  | ||||
|  | ||||
|  | ||||
| deepmerge | ||||
|   | ||||
		Reference in New Issue
	
	Block a user